portable-agent-layer 0.71.0 → 0.72.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/skill.ts +1 -1
  4. package/src/hooks/CompactRecover.ts +28 -86
  5. package/src/hooks/LedgerUnapplied.ts +3 -28
  6. package/src/hooks/LoadContext.ts +33 -60
  7. package/src/hooks/SecurityValidator.ts +16 -109
  8. package/src/hooks/handlers/failure-principle.ts +19 -44
  9. package/src/hooks/handlers/session-intelligence.ts +13 -70
  10. package/src/hooks/lib/capture-store.ts +103 -0
  11. package/src/hooks/lib/compact-recall.ts +89 -0
  12. package/src/hooks/lib/failure-principle.ts +98 -0
  13. package/src/hooks/lib/ledger-hook.ts +35 -0
  14. package/src/hooks/lib/ledger.ts +48 -1
  15. package/src/hooks/lib/security-gate.ts +159 -0
  16. package/src/hooks/lib/session-context.ts +74 -0
  17. package/src/tools/agent/algorithm-reflect.ts +28 -97
  18. package/src/tools/agent/analyze.ts +19 -120
  19. package/src/tools/agent/handoff-note.ts +29 -77
  20. package/src/tools/agent/project.ts +13 -134
  21. package/src/tools/agent/relationship-note.ts +27 -46
  22. package/src/tools/agent/synthesize.ts +1 -1
  23. package/src/tools/agent/thread.ts +43 -123
  24. package/src/tools/control-room/data.ts +2 -2
  25. package/src/tools/control-room/matrix.ts +1 -1
  26. package/src/tools/control-room/ui/ledger.tsx +2 -1
  27. package/src/tools/ledger/view.ts +3 -0
  28. package/src/tools/lib/algorithm-reflect.ts +84 -0
  29. package/src/tools/lib/analyze-report.ts +120 -0
  30. package/src/tools/lib/handoff-note.ts +88 -0
  31. package/src/tools/lib/note-flags.ts +59 -0
  32. package/src/tools/lib/project-isc.ts +151 -0
  33. package/src/tools/lib/relationship-reflect.ts +402 -0
  34. package/src/tools/lib/self-model.ts +499 -0
  35. package/src/tools/lib/session-usage.ts +216 -0
  36. package/src/tools/lib/skill-doctor.ts +457 -0
  37. package/src/tools/lib/thread.ts +119 -0
  38. package/src/tools/lib/token-report.ts +173 -0
  39. package/src/tools/lib/transcript-usage.ts +42 -0
  40. package/src/tools/lib/usage-buckets.ts +329 -0
  41. package/src/tools/relationship-reflect.ts +48 -412
  42. package/src/tools/self-model.ts +76 -558
  43. package/src/tools/session-summary.ts +8 -215
  44. package/src/tools/skill-doctor.ts +9 -444
  45. package/src/tools/token-cost.ts +18 -428
