talon-agent 4.5.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "4.5.0",
3
+ "version": "4.6.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  "test:watch": "vitest",
81
81
  "test:coverage": "vitest run --coverage",
82
82
  "typecheck": "tsc --noEmit",
83
- "lint": "oxlint src/",
83
+ "lint": "oxlint --max-warnings 0 src/",
84
84
  "depcruise": "node scripts/check-architecture.mjs",
85
85
  "ratchets": "node scripts/check-ratchets.mjs",
86
86
  "function-size": "node scripts/check-function-size.mjs",
@@ -0,0 +1,7 @@
1
+ ## Persistent Memory
2
+
3
+ The following is your memory store's core view — the durable claims that matter most: pinned items, standing directives, who you are talking to, your highest-salience facts, and current status. It is selected and budgeted, not a whole file, so anything not here is not gone. The rest of the store is one query away (`talon memory list`, or `/memory` in chat), and the full rendered projection is at ~/.talon/workspace/memory/memory.md.
4
+
5
+ Durable facts, not live status. Reference it naturally; the Memory and Recall policy in this prompt governs how you add to it and keep it current.
6
+
7
+ {{content}}
@@ -33,7 +33,6 @@ import {
33
33
  accountFailedTurn,
34
34
  nameSessionFromFirstMessage,
35
35
  finishCallbackTurn,
36
- type StreamState,
37
36
  } from "../shared/index.js";
38
37
  import type { RemoteAgentClient } from "./client.js";
