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.
- package/package.json +10 -7
- package/src/cli/migrate.ts +1 -1
- package/src/cli/server.ts +7 -0
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +34 -153
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +78 -7
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +92 -20
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +34 -10
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +102 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +212 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -136
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- 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 =
|
|
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[] }>(
|
|
189
|
-
|
|
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(
|
|
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 {
|
|
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 (
|
|
48
|
+
if (needsInference(pending)) {
|
|
51
49
|
try {
|
|
52
50
|
const { inference } = await import("../lib/inference");
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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 =
|
|
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 =
|
|
170
|
+
const prev = readCapture(sessionId);
|
|
228
171
|
if (prev?.filepath && existsSync(prev.filepath)) {
|
|
229
172
|
try {
|
|
230
173
|
await unlink(prev.filepath);
|
|
@@ -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
|
+
}
|