@@ -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);
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The last exchange before a compaction, and what is safe to do with it after.
3
+ *
4
+ * A summary can collapse the turn that was in flight when the window filled, so
5
+ * the originals are re-injected verbatim on the next session. Everything here
6
+ * used to sit inside the spawned hook: the budget split, the order the candidate
7
+ * files are tried in, and whether the file just read may be deleted.
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { paths } from "./paths";
13
+
14
+ /** Hook output is capped at 10,000 chars; the rest is headroom for the framing. */
15
+ const MAX_OUTPUT = 9_000;
16
+
17
+ /** The user's half. The assistant's reply is the longer of the two in most turns. */
18
+ const USER_SHARE = 0.4;
19
+
20
+ /** Held back for the reminder's headings and framing text. */
21
+ const FRAMING_RESERVE = 300;
22
+
23
+ export interface SavedExchange {
24
+ sessionId: string;
25
+ timestamp: string;
26
+ trigger: string | null;
27
+ customInstructions: string | null;
28
+ userMessage: string;
29
+ assistantMessage: string;
30
+ }
31
+
32
+ export interface RecallBudget {
33
+ user: number;
34
+ assistant: number;
35
+ }
36
+
37
+ export function recallBudget(max: number = MAX_OUTPUT): RecallBudget {
38
+ const user = Math.floor(max * USER_SHARE);
39
+ return { user, assistant: max - user - FRAMING_RESERVE };
40
+ }
41
+
42
+ /** Says how much was dropped, so a truncated message cannot read as a complete one. */
43
+ export function truncate(s: string, max: number): string {
44
+ if (s.length <= max) return s;
45
+ return `${s.slice(0, max)}\n[... truncated ${s.length - max} chars]`;
46
+ }
47
+
48
+ function exchangeDir(): string {
49
+ return resolve(paths.state(), "last-exchange");
50
+ }
51
+
52
+ /**
53
+ * The session's own file first, then the fallback. latest.json is overwritten by
54
+ * every compaction, so it is right only when nothing more specific exists.
55
+ */
56
+ export function findSavedExchange(sessionId?: string): string | null {
57
+ const candidates = [
58
+ sessionId ? resolve(exchangeDir(), `${sessionId}.json`) : null,
59
+ resolve(exchangeDir(), "latest.json"),
60
+ ].filter((path): path is string => path !== null);
61
+ return candidates.find((path) => existsSync(path)) ?? null;
62
+ }
63
+
64
+ /**
65
+ * Consume-on-read, but only for the session's own file: latest.json is the
66
+ * safety fallback and deleting it would leave the next compaction with nothing.
67
+ */
68
+ export function isConsumable(file: string, sessionId?: string): boolean {
69
+ if (!sessionId) return false;
70
+ return file === resolve(exchangeDir(), `${sessionId}.json`);
71
+ }
72
+
73
+ export function buildRecall(saved: SavedExchange, budget = recallBudget()): string {
74
+ return [
75
+ "<system-reminder>",
76
+ "## Last exchange before compaction",
77
+ "_Restored verbatim from PAL state. The compaction summary may have collapsed this; the originals are below._",
78
+ "",
79
+ "**User:**",
80
+ truncate(saved.userMessage || "(no user message captured)", budget.user),
81
+ "",
82
+ "**Assistant:**",
83
+ truncate(
84
+ saved.assistantMessage || "(no assistant message captured)",
85
+ budget.assistant
86
+ ),
87
+ "</system-reminder>",
88
+ ].join("\n");
89
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What a low-rated session is asked about, and what is kept from the answer.
3
+ *
4
+ * The handler around this is spawned detached — claude --print's cold start
5
+ * outruns the Stop hook's budget — so none of it was reachable from a test. The
6
+ * decisions are here instead: how much of the transcript the question carries,
7
+ * what the question is, and which of the two sources wins for each field.
8
+ */
9
+
10
+ import { extractContent, parseMessages } from "./transcript";
11
+
12
+ /** Enough of the ending to see what went wrong, without paying for the whole session. */
13
+ const MAX_MESSAGES = 10;
14
+
15
+ /** Per message, so one long tool dump cannot crowd out the other nine. */
16
+ const MAX_CHARS_PER_MESSAGE = 300;
17
+
18
+ export interface PendingFailure {
19
+ rating: number;
20
+ context: string;
21
+ detailedContext?: string;
22
+ principle?: string;
23
+ responsePreview?: string;
24
+ userPreview?: string;
25
+ cwd?: string;
26
+ }
27
+
28
+ export interface InferredPrinciple {
29
+ principle?: string;
30
+ detailedContext?: string;
31
+ }
32
+
33
+ export function recentExchange(transcript: string): string {
34
+ return parseMessages(transcript)
35
+ .slice(-MAX_MESSAGES)
36
+ .map(
37
+ (message) =>
38
+ `${message.role.toUpperCase()}: ${extractContent(message).slice(0, MAX_CHARS_PER_MESSAGE)}`
39
+ )
40
+ .join("\n\n");
41
+ }
42
+
43
+ /** The parent may already have a principle, in which case there is nothing to ask. */
44
+ export function needsInference(pending: PendingFailure): boolean {
45
+ return !pending.principle;
46
+ }
47
+
48
+ export function principleRequest(pending: PendingFailure, recent: string) {
49
+ return {
50
+ 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>"}.`,
51
+ user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
52
+ maxTokens: 400,
53
+ timeout: 90_000,
54
+ jsonSchema: {
55
+ type: "object" as const,
56
+ properties: {
57
+ principle: { type: "string" as const },
58
+ detailed_context: { type: "string" as const },
59
+ },
60
+ required: ["principle", "detailed_context"],
61
+ additionalProperties: false,
62
+ },
63
+ caller: "failure-principle",
64
+ };
65
+ }
66
+
67
+ function parseInferred(output: string | null): InferredPrinciple {
68
+ if (!output) return {};
69
+ try {
70
+ const parsed = JSON.parse(output) as {
71
+ principle?: string;
72
+ detailed_context?: string;
73
+ };
74
+ return {
75
+ principle: parsed.principle || undefined,
76
+ detailedContext: parsed.detailed_context || undefined,
77
+ };
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The two fields resolve differently on purpose. A principle the parent already
85
+ * had means inference never ran, so there is nothing to lose to; a detailed
86
+ * context it already had was written from the full session and outranks one
87
+ * inferred from ten messages.
88
+ */
89
+ export function mergeInferredPrinciple(
90
+ pending: PendingFailure,
91
+ output: string | null
92
+ ): InferredPrinciple {
93
+ const inferred = parseInferred(output);
94
+ return {
95
+ principle: pending.principle || inferred.principle,
96
+ detailedContext: pending.detailedContext ?? inferred.detailedContext,
97
+ };
98
+ }
@@ -13,6 +13,7 @@ import {
13
13
  claimPending,
14
14
  type LedgerEntry,
15
15
  type LedgerOutcome,
16
+ type PendingSnapshot,
16
17
  reapStalePending,
17
18
  recordAction,
18
19
  savePending,
@@ -245,6 +246,40 @@ export function commitApplied(call: LedgeredCall): LedgerEntry | null {
245
246
  return entry;
246
247
  }
247
248
 
249
+ /**
250
+ * The snapshot is the trustworthy source, but its absence is recoverable here in
251
+ * a way it never is after a successful edit: nothing landed, so whatever is on
252
+ * disk now is still the before-state.
253
+ */
254
+ export function unappliedBefore(
255
+ pending: PendingSnapshot | null,
256
+ target: string
257
+ ): string | null {
258
+ if (pending) return pending.before;
259
+ return contentsOf(target);
260
+ }
261
+
262
+ /**
263
+ * Record a call that did not land. Unlike the applied half this writes with or
264
+ * without a parked snapshot, because a missing snapshot here is recoverable and
265
+ * dropping the entry would lose the only record that the attempt happened.
266
+ */
267
+ export function commitUnapplied(
268
+ call: LedgeredCall,
269
+ verdict: UnappliedVerdict
270
+ ): LedgerEntry {
271
+ return recordAction({
272
+ tool: call.tool,
273
+ target: call.target,
274
+ outcome: verdict.outcome,
275
+ before: unappliedBefore(claimPending(call.toolUseId), call.target),
276
+ // Nothing landed. That is what this event means, and it is the difference
277
+ // between this entry and an applied one.
278
+ after: null,
279
+ reason: verdict.reason,
280
+ });
281
+ }
282
+
248
283
  export function toolUseIdOf(payload: Record<string, unknown>): string | null {
249
284
  for (const key of ["tool_use_id", "toolUseId", "tool_call_id"]) {
250
285
  const value = payload[key];
@@ -46,8 +46,14 @@ import { isSensitivePath } from "./sensitive-path";
46
46
  * lose the only signal in the record that says where the boundary was drawn,
47
47
  * and "what did I try that was refused" is a question worth being able to ask
48
48
  * separately from "what did I try that broke".
49
+ *
50
+ * `blocked` is a rule refusing, on the same reasoning: a person can be asked to
51
+ * reconsider and a rule cannot, so "PAL would not let me" and "you would not
52
+ * let me" are different facts about where the boundary sits. It is also the
53
+ * only outcome PAL itself decides, which is what makes the declare-enforce-record
54
+ * triad demonstrable rather than merely wired.
49
55
  */
50
- export type LedgerOutcome = "applied" | "failed" | "denied";
56
+ export type LedgerOutcome = "applied" | "failed" | "denied" | "blocked";
51
57
 
52
58
  /**
53
59
  * One side of a change, identified rather than reproduced. The hash ties the
@@ -107,6 +113,12 @@ export interface LedgerEntry extends RecordAttribution {
107
113
  delta?: LedgerDelta;
108
114
  /** Why the action did not land. Absent on an applied one. */
109
115
  reason?: string;
116
+ /**
117
+ * The shell command a rule refused. Only a blocked shell action carries one:
118
+ * it has no file to name, so without this the entry could say a command was
119
+ * refused but not which.
120
+ */
121
+ command?: string;
110
122
  }
111
123
 
112
124
  export interface RecordActionInput {
@@ -121,6 +133,7 @@ export interface RecordActionInput {
121
133
  /** Resulting content; null when nothing landed. */
122
134
  after: string | null;
123
135
  reason?: string;
136
+ command?: string;
124
137
  }
125
138
 
126
139
  /**
@@ -254,6 +267,7 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
254
267
  after: stateOf(input.after),
255
268
  ...(delta ? { delta } : {}),
256
269
  ...(input.reason ? { reason: input.reason } : {}),
270
+ ...(input.command ? { command: input.command } : {}),
257
271
  };
258
272
 
259
273
  const file = ledgerPath();
@@ -262,6 +276,39 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
262
276
  return entry;
263
277
  }
264
278
 
279
+ /** A refused command is quoted back, not stored whole. */
280
+ const MAX_COMMAND_CHARS = 500;
281
+
282
+ export interface RecordBlockedInput {
283
+ tool: string;
284
+ /** Absolute path of the file, or of the directory a refused command ran in. */
285
+ target: string;
286
+ /** The refused command, when the tool was a shell rather than an editor. */
287
+ command?: string;
288
+ /** What the rule told the agent — the same words, so both records agree. */
289
+ reason: string;
290
+ }
291
+
292
+ /**
293
+ * A rule refused this before it ran. Written by whoever enforces the rule, at
294
+ * the moment it fires, because a refusal produces no other event: nothing runs,
295
+ * so no post-tool hook reports it and nothing downstream can infer it happened.
296
+ *
297
+ * There is no before or after. The file is untouched, and a delta claiming
298
+ * otherwise would be the ledger describing a change that never occurred.
299
+ */
300
+ export function recordBlocked(input: RecordBlockedInput): LedgerEntry {
301
+ return recordAction({
302
+ tool: input.tool,
303
+ target: input.target,
304
+ outcome: "blocked",
305
+ before: null,
306
+ after: null,
307
+ reason: input.reason,
308
+ ...(input.command ? { command: input.command.slice(0, MAX_COMMAND_CHARS) } : {}),
309
+ });
310
+ }
311
+
265
312
  /**
266
313
  * The before-state, held between the two halves of one tool call.
267
314
  *