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.
Files changed (223) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -1
  3. package/prompts/README.md +2 -2
  4. package/prompts/system/memory-recall.md +16 -0
  5. package/src/app.ts +19 -4
  6. package/src/backend/claude-sdk/handler.ts +28 -7
  7. package/src/backend/claude-sdk/one-shot.ts +1 -1
  8. package/src/backend/claude-sdk/options.ts +7 -6
  9. package/src/backend/claude-sdk/stream.ts +30 -3
  10. package/src/backend/claude-sdk/warm.ts +1 -1
  11. package/src/backend/codex/constants.ts +1 -1
  12. package/src/backend/codex/factory.ts +2 -2
  13. package/src/backend/codex/handler/events.ts +1 -1
  14. package/src/backend/codex/handler/message.ts +26 -12
  15. package/src/backend/codex/handler/rollout-accounting.ts +1 -1
  16. package/src/backend/codex/init.ts +1 -1
  17. package/src/backend/codex/mcp-config.ts +1 -1
  18. package/src/backend/codex/one-shot.ts +1 -1
  19. package/src/backend/kilo/handler/message.ts +4 -1
  20. package/src/backend/openai-agents/constants.ts +1 -1
  21. package/src/backend/openai-agents/factory.ts +2 -2
  22. package/src/backend/openai-agents/handler/events.ts +1 -1
  23. package/src/backend/openai-agents/handler/message.ts +8 -4
  24. package/src/backend/openai-agents/init.ts +1 -1
  25. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  26. package/src/backend/opencode/handler/message.ts +4 -1
  27. package/src/backend/remote-server/chat-turn.ts +31 -23
  28. package/src/backend/remote-server/events.ts +3 -3
  29. package/src/backend/remote-server/factory.ts +6 -3
  30. package/src/backend/remote-server/index.ts +1 -1
  31. package/src/backend/remote-server/mcp.ts +1 -1
  32. package/src/backend/remote-server/one-shot.ts +1 -1
  33. package/src/backend/remote-server/server-bindings.ts +1 -1
  34. package/src/backend/remote-server/turn.ts +1 -1
  35. package/src/backend/runtime/cache/cache-metrics.ts +126 -0
  36. package/src/backend/{shared → runtime/cache}/cache-telemetry.ts +22 -2
  37. package/src/backend/{shared → runtime}/index.ts +32 -23
  38. package/src/backend/{shared → runtime/prompt}/delivery-contract.ts +1 -1
  39. package/src/backend/{shared → runtime/prompt}/prompt-format.ts +46 -2
  40. package/src/backend/{shared → runtime/prompt}/system-prompt.ts +4 -4
  41. package/src/backend/{shared → runtime/turn}/delivered-text.ts +1 -1
  42. package/src/backend/{shared → runtime/turn}/delivery.ts +2 -2
  43. package/src/backend/{shared → runtime/turn}/handle-retry.ts +5 -5
  44. package/src/backend/{shared → runtime/turn}/handler-to-events.ts +24 -10
  45. package/src/backend/{shared → runtime/turn}/handler-types.ts +7 -1
  46. package/src/backend/{shared → runtime/turn}/model-retry.ts +2 -2
  47. package/src/backend/{shared → runtime/turn}/result-events.ts +2 -2
  48. package/src/backend/{shared → runtime/turn}/stream-state.ts +2 -2
  49. package/src/backend/{shared → runtime/turn}/turn-interrupt.ts +2 -2
  50. package/src/backend/{shared → runtime/turn}/turn-phases.ts +6 -6
  51. package/src/bootstrap.ts +4 -19
  52. package/src/cli.ts +1 -1
  53. package/src/core/agent-runtime/capabilities.ts +11 -0
  54. package/src/core/agent-runtime/contract-tests.ts +92 -1
  55. package/src/core/agent-runtime/events.ts +1 -1
  56. package/src/core/background/{isolated-agent.ts → cron/isolated-agent.ts} +3 -3
  57. package/src/core/background/{job-health.ts → cron/job-health.ts} +1 -1
  58. package/src/core/background/{job-oneshot.ts → cron/job-oneshot.ts} +5 -5
  59. package/src/core/background/{job-prompt.ts → cron/job-prompt.ts} +1 -1
  60. package/src/core/background/{cron.ts → cron/scheduler.ts} +6 -6
  61. package/src/core/background/{cron-spec.ts → cron/spec.ts} +1 -1
  62. package/src/core/background/{dream.ts → dream/index.ts} +10 -15
  63. package/src/core/background/{plan-alerts.ts → pulse/plan-alerts.ts} +3 -3
  64. package/src/core/background/{pulse.ts → pulse/pulse.ts} +8 -5
  65. package/src/core/background/triggers/command.ts +1 -1
  66. package/src/core/config/index.ts +15 -11
  67. package/src/core/daemon/resource-sampler.ts +121 -0
  68. package/src/core/engine/dispatcher.ts +16 -0
  69. package/src/core/engine/gateway-actions/cron.ts +2 -2
  70. package/src/core/engine/gateway-actions/index.ts +3 -0
  71. package/src/core/engine/gateway-actions/memory.ts +335 -0
  72. package/src/core/engine/gateway.ts +0 -6
  73. package/src/core/memory/import.ts +3 -2
  74. package/src/core/memory/taps.ts +199 -0
  75. package/src/core/memory/turn-retrieval.ts +222 -0
  76. package/src/core/prompt/assemble.ts +2 -12
  77. package/src/core/prompt/index.ts +2 -2
  78. package/src/core/prompt/invalidation.ts +1 -1
  79. package/src/core/tasks/index.ts +1 -1
  80. package/src/core/tools/index.ts +2 -0
  81. package/src/core/tools/memory.ts +128 -0
  82. package/src/core/tools/types.ts +1 -0
  83. package/src/core/weaver/turn-cpu.ts +32 -0
  84. package/src/core/weaver/weaver.ts +36 -0
  85. package/src/frontend/discord/admin.ts +1 -1
  86. package/src/frontend/discord/callbacks/components/backend-select.ts +1 -1
  87. package/src/frontend/discord/callbacks/components/pulse.ts +1 -1
  88. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  89. package/src/frontend/discord/callbacks/modals.ts +4 -1
  90. package/src/frontend/discord/commands/admin.ts +1 -35
  91. package/src/frontend/discord/commands/definitions.ts +0 -11
  92. package/src/frontend/discord/commands/router.ts +0 -3
  93. package/src/frontend/discord/commands/session.ts +6 -0
  94. package/src/frontend/discord/commands/settings.ts +1 -1
  95. package/src/frontend/discord/middleware.ts +1 -25
  96. package/src/frontend/discord/runtime.ts +1 -2
  97. package/src/frontend/native/{auth.ts → bridge/auth.ts} +2 -2
  98. package/src/frontend/native/{discovery.ts → bridge/discovery.ts} +3 -3
  99. package/src/frontend/native/{routes → bridge/routes}/chats.ts +1 -1
  100. package/src/frontend/native/{routes → bridge/routes}/daemon.ts +1 -1
  101. package/src/frontend/native/{routes → bridge/routes}/host.ts +6 -3
  102. package/src/frontend/native/{routes → bridge/routes}/pre-auth.ts +1 -1
  103. package/src/frontend/native/{server.ts → bridge/server.ts} +3 -3
  104. package/src/frontend/native/{tls.ts → bridge/tls.ts} +2 -2
  105. package/src/frontend/native/{chat-lifecycle.ts → chats/chat-lifecycle.ts} +3 -3
  106. package/src/frontend/native/{chat-wire.ts → chats/chat-wire.ts} +4 -4
  107. package/src/frontend/native/{chats.ts → chats/chats.ts} +4 -4
  108. package/src/frontend/native/{empty-chat-sweep.ts → chats/empty-chat-sweep.ts} +4 -4
  109. package/src/frontend/native/{history.ts → chats/history.ts} +7 -7
  110. package/src/frontend/native/{reset.ts → chats/reset.ts} +7 -7
  111. package/src/frontend/native/index.ts +13 -10
  112. package/src/frontend/native/{media.ts → media/media.ts} +3 -3
  113. package/src/frontend/native/runtime.ts +1 -1
  114. package/src/frontend/native/{control.ts → surface/control.ts} +4 -4
  115. package/src/frontend/native/{extensions.ts → surface/extensions.ts} +12 -9
  116. package/src/frontend/native/{handlers.ts → surface/handlers.ts} +19 -14
  117. package/src/frontend/native/{logs.ts → surface/logs.ts} +2 -2
  118. package/src/frontend/native/{memory.ts → surface/memory.ts} +2 -2
  119. package/src/frontend/native/{models.ts → surface/models.ts} +17 -10
  120. package/src/frontend/native/{settings.ts → surface/settings.ts} +9 -9
  121. package/src/frontend/native/{status.ts → surface/status.ts} +3 -3
  122. package/src/frontend/native/{actions.ts → turn/actions.ts} +7 -4
  123. package/src/frontend/native/{context.ts → turn/context.ts} +8 -8
  124. package/src/frontend/native/{emit.ts → turn/emit.ts} +7 -7
  125. package/src/frontend/native/{queue.ts → turn/queue.ts} +3 -3
  126. package/src/frontend/native/{turn-meta.ts → turn/turn-meta.ts} +2 -2
  127. package/src/frontend/native/{turn.ts → turn/turn.ts} +9 -9
  128. package/src/frontend/shared/model-commands.ts +1 -1
  129. package/src/frontend/shared/session-status.ts +35 -8
  130. package/src/frontend/shared/status-context.ts +101 -2
  131. package/src/frontend/telegram/admin/background.ts +1 -1
  132. package/src/frontend/telegram/callbacks/model/backend.ts +1 -1
  133. package/src/frontend/telegram/callbacks/pulse.ts +1 -1
  134. package/src/frontend/telegram/callbacks/settings.ts +1 -1
  135. package/src/frontend/telegram/commands/admin.ts +2 -37
  136. package/src/frontend/telegram/commands/index.ts +1 -1
  137. package/src/frontend/telegram/commands/session.ts +6 -0
  138. package/src/frontend/telegram/commands/settings.ts +1 -1
  139. package/src/frontend/telegram/handlers/messages.ts +0 -10
  140. package/src/frontend/telegram/index.ts +1 -5
  141. package/src/frontend/telegram/middleware.ts +1 -15
  142. package/src/frontend/whatsapp/access.ts +2 -2
  143. package/src/frontend/whatsapp/actions/history.ts +2 -2
  144. package/src/frontend/whatsapp/actions/messaging.ts +2 -2
  145. package/src/frontend/whatsapp/actions/moderation.ts +1 -1
  146. package/src/frontend/whatsapp/actions/send.ts +1 -1
  147. package/src/frontend/whatsapp/commands.ts +8 -2
  148. package/src/frontend/whatsapp/{connection.ts → connection/connection.ts} +5 -5
  149. package/src/frontend/whatsapp/{pairing-service.ts → connection/pairing-service.ts} +3 -3
  150. package/src/frontend/whatsapp/{wa-logger.ts → connection/wa-logger.ts} +2 -2
  151. package/src/frontend/whatsapp/index.ts +4 -4
  152. package/src/frontend/whatsapp/{inbound.ts → messages/inbound.ts} +15 -15
  153. package/src/frontend/whatsapp/{media-store.ts → messages/media-store.ts} +4 -4
  154. package/src/frontend/whatsapp/{message-store.ts → messages/message-store.ts} +1 -1
  155. package/src/frontend/whatsapp/{turn-recovery.ts → messages/turn-recovery.ts} +4 -4
  156. package/src/frontend/whatsapp/registry.ts +1 -1
  157. package/src/frontend/whatsapp/runtime.ts +1 -1
  158. package/src/index.ts +1 -1
  159. package/src/storage/db.ts +6 -1
  160. package/src/storage/memory.ts +59 -8
  161. package/src/storage/metrics.ts +38 -0
  162. package/src/storage/repositories/sessions-repo.ts +6 -0
  163. package/src/storage/session-record.ts +13 -2
  164. package/src/storage/sessions.ts +12 -0
  165. package/src/storage/sql/db.sql +5 -0
  166. package/src/storage/sql/schema.sql +4 -0
  167. package/src/storage/sql/sessions.sql +3 -3
  168. package/src/storage/sql/statements.generated.ts +10 -3
  169. package/src/storage/sql/turn-meta.sql +1 -1
  170. package/src/util/boot-timer.ts +15 -1
  171. package/src/util/chat-id.ts +30 -0
  172. package/src/util/log.ts +1 -1
  173. package/src/util/paths.ts +0 -2
  174. package/src/core/soul/README.md +0 -110
  175. package/src/core/soul/RESEARCH.md +0 -98
  176. package/src/core/soul/associative.ts +0 -98
  177. package/src/core/soul/centrality.ts +0 -98
  178. package/src/core/soul/cluster.ts +0 -83
  179. package/src/core/soul/compiler.ts +0 -207
  180. package/src/core/soul/consolidate.ts +0 -179
  181. package/src/core/soul/critic.ts +0 -162
  182. package/src/core/soul/dag.ts +0 -265
  183. package/src/core/soul/delta.ts +0 -123
  184. package/src/core/soul/drift.ts +0 -99
  185. package/src/core/soul/embedder.ts +0 -129
  186. package/src/core/soul/emergent-critic.ts +0 -96
  187. package/src/core/soul/forgetting.ts +0 -131
  188. package/src/core/soul/governance.ts +0 -93
  189. package/src/core/soul/hash.ts +0 -97
  190. package/src/core/soul/hdc.ts +0 -154
  191. package/src/core/soul/kernel.ts +0 -540
  192. package/src/core/soul/lattice.ts +0 -103
  193. package/src/core/soul/lens.ts +0 -110
  194. package/src/core/soul/projector.ts +0 -240
  195. package/src/core/soul/reflect.ts +0 -170
  196. package/src/core/soul/reflex.ts +0 -164
  197. package/src/core/soul/retrieve.ts +0 -146
  198. package/src/core/soul/salience.ts +0 -146
  199. package/src/core/soul/service.ts +0 -204
  200. package/src/core/soul/settings.ts +0 -47
  201. package/src/core/soul/signals.ts +0 -117
  202. package/src/core/soul/talon-embedder.ts +0 -80
  203. package/src/core/soul/taps.ts +0 -199
  204. package/src/core/soul/types.ts +0 -298
  205. package/src/core/soul/valence.ts +0 -83
  206. /package/src/backend/{shared → runtime}/frontends.ts +0 -0
  207. /package/src/backend/{shared → runtime}/metrics.ts +0 -0
  208. /package/src/backend/{shared → runtime}/sleep.ts +0 -0
  209. /package/src/backend/{shared → runtime/turn}/flow-violation.ts +0 -0
  210. /package/src/backend/{shared → runtime}/usage.ts +0 -0
  211. /package/src/core/{scripting/lua-runner.ts → scripts/lua.ts} +0 -0
  212. /package/src/frontend/native/{routes → bridge/routes}/index.ts +0 -0
  213. /package/src/frontend/native/{routes → bridge/routes}/memory.ts +0 -0
  214. /package/src/frontend/native/{routes → bridge/routes}/mesh.ts +0 -0
  215. /package/src/frontend/native/{routes → bridge/routes}/models.ts +0 -0
  216. /package/src/frontend/native/{routes → bridge/routes}/params.ts +0 -0
  217. /package/src/frontend/native/{routes → bridge/routes}/table.ts +0 -0
  218. /package/src/frontend/native/{tool-result.ts → turn/tool-result.ts} +0 -0
  219. /package/src/frontend/whatsapp/{auth-state.ts → connection/auth-state.ts} +0 -0
  220. /package/src/frontend/whatsapp/{identity.ts → connection/identity.ts} +0 -0
  221. /package/src/frontend/whatsapp/{pairing-lock.ts → connection/pairing-lock.ts} +0 -0
  222. /package/src/frontend/whatsapp/{pairing.ts → connection/pairing.ts} +0 -0
  223. /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-spec.js";
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 the soul's first-person writing — a reflection,
356
- * never a fact source (plan §3.1) — so the filename pattern excludes it.
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();