portable-agent-layer 0.71.0 → 0.73.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 (104) hide show
  1. package/package.json +10 -7
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/server.ts +7 -0
  4. package/src/cli/skill.ts +1 -1
  5. package/src/hooks/CompactRecover.ts +28 -86
  6. package/src/hooks/LedgerUnapplied.ts +3 -28
  7. package/src/hooks/LoadContext.ts +33 -60
  8. package/src/hooks/SecurityValidator.ts +16 -109
  9. package/src/hooks/handlers/agenda.ts +195 -7
  10. package/src/hooks/handlers/failure-principle.ts +19 -44
  11. package/src/hooks/handlers/session-intelligence.ts +13 -70
  12. package/src/hooks/lib/agenda-store.ts +2 -0
  13. package/src/hooks/lib/capture-store.ts +103 -0
  14. package/src/hooks/lib/compact-recall.ts +89 -0
  15. package/src/hooks/lib/failure-principle.ts +98 -0
  16. package/src/hooks/lib/goal-links.ts +83 -0
  17. package/src/hooks/lib/ledger-hook.ts +35 -0
  18. package/src/hooks/lib/ledger.ts +48 -1
  19. package/src/hooks/lib/models.ts +1 -1
  20. package/src/hooks/lib/projects.ts +5 -0
  21. package/src/hooks/lib/security-gate.ts +159 -0
  22. package/src/hooks/lib/session-context.ts +74 -0
  23. package/src/hooks/lib/settings.ts +2 -0
  24. package/src/hooks/lib/telos-goals.ts +8 -2
  25. package/src/hooks/lib/token-usage.ts +2 -0
  26. package/src/tools/agent/algorithm-reflect.ts +28 -97
  27. package/src/tools/agent/analyze.ts +19 -120
  28. package/src/tools/agent/handoff-note.ts +29 -77
  29. package/src/tools/agent/project.ts +34 -153
  30. package/src/tools/agent/relationship-note.ts +27 -46
  31. package/src/tools/agent/synthesize.ts +1 -1
  32. package/src/tools/agent/thread.ts +43 -123
  33. package/src/tools/control-room/attention.ts +146 -0
  34. package/src/tools/control-room/data.ts +78 -7
  35. package/src/tools/control-room/detail.ts +158 -0
  36. package/src/tools/control-room/matrix.ts +92 -20
  37. package/src/tools/control-room/prefs.ts +96 -0
  38. package/src/tools/control-room/server-config.ts +8 -0
  39. package/src/tools/control-room/server.ts +196 -11
  40. package/src/tools/control-room/snooze.ts +65 -0
  41. package/src/tools/control-room/static.ts +68 -0
  42. package/src/tools/control-room/ui/app.tsx +196 -50
  43. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  44. package/src/tools/control-room/ui/components/README.md +18 -0
  45. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  46. package/src/tools/control-room/ui/components/button.tsx +43 -0
  47. package/src/tools/control-room/ui/components/card.tsx +45 -0
  48. package/src/tools/control-room/ui/components/input.tsx +24 -0
  49. package/src/tools/control-room/ui/components/label.tsx +18 -0
  50. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  51. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  52. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  53. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  54. package/src/tools/control-room/ui/components/table.tsx +60 -0
  55. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  56. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  57. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  58. package/src/tools/control-room/ui/dist/index.html +19 -0
  59. package/src/tools/control-room/ui/format.ts +0 -12
  60. package/src/tools/control-room/ui/frame.tsx +125 -0
  61. package/src/tools/control-room/ui/index.html +2 -2
  62. package/src/tools/control-room/ui/lib/api.ts +27 -0
  63. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  64. package/src/tools/control-room/ui/lib/write.ts +43 -0
  65. package/src/tools/control-room/ui/package.json +28 -0
  66. package/src/tools/control-room/ui/parts.tsx +235 -0
  67. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  68. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  69. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  70. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  71. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  72. package/src/tools/control-room/ui/theme.css +127 -0
  73. package/src/tools/control-room/ui/vite.config.ts +36 -0
  74. package/src/tools/control-room/writes.ts +106 -0
  75. package/src/tools/ledger/outcomes.ts +9 -0
  76. package/src/tools/ledger/query.ts +2 -0
  77. package/src/tools/ledger/view.ts +34 -10
  78. package/src/tools/lib/algorithm-reflect.ts +84 -0
  79. package/src/tools/lib/analyze-report.ts +120 -0
  80. package/src/tools/lib/handoff-note.ts +102 -0
  81. package/src/tools/lib/note-flags.ts +59 -0
  82. package/src/tools/lib/project-isc.ts +212 -0
  83. package/src/tools/lib/relationship-reflect.ts +402 -0
  84. package/src/tools/lib/self-model.ts +499 -0
  85. package/src/tools/lib/session-usage.ts +216 -0
  86. package/src/tools/lib/skill-doctor.ts +457 -0
  87. package/src/tools/lib/thread.ts +119 -0
  88. package/src/tools/lib/token-report.ts +173 -0
  89. package/src/tools/lib/transcript-usage.ts +42 -0
  90. package/src/tools/lib/usage-buckets.ts +329 -0
  91. package/src/tools/relationship-reflect.ts +48 -412
  92. package/src/tools/self-model.ts +76 -558
  93. package/src/tools/session-summary.ts +8 -215
  94. package/src/tools/skill-doctor.ts +9 -444
  95. package/src/tools/token-cost.ts +18 -428
  96. package/src/tools/control-room/ui/agenda.tsx +0 -43
  97. package/src/tools/control-room/ui/agents.tsx +0 -67
  98. package/src/tools/control-room/ui/app.css +0 -857
  99. package/src/tools/control-room/ui/board.tsx +0 -82
  100. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  101. package/src/tools/control-room/ui/ledger.tsx +0 -136
  102. package/src/tools/control-room/ui/matrix.tsx +0 -117
  103. package/src/tools/control-room/ui/panel.tsx +0 -60
  104. package/src/tools/control-room/ui/signal.tsx +0 -161
