portable-agent-layer 0.32.0 → 0.34.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 (38) hide show
  1. package/README.md +1 -0
  2. package/assets/skills/presentation/SKILL.md +124 -5
  3. package/assets/skills/presentation/WORKSHOP.md +128 -0
  4. package/assets/skills/presentation/theme-base/base.css +113 -0
  5. package/assets/skills/presentation/theme-base/layouts.css +11 -2
  6. package/assets/skills/presentation/tools/build.ts +136 -6
  7. package/assets/skills/presentation/tools/doctor.ts +106 -317
  8. package/assets/skills/presentation/tools/lib/lint-helpers.ts +150 -0
  9. package/assets/skills/presentation/tools/lib/lint-rules.ts +744 -0
  10. package/assets/skills/presentation/tools/lib/lint-types.ts +40 -0
  11. package/assets/skills/presentation/tools/new-deck.ts +9 -4
  12. package/assets/skills/presentation/vendor/reveal/plugin/highlight/github-dark.css +118 -0
  13. package/assets/skills/projects/SKILL.md +111 -0
  14. package/assets/skills/telos/SKILL.md +4 -1
  15. package/assets/templates/AGENTS.md.template +28 -7
  16. package/assets/templates/PAL/ALGORITHM.md +2 -0
  17. package/assets/templates/PAL/README.md +1 -2
  18. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
  19. package/assets/templates/pal-settings.json +2 -2
  20. package/package.json +2 -3
  21. package/src/cli/index.ts +7 -0
  22. package/src/hooks/UserPromptOrchestrator.ts +3 -1
  23. package/src/hooks/handlers/auto-graduate.ts +169 -0
  24. package/src/hooks/handlers/inject-retrieval.ts +50 -0
  25. package/src/hooks/handlers/project-touch.ts +39 -0
  26. package/src/hooks/lib/context.ts +9 -8
  27. package/src/hooks/lib/paths.ts +2 -0
  28. package/src/hooks/lib/projects.ts +270 -0
  29. package/src/hooks/lib/retrieval-index.ts +223 -0
  30. package/src/hooks/lib/retrieval.ts +170 -0
  31. package/src/hooks/lib/security.ts +2 -0
  32. package/src/hooks/lib/stop.ts +9 -1
  33. package/src/hooks/lib/text-similarity.ts +13 -9
  34. package/src/hooks/lib/wisdom.ts +155 -1
  35. package/src/tools/agent/project.ts +336 -0
  36. package/src/tools/self-model.ts +3 -3
  37. package/src/tools/token-cost.ts +4 -4
  38. package/assets/templates/PAL/CONTEXT_ROUTING.md +0 -30
