talon-agent 3.12.5 → 3.14.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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Persistent-memory view — what the model actually sees of `memory.md`.
3
+ *
4
+ * `memory.md` grows without bound and only the first
5
+ * `MEMORY_INJECT_MAX_CHARS` reach the prompt. Head-slicing that file makes
6
+ * the cut positional while the content is not priority-ordered, so the
7
+ * *least* durable material evicts the most durable. Observed on the live
8
+ * deployment: a 23.6k-char file cut at line 46 of 113, where everything
9
+ * above the line was one facts block plus three near-duplicate
10
+ * `## Inbox / CI Watch (as of …, Run #N)` status snapshots, and
11
+ * `## Active Investigations` — with the root-cause analysis in it — fell
12
+ * below the cut and was never injected at all.
13
+ *
14
+ * So this module makes truncation a *selection* problem:
15
+ *
16
+ * 1. Split the file into `## ` sections (an `h3` stays with its parent).
17
+ * 2. Collapse "state families" — sections whose headings differ only by a
18
+ * trailing timestamp / run number — down to the newest member. Three
19
+ * CI-watch snapshots describing the same recurring failures are one
20
+ * fact, not three.
21
+ * 3. Order by tier (directives → people → active work → general → status
22
+ * → historical), stable within a tier.
23
+ * 4. Emit whole sections until the budget is spent, and name the ones
24
+ * that didn't make it so the model knows to `Read` for them.
25
+ *
26
+ * Three deliberate constraints:
27
+ *
28
+ * - **Under the cap, output is byte-identical to the input.** Reordering
29
+ * only happens when the alternative is losing content, so deployments
30
+ * whose memory still fits see no behaviour change and no prompt-cache
31
+ * churn.
32
+ * - **Surviving sections are emitted in file order**, not tier order. The
33
+ * ranking decides *what* survives, not how it reads — and a
34
+ * tier-ordered body would rewrite the prompt prefix every time a
35
+ * section's heading changed tier.
36
+ * - **If nothing fits, fall back to head-slicing.** A single section
37
+ * larger than the whole budget must still give the model something
38
+ * rather than an empty memory block.
39
+ */
40
+
41
+ // ── Tunables ────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Cap on how much of `memory.md` is injected into the static prompt.
45
+ * Memory files grow without bound over months of use; injecting all of it
46
+ * bloats EVERY session from its very first turn. 12k chars is roughly 3k
47
+ * tokens — past that, the model gets the ranked selection below plus a
48
+ * pointer, and can Read the file on demand.
49
+ */
50
+ export const MEMORY_INJECT_MAX_CHARS = 12_000;
51
+
52
+ /** Cap on how many omitted section titles are named before eliding the rest. */
53
+ const MAX_OMITTED_NAMED = 8;
54
+
55
+ // ── Tiers ───────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Section priority, most-durable first. `status` sits second-to-last because
59
+ * a snapshot is true *now* and false later — it is the content most safely
60
+ * dropped and most cheaply re-derived. `historical` is last for the mirror
61
+ * reason: it will never change again, so it is the least urgent to carry.
62
+ */
63
+ const TIER_ORDER = [
64
+ "directive",
65
+ "people",
66
+ "active",
67
+ "general",
68
+ "status",
69
+ "historical",
70
+ ] as const;
71
+
72
+ type Tier = (typeof TIER_ORDER)[number];
73
+
74
+ const tier = (name: Tier): number => TIER_ORDER.indexOf(name);
75
+
76
+ /**
77
+ * Heading classifiers. First match wins, so order is the policy: a heading
78
+ * reading "Active investigation status" is active work, not a snapshot.
79
+ * `general` is the unmatched default and ranks above `status` — a section
80
+ * nobody labelled is more likely durable knowledge than a dated snapshot.
81
+ */
82
+ const MATCHERS: readonly { readonly tier: Tier; readonly match: RegExp }[] = [
83
+ {
84
+ tier: "historical",
85
+ match:
86
+ /\b(historical|history|archived?|superseded|resolved|closed|completed|past|old)\b/i,
87
+ },
88
+ {
89
+ tier: "directive",
90
+ match: /\b(directives?|preferences?|instructions?|rules?)\b/i,
91
+ },
92
+ {
93
+ tier: "active",
94
+ match:
95
+ /\b(active|current|investigations?|open|pending|todo|follow.?ups?|blocked|in.progress|priorit(?:y|ies)|goals?)\b/i,
96
+ },
97
+ {
98
+ tier: "people",
99
+ match: /\b(users?|people|person|contacts?|team|about)\b/i,
100
+ },
101
+ {
102
+ tier: "status",
103
+ match: /\b(as of|run #|status|health|watch|inbox|snapshot|report)\b/i,
104
+ },
105
+ ];
106
+
107
+ const STATUS_TIER = tier("status");
108
+ const GENERAL_TIER = tier("general");
109
+
110
+ function classify(title: string): number {
111
+ for (const m of MATCHERS) {
112
+ if (m.match.test(title)) return tier(m.tier);
113
+ }
114
+ return GENERAL_TIER;
115
+ }
116
+
117
+ // ── Parsing ─────────────────────────────────────────────────────────────────
118
+
119
+ type Section = {
120
+ /** Heading with markup and trailing qualifiers stripped — the display name. */
121
+ readonly title: string;
122
+ /** Whole section including its heading and any `###` children. */
123
+ readonly body: string;
124
+ /** Index in the original file, for stable ordering within a tier. */
125
+ readonly order: number;
126
+ readonly tier: number;
127
+ /** Key shared by every member of a state family (see `familyKey`). */
128
+ readonly family: string;
129
+ /** Recency score used to pick a family's survivor; higher is newer. */
130
+ readonly recency: string;
131
+ };
132
+
133
+ /** Strip `## ` markup and bold markers from a heading line. */
134
+ function headingTitle(heading: string): string {
135
+ return heading
136
+ .replace(/^#+\s*/, "")
137
+ .replace(/\*\*/g, "")
138
+ .trim();
139
+ }
140
+
141
+ /**
142
+ * The family a section belongs to: its title minus temporal qualifiers.
143
+ * `Inbox / CI Watch (as of 2026-07-03, Run #134)` → `inbox / ci watch`. That
144
+ * parenthetical is exactly what makes each snapshot look unique while the
145
+ * underlying topic repeats, so removing it is what lets a family collapse.
146
+ */
147
+ function familyKey(title: string): string {
148
+ return title
149
+ .replace(/\s*\([^)]*\)\s*$/, "")
150
+ .replace(/\s*[—–-]\s*(?:as of|run #).*$/i, "")
151
+ .trim()
152
+ .toLowerCase();
153
+ }
154
+
155
+ /**
156
+ * A sortable recency key: `<date>|<run>`, both fixed-width so a plain string
157
+ * compare orders correctly. Both fields are zero-filled when absent rather
158
+ * than left empty — an empty field would make the `|` separator the first
159
+ * character compared, and `|` sorts *above* every digit, which would rank an
160
+ * undated section as newer than a dated one. A heading with neither field
161
+ * scores lowest and falls back to file order, where the dream agent puts the
162
+ * newest section first.
163
+ */
164
+ function recencyKey(heading: string): string {
165
+ const date = /(\d{4}-\d{2}-\d{2})/.exec(heading)?.[1] ?? "0000-00-00";
166
+ const run = /\brun\s*#\s*(\d+)/i.exec(heading)?.[1] ?? "0";
167
+ return `${date}|${run.padStart(10, "0")}`;
168
+ }
169
+
170
+ /**
171
+ * Split content into a leading preamble (the `#` title and anything before
172
+ * the first `## `) plus one entry per `## ` section. The split is on `## `
173
+ * only, so an `h3` travels with the section it belongs to.
174
+ */
175
+ function parseSections(content: string): {
176
+ preamble: string;
177
+ sections: Section[];
178
+ } {
179
+ const parts = content.split(/^(?=## )/m);
180
+ const first = parts[0] ?? "";
181
+ const preamble = first.startsWith("## ") ? "" : (parts.shift() ?? "");
182
+ const sections: Section[] = [];
183
+ for (const body of parts) {
184
+ if (!body.trim()) continue;
185
+ const heading = body.split("\n", 1)[0] ?? "";
186
+ const title = headingTitle(heading);
187
+ sections.push({
188
+ title,
189
+ body,
190
+ order: sections.length,
191
+ tier: classify(title),
192
+ family: familyKey(title),
193
+ recency: recencyKey(heading),
194
+ });
195
+ }
196
+ return { preamble, sections };
197
+ }
198
+
199
+ /**
200
+ * Keep one section per state family: the newest by `recency`, and on a tie
201
+ * the one that appeared first. Only the `status` tier collapses — two
202
+ * sections about people are two different people, not two snapshots of one.
203
+ */
204
+ function collapseFamilies(sections: readonly Section[]): {
205
+ kept: Section[];
206
+ dropped: Section[];
207
+ } {
208
+ const winners = new Map<string, Section>();
209
+ for (const s of sections) {
210
+ if (s.tier !== STATUS_TIER) continue;
211
+ const held = winners.get(s.family);
212
+ if (!held || s.recency > held.recency) winners.set(s.family, s);
213
+ }
214
+ const kept: Section[] = [];
215
+ const dropped: Section[] = [];
216
+ for (const s of sections) {
217
+ if (s.tier !== STATUS_TIER || winners.get(s.family) === s) kept.push(s);
218
+ else dropped.push(s);
219
+ }
220
+ return { kept, dropped };
221
+ }
222
+
223
+ // ── Public API ──────────────────────────────────────────────────────────────
224
+
225
+ /** The memory block to inject, plus what had to be left out of it. */
226
+ export type MemoryView = {
227
+ /** Text to render into the persistent-memory prompt section. */
228
+ text: string;
229
+ /** True when the file did not fit whole — drives the "Read for more" note. */
230
+ truncated: boolean;
231
+ /** Human-readable list of ranked-out sections, or "" when none. */
232
+ omitted: string;
233
+ };
234
+
235
+ /** Head-slice at the cap, snapping back to a newline so the cut isn't mid-line. */
236
+ function headSlice(content: string, budget: number): string {
237
+ const head = content.slice(0, budget);
238
+ const lastNewline = head.lastIndexOf("\n");
239
+ return (lastNewline > 0 ? head.slice(0, lastNewline) : head).trimEnd();
240
+ }
241
+
242
+ /** Render omitted titles as one prose list, eliding a long tail. */
243
+ function formatOmitted(titles: readonly string[]): string {
244
+ if (titles.length === 0) return "";
245
+ if (titles.length <= MAX_OMITTED_NAMED) return titles.join("; ");
246
+ const named = titles.slice(0, MAX_OMITTED_NAMED).join("; ");
247
+ return `${named}; and ${titles.length - MAX_OMITTED_NAMED} more`;
248
+ }
249
+
250
+ /**
251
+ * Render the injectable view of `memory.md`.
252
+ *
253
+ * Under the cap this is the identity function — same bytes in, same bytes
254
+ * out. Over the cap, sections are collapsed and ranked as described in the
255
+ * module docstring, and anything that didn't fit is named in `omitted`.
256
+ */
257
+ export function renderMemoryView(
258
+ content: string,
259
+ budget: number = MEMORY_INJECT_MAX_CHARS,
260
+ ): MemoryView {
261
+ if (content.length <= budget) {
262
+ return { text: content, truncated: false, omitted: "" };
263
+ }
264
+
265
+ const { preamble, sections } = parseSections(content);
266
+ const { kept, dropped } = collapseFamilies(sections);
267
+
268
+ // Tier first, then original file order — stable within a tier so the
269
+ // author's own ordering survives wherever priority doesn't decide.
270
+ const ranked = [...kept].sort((a, b) => a.tier - b.tier || a.order - b.order);
271
+
272
+ const head = preamble.trimEnd();
273
+ let spent = head.length;
274
+ const chosen: Section[] = [];
275
+ const omitted: Section[] = [...dropped];
276
+ for (const s of ranked) {
277
+ const cost = s.body.trimEnd().length + 2; // body + section separator
278
+ if (spent + cost <= budget) {
279
+ spent += cost;
280
+ chosen.push(s);
281
+ } else {
282
+ omitted.push(s);
283
+ }
284
+ }
285
+
286
+ // Nothing fit — a single section is bigger than the whole budget. Degrade
287
+ // to the old behaviour rather than injecting an empty memory block.
288
+ if (chosen.length === 0) {
289
+ return { text: headSlice(content, budget), truncated: true, omitted: "" };
290
+ }
291
+
292
+ chosen.sort((a, b) => a.order - b.order);
293
+ const body = chosen.map((s) => s.body.trimEnd()).join("\n\n");
294
+
295
+ return {
296
+ text: head ? `${head}\n\n${body}` : body,
297
+ truncated: true,
298
+ omitted: formatOmitted(
299
+ omitted
300
+ .sort((a, b) => a.tier - b.tier || a.order - b.order)
301
+ .map((s) => s.title),
302
+ ),
303
+ };
304
+ }
package/src/util/paths.ts CHANGED
@@ -17,7 +17,11 @@
17
17
  * trigger-runs/ Trigger script bodies + run logs
18
18
  * workspace/ User-facing workspace (memory, uploads, logs)
19
19
  * memory/
20
+ * memory.md Durable memory — the dream agent owns it
21
+ * state.md Live operational status — the heartbeat owns
22
+ * it, rewritten in full each run
20
23
  * daily/ Per-day memory notes (YYYY-MM-DD.md)
24
+ * archive/ Pruned memory, kept for audit (YYYY-MM.md)
21
25
  * scripts/ Agent script bodies
22
26
  * skills/ Skill folders (SKILL.md + resources)
23
27
  * uploads/
@@ -56,6 +60,12 @@ export const dirs = {
56
60
  memory: resolve(TALON_ROOT, "workspace", "memory"),
57
61
  /** Daily memory notes: ~/.talon/workspace/memory/daily/ */
58
62
  dailyMemory: resolve(TALON_ROOT, "workspace", "memory", "daily"),
63
+ /**
64
+ * Pruned-memory archive: ~/.talon/workspace/memory/archive/
65
+ * Monthly files the dream agent appends to when it drops an entry, so
66
+ * forgetting stays auditable instead of silent.
67
+ */
68
+ memoryArchive: resolve(TALON_ROOT, "workspace", "memory", "archive"),
59
69
  /** Sticker packs: ~/.talon/workspace/stickers/ */
60
70
  stickers: resolve(TALON_ROOT, "workspace", "stickers"),
61
71
  /** Prompt files: ~/.talon/prompts/ */
@@ -110,6 +120,16 @@ export const files = {
110
120
  mediaIndex: resolve(TALON_ROOT, "data", "media-index.json"),
111
121
  /** Persistent memory: ~/.talon/workspace/memory/memory.md */
112
122
  memory: resolve(TALON_ROOT, "workspace", "memory", "memory.md"),
123
+ /**
124
+ * Live operational state: ~/.talon/workspace/memory/state.md
125
+ *
126
+ * Separate from `memory.md` on purpose. The heartbeat rewrites this file
127
+ * whole on every run; nothing appends to it. Keeping status snapshots out
128
+ * of the durable store is what stops "as of Run #N" sections accreting
129
+ * there — on the live deployment three of them had grown to 15.8k chars,
130
+ * pushing the actual knowledge past the prompt's injection cap.
131
+ */
132
+ state: resolve(TALON_ROOT, "workspace", "memory", "state.md"),
113
133
  /** Self-bootstrapping identity: ~/.talon/workspace/identity.md */
114
134
  identity: resolve(TALON_ROOT, "workspace", "identity.md"),
115
135
  /** Telegram userbot session: ~/.talon/.user-session */