portable-agent-layer 0.35.0 → 0.37.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 +2 -1
- package/assets/skills/analyze-pdf/tools/pdf-download.ts +1 -1
- package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -1
- package/assets/skills/consulting-report/tools/dev.ts +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +9 -9
- package/assets/skills/consulting-report/tools/scaffold.ts +2 -2
- package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +2 -2
- package/assets/skills/opinion/tools/opinion.ts +3 -2
- package/assets/skills/presentation/SKILL.md +1 -1
- package/assets/skills/presentation/tools/doctor.ts +2 -5
- package/assets/skills/presentation/tools/lib/inline.ts +6 -11
- package/assets/skills/presentation/tools/lib/lint-helpers.ts +2 -2
- package/assets/skills/presentation/tools/lib/lint-rules.ts +5 -2
- package/assets/skills/presentation/tools/setup-template.ts +10 -7
- package/assets/skills/projects/SKILL.md +44 -21
- package/assets/skills/research/tools/gemini-search.ts +2 -2
- package/assets/skills/research/tools/grok-search.ts +2 -2
- package/assets/skills/research/tools/perplexity-search.ts +2 -2
- package/assets/skills/telos/SKILL.md +7 -52
- package/assets/skills/telos/tools/update-telos.ts +0 -1
- package/assets/templates/PAL/ALGORITHM.md +54 -5
- package/assets/templates/PAL/PROJECT_LIFECYCLE.md +48 -0
- package/assets/templates/PAL/README.md +1 -1
- package/assets/templates/PAL/STEERING_RULES.md +4 -0
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +32 -17
- package/assets/templates/PAL/WORK_TRACKING.md +1 -1
- package/assets/templates/hooks.codex.json +44 -0
- package/assets/templates/hooks.cursor.json +11 -5
- package/assets/templates/pal-settings.json +1 -3
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +2 -1
- package/src/cli/index.ts +112 -14
- package/src/cli/migrate.ts +299 -0
- package/src/cli/setup-identity.ts +3 -3
- package/src/cli/setup-telos.ts +12 -80
- package/src/hooks/CompactRecover.ts +11 -5
- package/src/hooks/LoadContext.ts +35 -11
- package/src/hooks/PreCompactPersist.ts +26 -34
- package/src/hooks/SecurityValidator.ts +43 -21
- package/src/hooks/StopOrchestrator.ts +4 -1
- package/src/hooks/UserPromptOrchestrator.ts +4 -2
- package/src/hooks/handlers/auto-graduate.ts +2 -2
- package/src/hooks/handlers/backup.ts +3 -3
- package/src/hooks/handlers/context-digests.ts +74 -0
- package/src/hooks/handlers/failure.ts +5 -3
- package/src/hooks/handlers/inject-retrieval.ts +29 -6
- package/src/hooks/handlers/persist-last-exchange.ts +76 -0
- package/src/hooks/handlers/rating.ts +2 -1
- package/src/hooks/handlers/readme-sync.ts +3 -2
- package/src/hooks/handlers/session-intelligence.ts +17 -93
- package/src/hooks/handlers/session-name.ts +2 -2
- package/src/hooks/handlers/synthesis.ts +5 -2
- package/src/hooks/handlers/update-counts.ts +3 -2
- package/src/hooks/lib/agent.ts +20 -18
- package/src/hooks/lib/claude-md.ts +69 -14
- package/src/hooks/lib/context.ts +92 -246
- package/src/hooks/lib/entities.ts +7 -7
- package/src/hooks/lib/frontmatter.ts +4 -4
- package/src/hooks/lib/graduation.ts +7 -6
- package/src/hooks/lib/inference.ts +6 -2
- package/src/hooks/lib/learning-category.ts +1 -1
- package/src/hooks/lib/learning-store.ts +6 -1
- package/src/hooks/lib/notify.ts +2 -2
- package/src/hooks/lib/opinions.ts +3 -3
- package/src/hooks/lib/paths.ts +2 -0
- package/src/hooks/lib/projects.ts +142 -74
- package/src/hooks/lib/readme-sync.ts +1 -1
- package/src/hooks/lib/relationship.ts +4 -16
- package/src/hooks/lib/retrieval-index.ts +5 -3
- package/src/hooks/lib/retrieval.ts +11 -12
- package/src/hooks/lib/security.ts +24 -18
- package/src/hooks/lib/semi-static.ts +188 -0
- package/src/hooks/lib/session-names.ts +1 -1
- package/src/hooks/lib/settings.ts +1 -1
- package/src/hooks/lib/setup.ts +2 -65
- package/src/hooks/lib/signals.ts +2 -2
- package/src/hooks/lib/stdin.ts +1 -1
- package/src/hooks/lib/stop.ts +16 -6
- package/src/hooks/lib/token-usage.ts +1 -2
- package/src/hooks/lib/transcript.ts +1 -1
- package/src/hooks/lib/wisdom.ts +5 -5
- package/src/hooks/lib/work-tracking.ts +8 -14
- package/src/targets/claude/uninstall.ts +1 -1
- package/src/targets/codex/install.ts +95 -0
- package/src/targets/codex/uninstall.ts +70 -0
- package/src/targets/copilot/install.ts +39 -8
- package/src/targets/copilot/uninstall.ts +58 -17
- package/src/targets/cursor/install.ts +8 -0
- package/src/targets/cursor/uninstall.ts +18 -1
- package/src/targets/lib.ts +166 -14
- package/src/targets/opencode/install.ts +29 -1
- package/src/targets/opencode/plugin.ts +23 -12
- package/src/targets/opencode/uninstall.ts +30 -3
- package/src/tools/agent/algorithm-reflect.ts +1 -1
- package/src/tools/agent/analyze.ts +18 -18
- package/src/tools/agent/handoff-note.ts +116 -0
- package/src/tools/agent/project.ts +375 -75
- package/src/tools/agent/relationship-note.ts +51 -0
- package/src/tools/agent/synthesize.ts +6 -42
- package/src/tools/agent/thread.ts +15 -14
- package/src/tools/agent/wisdom-frame.ts +9 -3
- package/src/tools/import.ts +1 -1
- package/src/tools/relationship-reflect.ts +15 -13
- package/src/tools/self-model.ts +23 -19
- package/src/tools/session-summary.ts +3 -3
- package/src/tools/token-cost.ts +15 -16
- package/assets/skills/telos/tools/update-projects.ts +0 -106
- package/assets/templates/telos/PROJECTS.md +0 -7
|
@@ -18,13 +18,13 @@ import { similarity } from "./text-similarity";
|
|
|
18
18
|
export type EvidenceType = "supporting" | "counter" | "confirmation" | "contradiction";
|
|
19
19
|
export type OpinionCategory = "communication" | "technical" | "workflow" | "general";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
interface Evidence {
|
|
22
22
|
date: string;
|
|
23
23
|
type: EvidenceType;
|
|
24
24
|
source: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
interface Opinion {
|
|
28
28
|
id: string;
|
|
29
29
|
statement: string;
|
|
30
30
|
confidence: number;
|
|
@@ -145,7 +145,7 @@ export function createOpinion(statement: string, source: string): Opinion {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/** Check if an opinion already has evidence with this exact source text. */
|
|
148
|
-
|
|
148
|
+
function hasEvidence(opinion: Opinion, source: string): boolean {
|
|
149
149
|
return opinion.evidence.some((e) => e.source === source);
|
|
150
150
|
}
|
|
151
151
|
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const paths = {
|
|
|
54
54
|
projectHistory: () => ensureDir(home("memory", "projects")),
|
|
55
55
|
sessionLearning: () => ensureDir(home("memory", "learning", "session")),
|
|
56
56
|
synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
|
|
57
|
+
work: () => ensureDir(home("memory", "work")),
|
|
57
58
|
backups: () => ensureDir(home("backups")),
|
|
58
59
|
} as const;
|
|
59
60
|
|
|
@@ -78,6 +79,7 @@ export const assets = {
|
|
|
78
79
|
claudeSettingsTemplate: () => pkg("assets", "templates", "settings.claude.json"),
|
|
79
80
|
cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
|
|
80
81
|
copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
|
|
82
|
+
codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
|
|
81
83
|
agentTools: () => pkg("src", "tools", "agent"),
|
|
82
84
|
palDocs: () => pkg("assets", "templates", "PAL"),
|
|
83
85
|
} as const;
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projects — registry of user-curated projects
|
|
2
|
+
* Projects — registry of user-curated projects backed by ISA.md files.
|
|
3
3
|
*
|
|
4
|
-
* Each project lives in `~/.pal/memory/
|
|
5
|
-
*
|
|
4
|
+
* Each project lives in `~/.pal/memory/projects/{slug}/ISA.md`. Frontmatter
|
|
5
|
+
* holds operational state; the body holds ISA spec sections. The CLI
|
|
6
|
+
* (`src/tools/agent/project.ts`) is the primary writer; the Stop hook
|
|
6
7
|
* 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
8
|
*/
|
|
11
9
|
|
|
12
10
|
import {
|
|
@@ -14,35 +12,38 @@ import {
|
|
|
14
12
|
mkdirSync,
|
|
15
13
|
readdirSync,
|
|
16
14
|
readFileSync,
|
|
15
|
+
rmdirSync,
|
|
17
16
|
unlinkSync,
|
|
18
17
|
writeFileSync,
|
|
19
18
|
} from "node:fs";
|
|
20
19
|
import { basename, dirname, parse as parsePath, resolve, sep } from "node:path";
|
|
20
|
+
import { parse, stringify } from "./frontmatter";
|
|
21
21
|
import { paths } from "./paths";
|
|
22
22
|
|
|
23
23
|
export type ProjectStatus = "active" | "paused" | "complete" | "archived";
|
|
24
24
|
|
|
25
|
-
export interface Decision {
|
|
26
|
-
ts: string;
|
|
27
|
-
decision: string;
|
|
28
|
-
rationale: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
export interface ProjectProgress {
|
|
32
26
|
name: string;
|
|
33
27
|
path: string;
|
|
34
28
|
status: ProjectStatus;
|
|
35
29
|
created: string;
|
|
36
30
|
updated: string;
|
|
37
|
-
|
|
38
|
-
objectives?: string[];
|
|
39
|
-
next_steps?: string[];
|
|
31
|
+
next?: string[];
|
|
40
32
|
blockers?: string[];
|
|
41
33
|
handoff?: string;
|
|
42
|
-
|
|
34
|
+
// ISA body sections
|
|
35
|
+
problem?: string;
|
|
36
|
+
goal?: string;
|
|
37
|
+
criteria?: string;
|
|
38
|
+
vision?: string;
|
|
39
|
+
constraints?: string;
|
|
40
|
+
out_of_scope?: string;
|
|
41
|
+
context?: string;
|
|
42
|
+
decisions?: string;
|
|
43
|
+
changelog?: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
const PROJECT_STALE_DAYS_DEFAULT = 14;
|
|
46
47
|
|
|
47
48
|
const PROJECT_MARKERS = [
|
|
48
49
|
".git",
|
|
@@ -54,23 +55,69 @@ const PROJECT_MARKERS = [
|
|
|
54
55
|
"Gemfile",
|
|
55
56
|
];
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
type IsaMeta = {
|
|
59
|
+
name: string;
|
|
60
|
+
path: string;
|
|
61
|
+
status: ProjectStatus;
|
|
62
|
+
created: string;
|
|
63
|
+
updated: string;
|
|
64
|
+
next?: string[];
|
|
65
|
+
blockers?: string[];
|
|
66
|
+
handoff?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const BODY_SECTIONS: Array<[string, keyof ProjectProgress]> = [
|
|
70
|
+
["Problem", "problem"],
|
|
71
|
+
["Goal", "goal"],
|
|
72
|
+
["Criteria", "criteria"],
|
|
73
|
+
["Vision", "vision"],
|
|
74
|
+
["Constraints", "constraints"],
|
|
75
|
+
["Out of Scope", "out_of_scope"],
|
|
76
|
+
["Context", "context"],
|
|
77
|
+
["Decisions", "decisions"],
|
|
78
|
+
["Changelog", "changelog"],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const HEADER_TO_FIELD: Record<string, keyof ProjectProgress> = Object.fromEntries(
|
|
82
|
+
BODY_SECTIONS.map(([h, k]) => [h.toLowerCase(), k])
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
function buildBody(p: ProjectProgress): string {
|
|
86
|
+
return BODY_SECTIONS.filter(([, k]) => (p[k] as string | undefined)?.trim())
|
|
87
|
+
.map(([h, k]) => `## ${h}\n\n${(p[k] as string).trim()}`)
|
|
88
|
+
.join("\n\n");
|
|
61
89
|
}
|
|
62
90
|
|
|
63
|
-
function
|
|
64
|
-
|
|
91
|
+
function extractSections(body: string): Partial<ProjectProgress> {
|
|
92
|
+
const out: Partial<ProjectProgress> = {};
|
|
93
|
+
for (const part of body.split(/^## /m).slice(1)) {
|
|
94
|
+
const nl = part.indexOf("\n");
|
|
95
|
+
if (nl === -1) continue;
|
|
96
|
+
const header = part.slice(0, nl).trim().toLowerCase();
|
|
97
|
+
const content = part.slice(nl + 1).trim();
|
|
98
|
+
if (!content) continue;
|
|
99
|
+
const field = HEADER_TO_FIELD[header];
|
|
100
|
+
if (field) (out as Record<string, string>)[field as string] = content;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isaFilePath(slug: string): string {
|
|
106
|
+
return resolve(paths.projectHistory(), slug, "ISA.md");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ensureAndGetIsaFile(slug: string): string {
|
|
110
|
+
const dir = resolve(paths.projectHistory(), slug);
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
return resolve(dir, "ISA.md");
|
|
65
113
|
}
|
|
66
114
|
|
|
67
115
|
/**
|
|
68
116
|
* Compute the default project slug from a cwd.
|
|
69
117
|
*
|
|
70
118
|
* Returns the FULL last path segment, lowercased, with non-[a-z0-9_-] chars
|
|
71
|
-
* collapsed to a single hyphen.
|
|
72
|
-
*
|
|
73
|
-
* `portable-agent-layer`, NOT `layer`.
|
|
119
|
+
* collapsed to a single hyphen. Never splits on `-`. `/repos/portable-agent-layer`
|
|
120
|
+
* → `portable-agent-layer`, NOT `layer`.
|
|
74
121
|
*/
|
|
75
122
|
export function defaultSlug(cwd: string): string {
|
|
76
123
|
const base = basename(resolve(cwd));
|
|
@@ -83,8 +130,6 @@ export function defaultSlug(cwd: string): string {
|
|
|
83
130
|
|
|
84
131
|
/**
|
|
85
132
|
* 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
133
|
*/
|
|
89
134
|
export function looksLikeProjectRoot(cwd: string): boolean {
|
|
90
135
|
const cwdAbs = resolve(cwd);
|
|
@@ -92,56 +137,62 @@ export function looksLikeProjectRoot(cwd: string): boolean {
|
|
|
92
137
|
}
|
|
93
138
|
|
|
94
139
|
export function readAllProjects(): ProjectProgress[] {
|
|
95
|
-
const
|
|
96
|
-
if (!existsSync(
|
|
140
|
+
const base = paths.projectHistory();
|
|
141
|
+
if (!existsSync(base)) return [];
|
|
97
142
|
const out: ProjectProgress[] = [];
|
|
98
|
-
for (const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (parsed?.name && parsed?.path && parsed?.status) out.push(parsed);
|
|
104
|
-
} catch {
|
|
105
|
-
/* skip malformed */
|
|
106
|
-
}
|
|
143
|
+
for (const slug of readdirSync(base)) {
|
|
144
|
+
const file = resolve(base, slug, "ISA.md");
|
|
145
|
+
if (!existsSync(file)) continue;
|
|
146
|
+
const p = readProject(slug);
|
|
147
|
+
if (p) out.push(p);
|
|
107
148
|
}
|
|
108
149
|
return out;
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
export function readProject(name: string): ProjectProgress | null {
|
|
112
|
-
const file =
|
|
153
|
+
const file = isaFilePath(name);
|
|
113
154
|
if (!existsSync(file)) return null;
|
|
114
155
|
try {
|
|
115
|
-
|
|
156
|
+
const content = readFileSync(file, "utf-8");
|
|
157
|
+
const { meta, body } = parse<IsaMeta>(content);
|
|
158
|
+
if (!meta?.name || !meta?.path || !meta?.status) return null;
|
|
159
|
+
return { ...meta, ...extractSections(body) };
|
|
116
160
|
} catch {
|
|
117
161
|
return null;
|
|
118
162
|
}
|
|
119
163
|
}
|
|
120
164
|
|
|
121
165
|
export function writeProject(p: ProjectProgress): void {
|
|
122
|
-
|
|
166
|
+
const meta: Record<string, unknown> = {
|
|
167
|
+
name: p.name,
|
|
168
|
+
path: p.path,
|
|
169
|
+
status: p.status,
|
|
170
|
+
created: p.created,
|
|
171
|
+
updated: p.updated,
|
|
172
|
+
};
|
|
173
|
+
if (p.next?.length) meta.next = p.next;
|
|
174
|
+
if (p.blockers?.length) meta.blockers = p.blockers;
|
|
175
|
+
if (p.handoff) meta.handoff = p.handoff;
|
|
176
|
+
writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
|
|
123
177
|
}
|
|
124
178
|
|
|
125
179
|
export function deleteProject(name: string): boolean {
|
|
126
|
-
const file =
|
|
180
|
+
const file = isaFilePath(name);
|
|
127
181
|
if (!existsSync(file)) return false;
|
|
128
182
|
unlinkSync(file);
|
|
183
|
+
try {
|
|
184
|
+
rmdirSync(resolve(paths.projectHistory(), name));
|
|
185
|
+
} catch {
|
|
186
|
+
/* dir not empty or already gone */
|
|
187
|
+
}
|
|
129
188
|
return true;
|
|
130
189
|
}
|
|
131
190
|
|
|
132
191
|
/**
|
|
133
192
|
* Resolve `cwd` to the registered project that contains it, if any.
|
|
134
193
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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.
|
|
194
|
+
* Parent-dir browse mode (cwd is an ancestor of a registered project) → null.
|
|
195
|
+
* Multiple nested projects → longest registered path wins.
|
|
145
196
|
*/
|
|
146
197
|
export function resolveProjectFromCwd(
|
|
147
198
|
cwd: string,
|
|
@@ -171,7 +222,7 @@ export function isStale(
|
|
|
171
222
|
* Walk up from `cwd` looking for the nearest dir with a project marker.
|
|
172
223
|
* Returns absolute path or null. Bounded at 12 levels to avoid runaway walks.
|
|
173
224
|
*/
|
|
174
|
-
|
|
225
|
+
function findProjectRoot(cwd: string): string | null {
|
|
175
226
|
let dir = resolve(cwd);
|
|
176
227
|
const fsRoot = parsePath(dir).root;
|
|
177
228
|
for (let i = 0; i < 12; i++) {
|
|
@@ -199,15 +250,9 @@ const MAX_INLINE_BULLETS = 3;
|
|
|
199
250
|
/**
|
|
200
251
|
* Format the SessionStart "Active Projects" section.
|
|
201
252
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
|
253
|
+
* For the cwd-resolved project (`→ here`): full detail with Context, Objectives
|
|
254
|
+
* (from goal section), Next, and Blockers. For all others: compact one-liner with
|
|
255
|
+
* next/blocker counts only. Stale flag (>14d) and browse-mode hint included.
|
|
211
256
|
*/
|
|
212
257
|
export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
|
|
213
258
|
const all = readAllProjects();
|
|
@@ -223,8 +268,7 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
|
|
|
223
268
|
const lines: string[] = [];
|
|
224
269
|
|
|
225
270
|
if (visible.length > 0) {
|
|
226
|
-
lines.push("## Active Projects");
|
|
227
|
-
lines.push("");
|
|
271
|
+
lines.push("## Active Projects", "");
|
|
228
272
|
const sorted = [...visible].sort((a, b) => b.updated.localeCompare(a.updated));
|
|
229
273
|
for (const p of sorted) {
|
|
230
274
|
const ago = formatAgo(p.updated);
|
|
@@ -235,23 +279,47 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
|
|
|
235
279
|
|
|
236
280
|
if (isResolved) {
|
|
237
281
|
lines.push(`- **${p.name}** (${statusPrefix}${ago})${stale}${here}`);
|
|
238
|
-
if (p.
|
|
239
|
-
|
|
282
|
+
if (p.context) {
|
|
283
|
+
const bullets = p.context
|
|
284
|
+
.split("\n")
|
|
285
|
+
.map((l) => l.replace(/^[-*]\s*/, "").trim())
|
|
286
|
+
.filter(Boolean);
|
|
287
|
+
lines.push(` Facts: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
|
|
288
|
+
}
|
|
289
|
+
if (p.goal) {
|
|
290
|
+
const bullets = p.goal
|
|
291
|
+
.split("\n")
|
|
292
|
+
.map((l) => l.replace(/^[-*]\s*/, "").trim())
|
|
293
|
+
.filter(Boolean);
|
|
294
|
+
lines.push(` Objectives: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
|
|
240
295
|
}
|
|
241
|
-
if (p.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
296
|
+
if (p.constraints) {
|
|
297
|
+
const bullets = p.constraints
|
|
298
|
+
.split("\n")
|
|
299
|
+
.map((l) => l.replace(/^[-*]\s*/, "").trim())
|
|
300
|
+
.filter(Boolean);
|
|
301
|
+
lines.push(` Constraints: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
|
|
245
302
|
}
|
|
246
|
-
if (p.
|
|
247
|
-
lines.push(` Next: ${p.
|
|
303
|
+
if (p.next?.length) {
|
|
304
|
+
lines.push(` Next: ${p.next.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
|
|
248
305
|
}
|
|
249
306
|
if (p.blockers?.length) {
|
|
250
307
|
lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
|
|
251
308
|
}
|
|
309
|
+
if (p.criteria) {
|
|
310
|
+
const openIscs = p.criteria
|
|
311
|
+
.split("\n")
|
|
312
|
+
.filter((l) => /^-\s+\[ \]\s+ISC-\d+:/i.test(l))
|
|
313
|
+
.map((l) => l.replace(/^-\s+\[ \]\s+/, "").trim());
|
|
314
|
+
if (openIscs.length > 0) {
|
|
315
|
+
lines.push(
|
|
316
|
+
` Open ISCs (${openIscs.length}): ${openIscs.slice(0, MAX_INLINE_BULLETS).join("; ")}`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
252
320
|
} else {
|
|
253
321
|
const counts: string[] = [];
|
|
254
|
-
if (p.
|
|
322
|
+
if (p.next?.length) counts.push(`${p.next.length} next`);
|
|
255
323
|
if (p.blockers?.length) counts.push(`${p.blockers.length} blockers`);
|
|
256
324
|
const countsSuffix = counts.length > 0 ? ` — ${counts.join(", ")}` : "";
|
|
257
325
|
lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${stale}`);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Notes live at memory/relationship/YYYY-MM/YYYY-MM-DD.md
|
|
6
6
|
* W = world (facts about user's situation)
|
|
7
7
|
* O = opinion (preference with confidence)
|
|
8
|
-
*
|
|
8
|
+
* Session = what the AI did this session (first-person)
|
|
9
9
|
*
|
|
10
10
|
* Extraction is handled by the relationship handler via Haiku inference.
|
|
11
11
|
* This lib provides storage and reading utilities only.
|
|
@@ -15,9 +15,9 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { ensureDir, paths } from "./paths";
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
type NoteType = "W" | "O" | "Session";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface RelationshipNote {
|
|
21
21
|
type: NoteType;
|
|
22
22
|
text: string;
|
|
23
23
|
confidence?: number;
|
|
@@ -31,18 +31,6 @@ function dailyFilePath(date: Date): string {
|
|
|
31
31
|
return resolve(monthDir, `${yyyy}-${mm}-${dd}.md`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Check if a session already has notes in today's file */
|
|
35
|
-
export function hasSessionNotes(sessionId: string): boolean {
|
|
36
|
-
const filepath = dailyFilePath(new Date());
|
|
37
|
-
if (!existsSync(filepath)) return false;
|
|
38
|
-
try {
|
|
39
|
-
const content = readFileSync(filepath, "utf-8");
|
|
40
|
-
return content.includes(`<!-- session:${sessionId} -->`);
|
|
41
|
-
} catch {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
34
|
/** Deduplicate notes against what's already in today's file */
|
|
47
35
|
function dedup(notes: RelationshipNote[], filepath: string): RelationshipNote[] {
|
|
48
36
|
if (!existsSync(filepath)) return notes;
|
|
@@ -77,7 +65,7 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void
|
|
|
77
65
|
|
|
78
66
|
const timestamp = new Date().toTimeString().slice(0, 5);
|
|
79
67
|
lines.push(`## ${timestamp}`);
|
|
80
|
-
if (sessionId) lines.push(`<!-- session:${sessionId} -->`);
|
|
68
|
+
if (sessionId) lines.push(`<!-- session:${sessionId} cwd:${process.cwd()} -->`);
|
|
81
69
|
|
|
82
70
|
for (const note of fresh) {
|
|
83
71
|
if (note.type === "O" && note.confidence !== undefined) {
|
|
@@ -17,7 +17,7 @@ import { palPkg, paths } from "./paths";
|
|
|
17
17
|
import { similarity, tokenize } from "./text-similarity";
|
|
18
18
|
import { readFramesForRetrieval } from "./wisdom";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const INDEX_VERSION = 1;
|
|
21
21
|
|
|
22
22
|
export interface IndexedDoc {
|
|
23
23
|
id: string;
|
|
@@ -29,6 +29,7 @@ export interface IndexedDoc {
|
|
|
29
29
|
len: number;
|
|
30
30
|
displayPrinciple: string;
|
|
31
31
|
displayContext: string;
|
|
32
|
+
cwd?: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface RetrievalIndex {
|
|
@@ -116,6 +117,7 @@ export function buildIndex(): RetrievalIndex {
|
|
|
116
117
|
len,
|
|
117
118
|
displayPrinciple: f.principle,
|
|
118
119
|
displayContext: f.context,
|
|
120
|
+
cwd: f.cwd || undefined,
|
|
119
121
|
});
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -151,7 +153,7 @@ export function buildIndex(): RetrievalIndex {
|
|
|
151
153
|
};
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
|
|
156
|
+
function writeIndex(index: RetrievalIndex): void {
|
|
155
157
|
writeFileSync(paths.retrievalIndex(), JSON.stringify(index));
|
|
156
158
|
}
|
|
157
159
|
|
|
@@ -182,7 +184,7 @@ export function isStale(index: RetrievalIndex): boolean {
|
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
/** Detached background rebuild — fire and forget, never throws. */
|
|
185
|
-
|
|
187
|
+
function spawnBackgroundRebuild(): void {
|
|
186
188
|
try {
|
|
187
189
|
const script = resolve(palPkg(), "src", "hooks", "lib", "retrieval-index.ts");
|
|
188
190
|
const child = spawn("bun", ["run", script, "--rebuild"], {
|
|
@@ -18,7 +18,7 @@ const MAX_MATCHES = 2;
|
|
|
18
18
|
const MAX_REMINDER_BYTES = 500;
|
|
19
19
|
const PRINCIPLE_TRUNC = 200;
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
interface ScoredDoc {
|
|
22
22
|
doc: IndexedDoc;
|
|
23
23
|
score: number;
|
|
24
24
|
confidence: number;
|
|
@@ -38,7 +38,7 @@ function ageDecay(ts: string): number {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Raw IDF-weighted overlap score, capped tf, sqrt-length-normalized. */
|
|
41
|
-
|
|
41
|
+
function scoreDoc(
|
|
42
42
|
queryTerms: Set<string>,
|
|
43
43
|
doc: IndexedDoc,
|
|
44
44
|
df: Record<string, number>,
|
|
@@ -56,7 +56,7 @@ export function scoreDoc(
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Upper bound: query treated as a doc with tf=1 per unique term. */
|
|
59
|
-
|
|
59
|
+
function selfScore(
|
|
60
60
|
queryTerms: Set<string>,
|
|
61
61
|
df: Record<string, number>,
|
|
62
62
|
N: number
|
|
@@ -75,7 +75,7 @@ function scopeMatches(doc: IndexedDoc, scopeKey: string): boolean {
|
|
|
75
75
|
|
|
76
76
|
/** Rank the index against `query`. Returns docs above the confidence threshold,
|
|
77
77
|
* scope-boosted and age-decayed, sorted high-to-low, capped at MAX_MATCHES. */
|
|
78
|
-
|
|
78
|
+
function rank(query: string, index: RetrievalIndex, cwd: string): ScoredDoc[] {
|
|
79
79
|
const queryTerms = extractKeywords(query);
|
|
80
80
|
if (queryTerms.size === 0) return [];
|
|
81
81
|
|
|
@@ -92,7 +92,10 @@ export function rank(query: string, index: RetrievalIndex, cwd: string): ScoredD
|
|
|
92
92
|
for (const doc of index.docs) {
|
|
93
93
|
const raw = scoreDoc(queryTerms, doc, index.df, N);
|
|
94
94
|
if (raw === 0) continue;
|
|
95
|
-
|
|
95
|
+
// Exact cwd match when available; fingerprint heuristic for older captures.
|
|
96
|
+
const scopeMatch = doc.cwd
|
|
97
|
+
? doc.cwd === cwd
|
|
98
|
+
: [...scopeTokens].some((t) => scopeMatches(doc, t));
|
|
96
99
|
const boosted = raw * (scopeMatch ? SCOPE_BOOST : 1) * ageDecay(doc.ts);
|
|
97
100
|
const confidence = boosted / self;
|
|
98
101
|
if (confidence < CONFIDENCE_THRESHOLD) continue;
|
|
@@ -120,12 +123,8 @@ function formatAgo(ts: string): string {
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function formatLine(s: ScoredDoc): string {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
? `[${s.doc.displayContext}]`
|
|
126
|
-
: s.scopeMatch
|
|
127
|
-
? "[project]"
|
|
128
|
-
: "[global]";
|
|
126
|
+
const scopeTag = s.scopeMatch ? "[project]" : "[global]";
|
|
127
|
+
const tag = s.doc.source === "wisdom" ? `[${s.doc.displayContext}]` : scopeTag;
|
|
129
128
|
const text = s.doc.displayPrinciple || s.doc.displayContext || "";
|
|
130
129
|
const principle = truncate(text, PRINCIPLE_TRUNC);
|
|
131
130
|
if (s.doc.source === "wisdom") {
|
|
@@ -137,7 +136,7 @@ function formatLine(s: ScoredDoc): string {
|
|
|
137
136
|
}
|
|
138
137
|
|
|
139
138
|
/** Build the `<system-reminder>` block. Drops lowest-ranked lines until ≤500 bytes. */
|
|
140
|
-
|
|
139
|
+
function formatReminder(matches: ScoredDoc[]): string {
|
|
141
140
|
if (matches.length === 0) return "";
|
|
142
141
|
let lines = matches.map(formatLine);
|
|
143
142
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** Dangerous command patterns — always blocked */
|
|
7
|
-
|
|
7
|
+
const BLOCKED_COMMANDS: [RegExp, string][] = [
|
|
8
8
|
[/rm\s+-rf\s+[/~]/, "Recursive delete of root or home"],
|
|
9
9
|
[/mkfs\./, "Filesystem format"],
|
|
10
10
|
[/dd\s+if=.*of=\/dev\//, "Raw disk write"],
|
|
@@ -16,7 +16,7 @@ export const BLOCKED_COMMANDS: [RegExp, string][] = [
|
|
|
16
16
|
];
|
|
17
17
|
|
|
18
18
|
/** Hook-managed files — single source of truth */
|
|
19
|
-
|
|
19
|
+
const HOOK_MANAGED_FILES = [
|
|
20
20
|
"CLAUDE.md",
|
|
21
21
|
"AGENTS.md",
|
|
22
22
|
"ratings.jsonl",
|
|
@@ -40,7 +40,7 @@ export const HOOK_MANAGED_FILES = [
|
|
|
40
40
|
];
|
|
41
41
|
|
|
42
42
|
/** Hook-managed directories — AI must not write to or delete from these */
|
|
43
|
-
|
|
43
|
+
const HOOK_MANAGED_DIRS = [
|
|
44
44
|
"memory/signals",
|
|
45
45
|
"memory/learning/failures",
|
|
46
46
|
"memory/learning/session",
|
|
@@ -56,33 +56,33 @@ function escapeRegExp(s: string): string {
|
|
|
56
56
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** PAL-deployed dirs — engine-managed, overwritten on every `pal install` */
|
|
60
|
+
const PAL_INSTALLED_DIRS_RE = /[/\\]\.pal[/\\](?:docs|skills|tools)[/\\]/;
|
|
61
|
+
|
|
59
62
|
/** Paths that should never be written to */
|
|
60
|
-
|
|
63
|
+
const PROTECTED_PATHS: RegExp[] = [
|
|
61
64
|
/^\/etc\//,
|
|
62
65
|
/^\/boot\//,
|
|
63
66
|
/^\/System\//,
|
|
64
67
|
/\.ssh\/(?!config)/,
|
|
65
68
|
/\.gnupg\//,
|
|
69
|
+
// Claude Code auto-memory — PAL owns memory; writes here indicate wrong system is being used
|
|
70
|
+
/\.claude\/projects\/[^/]+\/memory\//,
|
|
71
|
+
PAL_INSTALLED_DIRS_RE,
|
|
66
72
|
// Derived from HOOK_MANAGED_FILES — scoped to managed roots only
|
|
67
73
|
...HOOK_MANAGED_FILES.map(
|
|
68
74
|
(name) =>
|
|
69
|
-
new RegExp(
|
|
75
|
+
new RegExp(
|
|
76
|
+
String.raw`[/\\]\.(?:pal|claude|agents|cursor)[/\\].*${escapeRegExp(name)}$`
|
|
77
|
+
)
|
|
70
78
|
),
|
|
71
79
|
];
|
|
72
80
|
|
|
73
|
-
/** Patterns that warrant a warning (logged but not blocked) */
|
|
74
|
-
export const WARN_COMMANDS: RegExp[] = [
|
|
75
|
-
/git\s+push\s+.*--force/,
|
|
76
|
-
/git\s+reset\s+--hard/,
|
|
77
|
-
/drop\s+(?:table|database)/i,
|
|
78
|
-
/truncate\s+table/i,
|
|
79
|
-
];
|
|
80
|
-
|
|
81
81
|
/** Roots where managed files/dirs are protected (user state, not repo templates) */
|
|
82
82
|
const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
|
|
83
83
|
|
|
84
84
|
function isUnderManagedRoot(path: string): boolean {
|
|
85
|
-
const normalized = path.
|
|
85
|
+
const normalized = path.replaceAll("\\", "/");
|
|
86
86
|
return MANAGED_ROOTS.some(
|
|
87
87
|
(root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
|
|
88
88
|
);
|
|
@@ -102,7 +102,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
102
102
|
const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
|
|
103
103
|
for (const name of HOOK_MANAGED_FILES) {
|
|
104
104
|
const pattern = new RegExp(
|
|
105
|
-
|
|
105
|
+
String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(name)}`
|
|
106
106
|
);
|
|
107
107
|
const managed = segments.filter((s) => pattern.test(s));
|
|
108
108
|
if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
|
|
@@ -111,7 +111,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
111
111
|
}
|
|
112
112
|
for (const dir of HOOK_MANAGED_DIRS) {
|
|
113
113
|
const pattern = new RegExp(
|
|
114
|
-
|
|
114
|
+
String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(dir)}`
|
|
115
115
|
);
|
|
116
116
|
const managed = segments.filter((s) => pattern.test(s));
|
|
117
117
|
if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
|
|
@@ -123,7 +123,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
123
123
|
|
|
124
124
|
/** Check a file path against protected patterns. Returns a reason string or null. */
|
|
125
125
|
export function checkFilePath(filePath: string): string | null {
|
|
126
|
-
const normalized = filePath.
|
|
126
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
127
127
|
// Check hook-managed files — only under managed roots (not repo templates)
|
|
128
128
|
if (isUnderManagedRoot(normalized)) {
|
|
129
129
|
const matchedFile = HOOK_MANAGED_FILES.find((name) =>
|
|
@@ -138,7 +138,13 @@ export function checkFilePath(filePath: string): string | null {
|
|
|
138
138
|
if (matchedDir) {
|
|
139
139
|
return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
|
|
140
140
|
}
|
|
141
|
-
//
|
|
141
|
+
// PAL-deployed dirs — edit source in the PAL repo, not the installed copy
|
|
142
|
+
if (PAL_INSTALLED_DIRS_RE.test(normalized)) {
|
|
143
|
+
const match = new RegExp(/\.pal[/\\](docs|skills|tools)/).exec(normalized);
|
|
144
|
+
const dir = match ? match[1] : "docs/skills/tools";
|
|
145
|
+
return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
|
|
146
|
+
}
|
|
147
|
+
// Check remaining system-protected paths
|
|
142
148
|
if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
|
|
143
149
|
return `Protected path: ${filePath}`;
|
|
144
150
|
}
|