39
38
  import type { RemoteServerBindings } from "./server-bindings.js";
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The **core view** — the store's contribution to the static system
3
+ * prompt (docs/memory-persona-plan.md §3.4).
4
+ *
5
+ * Retrieval has two tiers, and the boundary between them is a
6
+ * prompt-cache invariant:
7
+ *
8
+ * - **Core view** (this module) — pinned directives, the relationship
9
+ * layer and the top durable facts plus fresh `state`, computed
10
+ * **once per session build** and living in `staticText`. Budgeted,
11
+ * never truncated mid-section.
12
+ * - **Turn retrieval** (PR 8) — keyed to the incoming message and
13
+ * injected into the *user turn*, never into `prepareSystemPrompt()`.
14
+ *
15
+ * Because `prepareSystemPrompt` freezes the assembled prompt per
16
+ * `(chatId, sessionEpoch)`, a fact learned at turn 3 cannot appear in
17
+ * that session's core view. That is the design, not a bug: reaching for
18
+ * `notifyPromptInputsChanged()` to "fix" it would force a full-prompt
19
+ * cache write across every live session on every learned fact (plan
20
+ * §3.6). Nothing here may ever invalidate a snapshot.
21
+ *
22
+ * The store is consulted exactly once per build — one `listMemories`
23
+ * call, ranked and filtered in memory — so the cost of the core view is
24
+ * one query per session, not one per kind and not one per turn.
25
+ */
26
+
27
+ import { listMemories, type MemoryRow } from "../../storage/memory.js";
28
+ import { renderMemoryMarkdown } from "./render.js";
29
+
30
+ // ── Tunables ────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Char budget for the whole core view (~2 k tokens). Deliberately well
34
+ * under the 12 k file-injection cap: this block is paid for by every
35
+ * session on every frontend, and the rest of the store is a `talon
36
+ * memory list` away.
37
+ */
38
+ export const CORE_VIEW_MAX_CHARS = 8_000;
39
+
40
+ /**
41
+ * A `state` row older than this is stale and left out — a status
42
+ * snapshot nobody has touched in a week is noise in a prompt, and the
43
+ * heartbeat rewrites the live ones far more often than that. Pinning
44
+ * overrides it, which is what pinning is for.
45
+ */
46
+ const STATE_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
47
+
48
+ /**
49
+ * Rows pulled from the store before ranking. Generous: the budget, not
50
+ * this number, is what decides how much reaches the prompt.
51
+ */
52
+ const CORE_VIEW_ROW_LIMIT = 500;
53
+
54
+ /**
55
+ * Kinds eligible for the core view, most durable first. `episode` and
56
+ * `reflection` are absent on purpose (plan §3.1): episodes decay fast
57
+ * and are a retrieval-only source, and the diary is the persona layer
58
+ * and never a fact source. Neither enters the core view — not even
59
+ * pinned, since a pin must not promote a kind past the tier boundary.
60
+ */
61
+ const CORE_KINDS: readonly MemoryRow["kind"][] = [
62
+ "directive",
63
+ "relationship",
64
+ "fact",
65
+ ];
66
+
67
+ // ── Selection ───────────────────────────────────────────────────────────────
68
+
69
+ /** Salience first, then recency; id breaks the tie so the order is stable. */
70
+ function byRank(a: MemoryRow, b: MemoryRow): number {
71
+ return b.salience - a.salience || b.lastSeenAt - a.lastSeenAt || a.id - b.id;
72
+ }
73
+
74
+ function eligible(row: MemoryRow, now: number): boolean {
75
+ if (row.kind === "state")
76
+ return row.pinned || now - row.lastSeenAt <= STATE_FRESH_MS;
77
+ return CORE_KINDS.includes(row.kind);
78
+ }
79
+
80
+ /**
81
+ * Take rows until the budget is spent. A coarse pre-cut on row text
82
+ * only: the render does the exact accounting and cuts on section
83
+ * boundaries, so this exists to keep a 500-row store from being ranked
84
+ * into a document that can hold a fraction of it.
85
+ */
86
+ function withinBudget(rows: readonly MemoryRow[], budget: number): MemoryRow[] {
87
+ const taken: MemoryRow[] = [];
88
+ let spent = 0;
89
+ for (const row of rows) {
90
+ if (spent > budget) break;
91
+ spent += row.text.length;
92
+ taken.push(row);
93
+ }
94
+ return taken;
95
+ }
96
+
97
+ /** Options shared by the selection and the render. */
98
+ export type CoreViewOptions = {
99
+ /** Char budget for the rendered view. Defaults to `CORE_VIEW_MAX_CHARS`. */
100
+ budget?: number;
101
+ /** Clock for the `state` freshness window (tests pin it). */
102
+ now?: number;
103
+ };
104
+
105
+ /**
106
+ * The ordered rows behind the core view: pinned first (any eligible
107
+ * kind), then directives, then the relationship layer, then facts by
108
+ * salience, then fresh `state`.
109
+ *
110
+ * The render re-sorts with the same ranking (`render.ts` `compareRows`),
111
+ * so this order is what reaches the prompt rather than merely what is
112
+ * handed over — but the selection is asserted here, where the tiers are
113
+ * decided, and not through the rendered bytes.
114
+ */
115
+ export function selectCoreRows(opts: CoreViewOptions = {}): MemoryRow[] {
116
+ const now = opts.now ?? Date.now();
117
+ const live = listMemories({ limit: CORE_VIEW_ROW_LIMIT }).filter((row) =>
118
+ eligible(row, now),
119
+ );
120
+ const tiers: MemoryRow[][] = [
121
+ live.filter((row) => row.pinned).sort(byRank),
122
+ ...CORE_KINDS.map((kind) =>
123
+ live.filter((row) => !row.pinned && row.kind === kind).sort(byRank),
124
+ ),
125
+ live.filter((row) => !row.pinned && row.kind === "state").sort(byRank),
126
+ ];
127
+ const seen = new Set<number>();
128
+ const ordered = tiers.flat().filter((row) => {
129
+ if (seen.has(row.id)) return false;
130
+ seen.add(row.id);
131
+ return true;
132
+ });
133
+ return withinBudget(ordered, opts.budget ?? CORE_VIEW_MAX_CHARS);
134
+ }
135
+
136
+ // ── Rendering ───────────────────────────────────────────────────────────────
137
+
138
+ /** A rendered core view, with what it cost to carry. */
139
+ export type CoreView = {
140
+ text: string;
141
+ /** Rows selected into the view (0 means the store had nothing to say). */
142
+ rows: number;
143
+ /** Rendered size — the number recorded as `prompt.memory_chars`. */
144
+ chars: number;
145
+ };
146
+
147
+ /**
148
+ * Render the core view as a `memory.md`-shaped document, reusing the
149
+ * projection renderer so the store reads identically in the prompt and
150
+ * in the file. Budgeted, not truncated: sections are dropped whole from
151
+ * the bottom of the ranking and the tail is named.
152
+ */
153
+ export function renderCoreView(opts: CoreViewOptions = {}): CoreView {
154
+ const budget = opts.budget ?? CORE_VIEW_MAX_CHARS;
155
+ const rows = selectCoreRows({ ...opts, budget });
156
+ const text = renderMemoryMarkdown({ rows, budget });
157
+ return { text, rows: rows.length, chars: text.length };
158
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The one flag that gates the typed memory store's read path
3
+ * (docs/memory-persona-rollout.md, "One flag gates the whole new path").
4
+ *
5
+ * Kept in its own module because `core/prompt/assemble.ts` asks the
6
+ * question but must not reach into `storage/` to answer it: the prompt
7
+ * layer sees the flag and the core view, nothing else.
8
+ *
9
+ * Read per call, never cached: tests flip it between builds, and the
10
+ * cost is an env lookup on a path that already reads files from disk.
11
+ */
12
+
13
+ /** True when `TALON_MEMORY_STORE=1`. Off by default until PR 9 is measured. */
14
+ export function memoryStoreEnabled(): boolean {
15
+ return process.env.TALON_MEMORY_STORE === "1";
16
+ }
@@ -19,6 +19,10 @@
19
19
  * 4. Persistent memory (ranked, capped) prompts/system/persistent-memory.md
20
20
  * wrapping ~/.talon/workspace/memory/memory.md
21
21
  * via memory-view.ts
22
+ * — or, with TALON_MEMORY_STORE=1 and a
23
+ * non-empty store, the typed store's core
24
+ * view (memory/core-view.ts) wrapped in
25
+ * prompts/system/memory-core-view.md
22
26
  * 4.5 Live state (capped) prompts/system/live-state.md
23
27
  * wrapping ~/.talon/workspace/memory/state.md
24
28
  * (heartbeat-owned, rewritten whole)
@@ -65,6 +69,9 @@ import { renderMemoryView } from "./memory-view.js";
65
69
  import { renderWorkspaceListing } from "./workspace-listing.js";
66
70
  import { renderSkillsPrompt } from "../../storage/skills.js";
67
71
  import { renderStickerLibraryPrompt } from "../../storage/stickers.js";
72
+ import { recordHistogram } from "../../storage/metrics.js";
73
+ import { renderCoreView } from "../memory/core-view.js";
74
+ import { memoryStoreEnabled } from "../memory/flag.js";
68
75
  import { getSoul } from "../soul/service.js";
69
76
 
70
77
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -109,6 +116,14 @@ export function joinSystemPromptParts(parts: SystemPromptParts): string {
109
116
  */
110
117
  export const STATE_INJECT_MAX_CHARS = 2_000;
111
118
 
119
+ /**
120
+ * Size of the injected memory block, recorded on every build from both
121
+ * tiers. The whole point of the flag being default-off is that these two
122
+ * populations can be compared before the store becomes the default path
123
+ * (rollout Decision 4).
124
+ */
125
+ const MEMORY_CHARS_METRIC = "prompt.memory_chars";
126
+
112
127
  // ── Helpers ─────────────────────────────────────────────────────────────────
113
128
 
114
129
  function readOptionalFile(path: string): string {
@@ -122,6 +137,50 @@ function readOptionalFile(path: string): string {
122
137
 
123
138
  let lastLoggedPromptKey = "";
124
139
 
140
+ /** A memory block ready for the static prompt, with the label it logs under. */
141
+ type MemorySection = { section: string; label: string };
142
+
143
+ /**
144
+ * The store's core view, when `TALON_MEMORY_STORE` is on and the store
145
+ * has something to say. Budgeted (not truncated) and computed once per
146
+ * build — this is the session-frozen tier of plan §3.4, so it belongs in
147
+ * `staticText` and nowhere else.
148
+ *
149
+ * An empty store falls through to the file, which is what makes the flag
150
+ * safe to turn on before the import has ever run.
151
+ */
152
+ function coreViewSection(): MemorySection | undefined {
153
+ if (!memoryStoreEnabled()) return undefined;
154
+ const view = renderCoreView();
155
+ if (view.rows === 0) return undefined;
156
+ recordHistogram(MEMORY_CHARS_METRIC, view.chars);
157
+ return {
158
+ section: loadSystemTemplate("memory-core-view", { content: view.text }),
159
+ label: "memory(store)",
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The rendered `memory.md` file, ranked and capped. The path every
165
+ * deployment is on until the flag flips: over the cap the view ranks
166
+ * sections rather than head-slicing, so durable knowledge isn't evicted
167
+ * by whatever happens to sit at the top of the file (memory-view.ts).
168
+ */
169
+ function memoryFileSection(): MemorySection | undefined {
170
+ const memory = readOptionalFile(pathFiles.memory);
171
+ if (!memory) return undefined;
172
+ const { text, truncated, omitted } = renderMemoryView(memory);
173
+ recordHistogram(MEMORY_CHARS_METRIC, text.length);
174
+ return {
175
+ section: loadSystemTemplate("persistent-memory", {
176
+ content: text,
177
+ truncated: truncated ? "yes" : undefined,
178
+ omitted: omitted || undefined,
179
+ }),
180
+ label: truncated ? "memory(ranked)" : "memory",
181
+ };
182
+ }
183
+
125
184
  // ── Assembly ────────────────────────────────────────────────────────────────
126
185
 
127
186
  /** Inputs for `assembleSystemPrompt`. */
@@ -182,22 +241,16 @@ export function assembleSystemPrompt(
182
241
  loaded.push(frontendFile.replace(".md", ""));
183
242
  }
184
243
 
185
- // 4. Persistent memory — size-capped so a memory file that has grown
186
- // for months can't bloat every session from turn 0. Over the cap the
187
- // view ranks sections rather than head-slicing, so durable knowledge
188
- // isn't evicted by whatever happens to sit at the top of the file
189
- // (see prompt/memory-view.ts).
190
- const memory = readOptionalFile(pathFiles.memory);
191
- if (memory) {
192
- const { text, truncated, omitted } = renderMemoryView(memory);
193
- staticParts.push(
194
- loadSystemTemplate("persistent-memory", {
195
- content: text,
196
- truncated: truncated ? "yes" : undefined,
197
- omitted: omitted || undefined,
198
- }),
199
- );
200
- loaded.push(truncated ? "memory(ranked)" : "memory");
244
+ // 4. Persistent memory — the store's core view when the flag is on and
245
+ // the store has rows, else the ranked, size-capped `memory.md` file,
246
+ // so a memory file that has grown for months can't bloat every
247
+ // session from turn 0. Static either way: both tiers are frozen for
248
+ // the session's lifetime (plan §3.4/§3.6). Anything learned
249
+ // mid-session reaches the model through turn retrieval instead.
250
+ const memorySection = coreViewSection() ?? memoryFileSection();
251
+ if (memorySection) {
252
+ staticParts.push(memorySection.section);
253
+ loaded.push(memorySection.label);
201
254
  }
202
255
 
203
256
  // 4.5. Live state — the heartbeat's rewritten-whole status snapshot, kept
@@ -27,15 +27,16 @@ import asset13 from "../../../prompts/system/daily-memory.md" with { type: "file
27
27
  import asset14 from "../../../prompts/system/goals.md" with { type: "file" };
28
28
  import asset15 from "../../../prompts/system/heartbeat-agent.md" with { type: "file" };
29
29
  import asset16 from "../../../prompts/system/live-state.md" with { type: "file" };
30
- import asset17 from "../../../prompts/system/memory-recall.md" with { type: "file" };
31
- import asset18 from "../../../prompts/system/persistent-memory.md" with { type: "file" };
32
- import asset19 from "../../../prompts/system/skills.md" with { type: "file" };
33
- import asset20 from "../../../prompts/system/triggers.md" with { type: "file" };
34
- import asset21 from "../../../prompts/system/workspace.md" with { type: "file" };
35
- import asset22 from "../../../prompts/teams.md" with { type: "file" };
36
- import asset23 from "../../../prompts/telegram.md" with { type: "file" };
37
- import asset24 from "../../../prompts/terminal.md" with { type: "file" };
38
- import asset25 from "../../../prompts/whatsapp.md" with { type: "file" };
30
+ import asset17 from "../../../prompts/system/memory-core-view.md" with { type: "file" };
31
+ import asset18 from "../../../prompts/system/memory-recall.md" with { type: "file" };
32
+ import asset19 from "../../../prompts/system/persistent-memory.md" with { type: "file" };
33
+ import asset20 from "../../../prompts/system/skills.md" with { type: "file" };
34
+ import asset21 from "../../../prompts/system/triggers.md" with { type: "file" };
35
+ import asset22 from "../../../prompts/system/workspace.md" with { type: "file" };
36
+ import asset23 from "../../../prompts/teams.md" with { type: "file" };
37
+ import asset24 from "../../../prompts/telegram.md" with { type: "file" };
38
+ import asset25 from "../../../prompts/terminal.md" with { type: "file" };
39
+ import asset26 from "../../../prompts/whatsapp.md" with { type: "file" };
39
40
 
40
41
  /** rel path (posix, under prompts/) → embedded file path (/$bunfs/… when compiled). */
41
42
  const ASSETS: Record<string, string> = {
@@ -56,15 +57,16 @@ const ASSETS: Record<string, string> = {
56
57
  "system/goals.md": asset14,
57
58
  "system/heartbeat-agent.md": asset15,
58
59
  "system/live-state.md": asset16,
59
- "system/memory-recall.md": asset17,
60
- "system/persistent-memory.md": asset18,
61
- "system/skills.md": asset19,
62
- "system/triggers.md": asset20,
63
- "system/workspace.md": asset21,
64
- "teams.md": asset22,
65
- "telegram.md": asset23,
66
- "terminal.md": asset24,
67
- "whatsapp.md": asset25,
60
+ "system/memory-core-view.md": asset17,
61
+ "system/memory-recall.md": asset18,
62
+ "system/persistent-memory.md": asset19,
63
+ "system/skills.md": asset20,
64
+ "system/triggers.md": asset21,
65
+ "system/workspace.md": asset22,
66
+ "teams.md": asset23,
67
+ "telegram.md": asset24,
68
+ "terminal.md": asset25,
69
+ "whatsapp.md": asset26,
68
70
  };
69
71
 
70
72
  /** Read an embedded prompt by its rel path (e.g. "system/cron.md"). */
@@ -22,6 +22,14 @@ const PHASE_HISTOGRAM: Record<TurnPhase, string> = {
22
22
 
23
23
  const legacyCounters = new Map<string, number>();
24
24
 
25
+ /**
26
+ * Process-lifetime distributions for values that are not chat-turn
27
+ * latencies and so have nowhere to live on a session record — currently
28
+ * `prompt.memory_chars`, the size of the injected memory block, which is
29
+ * what the `TALON_MEMORY_STORE` before/after comparison reads.
30
+ */
31
+ const processHistograms = new Map<string, MetricsLatencyAgg>();
32
+
25
33
  export type MetricsSnapshot = {
26
34
  counters: Record<string, number>;
27
35
  histograms: Record<
@@ -34,6 +42,18 @@ export function incrementCounter(name: string, amount = 1): void {
34
42
  legacyCounters.set(name, (legacyCounters.get(name) ?? 0) + amount);
35
43
  }
36
44
 
45
+ /**
46
+ * Record one observation of a non-turn distribution. Lifetime-scoped and
47
+ * in-process, like the legacy counters: it surfaces in `getMetrics()`
48
+ * (count / avg / min / max), not in the daily rollup.
49
+ */
50
+ export function recordHistogram(name: string, value: number): void {
51
+ if (!Number.isFinite(value)) return;
52
+ const agg = processHistograms.get(name) ?? emptyAgg();
53
+ mergeAgg(agg, { count: 1, sumMs: value, minMs: value, maxMs: value });
54
+ processHistograms.set(name, agg);
55
+ }
56
+
37
57
  function addCounter(
38
58
  counters: Record<string, number>,
39
59
  name: string,
@@ -158,10 +178,14 @@ function buildSnapshot(
158
178
  export function getMetrics(): MetricsSnapshot {
159
179
  const counters: Record<string, number> = {};
160
180
  for (const [key, value] of legacyCounters) addCounter(counters, key, value);
161
- return buildSnapshot(
181
+ const snapshot = buildSnapshot(
162
182
  getAllSessions().map(({ info }) => info.metrics.lifetime),
163
183
  counters,
164
184
  );
185
+ for (const [name, agg] of processHistograms) {
186
+ if (agg.count) snapshot.histograms[name] = snapshotAgg(agg);
187
+ }
188
+ return snapshot;
165
189
  }
166
190
 
167
191
  /** Today's (UTC) fleet snapshot, aggregated from the sessions' daily
@@ -177,5 +201,6 @@ export function getTodayMetrics(): MetricsSnapshot {
177
201
 
178
202
  export function resetMetrics(): void {
179
203
  legacyCounters.clear();
204
+ processHistograms.clear();
180
205
  resetAllSessionMetrics();
181
206
  }
@@ -59,7 +59,8 @@ function rowToGoal(row: Row): Goal {
59
59
  }
60
60
 
61
61
  /**
62
- * Expand the `(/* statuses *​/)` placeholder list in a statement to
62
+ * Expand the `statuses` placeholder — a block comment naming it inside
63
+ * the parenthesised IN list of a statement — to
63
64
  * one `?` per status. The status values themselves stay bound
64
65
  * parameters — only the placeholder count is interpolated, never
65
66
  * caller data.
@@ -8,7 +8,7 @@ export async function mapConcurrent<T, R>(
8
8
  limit: number,
9
9
  fn: (item: T, index: number) => Promise<R>,
10
10
  ): Promise<R[]> {
11
- const results: R[] = new Array(items.length);
11
+ const results: R[] = Array.from({ length: items.length });
12
12
  const errors: unknown[] = [];
13
13
  let next = 0;
14
14
  const worker = async (): Promise<void> => {