talon-agent 4.6.1 → 5.0.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/README.md +3 -3
- package/package.json +2 -1
- package/prompts/README.md +2 -2
- package/prompts/system/memory-recall.md +16 -0
- package/src/app.ts +19 -4
- package/src/backend/claude-sdk/handler.ts +28 -7
- package/src/backend/claude-sdk/one-shot.ts +1 -1
- package/src/backend/claude-sdk/options.ts +7 -6
- package/src/backend/claude-sdk/stream.ts +30 -3
- package/src/backend/claude-sdk/warm.ts +1 -1
- package/src/backend/codex/constants.ts +1 -1
- package/src/backend/codex/factory.ts +2 -2
- package/src/backend/codex/handler/events.ts +1 -1
- package/src/backend/codex/handler/message.ts +26 -12
- package/src/backend/codex/handler/rollout-accounting.ts +1 -1
- package/src/backend/codex/init.ts +1 -1
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/kilo/handler/message.ts +4 -1
- package/src/backend/openai-agents/constants.ts +1 -1
- package/src/backend/openai-agents/factory.ts +2 -2
- package/src/backend/openai-agents/handler/events.ts +1 -1
- package/src/backend/openai-agents/handler/message.ts +8 -4
- package/src/backend/openai-agents/init.ts +1 -1
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/opencode/handler/message.ts +4 -1
- package/src/backend/remote-server/chat-turn.ts +31 -23
- package/src/backend/remote-server/events.ts +3 -3
- package/src/backend/remote-server/factory.ts +6 -3
- package/src/backend/remote-server/index.ts +1 -1
- package/src/backend/remote-server/mcp.ts +1 -1
- package/src/backend/remote-server/one-shot.ts +1 -1
- package/src/backend/remote-server/server-bindings.ts +1 -1
- package/src/backend/remote-server/turn.ts +1 -1
- package/src/backend/runtime/cache/cache-metrics.ts +126 -0
- package/src/backend/{shared → runtime/cache}/cache-telemetry.ts +22 -2
- package/src/backend/{shared → runtime}/index.ts +32 -23
- package/src/backend/{shared → runtime/prompt}/delivery-contract.ts +1 -1
- package/src/backend/{shared → runtime/prompt}/prompt-format.ts +46 -2
- package/src/backend/{shared → runtime/prompt}/system-prompt.ts +4 -4
- package/src/backend/{shared → runtime/turn}/delivered-text.ts +1 -1
- package/src/backend/{shared → runtime/turn}/delivery.ts +2 -2
- package/src/backend/{shared → runtime/turn}/handle-retry.ts +5 -5
- package/src/backend/{shared → runtime/turn}/handler-to-events.ts +24 -10
- package/src/backend/{shared → runtime/turn}/handler-types.ts +7 -1
- package/src/backend/{shared → runtime/turn}/model-retry.ts +2 -2
- package/src/backend/{shared → runtime/turn}/result-events.ts +2 -2
- package/src/backend/{shared → runtime/turn}/stream-state.ts +2 -2
- package/src/backend/{shared → runtime/turn}/turn-interrupt.ts +2 -2
- package/src/backend/{shared → runtime/turn}/turn-phases.ts +6 -6
- package/src/bootstrap.ts +4 -19
- package/src/cli.ts +1 -1
- package/src/core/agent-runtime/capabilities.ts +11 -0
- package/src/core/agent-runtime/contract-tests.ts +92 -1
- package/src/core/agent-runtime/events.ts +1 -1
- package/src/core/background/{isolated-agent.ts → cron/isolated-agent.ts} +3 -3
- package/src/core/background/{job-health.ts → cron/job-health.ts} +1 -1
- package/src/core/background/{job-oneshot.ts → cron/job-oneshot.ts} +5 -5
- package/src/core/background/{job-prompt.ts → cron/job-prompt.ts} +1 -1
- package/src/core/background/{cron.ts → cron/scheduler.ts} +6 -6
- package/src/core/background/{cron-spec.ts → cron/spec.ts} +1 -1
- package/src/core/background/{dream.ts → dream/index.ts} +10 -15
- package/src/core/background/{plan-alerts.ts → pulse/plan-alerts.ts} +3 -3
- package/src/core/background/{pulse.ts → pulse/pulse.ts} +8 -5
- package/src/core/background/triggers/command.ts +1 -1
- package/src/core/config/index.ts +15 -11
- package/src/core/daemon/resource-sampler.ts +121 -0
- package/src/core/engine/dispatcher.ts +16 -0
- package/src/core/engine/gateway-actions/cron.ts +2 -2
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/memory.ts +335 -0
- package/src/core/engine/gateway.ts +0 -6
- package/src/core/memory/import.ts +3 -2
- package/src/core/memory/taps.ts +199 -0
- package/src/core/memory/turn-retrieval.ts +222 -0
- package/src/core/prompt/assemble.ts +2 -12
- package/src/core/prompt/index.ts +2 -2
- package/src/core/prompt/invalidation.ts +1 -1
- package/src/core/tasks/index.ts +1 -1
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/memory.ts +128 -0
- package/src/core/tools/types.ts +1 -0
- package/src/core/weaver/turn-cpu.ts +32 -0
- package/src/core/weaver/weaver.ts +36 -0
- package/src/frontend/discord/admin.ts +1 -1
- package/src/frontend/discord/callbacks/components/backend-select.ts +1 -1
- package/src/frontend/discord/callbacks/components/pulse.ts +1 -1
- package/src/frontend/discord/callbacks/components/settings.ts +1 -1
- package/src/frontend/discord/callbacks/modals.ts +4 -1
- package/src/frontend/discord/commands/admin.ts +1 -35
- package/src/frontend/discord/commands/definitions.ts +0 -11
- package/src/frontend/discord/commands/router.ts +0 -3
- package/src/frontend/discord/commands/session.ts +6 -0
- package/src/frontend/discord/commands/settings.ts +1 -1
- package/src/frontend/discord/middleware.ts +1 -25
- package/src/frontend/discord/runtime.ts +1 -2
- package/src/frontend/native/{auth.ts → bridge/auth.ts} +2 -2
- package/src/frontend/native/{discovery.ts → bridge/discovery.ts} +3 -3
- package/src/frontend/native/{routes → bridge/routes}/chats.ts +1 -1
- package/src/frontend/native/{routes → bridge/routes}/daemon.ts +1 -1
- package/src/frontend/native/{routes → bridge/routes}/host.ts +6 -3
- package/src/frontend/native/{routes → bridge/routes}/pre-auth.ts +1 -1
- package/src/frontend/native/{server.ts → bridge/server.ts} +3 -3
- package/src/frontend/native/{tls.ts → bridge/tls.ts} +2 -2
- package/src/frontend/native/{chat-lifecycle.ts → chats/chat-lifecycle.ts} +3 -3
- package/src/frontend/native/{chat-wire.ts → chats/chat-wire.ts} +4 -4
- package/src/frontend/native/{chats.ts → chats/chats.ts} +4 -4
- package/src/frontend/native/{empty-chat-sweep.ts → chats/empty-chat-sweep.ts} +4 -4
- package/src/frontend/native/{history.ts → chats/history.ts} +7 -7
- package/src/frontend/native/{reset.ts → chats/reset.ts} +7 -7
- package/src/frontend/native/index.ts +13 -10
- package/src/frontend/native/{media.ts → media/media.ts} +3 -3
- package/src/frontend/native/runtime.ts +1 -1
- package/src/frontend/native/{control.ts → surface/control.ts} +4 -4
- package/src/frontend/native/{extensions.ts → surface/extensions.ts} +12 -9
- package/src/frontend/native/{handlers.ts → surface/handlers.ts} +19 -14
- package/src/frontend/native/{logs.ts → surface/logs.ts} +2 -2
- package/src/frontend/native/{memory.ts → surface/memory.ts} +2 -2
- package/src/frontend/native/{models.ts → surface/models.ts} +17 -10
- package/src/frontend/native/{settings.ts → surface/settings.ts} +9 -9
- package/src/frontend/native/{status.ts → surface/status.ts} +3 -3
- package/src/frontend/native/{actions.ts → turn/actions.ts} +7 -4
- package/src/frontend/native/{context.ts → turn/context.ts} +8 -8
- package/src/frontend/native/{emit.ts → turn/emit.ts} +7 -7
- package/src/frontend/native/{queue.ts → turn/queue.ts} +3 -3
- package/src/frontend/native/{turn-meta.ts → turn/turn-meta.ts} +2 -2
- package/src/frontend/native/{turn.ts → turn/turn.ts} +9 -9
- package/src/frontend/shared/model-commands.ts +1 -1
- package/src/frontend/shared/session-status.ts +35 -8
- package/src/frontend/shared/status-context.ts +101 -2
- package/src/frontend/telegram/admin/background.ts +1 -1
- package/src/frontend/telegram/callbacks/model/backend.ts +1 -1
- package/src/frontend/telegram/callbacks/pulse.ts +1 -1
- package/src/frontend/telegram/callbacks/settings.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +2 -37
- package/src/frontend/telegram/commands/index.ts +1 -1
- package/src/frontend/telegram/commands/session.ts +6 -0
- package/src/frontend/telegram/commands/settings.ts +1 -1
- package/src/frontend/telegram/handlers/messages.ts +0 -10
- package/src/frontend/telegram/index.ts +1 -5
- package/src/frontend/telegram/middleware.ts +1 -15
- package/src/frontend/whatsapp/access.ts +2 -2
- package/src/frontend/whatsapp/actions/history.ts +2 -2
- package/src/frontend/whatsapp/actions/messaging.ts +2 -2
- package/src/frontend/whatsapp/actions/moderation.ts +1 -1
- package/src/frontend/whatsapp/actions/send.ts +1 -1
- package/src/frontend/whatsapp/commands.ts +8 -2
- package/src/frontend/whatsapp/{connection.ts → connection/connection.ts} +5 -5
- package/src/frontend/whatsapp/{pairing-service.ts → connection/pairing-service.ts} +3 -3
- package/src/frontend/whatsapp/{wa-logger.ts → connection/wa-logger.ts} +2 -2
- package/src/frontend/whatsapp/index.ts +4 -4
- package/src/frontend/whatsapp/{inbound.ts → messages/inbound.ts} +15 -15
- package/src/frontend/whatsapp/{media-store.ts → messages/media-store.ts} +4 -4
- package/src/frontend/whatsapp/{message-store.ts → messages/message-store.ts} +1 -1
- package/src/frontend/whatsapp/{turn-recovery.ts → messages/turn-recovery.ts} +4 -4
- package/src/frontend/whatsapp/registry.ts +1 -1
- package/src/frontend/whatsapp/runtime.ts +1 -1
- package/src/index.ts +1 -1
- package/src/storage/db.ts +6 -1
- package/src/storage/memory.ts +59 -8
- package/src/storage/metrics.ts +38 -0
- package/src/storage/repositories/sessions-repo.ts +6 -0
- package/src/storage/session-record.ts +13 -2
- package/src/storage/sessions.ts +12 -0
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +4 -0
- package/src/storage/sql/sessions.sql +3 -3
- package/src/storage/sql/statements.generated.ts +10 -3
- package/src/storage/sql/turn-meta.sql +1 -1
- package/src/util/boot-timer.ts +15 -1
- package/src/util/chat-id.ts +30 -0
- package/src/util/log.ts +1 -1
- package/src/util/paths.ts +0 -2
- package/src/core/soul/README.md +0 -110
- package/src/core/soul/RESEARCH.md +0 -98
- package/src/core/soul/associative.ts +0 -98
- package/src/core/soul/centrality.ts +0 -98
- package/src/core/soul/cluster.ts +0 -83
- package/src/core/soul/compiler.ts +0 -207
- package/src/core/soul/consolidate.ts +0 -179
- package/src/core/soul/critic.ts +0 -162
- package/src/core/soul/dag.ts +0 -265
- package/src/core/soul/delta.ts +0 -123
- package/src/core/soul/drift.ts +0 -99
- package/src/core/soul/embedder.ts +0 -129
- package/src/core/soul/emergent-critic.ts +0 -96
- package/src/core/soul/forgetting.ts +0 -131
- package/src/core/soul/governance.ts +0 -93
- package/src/core/soul/hash.ts +0 -97
- package/src/core/soul/hdc.ts +0 -154
- package/src/core/soul/kernel.ts +0 -540
- package/src/core/soul/lattice.ts +0 -103
- package/src/core/soul/lens.ts +0 -110
- package/src/core/soul/projector.ts +0 -240
- package/src/core/soul/reflect.ts +0 -170
- package/src/core/soul/reflex.ts +0 -164
- package/src/core/soul/retrieve.ts +0 -146
- package/src/core/soul/salience.ts +0 -146
- package/src/core/soul/service.ts +0 -204
- package/src/core/soul/settings.ts +0 -47
- package/src/core/soul/signals.ts +0 -117
- package/src/core/soul/talon-embedder.ts +0 -80
- package/src/core/soul/taps.ts +0 -199
- package/src/core/soul/types.ts +0 -298
- package/src/core/soul/valence.ts +0 -83
- /package/src/backend/{shared → runtime}/frontends.ts +0 -0
- /package/src/backend/{shared → runtime}/metrics.ts +0 -0
- /package/src/backend/{shared → runtime}/sleep.ts +0 -0
- /package/src/backend/{shared → runtime/turn}/flow-violation.ts +0 -0
- /package/src/backend/{shared → runtime}/usage.ts +0 -0
- /package/src/core/{scripting/lua-runner.ts → scripts/lua.ts} +0 -0
- /package/src/frontend/native/{routes → bridge/routes}/index.ts +0 -0
- /package/src/frontend/native/{routes → bridge/routes}/memory.ts +0 -0
- /package/src/frontend/native/{routes → bridge/routes}/mesh.ts +0 -0
- /package/src/frontend/native/{routes → bridge/routes}/models.ts +0 -0
- /package/src/frontend/native/{routes → bridge/routes}/params.ts +0 -0
- /package/src/frontend/native/{routes → bridge/routes}/table.ts +0 -0
- /package/src/frontend/native/{tool-result.ts → turn/tool-result.ts} +0 -0
- /package/src/frontend/whatsapp/{auth-state.ts → connection/auth-state.ts} +0 -0
- /package/src/frontend/whatsapp/{identity.ts → connection/identity.ts} +0 -0
- /package/src/frontend/whatsapp/{pairing-lock.ts → connection/pairing-lock.ts} +0 -0
- /package/src/frontend/whatsapp/{pairing.ts → connection/pairing.ts} +0 -0
- /package/src/frontend/whatsapp/{pins.ts → messages/pins.ts} +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's accounting of its own resources — boot cost and resident
|
|
3
|
+
* memory. Measurement only: nothing here changes a prompt byte or a
|
|
4
|
+
* turn's behaviour.
|
|
5
|
+
*
|
|
6
|
+
* Why these numbers exist (docs/ts-migration-plan.md, Phase 0 — Measure):
|
|
7
|
+
* the migration plan's entry gate is "numbers in hand", and its kill
|
|
8
|
+
* criterion is stated in terms of the control plane's share of a turn plus
|
|
9
|
+
* "RSS is acceptable". Neither can be argued without a baseline, and the
|
|
10
|
+
* metrics store had latency and tokens but nothing about the process
|
|
11
|
+
* itself.
|
|
12
|
+
*
|
|
13
|
+
* - `boot.total_ms` — process start → frontends listening. The figure
|
|
14
|
+
* `Ready in …` already logs, kept as a distribution so successive
|
|
15
|
+
* restarts can be compared instead of grepped.
|
|
16
|
+
* - `boot.<phase>_ms` — each awaited startup phase (`util/boot-timer.ts`
|
|
17
|
+
* records them), so a slow boot names its own culprit.
|
|
18
|
+
* - `boot.rss_mb` / `boot.heap_mb` — what the process costs the moment
|
|
19
|
+
* it is serving, before any turn has run. The floor an alternative
|
|
20
|
+
* runtime or language would have to beat.
|
|
21
|
+
* - `rss.mb`, `heap_used.mb`, `external.mb`, `handles.count` — the same
|
|
22
|
+
* picture sampled every minute for the process lifetime, which is what
|
|
23
|
+
* answers "idle RSS" and "does it creep?" (the Phase 1 soak log asks
|
|
24
|
+
* exactly this and is currently filled in by hand).
|
|
25
|
+
*
|
|
26
|
+
* Sampling must never throw: this runs on a timer inside a live daemon,
|
|
27
|
+
* and a metric that can take the process down is worse than no metric.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { bootPhases } from "../../util/boot-timer.js";
|
|
31
|
+
import { recordHistogram } from "../../storage/metrics.js";
|
|
32
|
+
|
|
33
|
+
/** One minute. Idle RSS moves slowly; a tighter loop would only add noise. */
|
|
34
|
+
const SAMPLE_INTERVAL_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
39
|
+
|
|
40
|
+
function mb(bytes: number): number {
|
|
41
|
+
return Math.round(bytes / BYTES_PER_MB);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `frontends start` → `boot.frontends_start_ms`. Phase labels are prose
|
|
46
|
+
* ("backend + dispatcher"), metric names are not.
|
|
47
|
+
*/
|
|
48
|
+
function slug(label: string): string {
|
|
49
|
+
return (
|
|
50
|
+
label
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
53
|
+
.replace(/^_+|_+$/g, "") || "unnamed"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How many active handles the runtime reports, when it can report it. */
|
|
58
|
+
function activeResourceCount(): number | undefined {
|
|
59
|
+
// Node 18.11+ / Bun. Absent on older runtimes, and there is no cheap
|
|
60
|
+
// supported substitute — the metric is simply skipped there.
|
|
61
|
+
const read = (process as { getActiveResourcesInfo?: () => readonly string[] })
|
|
62
|
+
.getActiveResourcesInfo;
|
|
63
|
+
if (typeof read !== "function") return undefined;
|
|
64
|
+
const info = read.call(process);
|
|
65
|
+
return Array.isArray(info) ? info.length : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Record one resident-memory sample. Safe to call at any time. */
|
|
69
|
+
function sampleResources(): void {
|
|
70
|
+
try {
|
|
71
|
+
const mem = process.memoryUsage();
|
|
72
|
+
recordHistogram("rss.mb", mb(mem.rss));
|
|
73
|
+
recordHistogram("heap_used.mb", mb(mem.heapUsed));
|
|
74
|
+
recordHistogram("external.mb", mb(mem.external));
|
|
75
|
+
const handles = activeResourceCount();
|
|
76
|
+
if (handles !== undefined) recordHistogram("handles.count", handles);
|
|
77
|
+
} catch {
|
|
78
|
+
// A failed sample is a missing data point, never a failed daemon.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fold the boot into the metrics store: the total, every phase the boot
|
|
84
|
+
* timer recorded, and the memory the process holds now that it is serving.
|
|
85
|
+
*
|
|
86
|
+
* `totalMs` defaults to process uptime — the same figure `bootReport()`
|
|
87
|
+
* prints, so the log line and the histogram cannot disagree.
|
|
88
|
+
*/
|
|
89
|
+
export function recordBootMetrics(
|
|
90
|
+
totalMs = Math.round(process.uptime() * 1000),
|
|
91
|
+
): void {
|
|
92
|
+
try {
|
|
93
|
+
recordHistogram("boot.total_ms", totalMs);
|
|
94
|
+
for (const phase of bootPhases()) {
|
|
95
|
+
recordHistogram(`boot.${slug(phase.label)}_ms`, phase.ms);
|
|
96
|
+
}
|
|
97
|
+
const mem = process.memoryUsage();
|
|
98
|
+
recordHistogram("boot.rss_mb", mb(mem.rss));
|
|
99
|
+
recordHistogram("boot.heap_mb", mb(mem.heapUsed));
|
|
100
|
+
} catch {
|
|
101
|
+
// As above: measurement never breaks the boot it is measuring.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start the idle sampler. Unref'd, so it never holds the event loop open
|
|
107
|
+
* and never keeps a daemon alive that is otherwise done. Idempotent —
|
|
108
|
+
* a second call is a no-op rather than a second timer.
|
|
109
|
+
*/
|
|
110
|
+
export function startResourceSampler(): void {
|
|
111
|
+
if (timer) return;
|
|
112
|
+
timer = setInterval(sampleResources, SAMPLE_INTERVAL_MS);
|
|
113
|
+
timer.unref();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Stop the idle sampler (shutdown, and the test seam). */
|
|
117
|
+
export function stopResourceSampler(): void {
|
|
118
|
+
if (!timer) return;
|
|
119
|
+
clearInterval(timer);
|
|
120
|
+
timer = null;
|
|
121
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExecuteParams, ExecuteResult } from "../types.js";
|
|
12
12
|
import { formatRelayBlock, takePendingRelay } from "./cross-chat-relay.js";
|
|
13
|
+
import { recordMessageSignal } from "../memory/taps.js";
|
|
13
14
|
import { log } from "../../util/log.js";
|
|
14
15
|
import { taskTable, type KillOutcome } from "../tasks/index.js";
|
|
15
16
|
import { initWeaver, type Weaver, type WeaverDeps } from "../weaver/index.js";
|
|
@@ -71,9 +72,24 @@ export function stopAllTurns(): number {
|
|
|
71
72
|
* Every turn for every frontend and every source funnels through here,
|
|
72
73
|
* which makes it the one place a cross-chat reply can be folded in
|
|
73
74
|
* without teaching each frontend about the relay.
|
|
75
|
+
*
|
|
76
|
+
* It is also where the memory tap lives, for the same reason: a
|
|
77
|
+
* directive is a directive whether it arrives over Telegram, Discord,
|
|
78
|
+
* WhatsApp, Teams, the native bridge or the terminal, and the tap used
|
|
79
|
+
* to see only one of them. Only `source: "message"` is tapped — a pulse,
|
|
80
|
+
* a cron job or a trigger is Talon prompting itself, and "always reply
|
|
81
|
+
* in one line" written by a scheduler is not standing human intent.
|
|
74
82
|
*/
|
|
75
83
|
export async function execute(params: ExecuteParams): Promise<ExecuteResult> {
|
|
76
84
|
if (!weaver) throw new Error("Dispatcher not initialized");
|
|
85
|
+
// Fire-and-forget: the tap is a synchronous store write that must not
|
|
86
|
+
// be able to delay or fail the turn (it swallows its own errors).
|
|
87
|
+
if (params.source === "message")
|
|
88
|
+
recordMessageSignal({
|
|
89
|
+
text: params.prompt,
|
|
90
|
+
chatKey: params.chatId,
|
|
91
|
+
...(params.senderName ? { actor: params.senderName } : {}),
|
|
92
|
+
});
|
|
77
93
|
const relayed = takePendingRelay(params.chatId);
|
|
78
94
|
const prompt = relayed.length
|
|
79
95
|
? formatRelayBlock(relayed) + params.prompt
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
describeSchedule,
|
|
13
13
|
nextRunAt,
|
|
14
14
|
} from "../../../storage/cron.js";
|
|
15
|
-
import { runJobNow } from "../../background/cron.js";
|
|
16
|
-
import { parseCronSpec } from "../../background/cron
|
|
15
|
+
import { runJobNow } from "../../background/cron/scheduler.js";
|
|
16
|
+
import { parseCronSpec } from "../../background/cron/spec.js";
|
|
17
17
|
import { log } from "../../../util/log.js";
|
|
18
18
|
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
19
19
|
import {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - `cron` — scheduled-job CRUD
|
|
13
13
|
* - `triggers` — long-running watcher-script CRUD
|
|
14
14
|
* - `goals` — persistent multi-turn objectives
|
|
15
|
+
* - `memory` — remember / recall / forget over the typed memory store
|
|
15
16
|
* - `scripts` — reusable agent-authored scripts
|
|
16
17
|
* - `skills` — markdown workflows
|
|
17
18
|
* - `plugins` — plugin hot-reload
|
|
@@ -28,6 +29,7 @@ import { fetchUrlHandlers } from "./fetch-url.js";
|
|
|
28
29
|
import { cronHandlers } from "./cron.js";
|
|
29
30
|
import { triggerHandlers } from "./triggers.js";
|
|
30
31
|
import { goalHandlers } from "./goals.js";
|
|
32
|
+
import { memoryHandlers } from "./memory.js";
|
|
31
33
|
import { scriptHandlers } from "./scripts.js";
|
|
32
34
|
import { skillHandlers } from "./skills.js";
|
|
33
35
|
import { pluginHandlers } from "./plugins.js";
|
|
@@ -52,6 +54,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
52
54
|
...cronHandlers,
|
|
53
55
|
...triggerHandlers,
|
|
54
56
|
...goalHandlers,
|
|
57
|
+
...memoryHandlers,
|
|
55
58
|
...scriptHandlers,
|
|
56
59
|
...skillHandlers,
|
|
57
60
|
...pluginHandlers,
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory — the in-band write path over the typed store: remember /
|
|
3
|
+
* recall / forget (docs/memory-persona-plan.md §3.2).
|
|
4
|
+
*
|
|
5
|
+
* Three rules shape this module, and none of them live in the store:
|
|
6
|
+
*
|
|
7
|
+
* - **Single claim, supersede-candidate.** A `remember` that restates
|
|
8
|
+
* something already on file does NOT insert a second row. The FTS
|
|
9
|
+
* near-duplicate probe runs first, and a hit comes back as a refusal
|
|
10
|
+
* naming the rows to supersede. The caller then either picks one
|
|
11
|
+
* (`replace_id`) or declares the claim genuinely separate (`force`).
|
|
12
|
+
* - **Trust comes from the chat, not the caller.** Anything learned on
|
|
13
|
+
* a multi-party surface is `group_chat` — never pinnable, never in
|
|
14
|
+
* the core view (plan §5) — and a `directive` cannot be planted from
|
|
15
|
+
* one at all. The tier also gates what a context may *overwrite*:
|
|
16
|
+
* a claim may only be superseded or forgotten from a context at its
|
|
17
|
+
* own tier or stronger. Without that, both `replace_id` and `forget`
|
|
18
|
+
* are escalations — the first because `supersedeMemory` copies the
|
|
19
|
+
* old row's trust onto the successor, the second because the store
|
|
20
|
+
* drops whatever id it is handed.
|
|
21
|
+
* - **Writes never invalidate the prompt cache.** This module must not
|
|
22
|
+
* import `core/prompt/invalidation.js`. A `remember` that dropped
|
|
23
|
+
* every live session's prompt snapshot would turn a ~50-token claim
|
|
24
|
+
* into a 60–90 k cache write (plan §3.6). A fact learned mid-session
|
|
25
|
+
* reaches the model through turn retrieval, not the frozen core view.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
assertMemory,
|
|
30
|
+
dropMemory,
|
|
31
|
+
findSimilarMemories,
|
|
32
|
+
formatMemory,
|
|
33
|
+
getMemory,
|
|
34
|
+
isMemoryKind,
|
|
35
|
+
replaceStateKey,
|
|
36
|
+
searchMemories,
|
|
37
|
+
supersedeMemory,
|
|
38
|
+
touchMemory,
|
|
39
|
+
MEMORY_KINDS,
|
|
40
|
+
type MemoryKind,
|
|
41
|
+
type MemoryRow,
|
|
42
|
+
type MemorySource,
|
|
43
|
+
type MemoryTrust,
|
|
44
|
+
} from "../../../storage/memory.js";
|
|
45
|
+
import { chatScope } from "../../../util/chat-id.js";
|
|
46
|
+
import { log } from "../../../util/log.js";
|
|
47
|
+
import type { ActionResult } from "../../types.js";
|
|
48
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
49
|
+
|
|
50
|
+
/** `source.actor` on every row this path writes — the ownership marker. */
|
|
51
|
+
const REMEMBER_ACTOR = "remember";
|
|
52
|
+
|
|
53
|
+
/** Kinds whose subject is the chat itself when the caller names none. */
|
|
54
|
+
const CHAT_SCOPED_KINDS: readonly MemoryKind[] = ["episode", "relationship"];
|
|
55
|
+
|
|
56
|
+
/** Hard ceiling on `recall`, regardless of what the caller asks for. */
|
|
57
|
+
const MAX_RECALL_LIMIT = 20;
|
|
58
|
+
|
|
59
|
+
const KIND_LIST = MEMORY_KINDS.join(", ");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The trust tier for a claim learned in this chat.
|
|
63
|
+
*
|
|
64
|
+
* A group chat is a multi-party surface: any participant can assert
|
|
65
|
+
* anything, so what is learned there is `group_chat` — a tier the store
|
|
66
|
+
* refuses to pin and the core view never carries (plan §5). A DM with
|
|
67
|
+
* the operator, or a local terminal/native session, is the agent writing
|
|
68
|
+
* for its own principal, so that is `agent`.
|
|
69
|
+
*
|
|
70
|
+
* "Group" is read off the canonical chat-id grammar (`chatScope` in
|
|
71
|
+
* util/chat-id.ts), which is the only identity a gateway action holds.
|
|
72
|
+
* When the grammar cannot tell — Teams' `teams_chat_…` is 1:1 and group
|
|
73
|
+
* alike — this fails closed to `group_chat`: over-restricting a claim
|
|
74
|
+
* costs a pin, under-restricting one is a permanent prompt injection.
|
|
75
|
+
*/
|
|
76
|
+
function trustForChat(chatKey: string): MemoryTrust {
|
|
77
|
+
return chatScope(chatKey) === "dm" ? "agent" : "group_chat";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The trust tiers, weakest first. */
|
|
81
|
+
const TRUST_ORDER: readonly MemoryTrust[] = [
|
|
82
|
+
"group_chat",
|
|
83
|
+
"user_claim",
|
|
84
|
+
"agent",
|
|
85
|
+
"operator",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function trustRank(trust: MemoryTrust): number {
|
|
89
|
+
const rank = TRUST_ORDER.indexOf(trust);
|
|
90
|
+
// A tier this module does not know is treated as the strongest, so an
|
|
91
|
+
// unrecognised row is protected rather than freely overwritten.
|
|
92
|
+
return rank === -1 ? TRUST_ORDER.length : rank;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a claim made in `context` may overwrite or retire a row
|
|
97
|
+
* recorded at `row` trust — its own tier or weaker, never stronger.
|
|
98
|
+
*
|
|
99
|
+
* This is what keeps `replace_id` from being an escalation:
|
|
100
|
+
* `supersedeMemory` inherits the old row's frame, trust included, so
|
|
101
|
+
* without this check a group participant could hand new text to an
|
|
102
|
+
* `agent` row and have it land live at `agent` trust. `forget` needs the
|
|
103
|
+
* same guard for the mirror reason — the store drops whatever id it is
|
|
104
|
+
* given, so the policy has to sit here.
|
|
105
|
+
*/
|
|
106
|
+
function outranks(context: MemoryTrust, row: MemoryTrust): boolean {
|
|
107
|
+
return trustRank(context) >= trustRank(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fail(error: string): ActionResult {
|
|
111
|
+
return { ok: false, error };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Never let a store error escape as a throw — the gateway wants a result. */
|
|
115
|
+
function attempt(run: () => ActionResult): ActionResult {
|
|
116
|
+
try {
|
|
117
|
+
return run();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function storedLine(id: number): ActionResult {
|
|
124
|
+
const row = getMemory(id);
|
|
125
|
+
return { ok: true, id, line: row ? formatMemory(row) : `#${id}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Clamp a caller-supplied limit into 1..max, defaulting to max. */
|
|
129
|
+
function clampLimit(raw: unknown, max: number): number {
|
|
130
|
+
const n = Number(raw);
|
|
131
|
+
if (!Number.isFinite(n) || n < 1) return max;
|
|
132
|
+
return Math.min(Math.floor(n), max);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `episode` / `relationship` default to the chat; everything else must say. */
|
|
136
|
+
function resolveSubject(
|
|
137
|
+
kind: MemoryKind,
|
|
138
|
+
raw: unknown,
|
|
139
|
+
chatKey: string,
|
|
140
|
+
): string {
|
|
141
|
+
const given = raw === undefined ? "" : String(raw).trim();
|
|
142
|
+
if (given) return given;
|
|
143
|
+
return CHAT_SCOPED_KINDS.includes(kind) ? chatKey : "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The parts of a `remember` body that survive validation. */
|
|
147
|
+
type RememberClaim = {
|
|
148
|
+
kind: MemoryKind;
|
|
149
|
+
subject: string;
|
|
150
|
+
text: string;
|
|
151
|
+
trust: MemoryTrust;
|
|
152
|
+
source: MemorySource;
|
|
153
|
+
confidence?: number;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function parseClaim(
|
|
157
|
+
body: Record<string, unknown>,
|
|
158
|
+
chatKey: string,
|
|
159
|
+
): RememberClaim | ActionResult {
|
|
160
|
+
const kind = body.kind;
|
|
161
|
+
if (!isMemoryKind(kind))
|
|
162
|
+
return fail(
|
|
163
|
+
`Unknown kind "${String(kind ?? "")}" (expected one of ${KIND_LIST})`,
|
|
164
|
+
);
|
|
165
|
+
const text = String(body.text ?? "").trim();
|
|
166
|
+
if (!text) return fail("Missing text — a memory is one written claim");
|
|
167
|
+
const subject = resolveSubject(kind, body.subject, chatKey);
|
|
168
|
+
if (!subject)
|
|
169
|
+
return fail(`A ${kind} memory needs a subject (who or what it is about)`);
|
|
170
|
+
const trust = trustForChat(chatKey);
|
|
171
|
+
if (kind === "directive" && trust !== "agent")
|
|
172
|
+
return fail(
|
|
173
|
+
"A directive cannot be recorded from a group chat — durable standing " +
|
|
174
|
+
"intent only comes from a direct conversation with the operator. " +
|
|
175
|
+
"Record it as a fact instead, or ask the operator in a DM.",
|
|
176
|
+
);
|
|
177
|
+
const confidence =
|
|
178
|
+
body.confidence === undefined ? undefined : Number(body.confidence);
|
|
179
|
+
return {
|
|
180
|
+
kind,
|
|
181
|
+
subject,
|
|
182
|
+
text,
|
|
183
|
+
trust,
|
|
184
|
+
source: { chat: chatKey, actor: REMEMBER_ACTOR },
|
|
185
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The keyed-state path: a write replaces the live row for that key. */
|
|
190
|
+
function rememberState(
|
|
191
|
+
body: Record<string, unknown>,
|
|
192
|
+
claim: RememberClaim,
|
|
193
|
+
): ActionResult {
|
|
194
|
+
if (body.replace_id !== undefined)
|
|
195
|
+
return fail(
|
|
196
|
+
"A state memory replaces the live row for its key — omit replace_id",
|
|
197
|
+
);
|
|
198
|
+
const key = String(body.key ?? "").trim();
|
|
199
|
+
if (!key)
|
|
200
|
+
return fail("A state memory requires a key (e.g. heartbeat.health)");
|
|
201
|
+
const id = replaceStateKey(key, claim.text, claim.source, {
|
|
202
|
+
subject: claim.subject,
|
|
203
|
+
trust: claim.trust,
|
|
204
|
+
...(claim.confidence !== undefined ? { confidence: claim.confidence } : {}),
|
|
205
|
+
reason: "remember: state",
|
|
206
|
+
});
|
|
207
|
+
return storedLine(id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The explicit supersede: fold the new text into an existing live row. */
|
|
211
|
+
function rememberReplacing(
|
|
212
|
+
replaceId: number,
|
|
213
|
+
claim: RememberClaim,
|
|
214
|
+
): ActionResult {
|
|
215
|
+
const target = getMemory(replaceId);
|
|
216
|
+
if (!target) return fail(`No memory with id ${replaceId}`);
|
|
217
|
+
if (target.droppedAt !== undefined)
|
|
218
|
+
return fail(`Memory #${replaceId} is dropped; it cannot be superseded`);
|
|
219
|
+
if (target.supersededBy !== undefined)
|
|
220
|
+
return fail(
|
|
221
|
+
`Memory #${replaceId} is already superseded by #${target.supersededBy}`,
|
|
222
|
+
);
|
|
223
|
+
if (!outranks(claim.trust, target.trust))
|
|
224
|
+
return fail(
|
|
225
|
+
`Memory #${replaceId} was recorded at ${target.trust} trust; a ${claim.trust} context cannot replace it — record a separate claim instead`,
|
|
226
|
+
);
|
|
227
|
+
if (target.kind !== claim.kind)
|
|
228
|
+
return fail(
|
|
229
|
+
`Memory #${replaceId} is a ${target.kind}, not a ${claim.kind} — a supersede keeps the row's kind`,
|
|
230
|
+
);
|
|
231
|
+
return storedLine(
|
|
232
|
+
supersedeMemory(replaceId, claim.text, "remember: replaced"),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The refusal that offers supersede-instead-of-append (plan §3.2). */
|
|
237
|
+
function nearDuplicate(similar: MemoryRow[]): ActionResult {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: `Near-duplicate of #${similar[0]!.id}`,
|
|
241
|
+
similar: similar.map((row) => formatMemory(row)),
|
|
242
|
+
hint: "pass replace_id to supersede it, or force: true to store a separate claim",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function rememberClaim(
|
|
247
|
+
body: Record<string, unknown>,
|
|
248
|
+
chatKey: string,
|
|
249
|
+
): ActionResult {
|
|
250
|
+
const parsed = parseClaim(body, chatKey);
|
|
251
|
+
if ("ok" in parsed) return parsed;
|
|
252
|
+
if (parsed.kind === "state") return rememberState(body, parsed);
|
|
253
|
+
|
|
254
|
+
if (body.replace_id !== undefined) {
|
|
255
|
+
const replaceId = Number(body.replace_id);
|
|
256
|
+
if (!Number.isInteger(replaceId) || replaceId <= 0)
|
|
257
|
+
return fail(`Invalid replace_id "${String(body.replace_id)}"`);
|
|
258
|
+
return rememberReplacing(replaceId, parsed);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (body.force !== true) {
|
|
262
|
+
const similar = findSimilarMemories(
|
|
263
|
+
parsed.kind,
|
|
264
|
+
parsed.subject,
|
|
265
|
+
parsed.text,
|
|
266
|
+
);
|
|
267
|
+
if (similar.length > 0) return nearDuplicate(similar);
|
|
268
|
+
}
|
|
269
|
+
const { id } = assertMemory(parsed);
|
|
270
|
+
log("gateway", `remember: [${parsed.kind}] ${parsed.subject} #${id}`);
|
|
271
|
+
return storedLine(id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function recallRows(body: Record<string, unknown>): ActionResult {
|
|
275
|
+
const query = String(body.query ?? "").trim();
|
|
276
|
+
if (!query) return fail("Missing query");
|
|
277
|
+
const kind = body.kind;
|
|
278
|
+
if (kind !== undefined && !isMemoryKind(kind))
|
|
279
|
+
return fail(
|
|
280
|
+
`Unknown kind "${String(kind)}" (expected one of ${KIND_LIST})`,
|
|
281
|
+
);
|
|
282
|
+
const rows = searchMemories(query, {
|
|
283
|
+
...(kind !== undefined ? { kind } : {}),
|
|
284
|
+
limit: clampLimit(body.limit, MAX_RECALL_LIMIT),
|
|
285
|
+
});
|
|
286
|
+
if (rows.length === 0)
|
|
287
|
+
return { ok: true, rows: [], note: "nothing stored matches" };
|
|
288
|
+
// A retrieval hit is ranking feedback, not a content change: `touchMemory`
|
|
289
|
+
// writes no history row, and nothing here invalidates the prompt cache.
|
|
290
|
+
for (const row of rows) touchMemory(row.id);
|
|
291
|
+
return { ok: true, rows: rows.map((row) => formatMemory(row)) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Why a context is not allowed to retire this row. */
|
|
295
|
+
function forgetRefusal(
|
|
296
|
+
id: number,
|
|
297
|
+
rowTrust: MemoryTrust,
|
|
298
|
+
context: MemoryTrust,
|
|
299
|
+
): string {
|
|
300
|
+
// No chat context ever reaches `operator`, so an operator row can only
|
|
301
|
+
// be retired out of band — name the command rather than stonewalling.
|
|
302
|
+
if (rowTrust === "operator")
|
|
303
|
+
return `Memory #${id} is an operator memory — only the operator can forget it (talon memory forget ${id})`;
|
|
304
|
+
return `Memory #${id} was recorded at ${rowTrust} trust; a ${context} context cannot forget it`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function forgetRow(
|
|
308
|
+
body: Record<string, unknown>,
|
|
309
|
+
chatKey: string,
|
|
310
|
+
): ActionResult {
|
|
311
|
+
const id = Number(body.id);
|
|
312
|
+
if (!Number.isInteger(id) || id <= 0)
|
|
313
|
+
return fail(`Invalid id "${String(body.id ?? "")}"`);
|
|
314
|
+
const reason = String(body.reason ?? "").trim();
|
|
315
|
+
if (!reason)
|
|
316
|
+
return fail(
|
|
317
|
+
"A reason is required — every drop is auditable and reversible",
|
|
318
|
+
);
|
|
319
|
+
const row = getMemory(id);
|
|
320
|
+
if (!row) return fail(`No memory with id ${id}`);
|
|
321
|
+
const context = trustForChat(chatKey);
|
|
322
|
+
if (!outranks(context, row.trust))
|
|
323
|
+
return fail(forgetRefusal(id, row.trust, context));
|
|
324
|
+
dropMemory(id, reason);
|
|
325
|
+
log("gateway", `forget: #${id} (${reason})`);
|
|
326
|
+
return { ok: true, id, text: `Dropped #${id} to the graveyard: ${reason}` };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const memoryHandlers: SharedActionHandlers = {
|
|
330
|
+
remember: (body, _chatId, _backend, chatKey) =>
|
|
331
|
+
attempt(() => rememberClaim(body, chatKey)),
|
|
332
|
+
recall: (body) => attempt(() => recallRows(body)),
|
|
333
|
+
forget: (body, _chatId, _backend, chatKey) =>
|
|
334
|
+
attempt(() => forgetRow(body, chatKey)),
|
|
335
|
+
};
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
} from "./gateway-routes.js";
|
|
31
31
|
import { handlePluginAction } from "../plugin/index.js";
|
|
32
32
|
import type { FrontendActionHandler } from "../types.js";
|
|
33
|
-
import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
|
|
34
33
|
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
35
34
|
import { resolveOwnerFrontendId } from "../frontend-runtime/routing.js";
|
|
36
35
|
|
|
@@ -320,11 +319,6 @@ export class Gateway {
|
|
|
320
319
|
if (frontendHandler) {
|
|
321
320
|
const result = await frontendHandler(body, chatId);
|
|
322
321
|
if (result) {
|
|
323
|
-
// Soul tap: remember our own outgoing message ids so a later
|
|
324
|
-
// reaction update can be attributed to one of Talon's messages.
|
|
325
|
-
// No-op unless the soul is enabled.
|
|
326
|
-
if (BOT_MESSAGE_ACTIONS.has(action) && result.ok && result.message_id)
|
|
327
|
-
noteBotMessage(chatId, Number(result.message_id));
|
|
328
322
|
logDebug("gateway", `${action} chat=${chatId} ${Date.now() - t0}ms`);
|
|
329
323
|
return result;
|
|
330
324
|
}
|
|
@@ -352,8 +352,9 @@ export function importMemoryFile(path: string = files.memory): ImportCounts {
|
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* Import the daily notes: one `episode` per `YYYY-MM-DD.md`, subject the
|
|
355
|
-
* date. `diary-*.md` is
|
|
356
|
-
*
|
|
355
|
+
* date. `diary-*.md` is first-person writing — a reflection, never a fact
|
|
356
|
+
* source (plan §3.1) — so the filename pattern excludes it. The soul kernel
|
|
357
|
+
* that wrote those files is gone, but its output is still on disk.
|
|
357
358
|
*/
|
|
358
359
|
export function importDailyNotes(dir: string = dirs.dailyMemory): ImportCounts {
|
|
359
360
|
if (!existsSync(dir)) return emptyCounts();
|