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.
- package/README.md +1 -0
- package/assets/skills/presentation/SKILL.md +124 -5
- package/assets/skills/presentation/WORKSHOP.md +128 -0
- package/assets/skills/presentation/theme-base/base.css +113 -0
- package/assets/skills/presentation/theme-base/layouts.css +11 -2
- package/assets/skills/presentation/tools/build.ts +136 -6
- package/assets/skills/presentation/tools/doctor.ts +106 -317
- package/assets/skills/presentation/tools/lib/lint-helpers.ts +150 -0
- package/assets/skills/presentation/tools/lib/lint-rules.ts +744 -0
- package/assets/skills/presentation/tools/lib/lint-types.ts +40 -0
- package/assets/skills/presentation/tools/new-deck.ts +9 -4
- package/assets/skills/presentation/vendor/reveal/plugin/highlight/github-dark.css +118 -0
- package/assets/skills/projects/SKILL.md +111 -0
- package/assets/skills/telos/SKILL.md +4 -1
- package/assets/templates/AGENTS.md.template +28 -7
- package/assets/templates/PAL/ALGORITHM.md +2 -0
- package/assets/templates/PAL/README.md +1 -2
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
- package/assets/templates/pal-settings.json +2 -2
- package/package.json +2 -3
- package/src/cli/index.ts +7 -0
- package/src/hooks/UserPromptOrchestrator.ts +3 -1
- package/src/hooks/handlers/auto-graduate.ts +169 -0
- package/src/hooks/handlers/inject-retrieval.ts +50 -0
- package/src/hooks/handlers/project-touch.ts +39 -0
- package/src/hooks/lib/context.ts +9 -8
- package/src/hooks/lib/paths.ts +2 -0
- package/src/hooks/lib/projects.ts +270 -0
- package/src/hooks/lib/retrieval-index.ts +223 -0
- package/src/hooks/lib/retrieval.ts +170 -0
- package/src/hooks/lib/security.ts +2 -0
- package/src/hooks/lib/stop.ts +9 -1
- package/src/hooks/lib/text-similarity.ts +13 -9
- package/src/hooks/lib/wisdom.ts +155 -1
- package/src/tools/agent/project.ts +336 -0
- package/src/tools/self-model.ts +3 -3
- package/src/tools/token-cost.ts +4 -4
- 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
|
+
}
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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);
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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
|
+
}
|