@@ -10,10 +10,14 @@
10
10
  * answer to "what now" is rarely "open a repository".
11
11
  */
12
12
 
13
+ import { writeFile } from "node:fs/promises";
13
14
  import { matrix } from "../../tools/control-room/matrix";
15
+ import { handoffFile, readHandoffs, withWaitingOn } from "../../tools/lib/handoff-note";
14
16
  import { type AgendaMove, readAgenda, writeAgenda } from "../lib/agenda-store";
17
+ import { linkInputs, readGoalLinks, writeGoalLinks } from "../lib/goal-links";
15
18
  import { canInfer, inference } from "../lib/inference";
16
19
  import { logDebug, logError } from "../lib/log";
20
+ import { SONNET_MODEL } from "../lib/models";
17
21
  import { readAllProjects } from "../lib/projects";
18
22
  import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
19
23
  import { readTelosGoals } from "../lib/telos-goals";
@@ -21,6 +25,7 @@ import { logTokenUsage } from "../lib/token-usage";
21
25
 
22
26
  const FRESH_HOURS = 6;
23
27
  const MAX_PROJECTS_PER_GUESS = 40;
28
+ const MAX_WAITING_PER_RUN = 3;
24
29
 
25
30
  function hoursSince(iso: string, now: Date): number {
26
31
  const at = new Date(iso).getTime();
@@ -53,6 +58,45 @@ const SERVES_SCHEMA = {
53
58
  required: ["projects"] as const,
54
59
  };
55
60
 
61
+ const LINKS_SCHEMA = {
62
+ type: "object" as const,
63
+ additionalProperties: false,
64
+ properties: {
65
+ links: {
66
+ type: "array" as const,
67
+ description: "One entry per goal you were given, no others",
68
+ items: {
69
+ type: "object" as const,
70
+ additionalProperties: false,
71
+ properties: {
72
+ goalId: { type: "string" as const },
73
+ projects: {
74
+ type: "array" as const,
75
+ description:
76
+ "Slugs of the projects that move this goal forward, copied exactly. Empty when none does.",
77
+ items: { type: "string" as const },
78
+ },
79
+ },
80
+ required: ["goalId", "projects"] as const,
81
+ },
82
+ },
83
+ },
84
+ required: ["links"] as const,
85
+ };
86
+
87
+ const WAITING_SCHEMA = {
88
+ type: "object" as const,
89
+ additionalProperties: false,
90
+ properties: {
91
+ question: {
92
+ type: "string" as const,
93
+ description:
94
+ "The one thing the work needs from the person before it can move, in their own terms. Empty string when the handoff asks nothing of them.",
95
+ },
96
+ },
97
+ required: ["question"] as const,
98
+ };
99
+
56
100
  const MOVES_SCHEMA = {
57
101
  type: "object" as const,
58
102
  additionalProperties: false,
@@ -72,8 +116,13 @@ const MOVES_SCHEMA = {
72
116
  type: "string" as const,
73
117
  description: "One short clause naming the evidence it came from",
74
118
  },
119
+ project: {
120
+ type: "string" as const,
121
+ description:
122
+ "The slug of the project this move belongs to, exactly as given, or an empty string when it belongs to none",
123
+ },
75
124
  },
76
- required: ["move", "because"] as const,
125
+ required: ["move", "because", "project"] as const,
77
126
  },
