talon-agent 3.33.4 → 3.34.0
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 +2 -1
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- 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/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/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -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/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/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat lifecycle — create, rename and delete, each broadcast so every open
|
|
3
|
+
* client list updates in place.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { toClientChat } from "./chat-wire.js";
|
|
7
|
+
import type { ClientChat } from "./protocol.js";
|
|
8
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
9
|
+
import { clearTurnMeta } from "./turn-meta.js";
|
|
10
|
+
|
|
11
|
+
export function createChat(runtime: NativeRuntime, title?: string): ClientChat {
|
|
12
|
+
const entry = runtime.chats.create(title);
|
|
13
|
+
const chat = toClientChat(runtime, entry);
|
|
14
|
+
runtime.broadcast({ kind: "chat_created", chat });
|
|
15
|
+
return chat;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renameChat(
|
|
19
|
+
runtime: NativeRuntime,
|
|
20
|
+
chatId: string,
|
|
21
|
+
title: string,
|
|
22
|
+
): ClientChat | null {
|
|
23
|
+
const entry = runtime.chats.rename(chatId, title);
|
|
24
|
+
if (!entry) return null;
|
|
25
|
+
const chat = toClientChat(runtime, entry);
|
|
26
|
+
runtime.broadcast({ kind: "chat_updated", chat });
|
|
27
|
+
return chat;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deleteChat(runtime: NativeRuntime, chatId: string): boolean {
|
|
31
|
+
const ok = runtime.chats.remove(chatId);
|
|
32
|
+
if (ok) {
|
|
33
|
+
clearTurnMeta(chatId);
|
|
34
|
+
runtime.contextByChat.delete(chatId);
|
|
35
|
+
runtime.queuedByChat.delete(chatId);
|
|
36
|
+
runtime.broadcast({ kind: "chat_deleted", chatId });
|
|
37
|
+
}
|
|
38
|
+
return ok;
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chat wire projection — a registry entry plus its persisted settings,
|
|
3
|
+
* cached context fill and queued follow-up, as the `ClientChat` clients
|
|
4
|
+
* render. `chat_updated` is always built from here so every client sees the
|
|
5
|
+
* same shape.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getChatSettings,
|
|
10
|
+
getChatModelForBackend,
|
|
11
|
+
} from "../../storage/chat-settings.js";
|
|
12
|
+
import { getBackendIdForChat } from "../../core/engine/backend-controller/index.js";
|
|
13
|
+
import type { ChatEntry } from "./chats.js";
|
|
14
|
+
import type { ClientChat, QueuedMessage } from "./protocol.js";
|
|
15
|
+
import type { NativeRuntime } from "./runtime.js";
|
|
16
|
+
|
|
17
|
+
/** Project the stored queue entry to its wire shape (text + attachment flag). */
|
|
18
|
+
function toQueued(
|
|
19
|
+
runtime: NativeRuntime,
|
|
20
|
+
chatId: string,
|
|
21
|
+
): QueuedMessage | undefined {
|
|
22
|
+
const q = runtime.queuedByChat.get(chatId);
|
|
23
|
+
if (!q) return undefined;
|
|
24
|
+
return { text: q.text, hasAttachment: Boolean(q.attachmentPath) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toClientChat(
|
|
28
|
+
runtime: NativeRuntime,
|
|
29
|
+
entry: ChatEntry,
|
|
30
|
+
): ClientChat {
|
|
31
|
+
const settings = getChatSettings(entry.id);
|
|
32
|
+
let model: string | undefined;
|
|
33
|
+
let backend: string | undefined;
|
|
34
|
+
try {
|
|
35
|
+
// The persisted per-chat setting is the source of truth for what the
|
|
36
|
+
// user picked; the in-memory binding can lag it (boot-time rebind
|
|
37
|
+
// still pending or transiently failed). Reporting the binding here
|
|
38
|
+
// made clients show a chat "reset" to the default backend after a
|
|
39
|
+
// daemon restart even though the user's choice was intact.
|
|
40
|
+
backend = settings.backend ?? getBackendIdForChat(entry.id);
|
|
41
|
+
model = getChatModelForBackend(entry.id, backend);
|
|
42
|
+
} catch {
|
|
43
|
+
backend = settings.backend;
|
|
44
|
+
/* backend pool not ready (early boot) — omit model */
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
id: entry.id,
|
|
48
|
+
title: entry.title,
|
|
49
|
+
createdAt: entry.createdAt,
|
|
50
|
+
lastActive: entry.lastActive,
|
|
51
|
+
preview: entry.preview,
|
|
52
|
+
model,
|
|
53
|
+
backend,
|
|
54
|
+
effort: settings.effort,
|
|
55
|
+
pulse: settings.pulse,
|
|
56
|
+
context: runtime.contextByChat.get(entry.id),
|
|
57
|
+
queued: toQueued(runtime, entry.id),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function broadcastChatUpdated(
|
|
62
|
+
runtime: NativeRuntime,
|
|
63
|
+
entry: ChatEntry,
|
|
64
|
+
): void {
|
|
65
|
+
runtime.broadcast({
|
|
66
|
+
kind: "chat_updated",
|
|
67
|
+
chat: toClientChat(runtime, entry),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|