@@ -0,0 +1,50 @@
1
+ /**
2
+ * UserPromptSubmit handler: inject the top-N matching prior lessons into the prompt.
3
+ *
4
+ * Called from UserPromptOrchestrator. Reads the retrieval index, ranks the prompt
5
+ * against the corpus, prints a `<system-reminder>` block to stdout (Claude Code
6
+ * prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error or
7
+ * timeout produces empty output, never blocks the prompt.
8
+ */
9
+
10
+ import { logDebug, logError } from "../lib/log";
11
+ import { runRetrieval } from "../lib/retrieval";
12
+ import { ensureIndex } from "../lib/retrieval-index";
13
+ import { isEnabled } from "../lib/settings";
14
+
15
+ const TIMEOUT_MS = 250;
16
+
17
+ function withTimeout<T>(work: () => T, ms: number): Promise<T | null> {
18
+ return new Promise((resolve) => {
19
+ const timer = setTimeout(() => resolve(null), ms);
20
+ try {
21
+ const result = work();
22
+ clearTimeout(timer);
23
+ resolve(result);
24
+ } catch (err) {
25
+ clearTimeout(timer);
26
+ logError("inject-retrieval", err);
27
+ resolve(null);
28
+ }
29
+ });
30
+ }
31
+
32
+ export async function injectRetrieval(prompt: string): Promise<void> {
33
+ if (!prompt?.trim()) return;
34
+ if (!isEnabled("learningInjection")) return;
35
+
36
+ const result = await withTimeout(() => {
37
+ const index = ensureIndex();
38
+ if (index.corpusSize === 0) return null;
39
+ return runRetrieval(prompt, index, process.cwd());
40
+ }, TIMEOUT_MS);
41
+
42
+ if (!result?.reminder) return;
43
+
44
+ logDebug(
45
+ "inject-retrieval",
46
+ `injected ${result.matches.length} matches; top score=${result.matches[0]?.confidence.toFixed(3)}`
47
+ );
48
+
49
+ process.stdout.write(`${result.reminder}\n`);
50
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Stop handler: when cwd resolves to an active registered project, bump its
3
+ * `updated` timestamp and optionally capture a handoff from the last assistant
4
+ * message. Otherwise no-op (parent-dir browse mode, unregistered cwd,
5
+ * paused/complete/archived projects all fall through cleanly).
6
+ *
7
+ * The plan calls for the JSONs to never go stale silently — this is the
8
+ * mechanism. CLI invocation is for explicit edits; this handler is for the
9
+ * "you were just working in this project" auto-bump.
10
+ */
11
+
12
+ import { logDebug, logError } from "../lib/log";
13
+ import { readAllProjects, resolveProjectFromCwd, writeProject } from "../lib/projects";
14
+ import { extractHandoff } from "../lib/work-tracking";
15
+
16
+ const HANDOFF_CAP = 300;
17
+
18
+ export async function projectTouch(lastAssistantMessage?: string): Promise<void> {
19
+ try {
20
+ const projects = readAllProjects();
21
+ if (projects.length === 0) return;
22
+
23
+ const resolved = resolveProjectFromCwd(process.cwd(), projects);
24
+ if (!resolved) return;
25
+ if (resolved.status !== "active") return;
26
+
27
+ resolved.updated = new Date().toISOString();
28
+
29
+ if (lastAssistantMessage?.trim()) {
30
+ const handoff = extractHandoff(lastAssistantMessage).slice(0, HANDOFF_CAP);
31
+ if (handoff) resolved.handoff = handoff;
32
+ }
33
+
34
+ writeProject(resolved);
35
+ logDebug("project-touch", `bumped ${resolved.name} (${resolved.path})`);
36
+ } catch (err) {
37
+ logError("project-touch", err);
38
+ }
39
+ }
@@ -10,6 +10,7 @@ import { parse } from "./frontmatter";
10
10
  import { readFailures, readLearnings } from "./learning-store";
11
11
  import { loadOpinionContext } from "./opinions";
12
12
  import { paths } from "./paths";
13
+ import { loadActiveProjectsContext } from "./projects";
13
14
  import { loadRecentNotes } from "./relationship";
14
15
  import { readSessionNames } from "./session-names";
15
16
  import * as settings from "./settings";
@@ -301,7 +302,7 @@ export function loadSignalTrends(): string {
301
302
  export function loadProjectHistoryContext(): string {
302
303
  try {
303
304
  const cwd = process.cwd();
304
- const entries = readProjectHistory(cwd, 15);
305
+ const entries = readProjectHistory(cwd, 3);
305
306
  if (entries.length === 0) return "";
306
307
 
307
308
  const lines: string[] = ["## This Project — Session History"];
@@ -351,13 +352,9 @@ export function loadSessionIntelligence(): string {
351
352
  }
352
353
  lines.push("→ These are directly relevant to your current work.");
353
354
  }
354
- if (other.length > 0) {
355
- lines.push("");
356
- lines.push(`**Open threads — other projects (${other.length}):**`);
357
- for (const t of other) {
358
- lines.push(`- ${t.title} (opened ${t.opened})`);
359
- }
360
- }
355
+ // Cross-project threads intentionally omitted — they were noise in 90%+ of sessions.
356
+ // To surface them on demand, add a `threads` slash-command or a flag in pal-settings.
357
+ void other;
361
358
  }
362
359
 
363
360
  // Rating Trend
@@ -455,6 +452,9 @@ export function buildSystemReminder(): string {
455
452
  const projectHistory = settings.isEnabled("projectHistory")
456
453
  ? loadProjectHistoryContext()
457
454
  : "";
455
+ const activeProjects = settings.isEnabled("projects")
456
+ ? loadActiveProjectsContext()
457
+ : "";
458
458
  const trends = settings.isEnabled("signalTrends") ? loadSignalTrends() : "";
459
459
  const failures = settings.isEnabled("failurePatterns") ? loadFailurePatterns() : "";
460
460
  const synthesis = settings.isEnabled("synthesis") ? loadSynthesisRecommendations() : "";
@@ -472,6 +472,7 @@ export function buildSystemReminder(): string {
472
472
  if (opinions) parts.push(opinions);
473
473
  if (intelligence) parts.push(intelligence);
474
474
  if (relationship) parts.push(relationship);
475
+ if (activeProjects) parts.push(activeProjects);
475
476
  if (projectHistory) parts.push(projectHistory);
476
477
  if (digest) parts.push(digest);
477
478
  if (synthesis) parts.push(synthesis);
@@ -49,6 +49,8 @@ export const paths = {
49
49
  relationship: () => ensureDir(home("memory", "relationship")),
50
50
  entities: () => ensureDir(home("memory", "entities")),
51
51
  failures: () => ensureDir(home("memory", "learning", "failures")),
52
+ retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
53
+ progress: () => ensureDir(home("memory", "state", "progress")),
52
54
  projectHistory: () => ensureDir(home("memory", "projects")),
53
55
  sessionLearning: () => ensureDir(home("memory", "learning", "session")),
54
56
  synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Projects — registry of user-curated projects with auto-managed state.
3
+ *
4
+ * Each project lives in `~/.pal/memory/state/progress/{slug}.json`. The CLI
5
+ * (`src/tools/agent/project.ts`) is the user/AI-facing writer; the Stop hook
6
+ * auto-touches `updated` when cwd resolves into a registered project.
7
+ *
8
+ * Replaces the hand-edited `~/.pal/telos/PROJECTS.md` — see plan
9
+ * `~/.claude/plans/clever-frolicking-harp.md` for context.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { basename, dirname, parse as parsePath, resolve, sep } from "node:path";
21
+ import { paths } from "./paths";
22
+
23
+ export type ProjectStatus = "active" | "paused" | "complete" | "archived";
24
+
25
+ export interface Decision {
26
+ ts: string;
27
+ decision: string;
28
+ rationale: string;
29
+ }
30
+
31
+ export interface ProjectProgress {
32
+ name: string;
33
+ path: string;
34
+ status: ProjectStatus;
35
+ created: string;
36
+ updated: string;
37
+ facts?: string[];
38
+ objectives?: string[];
39
+ next_steps?: string[];
40
+ blockers?: string[];
41
+ handoff?: string;
42
+ decisions?: Decision[];
43
+ }
44
+
45
+ export const PROJECT_STALE_DAYS_DEFAULT = 14;
46
+
47
+ const PROJECT_MARKERS = [
48
+ ".git",
49
+ "package.json",
50
+ "pyproject.toml",
51
+ "Cargo.toml",
52
+ "go.mod",
53
+ "deno.json",
54
+ "Gemfile",
55
+ ];
56
+
57
+ function progressDir(): string {
58
+ const dir = paths.progress();
59
+ mkdirSync(dir, { recursive: true });
60
+ return dir;
61
+ }
62
+
63
+ function progressFile(slug: string): string {
64
+ return resolve(progressDir(), `${slug}.json`);
65
+ }
66
+
67
+ /**
68
+ * Compute the default project slug from a cwd.
69
+ *
70
+ * Returns the FULL last path segment, lowercased, with non-[a-z0-9_-] chars
71
+ * collapsed to a single hyphen. Critically: never split on `-` or any
72
+ * separator within the basename. `/repos/portable-agent-layer` →
73
+ * `portable-agent-layer`, NOT `layer`.
74
+ */
75
+ export function defaultSlug(cwd: string): string {
76
+ const base = basename(resolve(cwd));
77
+ const cleaned = base
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9_-]+/g, "-")
80
+ .replace(/^-+|-+$/g, "");
81
+ return cleaned || "unnamed";
82
+ }
83
+
84
+ /**
85
+ * Heuristic: does this directory look like a project root (has a project marker)?
86
+ * Used by the SessionStart loader to hint the AI when an unregistered cwd is
87
+ * project-shaped, so registration can be suggested in conversation.
88
+ */
89
+ export function looksLikeProjectRoot(cwd: string): boolean {
90
+ const cwdAbs = resolve(cwd);
91
+ return PROJECT_MARKERS.some((marker) => existsSync(resolve(cwdAbs, marker)));
92
+ }
93
+
94
+ export function readAllProjects(): ProjectProgress[] {
95
+ const dir = progressDir();
96
+ if (!existsSync(dir)) return [];
97
+ const out: ProjectProgress[] = [];
98
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
99
+ try {
100
+ const parsed = JSON.parse(
101
+ readFileSync(resolve(dir, file), "utf-8")
102
+ ) as ProjectProgress;
103
+ if (parsed?.name && parsed?.path && parsed?.status) out.push(parsed);
104
+ } catch {
105
+ /* skip malformed */
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ export function readProject(name: string): ProjectProgress | null {
112
+ const file = progressFile(name);
113
+ if (!existsSync(file)) return null;
114
+ try {
115
+ return JSON.parse(readFileSync(file, "utf-8")) as ProjectProgress;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ export function writeProject(p: ProjectProgress): void {
122
+ writeFileSync(progressFile(p.name), `${JSON.stringify(p, null, 2)}\n`, "utf-8");
123
+ }
124
+
125
+ export function deleteProject(name: string): boolean {
126
+ const file = progressFile(name);
127
+ if (!existsSync(file)) return false;
128
+ unlinkSync(file);
129
+ return true;
130
+ }
131
+
132
+ /**
133
+ * Resolve `cwd` to the registered project that contains it, if any.
134
+ *
135
+ * Rules:
136
+ * - Exact-match registered path → that project.
137
+ * - Descendant of exactly one registered path → that project.
138
+ * - Descendant of multiple (nested) → longest registered path wins.
139
+ * - Ancestor of registered project (parent-dir browse mode) → null.
140
+ * - Unrelated cwd → null.
141
+ *
142
+ * The parent-dir case is the load-bearing one: opening `/repos/` (an ancestor
143
+ * of multiple registered repos) MUST return null so the Stop-hook auto-write
144
+ * doesn't ambiguously bump one of N children.
145
+ */
146
+ export function resolveProjectFromCwd(
147
+ cwd: string,
148
+ projects: ProjectProgress[]
149
+ ): ProjectProgress | null {
150
+ const cwdAbs = resolve(cwd);
151
+ const matches = projects.filter((p) => {
152
+ const projAbs = resolve(p.path);
153
+ return cwdAbs === projAbs || cwdAbs.startsWith(projAbs + sep);
154
+ });
155
+ if (matches.length === 0) return null;
156
+ matches.sort((a, b) => b.path.length - a.path.length);
157
+ return matches[0];
158
+ }
159
+
160
+ export function isStale(
161
+ p: ProjectProgress,
162
+ thresholdDays = PROJECT_STALE_DAYS_DEFAULT
163
+ ): boolean {
164
+ if (!p.updated) return false;
165
+ const age = Date.now() - new Date(p.updated).getTime();
166
+ if (!Number.isFinite(age) || age < 0) return false;
167
+ return age > thresholdDays * 86_400_000;
168
+ }
169
+
170
+ /**
171
+ * Walk up from `cwd` looking for the nearest dir with a project marker.
172
+ * Returns absolute path or null. Bounded at 12 levels to avoid runaway walks.
173
+ */
174
+ export function findProjectRoot(cwd: string): string | null {
175
+ let dir = resolve(cwd);
176
+ const fsRoot = parsePath(dir).root;
177
+ for (let i = 0; i < 12; i++) {
178
+ if (looksLikeProjectRoot(dir)) return dir;
179
+ if (dir === fsRoot) return null;
180
+ dir = dirname(dir);
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function formatAgo(ts: string): string {
186
+ if (!ts) return "?";
187
+ const age = Date.now() - new Date(ts).getTime();
188
+ if (!Number.isFinite(age)) return "?";
189
+ if (age < 60_000) return "just now";
190
+ if (age < 3_600_000) return `${Math.floor(age / 60_000)}m ago`;
191
+ if (age < 86_400_000) return "today";
192
+ const days = Math.floor(age / 86_400_000);
193
+ if (days < 30) return `${days}d ago`;
194
+ return `${Math.floor(days / 30)}mo ago`;
195
+ }
196
+
197
+ const MAX_INLINE_BULLETS = 3;
198
+
199
+ /**
200
+ * Format the SessionStart "Active Projects" section.
201
+ *
202
+ * Output is empty when there's nothing to say (no active/paused projects AND
203
+ * no project-shaped unregistered cwd). When non-empty, includes:
204
+ * - A `## Active Projects` block listing every active/paused project
205
+ * - Full Objectives / Next / Blockers detail ONLY for the cwd-resolved project
206
+ * (`→ here`); every other project renders as a one-liner with next/blocker
207
+ * counts, to keep the section bounded as the project corpus grows
208
+ * - A `⚠ stale` flag for projects updated > threshold days ago
209
+ * - A trailing AI-visible hint when cwd resolves to no project but
210
+ * `findProjectRoot(cwd)` reveals a project-shaped ancestor
211
+ */
212
+ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
213
+ const all = readAllProjects();
214
+ const visible = all.filter((p) => p.status === "active" || p.status === "paused");
215
+ const resolved = resolveProjectFromCwd(cwd, visible);
216
+ const projectRoot = findProjectRoot(cwd);
217
+ const alreadyRegistered =
218
+ projectRoot !== null && all.some((p) => resolve(p.path) === projectRoot);
219
+ const showHint = resolved === null && projectRoot !== null && !alreadyRegistered;
220
+
221
+ if (visible.length === 0 && !showHint) return "";
222
+
223
+ const lines: string[] = [];
224
+
225
+ if (visible.length > 0) {
226
+ lines.push("## Active Projects");
227
+ lines.push("");
228
+ const sorted = [...visible].sort((a, b) => b.updated.localeCompare(a.updated));
229
+ for (const p of sorted) {
230
+ const ago = formatAgo(p.updated);
231
+ const stale = isStale(p) ? " ⚠ stale" : "";
232
+ const isResolved = resolved !== null && p.name === resolved.name;
233
+ const here = isResolved ? " → here" : "";
234
+ const statusPrefix = p.status === "paused" ? "paused, " : "";
235
+
236
+ if (isResolved) {
237
+ lines.push(`- **${p.name}** (${statusPrefix}${ago})${stale}${here}`);
238
+ if (p.facts?.length) {
239
+ lines.push(` Facts: ${p.facts.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
240
+ }
241
+ if (p.objectives?.length) {
242
+ lines.push(
243
+ ` Objectives: ${p.objectives.slice(0, MAX_INLINE_BULLETS).join("; ")}`
244
+ );
245
+ }
246
+ if (p.next_steps?.length) {
247
+ lines.push(` Next: ${p.next_steps.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
248
+ }
249
+ if (p.blockers?.length) {
250
+ lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
251
+ }
252
+ } else {
253
+ const counts: string[] = [];
254
+ if (p.next_steps?.length) counts.push(`${p.next_steps.length} next`);
255
+ if (p.blockers?.length) counts.push(`${p.blockers.length} blockers`);
256
+ const countsSuffix = counts.length > 0 ? ` — ${counts.join(", ")}` : "";
257
+ lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${stale}`);
258
+ }
259
+ }
260
+ }
261
+
262
+ if (showHint) {
263
+ if (visible.length > 0) lines.push("");
264
+ lines.push(
265
+ `💡 \`${projectRoot}\` looks like a project but isn't registered. If substantive work starts here, suggest registering it via \`bun ~/.pal/tools/project.ts create\`.`
266
+ );
267
+ }
268
+
269
+ return lines.join("\n");
270
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Retrieval index — single JSON file with per-doc term-frequency vectors and a global
3
+ * document-frequency table over the failures + wisdom-frames corpus.
4
+ *
5
+ * Built once on first read, rebuilt in the background when source dirs change.
6
+ * Read by the UserPromptSubmit retrieval handler; written by ensureIndex (sync bootstrap)
7
+ * or by a detached `bun src/hooks/lib/retrieval-index.ts --rebuild` invocation.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { SIMILARITY_THRESHOLD } from "./graduation";
14
+ import { readFailures } from "./learning-store";
15
+ import { logDebug, logError } from "./log";
16
+ import { palPkg, paths } from "./paths";
17
+ import { similarity, tokenize } from "./text-similarity";
18
+ import { readFramesForRetrieval } from "./wisdom";
19
+
20
+ export const INDEX_VERSION = 1;
21
+
22
+ export interface IndexedDoc {
23
+ id: string;
24
+ source: "failure" | "wisdom";
25
+ path: string;
26
+ rating: number;
27
+ ts: string;
28
+ tf: Record<string, number>;
29
+ len: number;
30
+ displayPrinciple: string;
31
+ displayContext: string;
32
+ }
33
+
34
+ export interface RetrievalIndex {
35
+ version: number;
36
+ builtAt: string;
37
+ corpusSize: number;
38
+ df: Record<string, number>;
39
+ docs: IndexedDoc[];
40
+ }
41
+
42
+ const CONVERSATION_BLOCK_RE = /## Conversation Summary[\s\S]*?(?=\n## |\n# |$)/g;
43
+
44
+ /** Read first 800 chars of body excluding the Conversation Summary block. */
45
+ function failureBodyExcerpt(content: string): string {
46
+ const stripped = content.replace(CONVERSATION_BLOCK_RE, "");
47
+ const bodyStart = stripped.indexOf("\n---\n");
48
+ const body = bodyStart >= 0 ? stripped.slice(bodyStart + 5) : stripped;
49
+ return body.slice(0, 800);
50
+ }
51
+
52
+ function buildTermFreq(tokens: string[]): { tf: Record<string, number>; len: number } {
53
+ const tf: Record<string, number> = {};
54
+ for (const t of tokens) tf[t] = (tf[t] ?? 0) + 1;
55
+ return { tf, len: tokens.length };
56
+ }
57
+
58
+ /** Concatenate weighted fields, tokenize, count tf. Weights produce N-fold token repetition. */
59
+ function buildDocTokens(parts: { text: string; weight: number }[]): string[] {
60
+ const all: string[] = [];
61
+ for (const { text, weight } of parts) {
62
+ if (!text) continue;
63
+ const toks = tokenize(text);
64
+ for (let i = 0; i < weight; i++) all.push(...toks);
65
+ }
66
+ return all;
67
+ }
68
+
69
+ export function buildIndex(): RetrievalIndex {
70
+ const docs: IndexedDoc[] = [];
71
+ const df: Record<string, number> = {};
72
+
73
+ const frames = readFramesForRetrieval();
74
+ const framePrinciples = frames.map((fr) => fr.principle).filter(Boolean);
75
+
76
+ // Failures — skip captures whose principle has already graduated into a frame.
77
+ // Reuses graduation.ts's threshold so the same Dice-similarity rule that promotes
78
+ // patterns to wisdom is the one that hides their source captures from retrieval.
79
+ const allFailures = readFailures(paths.failures());
80
+ const failures = allFailures.filter((f) => {
81
+ if (!f.principle) return true;
82
+ return !framePrinciples.some(
83
+ (fp) => similarity(f.principle, fp) >= SIMILARITY_THRESHOLD
84
+ );
85
+ });
86
+ const skipped = allFailures.length - failures.length;
87
+ if (skipped > 0)
88
+ logDebug(
89
+ "retrieval-index",
90
+ `dedup: skipped ${skipped} captures already represented by graduated frames`
91
+ );
92
+
93
+ for (const f of failures) {
94
+ let raw = "";
95
+ try {
96
+ raw = readFileSync(f.path, "utf-8");
97
+ } catch {
98
+ continue;
99
+ }
100
+ const body = failureBodyExcerpt(raw);
101
+ const tokens = buildDocTokens([
102
+ { text: f.principle, weight: 3 },
103
+ { text: f.context, weight: 2 },
104
+ { text: body, weight: 1 },
105
+ ]);
106
+ if (tokens.length === 0) continue;
107
+ const { tf, len } = buildTermFreq(tokens);
108
+ for (const term of Object.keys(tf)) df[term] = (df[term] ?? 0) + 1;
109
+ docs.push({
110
+ id: f.slug,
111
+ source: "failure",
112
+ path: f.path,
113
+ rating: f.rating,
114
+ ts: f.ts,
115
+ tf,
116
+ len,
117
+ displayPrinciple: f.principle,
118
+ displayContext: f.context,
119
+ });
120
+ }
121
+
122
+ // Wisdom frames (each principle = pseudo-doc)
123
+ for (const fr of frames) {
124
+ const tokens = buildDocTokens([
125
+ { text: fr.principle, weight: 3 },
126
+ { text: fr.domain, weight: 2 },
127
+ { text: fr.body, weight: 1 },
128
+ ]);
129
+ if (tokens.length === 0) continue;
130
+ const { tf, len } = buildTermFreq(tokens);
131
+ for (const term of Object.keys(tf)) df[term] = (df[term] ?? 0) + 1;
132
+ docs.push({
133
+ id: `${fr.domain}:${fr.principle.slice(0, 40)}`,
134
+ source: "wisdom",
135
+ path: resolve(paths.wisdom(), `${fr.domain}.md`),
136
+ rating: fr.confidence,
137
+ ts: "",
138
+ tf,
139
+ len,
140
+ displayPrinciple: fr.principle,
141
+ displayContext: fr.domain,
142
+ });
143
+ }
144
+
145
+ return {
146
+ version: INDEX_VERSION,
147
+ builtAt: new Date().toISOString(),
148
+ corpusSize: docs.length,
149
+ df,
150
+ docs,
151
+ };
152
+ }
153
+
154
+ export function writeIndex(index: RetrievalIndex): void {
155
+ writeFileSync(paths.retrievalIndex(), JSON.stringify(index));
156
+ }
157
+
158
+ export function readIndex(): RetrievalIndex | null {
159
+ const p = paths.retrievalIndex();
160
+ if (!existsSync(p)) return null;
161
+ try {
162
+ const parsed = JSON.parse(readFileSync(p, "utf-8")) as RetrievalIndex;
163
+ if (parsed?.version !== INDEX_VERSION) return null;
164
+ return parsed;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /** True if any source directory was modified after the index was built. */
171
+ export function isStale(index: RetrievalIndex): boolean {
172
+ try {
173
+ const builtMs = new Date(index.builtAt).getTime();
174
+ for (const dir of [paths.failures(), paths.wisdom()]) {
175
+ if (!existsSync(dir)) continue;
176
+ if (statSync(dir).mtimeMs > builtMs) return true;
177
+ }
178
+ return false;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** Detached background rebuild — fire and forget, never throws. */
185
+ export function spawnBackgroundRebuild(): void {
186
+ try {
187
+ const script = resolve(palPkg(), "src", "hooks", "lib", "retrieval-index.ts");
188
+ const child = spawn("bun", ["run", script, "--rebuild"], {
189
+ detached: true,
190
+ stdio: "ignore",
191
+ env: process.env,
192
+ });
193
+ child.unref();
194
+ } catch (err) {
195
+ logError("retrieval-index:spawn", err);
196
+ }
197
+ }
198
+
199
+ /** Return the freshest usable index. Builds synchronously on first run; otherwise
200
+ * returns the cached index and triggers a background rebuild if the corpus moved. */
201
+ export function ensureIndex(): RetrievalIndex {
202
+ const existing = readIndex();
203
+ if (!existing) {
204
+ logDebug("retrieval-index", "no index — building synchronously");
205
+ const fresh = buildIndex();
206
+ writeIndex(fresh);
207
+ return fresh;
208
+ }
209
+ if (isStale(existing)) {
210
+ logDebug("retrieval-index", "stale index — using cached, rebuilding in background");
211
+ spawnBackgroundRebuild();
212
+ }
213
+ return existing;
214
+ }
215
+
216
+ // CLI entry — `bun run src/hooks/lib/retrieval-index.ts --rebuild`
217
+ if (import.meta.main) {
218
+ const fresh = buildIndex();
219
+ writeIndex(fresh);
220
+ console.log(
221
+ `built retrieval index — ${fresh.corpusSize} docs, ${Object.keys(fresh.df).length} terms`
222
+ );
223
+ }