78
127
  },
79
128
  },
@@ -129,10 +178,11 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
129
178
  maxTokens: 700,
130
179
  timeout: 90000,
131
180
  jsonSchema: SERVES_SCHEMA,
181
+ model: SONNET_MODEL,
132
182
  caller: "agenda-serves",
133
183
  sessionId,
134
184
  });
135
- if (result.usage) logTokenUsage("agenda-serves", result.usage);
185
+ if (result.usage) logTokenUsage("agenda-serves", result.usage, SONNET_MODEL);
136
186
  if (!result.success || !result.output) return 0;
137
187
 
138
188
  const parsed = parsePayload<{
@@ -157,7 +207,7 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
157
207
 
158
208
  function matrixBrief(): string {
159
209
  const grid = matrix();
160
- const lines = [...grid.now, ...grid.plan, ...grid.noise].map((item) => {
210
+ const lines = rankedItems(grid).map((item) => {
161
211
  const why = item.urgentBecause.join(", ") || "nothing pressing";
162
212
  const waiting = item.waitingOn ? ` — waiting on the user for: ${item.waitingOn}` : "";
163
213
  return `- [${item.kind}] ${item.label}: ${item.importantBecause}; ${why}${waiting}`;
@@ -165,7 +215,129 @@ function matrixBrief(): string {
165
215
  return lines.length > 0 ? lines.join("\n") : "Nothing is ranked yet.";
166
216
  }
167
217
 
218
+ /**
219
+ * Only when the goals or the project set moved. A stop that changed neither
220
+ * costs nothing, which is what makes this affordable on every session.
221
+ */
222
+ async function refreshGoalLinks(
223
+ sessionId?: string
224
+ ): Promise<"fresh" | "written" | "failed"> {
225
+ const goals = readTelosGoals();
226
+ const projects = readAllProjects().filter((p) => p.status === "active");
227
+ if (goals.length === 0 || projects.length === 0) return "fresh";
228
+
229
+ const inputs = linkInputs(
230
+ goals.map((g) => g.id),
231
+ projects.map((p) => p.name)
232
+ );
233
+ if (readGoalLinks()?.inputs === inputs) return "fresh";
234
+
235
+ const described = projects
236
+ .map(
237
+ (p) =>
238
+ `- ${p.name}: ${(p.goal ?? p.problem ?? "").replace(/\s+/g, " ").slice(0, 200) || "no description on record"}`
239
+ )
240
+ .join("\n");
241
+ const listed = goals.map((g) => `- ${g.id}: ${g.text}`).join("\n");
242
+
243
+ const result = await inference({
244
+ system: [
245
+ "You are told a person's stated goals and their active projects.",
246
+ "For each goal, name the projects that move it forward.",
247
+ "Judge from the goal and the project description only. A project that merely resembles a goal in subject does not serve it — it has to move it.",
248
+ "A goal that nothing serves gets an empty list, and that is a useful answer rather than a failure.",
249
+ "Copy project slugs exactly. Return one entry per goal you were given.",
250
+ ].join("\n"),
251
+ user: `Their goals:\n${listed}\n\nTheir active projects:\n${described}`,
252
+ maxTokens: 600,
253
+ timeout: 90000,
254
+ jsonSchema: LINKS_SCHEMA,
255
+ model: SONNET_MODEL,
256
+ caller: "agenda-goal-links",
257
+ sessionId,
258
+ });
259
+ if (result.usage) logTokenUsage("agenda-goal-links", result.usage, SONNET_MODEL);
260
+ if (!result.success || !result.output) return "failed";
261
+
262
+ const parsed = parsePayload<{ links: { goalId: string; projects: unknown }[] }>(
263
+ result.output,
264
+ "agenda:goal-links"
265
+ );
266
+ if (!parsed?.links) return "failed";
267
+
268
+ const goalIds = new Set(goals.map((g) => g.id));
269
+ const slugs = new Set(projects.map((p) => p.name));
270
+ const links = parsed.links
271
+ .filter((l) => goalIds.has(l.goalId))
272
+ .map((l) => ({
273
+ goalId: l.goalId,
274
+ projects: (Array.isArray(l.projects) ? l.projects : []).filter(
275
+ (slug): slug is string => typeof slug === "string" && slugs.has(slug)
276
+ ),
277
+ }));
278
+
279
+ await writeGoalLinks({ generatedAt: new Date().toISOString(), inputs, links });
280
+ return "written";
281
+ }
282
+
283
+ /**
284
+ * An auto handoff is a transcript excerpt nobody framed, so it never carries the
285
+ * question the work is stuck on — which is exactly the field three surfaces read.
286
+ * Most handoffs are stuck on nothing, so an empty answer is the common one.
287
+ */
288
+ async function extractWaitingOn(sessionId?: string): Promise<number> {
289
+ const pending = Object.entries(readHandoffs()).filter(
290
+ ([, h]) =>
291
+ h.source === "auto" && h.status === "in-progress" && h.handoff && !h.waitingOn
292
+ );
293
+ if (pending.length === 0) return 0;
294
+
295
+ let written = 0;
296
+ for (const [cwd, entry] of pending.slice(0, MAX_WAITING_PER_RUN)) {
297
+ const result = await inference({
298
+ system: [
299
+ "You are given the last exchange of a coding session that stopped mid-work.",
300
+ "Decide whether the work is blocked on the person — a decision only they can make, a question they were asked and did not answer, an approval the work needs.",
301
+ "Most sessions are not blocked on anyone. Waiting for an agent to continue is not being blocked on the person.",
302
+ "When it is blocked, answer with the question in their own terms, under fifteen words.",
303
+ "When it is not, answer with an empty string. That is the ordinary answer.",
304
+ ].join("\n"),
305
+ user: entry.handoff,
306
+ maxTokens: 120,
307
+ timeout: 60000,
308
+ jsonSchema: WAITING_SCHEMA,
309
+ model: SONNET_MODEL,
310
+ caller: "agenda-waiting-on",
311
+ sessionId,
312
+ });
313
+ if (result.usage) logTokenUsage("agenda-waiting-on", result.usage, SONNET_MODEL);
314
+ if (!result.success || !result.output) continue;
315
+
316
+ const parsed = parsePayload<{ question: string }>(result.output, "agenda:waiting-on");
317
+ const question = parsed?.question?.trim();
318
+ if (!question) continue;
319
+ const store = withWaitingOn(readHandoffs(), cwd, question);
320
+ await writeFile(handoffFile(), JSON.stringify(store, null, 2), "utf-8");
321
+ written++;
322
+ }
323
+ return written;
324
+ }
325
+
326
+ function rankedItems(grid: ReturnType<typeof matrix>) {
327
+ return [...grid.now, ...grid.plan, ...grid.noise];
328
+ }
329
+
330
+ /** A model asked for a project name will invent one; only a slug it was shown counts. */
331
+ function knownSlug(candidate: unknown, slugs: Set<string>): string | null {
332
+ return typeof candidate === "string" && slugs.has(candidate) ? candidate : null;
333
+ }
334
+
168
335
  async function writeMoves(sessionId?: string): Promise<boolean> {
336
+ const slugs = new Set(
337
+ rankedItems(matrix())
338
+ .filter((i) => i.kind === "project")
339
+ .map((i) => i.id)
340
+ );
169
341
  const result = await inference({
170
342
  system: [
171
343
  "You write the first three lines a person reads in the morning.",
@@ -173,20 +345,31 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
173
345
  "Write exactly three moves, most consequential first.",
174
346
  "A move is a sentence naming an action, not a project name: 'Send ACE the mapping one-pager' beats 'work on ontology'.",
175
347
  "Prefer what is blocked on the person themselves, then what serves a goal, then what is merely urgent.",
348
+ "Name the project each move belongs to, copying its slug exactly from the list you were given. A move that belongs to no project takes an empty string.",
176
349
  "Never invent a fact that is not in what you were given.",
177
350
  ].join("\n"),
178
351
  user: `Their goals:\n${goalsBrief()}\n\nWhat is ranked and why:\n${matrixBrief()}`,
179
352
  maxTokens: 400,
180
353
  timeout: 90000,
181
354
  jsonSchema: MOVES_SCHEMA,
355
+ model: SONNET_MODEL,
182
356
  caller: "agenda-moves",
183
357
  sessionId,
184
358
  });
185
- if (result.usage) logTokenUsage("agenda-moves", result.usage);
359
+ if (result.usage) logTokenUsage("agenda-moves", result.usage, SONNET_MODEL);
186
360
  if (!result.success || !result.output) return false;
187
361
 
188
- const parsed = parsePayload<{ moves: AgendaMove[] }>(result.output, "agenda:moves");
189
- const moves = (parsed?.moves ?? []).filter((m) => m.move).slice(0, 3);
362
+ const parsed = parsePayload<{ moves: (AgendaMove & { project?: unknown })[] }>(
363
+ result.output,
364
+ "agenda:moves"
365
+ );
366
+ const moves: AgendaMove[] = (parsed?.moves ?? [])
367
+ .filter((m) => m.move)
368
+ .slice(0, 3)
369
+ .map(({ move, because, project }) => {
370
+ const slug = knownSlug(project, slugs);
371
+ return slug ? { move, because, project: slug } : { move, because };
372
+ });
190
373
  if (moves.length === 0) return false;
191
374
 
192
375
  await writeAgenda({ generatedAt: new Date().toISOString(), moves });
@@ -206,8 +389,13 @@ export async function refreshAgenda(
206
389
  if (!canInfer()) return "no-inference";
207
390
 
208
391
  const guessed = await guessMissingServes(sessionId);
392
+ const linked = await refreshGoalLinks(sessionId);
393
+ const waiting = await extractWaitingOn(sessionId);
209
394
  const wrote = await writeMoves(sessionId);
210
- logDebug("agenda", `serves guessed: ${guessed}, moves written: ${wrote}`);
395
+ logDebug(
396
+ "agenda",
397
+ `serves guessed: ${guessed}, links: ${linked}, waitingOn: ${waiting}, moves written: ${wrote}`
398
+ );
211
399
  return wrote ? "written" : "failed";
212
400
  }
213
401
 
@@ -11,24 +11,22 @@
11
11
 
12
12
  import { existsSync } from "node:fs";
13
13
  import { readFile, unlink } from "node:fs/promises";
14
- import { extractContent, parseMessages } from "../lib/transcript";
14
+ import {
15
+ mergeInferredPrinciple,
16
+ needsInference,
17
+ type PendingFailure,
18
+ principleRequest,
19
+ recentExchange,
20
+ } from "../lib/failure-principle";
15
21
  import { captureFailure } from "./failure";
16
22
 
17
- interface PendingFailure {
18
- rating: number;
19
- context: string;
20
- detailedContext?: string;
21
- principle?: string;
22
- responsePreview?: string;
23
- userPreview?: string;
24
- cwd?: string;
25
- }
26
-
27
23
  /**
28
24
  * Inference the principle (if missing) and persist the failure record.
29
25
  * Reads pending data + transcript from the provided tmp paths and unlinks them.
26
+ *
27
+ * @lintignore exercised directly by test/failure-principle.test.ts
30
28
  */
31
- async function processFailurePrinciple(
29
+ export async function processFailurePrinciple(
32
30
  pendingPath: string,
33
31
  transcriptPath: string
34
32
  ): Promise<void> {
@@ -47,42 +45,19 @@ async function processFailurePrinciple(
47
45
  logDebug("failure-principle", `processing rating=${pending.rating}`);
48
46
 
49
47
  let { principle, detailedContext } = pending;
50
- if (!principle) {
48
+ if (needsInference(pending)) {
51
49
  try {
52
50
  const { inference } = await import("../lib/inference");
53
- const msgs = parseMessages(transcript);
54
- const recent = msgs
55
- .slice(-10)
56
- .map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
57
- .join("\n\n");
58
-
59
- const result = await inference({
60
- system: `Analyze this failed AI interaction (rated ${pending.rating}/10). Return JSON: {"principle": "<verb-first actionable rule, 10-20 words — write a full sentence, not a fragment>", "detailed_context": "<root cause and what to do differently, 50-150 words>"}.`,
61
- user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
62
- maxTokens: 400,
63
- timeout: 90000,
64
- jsonSchema: {
65
- type: "object" as const,
66
- properties: {
67
- principle: { type: "string" as const },
68
- detailed_context: { type: "string" as const },
69
- },
70
- required: ["principle", "detailed_context"],
71
- additionalProperties: false,
72
- },
73
- caller: "failure-principle",
74
- });
75
-
76
- if (result.success && result.output) {
77
- const parsed = JSON.parse(result.output) as {
78
- principle?: string;
79
- detailed_context?: string;
80
- };
81
- principle = parsed.principle || undefined;
82
- detailedContext ??= parsed.detailed_context || undefined;
83
- } else {
51
+ const result = await inference(
52
+ principleRequest(pending, recentExchange(transcript))
53
+ );
54
+ if (!result.success || !result.output) {
84
55
  logError("failure-principle", `inference failed (no output)`);
85
56
  }
57
+ ({ principle, detailedContext } = mergeInferredPrinciple(
58
+ pending,
59
+ result.output ?? null
60
+ ));
86
61
  } catch (err) {
87
62
  logError("failure-principle:inference", err);
88
63
  }
@@ -9,9 +9,15 @@
9
9
  *
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { existsSync } from "node:fs";
13
13
  import { unlink, writeFile } from "node:fs/promises";
14
14
  import { resolve } from "node:path";
15
+ import {
16
+ isRecaptureWorthwhile,
17
+ learningSlug,
18
+ markCaptured,
19
+ readCapture,
20
+ } from "../lib/capture-store";
15
21
  import { stringify } from "../lib/frontmatter";
16
22
  import { canInfer, inference } from "../lib/inference";
17
23
  import { categorizeLearning } from "../lib/learning-category";
@@ -27,68 +33,6 @@ import {
27
33
  } from "../lib/transcript";
28
34
  import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
29
35
 
30
- // ── Dedup tracking ──
31
-
32
- interface CaptureEntry {
33
- filepath: string;
34
- messageCount: number;
35
- }
36
-
37
- const MIN_NEW_MESSAGES = 10;
38
-
39
- function capturedPath(): string {
40
- return resolve(paths.state(), "captured-learnings.json");
41
- }
42
-
43
- function getPreviousCapture(sessionId: string): CaptureEntry | null {
44
- const p = capturedPath();
45
- if (!existsSync(p)) return null;
46
- try {
47
- const raw = JSON.parse(readFileSync(p, "utf-8"));
48
- if (Array.isArray(raw)) return null;
49
- const entry = raw[sessionId];
50
- if (!entry) return null;
51
- if (typeof entry === "string") return { filepath: entry, messageCount: 0 };
52
- return entry as CaptureEntry;
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- function markCaptured(sessionId: string, filepath: string, messageCount: number): void {
59
- const p = capturedPath();
60
- let data: Record<string, CaptureEntry> = {};
61
- try {
62
- if (existsSync(p)) {
63
- const raw = JSON.parse(readFileSync(p, "utf-8"));
64
- if (!Array.isArray(raw) && typeof raw === "object") {
65
- for (const [k, v] of Object.entries(raw)) {
66
- data[k] =
67
- typeof v === "string"
68
- ? { filepath: v, messageCount: 0 }
69
- : (v as CaptureEntry);
70
- }
71
- }
72
- }
73
- } catch {
74
- /* start fresh */
75
- }
76
- data[sessionId] = { filepath, messageCount };
77
- const entries = Object.entries(data);
78
- if (entries.length > 50) data = Object.fromEntries(entries.slice(-50));
79
- writeFileSync(p, JSON.stringify(data, null, 2), "utf-8");
80
- }
81
-
82
- function slugify(text: string): string {
83
- return text
84
- .toLowerCase()
85
- .replace(/[^a-z0-9\s]/g, "")
86
- .trim()
87
- .split(/\s+/)
88
- .slice(0, 4)
89
- .join("-");
90
- }
91
-
92
36
  // ── JSON schema for merged Haiku call ──
93
37
 
94
38
  const INTELLIGENCE_SCHEMA = {
@@ -124,17 +68,16 @@ interface IntelligenceOutput {
124
68
 
125
69
  // ── Main handler ──
126
70
 
127
- async function captureSessionIntelligence(
71
+ /** @lintignore exercised directly by test/session-intelligence.test.ts */
72
+ export async function captureSessionIntelligence(
128
73
  transcript: string,
129
74
  sessionId?: string
130
75
  ): Promise<void> {
131
76
  const messages = parseMessages(transcript);
132
77
  if (messages.length < 6 || transcript.length < 2000) return;
133
78
 
134
- // Dedup check
135
- if (sessionId) {
136
- const prev = getPreviousCapture(sessionId);
137
- if (prev && messages.length - prev.messageCount < MIN_NEW_MESSAGES) return;
79
+ if (sessionId && !isRecaptureWorthwhile(readCapture(sessionId), messages.length)) {
80
+ return;
138
81
  }
139
82
 
140
83
  // Skip if no inference path is available (no CLI binary AND no API key)
@@ -200,7 +143,7 @@ async function captureSessionIntelligence(
200
143
  // ── Write session learning file ──
201
144
 
202
145
  const category = categorizeLearning(title, summary);
203
- const slug = slugify(title);
146
+ const slug = learningSlug(title);
204
147
  const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
205
148
  const filename = `${fileTimestamp()}_${category}_${slug}.md`;
206
149
 
@@ -224,7 +167,7 @@ async function captureSessionIntelligence(
224
167
 
225
168
  // Remove previous capture for this session
226
169
  if (sessionId) {
227
- const prev = getPreviousCapture(sessionId);
170
+ const prev = readCapture(sessionId);
228
171
  if (prev?.filepath && existsSync(prev.filepath)) {
229
172
  try {
230
173
  await unlink(prev.filepath);
@@ -13,6 +13,8 @@ import { paths } from "./paths";
13
13
  export interface AgendaMove {
14
14
  move: string;
15
15
  because: string;
16
+ /** The project the move belongs to, when it belongs to one the page can open. */
17
+ project?: string;
16
18
  }
17
19
 
18
20
  export interface Agenda {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Which sessions have already had their learning captured, and where the file went.
3
+ *
4
+ * A session's Stop event fires after every response, so the same session reaches
5
+ * the capture handler many times. Without a record of what was already written,
6
+ * each pass would produce another near-identical learning file for one session.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { paths } from "./paths";
12
+
13
+ export interface CaptureEntry {
14
+ filepath: string;
15
+ messageCount: number;
16
+ }
17
+
18
+ /** Below this many new messages, a re-capture would restate what was already written. */
19
+ const MIN_NEW_MESSAGES = 10;
20
+
21
+ /** How many sessions the file remembers before the oldest are dropped. */
22
+ const MAX_REMEMBERED = 50;
23
+
24
+ /** @lintignore exercised directly by test/capture-store.test.ts */
25
+ export function capturedPath(): string {
26
+ return resolve(paths.state(), "captured-learnings.json");
27
+ }
28
+
29
+ /**
30
+ * Entries were once a bare filepath string. One is read as a capture at message
31
+ * zero, which makes any later session look new enough to re-capture — the safe
32
+ * direction for a record whose message count was never written down.
33
+ */
34
+ function asEntry(value: unknown): CaptureEntry | null {
35
+ if (typeof value === "string") return { filepath: value, messageCount: 0 };
36
+ if (value && typeof value === "object") return value as CaptureEntry;
37
+ return null;
38
+ }
39
+
40
+ function readAll(): Record<string, CaptureEntry> {
41
+ const path = capturedPath();
42
+ if (!existsSync(path)) return {};
43
+ try {
44
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
45
+ if (Array.isArray(raw) || !raw || typeof raw !== "object") return {};
46
+ const entries: Record<string, CaptureEntry> = {};
47
+ for (const [id, value] of Object.entries(raw)) {
48
+ const entry = asEntry(value);
49
+ if (entry) entries[id] = entry;
50
+ }
51
+ return entries;
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ export function readCapture(sessionId: string): CaptureEntry | null {
58
+ return readAll()[sessionId] ?? null;
59
+ }
60
+
61
+ /**
62
+ * A session with no previous capture is always worth writing. One that has grown
63
+ * by fewer than MIN_NEW_MESSAGES since is not: the transcript window the summary
64
+ * is drawn from has barely moved, so the second file would say the same thing.
65
+ */
66
+ export function isRecaptureWorthwhile(
67
+ previous: CaptureEntry | null,
68
+ messageCount: number
69
+ ): boolean {
70
+ if (!previous) return true;
71
+ return messageCount - previous.messageCount >= MIN_NEW_MESSAGES;
72
+ }
73
+
74
+ export function markCaptured(
75
+ sessionId: string,
76
+ filepath: string,
77
+ messageCount: number
78
+ ): void {
79
+ const data = readAll();
80
+ data[sessionId] = { filepath, messageCount };
81
+ const entries = Object.entries(data);
82
+ const kept = entries.length > MAX_REMEMBERED ? entries.slice(-MAX_REMEMBERED) : entries;
83
+ writeFileSync(
84
+ capturedPath(),
85
+ JSON.stringify(Object.fromEntries(kept), null, 2),
86
+ "utf-8"
87
+ );
88
+ }
89
+
90
+ /**
91
+ * The readable half of a learning file's name. Four words, because the rest of
92
+ * the name is already a timestamp and a category and the whole thing has to stay
93
+ * a filename.
94
+ */
95
+ export function learningSlug(title: string): string {
96
+ return title
97
+ .toLowerCase()
98
+ .replace(/[^a-z0-9\s]/g, "")
99
+ .trim()
100
+ .split(/\s+/)
101
+ .slice(0, 4)
102
+ .join("-");
103
+ }