feat: unify main session and icon cues
This commit is contained in:
@@ -129,6 +129,12 @@ clawdis relay # Start listening
|
|||||||
| `clawdis status` | Show recent messages |
|
| `clawdis status` | Show recent messages |
|
||||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
| `clawdis heartbeat` | Trigger a heartbeat |
|
||||||
|
|
||||||
|
### Sessions, surfaces, and WebChat
|
||||||
|
|
||||||
|
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
||||||
|
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
|
||||||
|
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ final class AppState: ObservableObject {
|
|||||||
didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) }
|
didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var isWorking: Bool = false
|
||||||
|
@Published var earBoostActive: Bool = false
|
||||||
|
|
||||||
|
private var earBoostTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||||
self.defaultSound = UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? ""
|
self.defaultSound = UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? ""
|
||||||
@@ -106,6 +111,19 @@ final class AppState: ObservableObject {
|
|||||||
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
||||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func triggerVoiceEars(ttl: TimeInterval = 5) {
|
||||||
|
self.earBoostTask?.cancel()
|
||||||
|
self.earBoostActive = true
|
||||||
|
self.earBoostTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000))
|
||||||
|
await MainActor.run { [weak self] in self?.earBoostActive = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWorking(_ working: Bool) {
|
||||||
|
self.isWorking = working
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -578,7 +596,12 @@ struct ClawdisApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
MenuBarExtra { MenuContent(state: self.state) } label: { CritterStatusLabel(isPaused: self.state.isPaused) }
|
MenuBarExtra { MenuContent(state: self.state) } label: {
|
||||||
|
CritterStatusLabel(
|
||||||
|
isPaused: self.state.isPaused,
|
||||||
|
isWorking: self.state.isWorking,
|
||||||
|
earBoostActive: self.state.earBoostActive)
|
||||||
|
}
|
||||||
.menuBarExtraStyle(.menu)
|
.menuBarExtraStyle(.menu)
|
||||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||||
self.statusItem = item
|
self.statusItem = item
|
||||||
@@ -632,19 +655,19 @@ private struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func primarySessionKey() -> String {
|
private func primarySessionKey() -> String {
|
||||||
// Prefer the most recently updated session from the store; fall back to default
|
// Prefer canonical main session; fall back to most recent.
|
||||||
let storePath = SessionLoader.defaultStorePath
|
let storePath = SessionLoader.defaultStorePath
|
||||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
|
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
|
||||||
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||||
{
|
{
|
||||||
|
if decoded.keys.contains("main") { return "main" }
|
||||||
|
|
||||||
let sorted = decoded.sorted { a, b -> Bool in
|
let sorted = decoded.sorted { a, b -> Bool in
|
||||||
let lhs = a.value.updatedAt ?? 0
|
let lhs = a.value.updatedAt ?? 0
|
||||||
let rhs = b.value.updatedAt ?? 0
|
let rhs = b.value.updatedAt ?? 0
|
||||||
return lhs > rhs
|
return lhs > rhs
|
||||||
}
|
}
|
||||||
if let first = sorted.first {
|
if let first = sorted.first { return first.key }
|
||||||
return first.key
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return "+1003"
|
return "+1003"
|
||||||
}
|
}
|
||||||
@@ -652,6 +675,8 @@ private struct MenuContent: View {
|
|||||||
|
|
||||||
private struct CritterStatusLabel: View {
|
private struct CritterStatusLabel: View {
|
||||||
var isPaused: Bool
|
var isPaused: Bool
|
||||||
|
var isWorking: Bool
|
||||||
|
var earBoostActive: Bool
|
||||||
|
|
||||||
@State private var blinkAmount: CGFloat = 0
|
@State private var blinkAmount: CGFloat = 0
|
||||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||||
@@ -672,8 +697,9 @@ private struct CritterStatusLabel: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(nsImage: CritterIconRenderer.makeIcon(
|
Image(nsImage: CritterIconRenderer.makeIcon(
|
||||||
blink: self.blinkAmount,
|
blink: self.blinkAmount,
|
||||||
legWiggle: self.legWiggle,
|
legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0),
|
||||||
earWiggle: self.earWiggle))
|
earWiggle: self.earWiggle,
|
||||||
|
earScale: self.earBoostActive ? 1.9 : 1.0))
|
||||||
.frame(width: 18, height: 16)
|
.frame(width: 18, height: 16)
|
||||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||||
.offset(x: self.wiggleOffset)
|
.offset(x: self.wiggleOffset)
|
||||||
@@ -697,6 +723,10 @@ private struct CritterStatusLabel: View {
|
|||||||
self.wiggleEars()
|
self.wiggleEars()
|
||||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.isWorking {
|
||||||
|
self.scurry()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||||
}
|
}
|
||||||
@@ -743,6 +773,20 @@ private struct CritterStatusLabel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scurry() {
|
||||||
|
let target = CGFloat.random(in: 0.7...1.0)
|
||||||
|
withAnimation(.easeInOut(duration: 0.12)) {
|
||||||
|
self.legWiggle = target
|
||||||
|
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||||
|
withAnimation(.easeOut(duration: 0.16)) {
|
||||||
|
self.legWiggle = 0.25
|
||||||
|
self.wiggleOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func wiggleEars() {
|
private func wiggleEars() {
|
||||||
let target = CGFloat.random(in: -1.2...1.2)
|
let target = CGFloat.random(in: -1.2...1.2)
|
||||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||||
@@ -757,7 +801,12 @@ private struct CritterStatusLabel: View {
|
|||||||
enum CritterIconRenderer {
|
enum CritterIconRenderer {
|
||||||
private static let size = NSSize(width: 18, height: 16)
|
private static let size = NSSize(width: 18, height: 16)
|
||||||
|
|
||||||
static func makeIcon(blink: CGFloat, legWiggle: CGFloat = 0, earWiggle: CGFloat = 0) -> NSImage {
|
static func makeIcon(
|
||||||
|
blink: CGFloat,
|
||||||
|
legWiggle: CGFloat = 0,
|
||||||
|
earWiggle: CGFloat = 0,
|
||||||
|
earScale: CGFloat = 1
|
||||||
|
) -> NSImage {
|
||||||
let image = NSImage(size: size)
|
let image = NSImage(size: size)
|
||||||
image.lockFocus()
|
image.lockFocus()
|
||||||
defer { image.unlockFocus() }
|
defer { image.unlockFocus() }
|
||||||
@@ -774,7 +823,7 @@ enum CritterIconRenderer {
|
|||||||
let bodyCorner = w * 0.09
|
let bodyCorner = w * 0.09
|
||||||
|
|
||||||
let earW = w * 0.22
|
let earW = w * 0.22
|
||||||
let earH = bodyH * 0.66 * (1 - 0.08 * abs(earWiggle))
|
let earH = bodyH * 0.66 * earScale * (1 - 0.08 * abs(earWiggle))
|
||||||
let earCorner = earW * 0.24
|
let earCorner = earW * 0.24
|
||||||
|
|
||||||
let legW = w * 0.11
|
let legW = w * 0.11
|
||||||
@@ -2009,6 +2058,7 @@ final class VoiceWakeTester {
|
|||||||
{
|
{
|
||||||
if matched, !text.isEmpty {
|
if matched, !text.isEmpty {
|
||||||
self.stop()
|
self.stop()
|
||||||
|
AppStateStore.shared.triggerVoiceEars()
|
||||||
onUpdate(.detected(text))
|
onUpdate(.detected(text))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
final class WebChatWindowController: NSWindowController, WKScriptMessageHandler {
|
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
||||||
|
|
||||||
|
final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, WKNavigationDelegate {
|
||||||
private let webView: WKWebView
|
private let webView: WKWebView
|
||||||
private let sessionKey: String
|
private let sessionKey: String
|
||||||
|
private let initialMessagesJSON: String
|
||||||
|
|
||||||
init(sessionKey: String) {
|
init(sessionKey: String) {
|
||||||
self.sessionKey = sessionKey
|
self.sessionKey = sessionKey
|
||||||
|
self.initialMessagesJSON = WebChatWindowController.loadInitialMessagesJSON(for: sessionKey)
|
||||||
|
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
let contentController = WKUserContentController()
|
let contentController = WKUserContentController()
|
||||||
@@ -40,6 +45,11 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
window.webkit?.messageHandlers?.clawdis?.postMessage({ id: 'log', log: String(msg) });
|
window.webkit?.messageHandlers?.clawdis?.postMessage({ id: 'log', log: String(msg) });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
|
const __origConsoleLog = console.log;
|
||||||
|
console.log = function(...args) {
|
||||||
|
try { window.__clawdisLog(args.join(' ')); } catch (_) {}
|
||||||
|
__origConsoleLog.apply(console, args);
|
||||||
|
};
|
||||||
"""
|
"""
|
||||||
let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||||
contentController.addUserScript(userScript)
|
contentController.addUserScript(userScript)
|
||||||
@@ -53,6 +63,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
window.title = "Clawd Web Chat"
|
window.title = "Clawd Web Chat"
|
||||||
window.contentView = self.webView
|
window.contentView = self.webView
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
|
self.webView.navigationDelegate = self
|
||||||
contentController.add(self, name: "clawdis")
|
contentController.add(self, name: "clawdis")
|
||||||
self.loadPage()
|
self.loadPage()
|
||||||
}
|
}
|
||||||
@@ -61,6 +72,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
private func loadPage() {
|
private func loadPage() {
|
||||||
|
let messagesJSON = self.initialMessagesJSON.replacingOccurrences(of: "</script>", with: "<\\/script>")
|
||||||
guard let webChatURL = Bundle.main.url(forResource: "WebChat", withExtension: nil) else {
|
guard let webChatURL = Bundle.main.url(forResource: "WebChat", withExtension: nil) else {
|
||||||
NSLog("WebChat resources missing")
|
NSLog("WebChat resources missing")
|
||||||
return
|
return
|
||||||
@@ -119,6 +131,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
const initialMessages = \(messagesJSON);
|
||||||
const status = (msg) => {
|
const status = (msg) => {
|
||||||
console.log(msg);
|
console.log(msg);
|
||||||
window.__clawdisLog(msg);
|
window.__clawdisLog(msg);
|
||||||
@@ -169,7 +182,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
systemPrompt: 'You are Clawd (primary session).',
|
systemPrompt: 'You are Clawd (primary session).',
|
||||||
model: getModel('anthropic', 'claude-opus-4-5'),
|
model: getModel('anthropic', 'claude-opus-4-5'),
|
||||||
thinkingLevel: 'off',
|
thinkingLevel: 'off',
|
||||||
messages: []
|
messages: initialMessages
|
||||||
},
|
},
|
||||||
transport: new NativeTransport()
|
transport: new NativeTransport()
|
||||||
});
|
});
|
||||||
@@ -212,6 +225,16 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
self.webView.loadHTMLString(html, baseURL: webChatURL)
|
self.webView.loadHTMLString(html, baseURL: webChatURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
webView.evaluateJavaScript("document.body.innerText") { result, error in
|
||||||
|
if let error {
|
||||||
|
webChatLogger.error("eval error: \(error.localizedDescription, privacy: .public)")
|
||||||
|
} else if let text = result as? String {
|
||||||
|
webChatLogger.debug("body text snapshot: \(String(text.prefix(200)), privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
guard let body = message.body as? [String: Any],
|
guard let body = message.body as? [String: Any],
|
||||||
let id = body["id"] as? String,
|
let id = body["id"] as? String,
|
||||||
@@ -219,7 +242,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
if id == "log", let log = body["log"] as? String {
|
if id == "log", let log = body["log"] as? String {
|
||||||
NSLog("WebChat JS: %@", log)
|
webChatLogger.debug("JS: \(log, privacy: .public)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +268,8 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) {
|
private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) {
|
||||||
|
await MainActor.run { AppStateStore.shared.setWorking(true) }
|
||||||
|
defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } }
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try await Task.detached(priority: .utility) { () -> Data in
|
data = try await Task.detached(priority: .utility) { () -> Data in
|
||||||
@@ -280,6 +305,48 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler
|
|||||||
}
|
}
|
||||||
return (String(data: data, encoding: .utf8), nil)
|
return (String(data: data, encoding: .utf8), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func loadInitialMessagesJSON(for sessionKey: String) -> String {
|
||||||
|
guard let sessionId = self.sessionId(for: sessionKey) else { return "[]" }
|
||||||
|
let path = self.expand("~/.clawdis/sessions/\(sessionId).jsonl")
|
||||||
|
guard FileManager.default.fileExists(atPath: path) else { return "[]" }
|
||||||
|
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return "[]" }
|
||||||
|
|
||||||
|
var messages: [[String: Any]] = []
|
||||||
|
for line in content.split(whereSeparator: { $0.isNewline }) {
|
||||||
|
guard let data = String(line).data(using: .utf8),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else { continue }
|
||||||
|
let message = (obj["message"] as? [String: Any]) ?? obj
|
||||||
|
guard let role = message["role"] as? String,
|
||||||
|
["user", "assistant", "system"].contains(role)
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
var contentPayload = message["content"] as? [[String: Any]]
|
||||||
|
if contentPayload == nil, let text = message["text"] as? String {
|
||||||
|
contentPayload = [["type": "text", "text": text]]
|
||||||
|
}
|
||||||
|
guard let finalContent = contentPayload else { continue }
|
||||||
|
messages.append(["role": role, "content": finalContent])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: messages, options: []) else {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return String(data: data, encoding: .utf8) ?? "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sessionId(for key: String) -> String? {
|
||||||
|
let storePath = self.expand("~/.clawdis/sessions/sessions.json")
|
||||||
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)) else { return nil }
|
||||||
|
guard let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
|
guard let entry = decoded[key] as? [String: Any] else { return nil }
|
||||||
|
return entry["sessionId"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func expand(_ path: String) -> String {
|
||||||
|
(path as NSString).expandingTildeInPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
21
docs/mac/icon.md
Normal file
21
docs/mac/icon.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Menu Bar Icon States
|
||||||
|
|
||||||
|
Author: steipete · Updated: 2025-12-06 · Scope: macOS app (`apps/macos`)
|
||||||
|
|
||||||
|
- **Idle:** Normal icon animation (blink, occasional wiggle).
|
||||||
|
- **Paused:** Status item uses `appearsDisabled`; no motion.
|
||||||
|
- **Voice trigger (big ears):** Voice wake detector calls `AppState.triggerVoiceEars()` → `earBoostActive=true` for ~5s. Ears scale up (1.9x) then auto-reset. Only fired from the in-app voice pipeline.
|
||||||
|
- **Working (agent running):** `AppState.isWorking=true` drives a “tail/leg scurry” micro-motion: faster leg wiggle and slight offset while work is in-flight. Currently toggled around WebChat agent runs; add the same toggle around other long tasks when you wire them.
|
||||||
|
|
||||||
|
Wiring points
|
||||||
|
- Voice wake: see `VoiceWakeTester.handleResult` in `AppMain.swift`—on detection it calls `triggerVoiceEars()`.
|
||||||
|
- Agent activity: set `AppStateStore.shared.setWorking(true/false)` around work spans (already done in WebChat agent call). Keep spans short and reset in `defer` blocks to avoid stuck animations.
|
||||||
|
|
||||||
|
Shapes & sizes
|
||||||
|
- Base icon drawn in `CritterIconRenderer.makeIcon(blink:legWiggle:earWiggle:earScale:)`.
|
||||||
|
- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` without changing overall frame (18×16pt template image).
|
||||||
|
- Scurry uses leg wiggle up to ~1.0 with a small horizontal jiggle; it’s additive to any existing idle wiggle.
|
||||||
|
|
||||||
|
Behavioral notes
|
||||||
|
- No external CLI/XPC toggle for ears/working; keep it internal to the app’s own signals to avoid accidental flapping.
|
||||||
|
- Keep TTLs short (<10s) so the icon returns to baseline quickly if a job hangs.
|
||||||
@@ -10,11 +10,13 @@ CLAWDIS keeps lightweight session state so your agent can remember context betwe
|
|||||||
|
|
||||||
## How session keys are chosen
|
## How session keys are chosen
|
||||||
|
|
||||||
- Direct chats: normalized E.164 sender number (e.g., `+15551234567`).
|
- Direct chats: by default collapse to the canonical key `main` so all 1:1 channels (WhatsApp, WebChat, Telegram) share a single session.
|
||||||
- Group chats: `group:<whatsapp-jid>` so group history stays isolated from DMs.
|
- Group chats: `group:<whatsapp-jid>` so group history stays isolated from DMs.
|
||||||
- Global mode: set `inbound.reply.session.scope = "global"` to force a single shared session for all chats.
|
- Global mode: set `inbound.reply.session.scope = "global"` to force a single shared session for all chats.
|
||||||
- Unknown senders fall back to `unknown`.
|
- Unknown senders fall back to `unknown`.
|
||||||
|
|
||||||
|
To change the canonical key (or disable collapsing), set `inbound.reply.session.mainKey` to another string or leave it empty.
|
||||||
|
|
||||||
## When sessions reset
|
## When sessions reset
|
||||||
|
|
||||||
- Idle timeout: `inbound.reply.session.idleMinutes` (default 60). If no messages arrive within this window, a new `sessionId` is created on the next message.
|
- Idle timeout: `inbound.reply.session.idleMinutes` (default 60). If no messages arrive within this window, a new `sessionId` is created on the next message.
|
||||||
@@ -32,11 +34,20 @@ CLAWDIS keeps lightweight session state so your agent can remember context betwe
|
|||||||
scope: "per-sender", // or "global"
|
scope: "per-sender", // or "global"
|
||||||
resetTriggers: ["/new"], // additional triggers allowed
|
resetTriggers: ["/new"], // additional triggers allowed
|
||||||
idleMinutes: 120, // extend or shrink timeout (min 1)
|
idleMinutes: 120, // extend or shrink timeout (min 1)
|
||||||
store: "~/state/clawdis-sessions.json" // optional custom path
|
store: "~/state/clawdis-sessions.json", // optional custom path
|
||||||
|
mainKey: "main" // canonical direct-chat bucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
## Surfaces (channel labels)
|
||||||
|
|
||||||
|
Each inbound message can carry a `Surface` hint in the templating context (e.g., `whatsapp`, `webchat`, `telegram`, `voice`). Routing stays deterministic: replies are sent back to the origin surface, but the shared `main` session keeps context unified across direct channels. Groups retain their `group:<jid>` buckets.
|
||||||
|
|
||||||
|
## WebChat history
|
||||||
|
|
||||||
|
WebChat always attaches to the `main` session and hydrates the full Tau JSONL transcript from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view reflects all turns, even those that arrived via WhatsApp/Telegram.
|
||||||
```
|
```
|
||||||
|
|
||||||
Other session-related behaviors:
|
Other session-related behaviors:
|
||||||
|
|||||||
14
docs/surface.md
Normal file
14
docs/surface.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Surfaces & Routing
|
||||||
|
|
||||||
|
Updated: 2025-12-06
|
||||||
|
|
||||||
|
Goal: make replies deterministic per channel while keeping one shared context for direct chats.
|
||||||
|
|
||||||
|
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
|
||||||
|
- **Canonical direct session:** Direct chats collapse into `main` by default via `inbound.reply.session.mainKey` (configurable). Groups stay `group:<jid>`. This keeps context unified across WhatsApp/WebChat/Telegram while preserving group isolation.
|
||||||
|
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the Tau JSONL path still lives under `~/.clawdis/sessions/<SessionId>.jsonl`.
|
||||||
|
- **WebChat:** Always attaches to `main`, loads the full Tau transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
|
||||||
|
- **Implementation hints:**
|
||||||
|
- Set `Surface` in each ingress (WhatsApp relay, WebChat bridge, future Telegram).
|
||||||
|
- Keep routing deterministic: originate → same surface. Use IPC/web senders accordingly.
|
||||||
|
- Do not let the agent emit “send to X” decisions; keep that policy in the host code.
|
||||||
@@ -6,7 +6,7 @@ The macOS Clawdis app ships a built-in web chat window that reuses your primary
|
|||||||
|
|
||||||
- UI: `pi-mono/packages/web-ui` bundle loaded in a `WKWebView`.
|
- UI: `pi-mono/packages/web-ui` bundle loaded in a `WKWebView`.
|
||||||
- Bridge: `WKScriptMessageHandler` named `clawdis` (see `apps/macos/Sources/Clawdis/WebChatWindow.swift`). The page posts `sessionKey` + message; Swift shells `pnpm clawdis agent --to <sessionKey> --message <text> --json` and returns the first payload text to the page. No sockets are opened.
|
- Bridge: `WKScriptMessageHandler` named `clawdis` (see `apps/macos/Sources/Clawdis/WebChatWindow.swift`). The page posts `sessionKey` + message; Swift shells `pnpm clawdis agent --to <sessionKey> --message <text> --json` and returns the first payload text to the page. No sockets are opened.
|
||||||
- Session selection: picks the most recently updated entry in `~/.clawdis/sessions/sessions.json`; falls back to `+1003` if none exist. This keeps the web chat on the same primary conversation as the relay/CLI.
|
- Session selection: always uses the canonical `main` session key (or `inbound.reply.session.mainKey`), hydrating from the Tau JSONL session file so you see the full history even when messages arrived via WhatsApp/Telegram.
|
||||||
- Assets: the entire `pi-web-ui` dist plus dependencies (pi-ai, mini-lit, lit, lucide, pdfjs-dist, docx-preview, jszip) is bundled into `apps/macos/Sources/Clawdis/Resources/WebChat/` and shipped with the app. No external checkout is required at runtime.
|
- Assets: the entire `pi-web-ui` dist plus dependencies (pi-ai, mini-lit, lit, lucide, pdfjs-dist, docx-preview, jszip) is bundled into `apps/macos/Sources/Clawdis/Resources/WebChat/` and shipped with the app. No external checkout is required at runtime.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { loadConfig, type WarelayConfig } from "../config/config.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGER,
|
DEFAULT_RESET_TRIGGER,
|
||||||
deriveSessionKey,
|
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
@@ -210,6 +210,7 @@ export async function getReplyFromConfig(
|
|||||||
|
|
||||||
// Optional session handling (conversation reuse + /new resets)
|
// Optional session handling (conversation reuse + /new resets)
|
||||||
const sessionCfg = reply?.session;
|
const sessionCfg = reply?.session;
|
||||||
|
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||||
? sessionCfg.resetTriggers
|
? sessionCfg.resetTriggers
|
||||||
: [DEFAULT_RESET_TRIGGER];
|
: [DEFAULT_RESET_TRIGGER];
|
||||||
@@ -261,7 +262,7 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionKey = deriveSessionKey(sessionScope, ctx);
|
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
||||||
sessionStore = loadSessionStore(storePath);
|
sessionStore = loadSessionStore(storePath);
|
||||||
const entry = sessionStore[sessionKey];
|
const entry = sessionStore[sessionKey];
|
||||||
const idleMs = idleMinutes * 60_000;
|
const idleMs = idleMinutes * 60_000;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type MsgContext = {
|
|||||||
GroupMembers?: string;
|
GroupMembers?: string;
|
||||||
SenderName?: string;
|
SenderName?: string;
|
||||||
SenderE164?: string;
|
SenderE164?: string;
|
||||||
|
Surface?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
|
|||||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
deriveSessionKey,
|
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
@@ -67,6 +67,7 @@ function resolveSession(opts: {
|
|||||||
}): SessionResolution {
|
}): SessionResolution {
|
||||||
const sessionCfg = opts.replyCfg?.session;
|
const sessionCfg = opts.replyCfg?.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||||
const idleMinutes = Math.max(
|
const idleMinutes = Math.max(
|
||||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||||
1,
|
1,
|
||||||
@@ -78,7 +79,7 @@ function resolveSession(opts: {
|
|||||||
|
|
||||||
let sessionKey: string | undefined =
|
let sessionKey: string | undefined =
|
||||||
sessionStore && opts.to
|
sessionStore && opts.to
|
||||||
? deriveSessionKey(scope, { From: opts.to } as MsgContext)
|
? resolveSessionKey(scope, { From: opts.to } as MsgContext, mainKey)
|
||||||
: undefined;
|
: undefined;
|
||||||
let sessionEntry =
|
let sessionEntry =
|
||||||
sessionKey && sessionStore ? sessionStore[sessionKey] : undefined;
|
sessionKey && sessionStore ? sessionStore[sessionKey] : undefined;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type SessionConfig = {
|
|||||||
sessionIntro?: string;
|
sessionIntro?: string;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
heartbeatMinutes?: number;
|
heartbeatMinutes?: number;
|
||||||
|
mainKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
@@ -135,6 +136,7 @@ const ReplySchema = z
|
|||||||
sendSystemOnce: z.boolean().optional(),
|
sendSystemOnce: z.boolean().optional(),
|
||||||
sessionIntro: z.string().optional(),
|
sessionIntro: z.string().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
|
mainKey: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { deriveSessionKey } from "./sessions.js";
|
import { deriveSessionKey, resolveSessionKey } from "./sessions.js";
|
||||||
|
|
||||||
describe("sessions", () => {
|
describe("sessions", () => {
|
||||||
it("returns normalized per-sender key", () => {
|
it("returns normalized per-sender key", () => {
|
||||||
@@ -22,4 +22,16 @@ describe("sessions", () => {
|
|||||||
"group:12345-678@g.us",
|
"group:12345-678@g.us",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps direct chats to main key when provided", () => {
|
||||||
|
expect(
|
||||||
|
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
|
||||||
|
).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves groups untouched even with main key", () => {
|
||||||
|
expect(
|
||||||
|
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
|
||||||
|
).toBe("group:12345-678@g.us");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,3 +77,20 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
|||||||
}
|
}
|
||||||
return from || "unknown";
|
return from || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the session key with an optional canonical direct-chat key (e.g., "main").
|
||||||
|
* All non-group direct chats collapse to `mainKey` when provided, keeping group isolation.
|
||||||
|
*/
|
||||||
|
export function resolveSessionKey(
|
||||||
|
scope: SessionScope,
|
||||||
|
ctx: MsgContext,
|
||||||
|
mainKey?: string,
|
||||||
|
) {
|
||||||
|
const raw = deriveSessionKey(scope, ctx);
|
||||||
|
if (scope === "global") return raw;
|
||||||
|
const canonical = (mainKey ?? "").trim();
|
||||||
|
const isGroup = raw.startsWith("group:") || raw.includes("@g.us");
|
||||||
|
if (!isGroup && canonical) return canonical;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { loadConfig } from "./config/config.js";
|
|||||||
import {
|
import {
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "./config/sessions.js";
|
} from "./config/sessions.js";
|
||||||
@@ -52,6 +53,7 @@ export {
|
|||||||
normalizeE164,
|
normalizeE164,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
promptYesNo,
|
promptYesNo,
|
||||||
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
runExec,
|
runExec,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { waitForever } from "../cli/wait.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
deriveSessionKey,
|
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
@@ -213,11 +213,15 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cfg = cfgOverride ?? loadConfig();
|
const cfg = cfgOverride ?? loadConfig();
|
||||||
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
|
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||||
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
store[to] = {
|
store[sessionKey] = {
|
||||||
...(store[to] ?? {}),
|
...(store[sessionKey] ?? {}),
|
||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -432,7 +436,11 @@ function getSessionSnapshot(
|
|||||||
) {
|
) {
|
||||||
const sessionCfg = cfg.inbound?.reply?.session;
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
const key = deriveSessionKey(scope, { From: from, To: "", Body: "" });
|
const key = resolveSessionKey(
|
||||||
|
scope,
|
||||||
|
{ From: from, To: "", Body: "" },
|
||||||
|
sessionCfg?.mainKey ?? "main",
|
||||||
|
);
|
||||||
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
||||||
const entry = store[key];
|
const entry = store[key];
|
||||||
const idleMinutes = Math.max(
|
const idleMinutes = Math.max(
|
||||||
@@ -790,6 +798,7 @@ export async function monitorWebProvider(
|
|||||||
GroupMembers: latest.groupParticipants?.join(", "),
|
GroupMembers: latest.groupParticipants?.join(", "),
|
||||||
SenderName: latest.senderName,
|
SenderName: latest.senderName,
|
||||||
SenderE164: latest.senderE164,
|
SenderE164: latest.senderE164,
|
||||||
|
Surface: "whatsapp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: latest.sendComposing,
|
onReplyStart: latest.sendComposing,
|
||||||
|
|||||||
Reference in New Issue
Block a user