talon-agent 3.33.4 → 3.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-window readout — compute a chat's fill from its session usage,
|
|
3
|
+
* cache it on the runtime, and push changes to clients.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getSessionInfo } from "../../storage/sessions.js";
|
|
7
|
+
import { buildContextDisplay } from "../shared/status-context.js";
|
|
8
|
+
import { resolveActiveModelForChat } from "../../core/models/active-model.js";
|
|
9
|
+
import {
|
|
10
|
+
getBackendForChat,
|
|
11
|
+
getBackendIdForChat,
|
|
12
|
+
} from "../../core/engine/backend-controller/index.js";
|
|
13
|
+
import { broadcastChatUpdated } from "./chat-wire.js";
|
|
14
|
+
import type { ChatEntry } from "./chats.js";
|
|
15
|
+
import type { ContextInfo } from "./protocol.js";
|
|
16
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
17
|
+
|
|
18
|
+
type ContextOptions = { resolveModel?: boolean };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute the current context-window fill for a chat from its session
|
|
22
|
+
* usage, reusing the same `buildContextDisplay` the terminal/Discord
|
|
23
|
+
* `/status` line uses. When the session's usage doesn't carry a window
|
|
24
|
+
* size yet (fresh session), fall back to the resolved model's window so
|
|
25
|
+
* the readout still lands. Returns undefined when nothing is known — the
|
|
26
|
+
* clients hide the indicator rather than render a bogus 0%.
|
|
27
|
+
*/
|
|
28
|
+
async function computeContext(
|
|
29
|
+
runtime: NativeRuntime,
|
|
30
|
+
chatId: string,
|
|
31
|
+
options?: ContextOptions,
|
|
32
|
+
): Promise<ContextInfo | undefined> {
|
|
33
|
+
try {
|
|
34
|
+
const info = getSessionInfo(chatId);
|
|
35
|
+
const u = info.usage;
|
|
36
|
+
let ctxMax = u.contextWindow;
|
|
37
|
+
// The model fallback touches the backend pool, which is fine for one
|
|
38
|
+
// chat the user just opened and wrong for every chat at boot — the
|
|
39
|
+
// startup warm asks for the cheap path and lets anything it can't
|
|
40
|
+
// resolve fill in when that chat is next opened.
|
|
41
|
+
if (!ctxMax && options?.resolveModel !== false) {
|
|
42
|
+
try {
|
|
43
|
+
const backend = getBackendForChat(chatId);
|
|
44
|
+
const backendId = getBackendIdForChat(chatId);
|
|
45
|
+
const { ref } = await resolveActiveModelForChat(
|
|
46
|
+
chatId,
|
|
47
|
+
backend,
|
|
48
|
+
backendId,
|
|
49
|
+
runtime.config,
|
|
50
|
+
);
|
|
51
|
+
if (ref?.contextWindow) ctxMax = ref.contextWindow;
|
|
52
|
+
} catch {
|
|
53
|
+
/* backend pool not ready — leave max unknown */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const ctx = buildContextDisplay({
|
|
57
|
+
contextTokens: u.contextTokens,
|
|
58
|
+
lastPromptTokens: u.lastPromptTokens,
|
|
59
|
+
contextWindow: ctxMax,
|
|
60
|
+
});
|
|
61
|
+
if (!ctx.known && ctx.max === 0) return undefined;
|
|
62
|
+
return {
|
|
63
|
+
known: ctx.known,
|
|
64
|
+
used: ctx.used,
|
|
65
|
+
max: ctx.max,
|
|
66
|
+
pct: ctx.pct,
|
|
67
|
+
warn: ctx.warn,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Recompute a chat's context fill and, if it changed, push it to clients. */
|
|
75
|
+
export async function refreshContext(
|
|
76
|
+
runtime: NativeRuntime,
|
|
77
|
+
entry: ChatEntry,
|
|
78
|
+
options?: ContextOptions,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const next = await computeContext(runtime, entry.id, options);
|
|
81
|
+
if (!next) return;
|
|
82
|
+
const prev = runtime.contextByChat.get(entry.id);
|
|
83
|
+
if (prev && prev.used === next.used && prev.max === next.max) return;
|
|
84
|
+
runtime.contextByChat.set(entry.id, next);
|
|
85
|
+
broadcastChatUpdated(runtime, entry);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fill the context cache for chats restored at startup.
|
|
90
|
+
*
|
|
91
|
+
* The readout is served from an in-memory map that was only ever written at
|
|
92
|
+
* turn end, so after a restart every existing chat reported no context at
|
|
93
|
+
* all — the header chip vanished until that chat ran another turn, which
|
|
94
|
+
* read as "context usage doesn't save". The numbers themselves are
|
|
95
|
+
* persisted with the session; only the cache was cold. This re-reads them
|
|
96
|
+
* for the most recently active chats (bounded, and on the cheap path that
|
|
97
|
+
* never touches the backend pool); anything it skips or can't resolve is
|
|
98
|
+
* filled in the moment the chat is opened.
|
|
99
|
+
*/
|
|
100
|
+
const CONTEXT_WARM_LIMIT = 40;
|
|
101
|
+
|
|
102
|
+
export async function warmContextCache(runtime: NativeRuntime): Promise<void> {
|
|
103
|
+
for (const entry of runtime.chats.list().slice(0, CONTEXT_WARM_LIMIT)) {
|
|
104
|
+
await refreshContext(runtime, entry, { resolveModel: false });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon control — the restart / dream actions the app fires from Settings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { isBunRuntime } from "../../util/runtime.js";
|
|
8
|
+
import { logError } from "../../util/log.js";
|
|
9
|
+
import { PKG_ROOT } from "../../cli/context.js";
|
|
10
|
+
import { forceDream } from "../../core/background/dream.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Restart the daemon by spawning a detached `talon restart` — the same
|
|
14
|
+
* command a human runs. It must be an independent, detached process: the
|
|
15
|
+
* restart stops *this* process, so doing it in-process would kill us before
|
|
16
|
+
* the successor is spawned. The child outlives us, tears the daemon down,
|
|
17
|
+
* and brings a fresh one up. Mirrors `core/daemon/control.ts`'s own
|
|
18
|
+
* source-vs-compiled-binary spawn recipe.
|
|
19
|
+
*/
|
|
20
|
+
function spawnDaemonRestart(): void {
|
|
21
|
+
const isBunBinary =
|
|
22
|
+
(process.argv[1] ?? "").includes("~BUN") ||
|
|
23
|
+
(process.argv[1] ?? "").includes("$bunfs");
|
|
24
|
+
const cmd = process.execPath;
|
|
25
|
+
const args = isBunBinary
|
|
26
|
+
? ["restart"]
|
|
27
|
+
: isBunRuntime()
|
|
28
|
+
? [resolve(PKG_ROOT, "src", "cli.ts"), "restart"]
|
|
29
|
+
: [
|
|
30
|
+
resolve(PKG_ROOT, "node_modules", "tsx", "dist", "cli.mjs"),
|
|
31
|
+
resolve(PKG_ROOT, "src", "cli.ts"),
|
|
32
|
+
"restart",
|
|
33
|
+
];
|
|
34
|
+
const cwd = isBunBinary ? dirname(process.execPath) : PKG_ROOT;
|
|
35
|
+
const child = spawn(cmd, args, {
|
|
36
|
+
cwd,
|
|
37
|
+
detached: true,
|
|
38
|
+
stdio: "ignore",
|
|
39
|
+
env: { ...process.env },
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Daemon-level control actions the app fires from Settings. Kept minimal and
|
|
47
|
+
* explicit — each maps to a well-understood operation the CLI already
|
|
48
|
+
* exposes, so there's no new privileged surface beyond "what a local admin
|
|
49
|
+
* could already do".
|
|
50
|
+
*/
|
|
51
|
+
export async function control(
|
|
52
|
+
action: string,
|
|
53
|
+
): Promise<{ ok: boolean; message: string }> {
|
|
54
|
+
switch (action) {
|
|
55
|
+
case "restart":
|
|
56
|
+
spawnDaemonRestart();
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
message: "Restarting Talon — back online in a few seconds.",
|
|
60
|
+
};
|
|
61
|
+
case "dream":
|
|
62
|
+
// Fire-and-forget: a dream run can take a while; the app just needs
|
|
63
|
+
// to know it started. forceDream throws if one is already running.
|
|
64
|
+
try {
|
|
65
|
+
void forceDream().catch((err) =>
|
|
66
|
+
logError("native", "Manual dream run failed", err),
|
|
67
|
+
);
|
|
68
|
+
return { ok: true, message: "Dream started — consolidating memory." };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
message: err instanceof Error ? err.message : String(err),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
return { ok: false, message: `Unknown control action: ${action}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound message helpers — persist a message to history (where it should
|
|
3
|
+
* persist), touch the chat, and broadcast it to every client.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { pushMessage } from "../../storage/history.js";
|
|
7
|
+
import { extractSessionName } from "../../util/session-name.js";
|
|
8
|
+
import { broadcastChatUpdated } from "./chat-wire.js";
|
|
9
|
+
import { DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
|
|
10
|
+
import { mediaUrl, registerMedia } from "./media.js";
|
|
11
|
+
import {
|
|
12
|
+
BOT_SENDER_ID,
|
|
13
|
+
USER_SENDER_ID,
|
|
14
|
+
type ClientButton,
|
|
15
|
+
type ClientMessage,
|
|
16
|
+
} from "./protocol.js";
|
|
17
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Name a chat from its first user message — instantly and for free.
|
|
21
|
+
*
|
|
22
|
+
* The title is a trimmed slice of the first message (no model call), so it
|
|
23
|
+
* lands the moment the user hits send instead of staying "New chat" until
|
|
24
|
+
* the turn finishes and a restart re-hydrates the persisted name. Only fires
|
|
25
|
+
* while the chat still carries the placeholder title, so a user's manual
|
|
26
|
+
* rename is never clobbered. Callers broadcast `chat_updated` afterwards, so
|
|
27
|
+
* the change propagates to every connected client live.
|
|
28
|
+
*/
|
|
29
|
+
function maybeAutoTitle(
|
|
30
|
+
runtime: NativeRuntime,
|
|
31
|
+
entry: ChatEntry,
|
|
32
|
+
text: string,
|
|
33
|
+
): void {
|
|
34
|
+
if (entry.title && entry.title !== DEFAULT_CHAT_TITLE) return;
|
|
35
|
+
const title = extractSessionName(text);
|
|
36
|
+
if (title) runtime.chats.rename(entry.id, title);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function emitAssistant(
|
|
40
|
+
runtime: NativeRuntime,
|
|
41
|
+
entry: ChatEntry,
|
|
42
|
+
text: string,
|
|
43
|
+
buttons?: ClientButton[][],
|
|
44
|
+
): number {
|
|
45
|
+
const id = runtime.nextId();
|
|
46
|
+
const ts = Date.now();
|
|
47
|
+
const message: ClientMessage = {
|
|
48
|
+
id: String(id),
|
|
49
|
+
chatId: entry.id,
|
|
50
|
+
role: "assistant",
|
|
51
|
+
text,
|
|
52
|
+
ts,
|
|
53
|
+
...(buttons ? { buttons } : {}),
|
|
54
|
+
};
|
|
55
|
+
pushMessage(entry.id, {
|
|
56
|
+
msgId: id,
|
|
57
|
+
senderId: BOT_SENDER_ID,
|
|
58
|
+
senderName: runtime.botName,
|
|
59
|
+
text,
|
|
60
|
+
timestamp: ts,
|
|
61
|
+
});
|
|
62
|
+
runtime.chats.touch(entry.id, text);
|
|
63
|
+
runtime.lastAssistantId.set(entry.id, String(id));
|
|
64
|
+
runtime.broadcast({ kind: "message", chatId: entry.id, message });
|
|
65
|
+
broadcastChatUpdated(runtime, entry);
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Persist + broadcast an assistant photo message (image + optional caption). */
|
|
70
|
+
export function emitPhoto(
|
|
71
|
+
runtime: NativeRuntime,
|
|
72
|
+
entry: ChatEntry,
|
|
73
|
+
filePath: string,
|
|
74
|
+
caption?: string,
|
|
75
|
+
): number {
|
|
76
|
+
const id = runtime.nextId();
|
|
77
|
+
const ts = Date.now();
|
|
78
|
+
const text = caption?.trim() ?? "";
|
|
79
|
+
const mediaId = registerMedia(runtime, filePath);
|
|
80
|
+
const message: ClientMessage = {
|
|
81
|
+
id: String(id),
|
|
82
|
+
chatId: entry.id,
|
|
83
|
+
role: "assistant",
|
|
84
|
+
text,
|
|
85
|
+
ts,
|
|
86
|
+
imagePath: mediaUrl(mediaId),
|
|
87
|
+
};
|
|
88
|
+
// Persist the image reference so it re-renders when history reloads (the
|
|
89
|
+
// filePath is re-registered into the media map on read). The caption is
|
|
90
|
+
// stored as plain text; the `photo` mediaType carries the image marker.
|
|
91
|
+
pushMessage(entry.id, {
|
|
92
|
+
msgId: id,
|
|
93
|
+
senderId: BOT_SENDER_ID,
|
|
94
|
+
senderName: runtime.botName,
|
|
95
|
+
text,
|
|
96
|
+
timestamp: ts,
|
|
97
|
+
mediaType: "photo",
|
|
98
|
+
filePath,
|
|
99
|
+
});
|
|
100
|
+
runtime.chats.touch(entry.id, text || "[photo]");
|
|
101
|
+
runtime.lastAssistantId.set(entry.id, String(id));
|
|
102
|
+
runtime.broadcast({ kind: "message", chatId: entry.id, message });
|
|
103
|
+
broadcastChatUpdated(runtime, entry);
|
|
104
|
+
return id;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Persist + broadcast a user message; returns its numeric id so the turn
|
|
108
|
+
* hands the model that same id (as `[msg_id:N]`) to react/reply to.
|
|
109
|
+
* An optional imagePath renders an attached image inline. */
|
|
110
|
+
export function emitUser(
|
|
111
|
+
runtime: NativeRuntime,
|
|
112
|
+
entry: ChatEntry,
|
|
113
|
+
text: string,
|
|
114
|
+
imagePath?: string,
|
|
115
|
+
attachmentPath?: string,
|
|
116
|
+
): number {
|
|
117
|
+
const id = runtime.nextId();
|
|
118
|
+
const ts = Date.now();
|
|
119
|
+
const message: ClientMessage = {
|
|
120
|
+
id: String(id),
|
|
121
|
+
chatId: entry.id,
|
|
122
|
+
role: "user",
|
|
123
|
+
text,
|
|
124
|
+
ts,
|
|
125
|
+
...(imagePath ? { imagePath } : {}),
|
|
126
|
+
};
|
|
127
|
+
// For an attached image, persist the on-disk path + a `photo` mediaType so
|
|
128
|
+
// it re-renders on history reload (rehydrated in the `history` handler)
|
|
129
|
+
// instead of vanishing to a text-only placeholder. Caption stays as text.
|
|
130
|
+
pushMessage(entry.id, {
|
|
131
|
+
msgId: id,
|
|
132
|
+
senderId: USER_SENDER_ID,
|
|
133
|
+
senderName: "User",
|
|
134
|
+
text,
|
|
135
|
+
timestamp: ts,
|
|
136
|
+
...(imagePath
|
|
137
|
+
? {
|
|
138
|
+
mediaType: "photo" as const,
|
|
139
|
+
...(attachmentPath ? { filePath: attachmentPath } : {}),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
});
|
|
143
|
+
runtime.chats.touch(entry.id, imagePath ? text || "[photo]" : text);
|
|
144
|
+
maybeAutoTitle(runtime, entry, text);
|
|
145
|
+
runtime.broadcast({ kind: "message", chatId: entry.id, message });
|
|
146
|
+
broadcastChatUpdated(runtime, entry);
|
|
147
|
+
return id;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Transient, non-persisted notice (e.g. "session reset"). */
|
|
151
|
+
export function emitSystem(
|
|
152
|
+
runtime: NativeRuntime,
|
|
153
|
+
entry: ChatEntry,
|
|
154
|
+
text: string,
|
|
155
|
+
): void {
|
|
156
|
+
runtime.broadcast({
|
|
157
|
+
kind: "message",
|
|
158
|
+
chatId: entry.id,
|
|
159
|
+
message: {
|
|
160
|
+
id: `sys-${runtime.nextId()}`,
|
|
161
|
+
chatId: entry.id,
|
|
162
|
+
role: "system",
|
|
163
|
+
text,
|
|
164
|
+
ts: Date.now(),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty-chat sweeper.
|
|
3
|
+
*
|
|
4
|
+
* "New chat" creates a real chat on the daemon the instant it's tapped, so
|
|
5
|
+
* opening one and backing out of it leaves an empty row in every client's
|
|
6
|
+
* list forever. The app deletes an untouched chat as soon as you leave it,
|
|
7
|
+
* which covers the normal case immediately; this is the backstop for the
|
|
8
|
+
* ones no client got to clean up — the app was killed, the network dropped,
|
|
9
|
+
* or the chat was created by something that never came back.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately conservative: only chats that never carried a single message
|
|
12
|
+
* (see ChatEntry.used, which a reset does NOT clear), only after an hour,
|
|
13
|
+
* never one with a turn running or a message queued, and never one whose
|
|
14
|
+
* history says otherwise. Deleting via the same path every client uses
|
|
15
|
+
* means the removal is broadcast, so open lists update in place.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { log, logError } from "../../util/log.js";
|
|
19
|
+
import { getRecentHistory } from "../../storage/history.js";
|
|
20
|
+
import { deleteChat } from "./chat-lifecycle.js";
|
|
21
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
22
|
+
import { isBusy } from "./turn.js";
|
|
23
|
+
|
|
24
|
+
const EMPTY_CHAT_MIN_AGE_MS = 60 * 60_000;
|
|
25
|
+
const EMPTY_CHAT_SWEEP_INTERVAL_MS = 30 * 60_000;
|
|
26
|
+
|
|
27
|
+
function sweepEmptyChats(runtime: NativeRuntime): void {
|
|
28
|
+
for (const entry of runtime.chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
|
|
29
|
+
if (isBusy(runtime, entry.id) || runtime.queuedByChat.has(entry.id))
|
|
30
|
+
continue;
|
|
31
|
+
if (getRecentHistory(entry.id, 1).length > 0) continue;
|
|
32
|
+
if (deleteChat(runtime, entry.id)) {
|
|
33
|
+
log("native", `Swept empty chat ${entry.id}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Run the sweep on its interval; returns the function that stops it. */
|
|
39
|
+
export function startEmptyChatSweep(runtime: NativeRuntime): () => void {
|
|
40
|
+
const timer = setInterval(() => {
|
|
41
|
+
try {
|
|
42
|
+
sweepEmptyChats(runtime);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logError("native", "Empty-chat sweep failed", err);
|
|
45
|
+
}
|
|
46
|
+
}, EMPTY_CHAT_SWEEP_INTERVAL_MS);
|
|
47
|
+
// Housekeeping must never be the reason the process stays alive.
|
|
48
|
+
timer.unref?.();
|
|
49
|
+
return () => clearInterval(timer);
|
|
50
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge server handlers — the table that binds the transport's routes
|
|
3
|
+
* (server.ts) to the runtime modules. Each entry is a one-line delegation;
|
|
4
|
+
* anything with logic of its own lives in the module named for it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { files } from "../../util/paths.js";
|
|
8
|
+
import { setChatPulse } from "../../storage/chat-settings.js";
|
|
9
|
+
import { getPooledBackend } from "../../core/engine/backend-controller/index.js";
|
|
10
|
+
import { createChat, deleteChat, renameChat } from "./chat-lifecycle.js";
|
|
11
|
+
import { broadcastChatUpdated, toClientChat } from "./chat-wire.js";
|
|
12
|
+
import { control } from "./control.js";
|
|
13
|
+
import {
|
|
14
|
+
pluginItems,
|
|
15
|
+
skillItems,
|
|
16
|
+
togglePlugin,
|
|
17
|
+
toggleSkill,
|
|
18
|
+
} from "./extensions.js";
|
|
19
|
+
import { historyPage, searchHistory } from "./history.js";
|
|
20
|
+
import { readLogEntries } from "./logs.js";
|
|
21
|
+
import { mediaUrl, registerMedia, saveUpload } from "./media.js";
|
|
22
|
+
import {
|
|
23
|
+
effortLevels,
|
|
24
|
+
listBackends,
|
|
25
|
+
listModels,
|
|
26
|
+
setBackend,
|
|
27
|
+
setEffort,
|
|
28
|
+
setModel,
|
|
29
|
+
} from "./models.js";
|
|
30
|
+
import { setQueued } from "./queue.js";
|
|
31
|
+
import { resetChat } from "./reset.js";
|
|
32
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
33
|
+
import type { BridgeServerHandlers } from "./server.js";
|
|
34
|
+
import { configSnapshot, applyConfigUpdate } from "./settings.js";
|
|
35
|
+
import { bridgeStatus, broadcastStatus } from "./status.js";
|
|
36
|
+
import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
|
|
37
|
+
|
|
38
|
+
export function buildBridgeHandlers(
|
|
39
|
+
runtime: NativeRuntime,
|
|
40
|
+
): BridgeServerHandlers {
|
|
41
|
+
const { chats, config, mesh } = runtime;
|
|
42
|
+
return {
|
|
43
|
+
status: () => bridgeStatus(runtime),
|
|
44
|
+
listChats: () => chats.list().map((entry) => toClientChat(runtime, entry)),
|
|
45
|
+
createChat: (title) => createChat(runtime, title),
|
|
46
|
+
renameChat: (id, title) => renameChat(runtime, id, title),
|
|
47
|
+
deleteChat: (id) => deleteChat(runtime, id),
|
|
48
|
+
history: (id, opts) => historyPage(runtime, id, opts),
|
|
49
|
+
search: (query, chatId) => searchHistory(runtime, query, chatId),
|
|
50
|
+
send: (id, text, opts) => {
|
|
51
|
+
const entry = chats.get(id) ?? chats.ensure(id);
|
|
52
|
+
// A turn is already running for this chat — don't interrupt it. Park the
|
|
53
|
+
// message as the single queued follow-up (synced to every client); it
|
|
54
|
+
// auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
|
|
55
|
+
// which `runTurn` sets synchronously, so even a rapid second /send from
|
|
56
|
+
// any client is caught here rather than starting a concurrent turn.
|
|
57
|
+
if (isBusy(runtime, entry.id)) {
|
|
58
|
+
setQueued(runtime, entry.id, {
|
|
59
|
+
text,
|
|
60
|
+
imagePath: opts?.imagePath,
|
|
61
|
+
attachmentPath: opts?.attachmentPath,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
startTurn(runtime, entry, text, opts);
|
|
66
|
+
},
|
|
67
|
+
queueMessage: (id, text) => {
|
|
68
|
+
// Edit/replace the queued follow-up (text-only). Empty clears it.
|
|
69
|
+
const entry = chats.get(id);
|
|
70
|
+
if (entry) setQueued(runtime, entry.id, { text });
|
|
71
|
+
},
|
|
72
|
+
upload: async (filename, _contentType, bytes) => {
|
|
73
|
+
const path = await saveUpload(runtime, filename, bytes);
|
|
74
|
+
return { imagePath: mediaUrl(registerMedia(runtime, path)), path };
|
|
75
|
+
},
|
|
76
|
+
listModels: (chatId) => listModels(runtime, chatId),
|
|
77
|
+
setModel: (id, model) => setModel(runtime, id, model),
|
|
78
|
+
listBackends: (id) => listBackends(runtime, id),
|
|
79
|
+
setBackend: (id, backend) => setBackend(runtime, id, backend),
|
|
80
|
+
setEffort: (id, effort) => setEffort(runtime, id, effort),
|
|
81
|
+
effortLevels: (id) => effortLevels(runtime, id),
|
|
82
|
+
interruptTurn: (id) => interruptTurn(runtime, id),
|
|
83
|
+
resetChat: (id) => resetChat(runtime, id),
|
|
84
|
+
setPulse: (id, on) => {
|
|
85
|
+
const entry = chats.get(id);
|
|
86
|
+
if (!entry) return;
|
|
87
|
+
setChatPulse(id, on);
|
|
88
|
+
broadcastChatUpdated(runtime, entry);
|
|
89
|
+
},
|
|
90
|
+
getConfig: () => configSnapshot(config),
|
|
91
|
+
setConfig: (update) => {
|
|
92
|
+
const snap = applyConfigUpdate(config, update);
|
|
93
|
+
broadcastStatus(runtime);
|
|
94
|
+
return snap;
|
|
95
|
+
},
|
|
96
|
+
listPlugins: () => pluginItems(config),
|
|
97
|
+
setPluginEnabled: (name, enabled) =>
|
|
98
|
+
togglePlugin(config, getPooledBackend(config.backend), name, enabled),
|
|
99
|
+
listSkills: () => skillItems(),
|
|
100
|
+
setSkillEnabled: (name, enabled) =>
|
|
101
|
+
toggleSkill(config, getPooledBackend(config.backend), name, enabled),
|
|
102
|
+
control,
|
|
103
|
+
logs: ({ lines, minLevel, component }) =>
|
|
104
|
+
readLogEntries(files.log, { limit: lines, minLevel, component }),
|
|
105
|
+
liveTurnEvents: () => liveTurnEvents(runtime),
|
|
106
|
+
mediaPath: (id) => runtime.media.get(id) ?? null,
|
|
107
|
+
// Mesh routes are thin transport shims over the shared core service —
|
|
108
|
+
// storeLocation wakes any pending fresh-fix waiters inside the service.
|
|
109
|
+
registerDevice: (body) => mesh.register(body),
|
|
110
|
+
storeLocation: (body) => mesh.storeLocation(body),
|
|
111
|
+
listDevices: () => mesh.list(),
|
|
112
|
+
completeCommand: (body) => mesh.completeCommand(body),
|
|
113
|
+
acceptFileUpload: (token, body, fromDeviceId) =>
|
|
114
|
+
mesh.acceptFileUpload(token, body, fromDeviceId),
|
|
115
|
+
openFileDownload: (token, fromDeviceId) =>
|
|
116
|
+
mesh.openFileDownload(token, fromDeviceId),
|
|
117
|
+
openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
|
|
118
|
+
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
119
|
+
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History pages + search — persisted rows re-hydrated into the wire shape
|
|
3
|
+
* (attached images get a fresh /media URL, assistant rows their turn meta).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getRecentHistory,
|
|
8
|
+
getHistoryBefore,
|
|
9
|
+
searchHistoryMessages,
|
|
10
|
+
type HistoryMessage,
|
|
11
|
+
} from "../../storage/history.js";
|
|
12
|
+
import { isDeliveryTool } from "../../core/tools/index.js";
|
|
13
|
+
import type { ChatEntry } from "./chats.js";
|
|
14
|
+
import { refreshContext } from "./context.js";
|
|
15
|
+
import { mediaUrl, registerMedia } from "./media.js";
|
|
16
|
+
import {
|
|
17
|
+
historyToClientMessage,
|
|
18
|
+
type ClientMessage,
|
|
19
|
+
type SearchResult,
|
|
20
|
+
} from "./protocol.js";
|
|
21
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
22
|
+
import { getTurnMeta } from "./turn-meta.js";
|
|
23
|
+
|
|
24
|
+
export type HistoryPageOptions = { before?: number; limit?: number };
|
|
25
|
+
|
|
26
|
+
function hydrateHistoryRow(
|
|
27
|
+
runtime: NativeRuntime,
|
|
28
|
+
chatId: string,
|
|
29
|
+
row: HistoryMessage,
|
|
30
|
+
): ClientMessage {
|
|
31
|
+
const msg = historyToClientMessage(row, chatId);
|
|
32
|
+
// Re-hydrate an attached image: re-register its on-disk path into
|
|
33
|
+
// the media map and hand back a fresh /media URL so the image shows
|
|
34
|
+
// in reloaded history (survives restarts as long as the file exists).
|
|
35
|
+
if (row.mediaType === "photo" && row.filePath) {
|
|
36
|
+
msg.imagePath = mediaUrl(registerMedia(runtime, row.filePath));
|
|
37
|
+
}
|
|
38
|
+
// Re-hydrate turn meta (tool timeline + stats) for assistant rows.
|
|
39
|
+
if (msg.role === "assistant") {
|
|
40
|
+
const meta = getTurnMeta(chatId, msg.id);
|
|
41
|
+
if (meta) {
|
|
42
|
+
// Delivery tools are excluded at record time, but metas
|
|
43
|
+
// persisted by older daemons still carry them — filter on
|
|
44
|
+
// the way out so upgraded installs get clean history too.
|
|
45
|
+
const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
|
|
46
|
+
if (tools?.length) msg.tools = tools;
|
|
47
|
+
if (meta.durationMs) msg.durationMs = meta.durationMs;
|
|
48
|
+
if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
|
|
49
|
+
if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return msg;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A page of history: newest window, or the window before `before`. */
|
|
56
|
+
export function historyPage(
|
|
57
|
+
runtime: NativeRuntime,
|
|
58
|
+
chatId: string,
|
|
59
|
+
options?: HistoryPageOptions,
|
|
60
|
+
): ClientMessage[] {
|
|
61
|
+
// Opening a chat is the one moment we know a client wants its numbers:
|
|
62
|
+
// refresh the context readout on the full path (model fallback and
|
|
63
|
+
// all), which covers every chat the startup warm skipped or couldn't
|
|
64
|
+
// resolve. Fire-and-forget — the figure arrives as a chat_updated.
|
|
65
|
+
const entry = runtime.chats.get(chatId);
|
|
66
|
+
if (entry && options?.before === undefined) {
|
|
67
|
+
void refreshContext(runtime, entry).catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
const limit = Math.min(Math.max(options?.limit ?? 200, 1), 500);
|
|
70
|
+
const rows =
|
|
71
|
+
options?.before !== undefined
|
|
72
|
+
? getHistoryBefore(chatId, options.before, limit)
|
|
73
|
+
: getRecentHistory(chatId, limit);
|
|
74
|
+
return rows
|
|
75
|
+
.map((m) => hydrateHistoryRow(runtime, chatId, m))
|
|
76
|
+
.sort((a, b) => Number(a.id) - Number(b.id));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Full-text search across chats (or one chat when `chatId` is given). */
|
|
80
|
+
export function searchHistory(
|
|
81
|
+
runtime: NativeRuntime,
|
|
82
|
+
query: string,
|
|
83
|
+
chatId?: string,
|
|
84
|
+
): SearchResult[] {
|
|
85
|
+
const targets = chatId
|
|
86
|
+
? [runtime.chats.get(chatId)].filter((e): e is ChatEntry => e != null)
|
|
87
|
+
: runtime.chats.list();
|
|
88
|
+
const results: SearchResult[] = [];
|
|
89
|
+
for (const entry of targets) {
|
|
90
|
+
for (const m of searchHistoryMessages(entry.id, query, 10)) {
|
|
91
|
+
results.push({
|
|
92
|
+
chatId: entry.id,
|
|
93
|
+
chatTitle: entry.title,
|
|
94
|
+
message: historyToClientMessage(m, entry.id),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Newest hits first, bounded across all chats.
|
|
99
|
+
results.sort((a, b) => b.message.ts - a.message.ts);
|
|
100
|
+
return results.slice(0, 50);
|
|
101
|
+
}
|