portable-agent-layer 0.32.0 → 0.33.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 (35) hide show
  1. package/assets/skills/presentation/SKILL.md +124 -5
  2. package/assets/skills/presentation/WORKSHOP.md +128 -0
  3. package/assets/skills/presentation/theme-base/base.css +113 -0
  4. package/assets/skills/presentation/theme-base/layouts.css +11 -2
  5. package/assets/skills/presentation/tools/build.ts +136 -6
  6. package/assets/skills/presentation/tools/doctor.ts +106 -317
  7. package/assets/skills/presentation/tools/lib/lint-helpers.ts +150 -0
  8. package/assets/skills/presentation/tools/lib/lint-rules.ts +744 -0
  9. package/assets/skills/presentation/tools/lib/lint-types.ts +40 -0
  10. package/assets/skills/presentation/tools/new-deck.ts +9 -4
  11. package/assets/skills/presentation/vendor/reveal/plugin/highlight/github-dark.css +118 -0
  12. package/assets/skills/projects/SKILL.md +111 -0
  13. package/assets/skills/telos/SKILL.md +4 -1
  14. package/assets/templates/AGENTS.md.template +28 -7
  15. package/assets/templates/PAL/ALGORITHM.md +2 -0
  16. package/assets/templates/PAL/README.md +0 -1
  17. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
  18. package/assets/templates/pal-settings.json +2 -2
  19. package/package.json +1 -1
  20. package/src/hooks/UserPromptOrchestrator.ts +3 -1
  21. package/src/hooks/handlers/auto-graduate.ts +169 -0
  22. package/src/hooks/handlers/inject-retrieval.ts +50 -0
  23. package/src/hooks/handlers/project-touch.ts +39 -0
  24. package/src/hooks/lib/context.ts +9 -8
  25. package/src/hooks/lib/paths.ts +2 -0
  26. package/src/hooks/lib/projects.ts +270 -0
  27. package/src/hooks/lib/retrieval-index.ts +223 -0
  28. package/src/hooks/lib/retrieval.ts +170 -0
  29. package/src/hooks/lib/security.ts +2 -0
  30. package/src/hooks/lib/stop.ts +9 -1
  31. package/src/hooks/lib/text-similarity.ts +13 -9
  32. package/src/hooks/lib/wisdom.ts +155 -1
  33. package/src/tools/agent/project.ts +336 -0
  34. package/src/tools/self-model.ts +3 -3
  35. package/assets/templates/PAL/CONTEXT_ROUTING.md +0 -30
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Retrieval ranker — score the indexed corpus against a fresh prompt and produce a
3
+ * ≤500-char `<system-reminder>` block of the top-N matching principles.
4
+ *
5
+ * Algorithm: IDF-weighted term overlap with capped tf, sqrt length-norm, age decay,
6
+ * cwd-fingerprint scope boost, confidence threshold.
7
+ */
8
+
9
+ import { basename } from "node:path";
10
+ import type { IndexedDoc, RetrievalIndex } from "./retrieval-index";
11
+ import { extractKeywords } from "./text-similarity";
12
+
13
+ const TF_CAP = 3;
14
+ const TIME_HALF_LIFE_DAYS = 90;
15
+ const SCOPE_BOOST = 1.5;
16
+ const CONFIDENCE_THRESHOLD = 0.18;
17
+ const MAX_MATCHES = 2;
18
+ const MAX_REMINDER_BYTES = 500;
19
+ const PRINCIPLE_TRUNC = 200;
20
+
21
+ export interface ScoredDoc {
22
+ doc: IndexedDoc;
23
+ score: number;
24
+ confidence: number;
25
+ scopeMatch: boolean;
26
+ }
27
+
28
+ function idf(term: string, df: Record<string, number>, N: number): number {
29
+ return Math.log((N + 1) / ((df[term] ?? 0) + 1)) + 1;
30
+ }
31
+
32
+ function ageDecay(ts: string): number {
33
+ if (!ts) return 1;
34
+ const age = Date.now() - new Date(ts).getTime();
35
+ if (!Number.isFinite(age) || age <= 0) return 1;
36
+ const days = age / 86_400_000;
37
+ return Math.exp(-days / TIME_HALF_LIFE_DAYS);
38
+ }
39
+
40
+ /** Raw IDF-weighted overlap score, capped tf, sqrt-length-normalized. */
41
+ export function scoreDoc(
42
+ queryTerms: Set<string>,
43
+ doc: IndexedDoc,
44
+ df: Record<string, number>,
45
+ N: number
46
+ ): number {
47
+ if (doc.len === 0) return 0;
48
+ let s = 0;
49
+ for (const t of queryTerms) {
50
+ const tf = doc.tf[t];
51
+ if (!tf) continue;
52
+ s += idf(t, df, N) * Math.min(tf, TF_CAP);
53
+ }
54
+ if (s === 0) return 0;
55
+ return s / Math.sqrt(doc.len);
56
+ }
57
+
58
+ /** Upper bound: query treated as a doc with tf=1 per unique term. */
59
+ export function selfScore(
60
+ queryTerms: Set<string>,
61
+ df: Record<string, number>,
62
+ N: number
63
+ ): number {
64
+ if (queryTerms.size === 0) return 0;
65
+ let s = 0;
66
+ for (const t of queryTerms) s += idf(t, df, N);
67
+ return s / Math.sqrt(queryTerms.size);
68
+ }
69
+
70
+ /** Does any token of the doc's tf map match the cwd basename (lowercased)? */
71
+ function scopeMatches(doc: IndexedDoc, scopeKey: string): boolean {
72
+ if (!scopeKey) return false;
73
+ return doc.tf[scopeKey] !== undefined;
74
+ }
75
+
76
+ /** Rank the index against `query`. Returns docs above the confidence threshold,
77
+ * scope-boosted and age-decayed, sorted high-to-low, capped at MAX_MATCHES. */
78
+ export function rank(query: string, index: RetrievalIndex, cwd: string): ScoredDoc[] {
79
+ const queryTerms = extractKeywords(query);
80
+ if (queryTerms.size === 0) return [];
81
+
82
+ const N = Math.max(index.corpusSize, 1);
83
+ const self = selfScore(queryTerms, index.df, N);
84
+ if (self === 0) return [];
85
+
86
+ const scopeKey = basename(cwd)
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9-]/g, "");
89
+ const scopeTokens = scopeKey ? extractKeywords(scopeKey) : new Set<string>();
90
+
91
+ const scored: ScoredDoc[] = [];
92
+ for (const doc of index.docs) {
93
+ const raw = scoreDoc(queryTerms, doc, index.df, N);
94
+ if (raw === 0) continue;
95
+ const scopeMatch = [...scopeTokens].some((t) => scopeMatches(doc, t));
96
+ const boosted = raw * (scopeMatch ? SCOPE_BOOST : 1) * ageDecay(doc.ts);
97
+ const confidence = boosted / self;
98
+ if (confidence < CONFIDENCE_THRESHOLD) continue;
99
+ scored.push({ doc, score: boosted, confidence, scopeMatch });
100
+ }
101
+
102
+ scored.sort((a, b) => b.score - a.score);
103
+ return scored.slice(0, MAX_MATCHES);
104
+ }
105
+
106
+ function truncate(text: string, max: number): string {
107
+ if (text.length <= max) return text;
108
+ return `${text.slice(0, max - 1).trimEnd()}…`;
109
+ }
110
+
111
+ function formatAgo(ts: string): string {
112
+ if (!ts) return "";
113
+ const age = Date.now() - new Date(ts).getTime();
114
+ if (!Number.isFinite(age) || age <= 0) return "";
115
+ const days = Math.floor(age / 86_400_000);
116
+ if (days < 1) return "today";
117
+ if (days < 30) return `${days}d ago`;
118
+ const months = Math.floor(days / 30);
119
+ return `${months}mo ago`;
120
+ }
121
+
122
+ function formatLine(s: ScoredDoc): string {
123
+ const tag =
124
+ s.doc.source === "wisdom"
125
+ ? `[${s.doc.displayContext}]`
126
+ : s.scopeMatch
127
+ ? "[project]"
128
+ : "[global]";
129
+ const text = s.doc.displayPrinciple || s.doc.displayContext || "";
130
+ const principle = truncate(text, PRINCIPLE_TRUNC);
131
+ if (s.doc.source === "wisdom") {
132
+ return `- ${tag} ${principle} (CRYSTAL ${s.doc.rating}%)`;
133
+ }
134
+ const ago = formatAgo(s.doc.ts);
135
+ const meta = ago ? `rating ${s.doc.rating}/10, ${ago}` : `rating ${s.doc.rating}/10`;
136
+ return `- ${tag} ${principle} (${meta})`;
137
+ }
138
+
139
+ /** Build the `<system-reminder>` block. Drops lowest-ranked lines until ≤500 bytes. */
140
+ export function formatReminder(matches: ScoredDoc[]): string {
141
+ if (matches.length === 0) return "";
142
+ let lines = matches.map(formatLine);
143
+
144
+ let block = render(lines);
145
+ while (block.length > MAX_REMINDER_BYTES && lines.length > 1) {
146
+ lines = lines.slice(0, -1);
147
+ block = render(lines);
148
+ }
149
+ if (block.length > MAX_REMINDER_BYTES) return "";
150
+ return block;
151
+ }
152
+
153
+ function render(lines: string[]): string {
154
+ return [
155
+ "<system-reminder>",
156
+ "**Relevant prior lessons** (matched on your prompt):",
157
+ ...lines,
158
+ "</system-reminder>",
159
+ ].join("\n");
160
+ }
161
+
162
+ /** End-to-end retrieval: takes a query + index, returns the formatted reminder string. */
163
+ export function runRetrieval(
164
+ query: string,
165
+ index: RetrievalIndex,
166
+ cwd: string
167
+ ): { reminder: string; matches: ScoredDoc[] } {
168
+ const matches = rank(query, index, cwd);
169
+ return { reminder: formatReminder(matches), matches };
170
+ }
@@ -36,6 +36,7 @@ export const HOOK_MANAGED_FILES = [
36
36
  "pal-settings.json",
37
37
  "skill-index.json",
38
38
  "algorithm-reflections.jsonl",
39
+ ".retrieval-index.json",
39
40
  ];
40
41
 
41
42
  /** Hook-managed directories — AI must not write to or delete from these */
@@ -47,6 +48,7 @@ export const HOOK_MANAGED_DIRS = [
47
48
  "memory/relationship",
48
49
  "memory/wisdom/state",
49
50
  "memory/projects",
51
+ "memory/state/progress",
50
52
  ];
51
53
 
52
54
  /** Escape a string for use in a RegExp */
@@ -5,9 +5,11 @@
5
5
 
6
6
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
+ import { autoGraduate } from "../handlers/auto-graduate";
8
9
  import { autoBackup } from "../handlers/backup";
9
10
  import { notifyDesktop } from "../handlers/desktop-notify";
10
11
  import { captureFailure } from "../handlers/failure";
12
+ import { projectTouch } from "../handlers/project-touch";
11
13
  import { checkReflectTrigger } from "../handlers/reflect-trigger";
12
14
  import { checkSelfModelTrigger } from "../handlers/self-model-trigger";
13
15
  import { captureSessionIntelligence } from "../handlers/session-intelligence";
@@ -38,7 +40,9 @@ export async function runStopHandlers(
38
40
  // Cache last assistant response (session-scoped)
39
41
  cacheLastResponse(messages, options.lastAssistantMessage, options.sessionId);
40
42
 
41
- // Run all handlers concurrently (manual wisdom extraction only - no automatic extraction)
43
+ // Run all handlers concurrently. Auto-graduate is idempotent (24h TTL +
44
+ // state-dedup + content-dedup) so it's safe to fire on every Stop.
45
+ // project-touch only fires when cwd resolves to an active registered project.
42
46
  const results = await Promise.allSettled([
43
47
  captureWorkSession(transcript, options.sessionId),
44
48
  resetTab(),
@@ -49,6 +53,8 @@ export async function runStopHandlers(
49
53
  checkReflectTrigger(),
50
54
  checkSelfModelTrigger(),
51
55
  runSynthesis(),
56
+ autoGraduate(),
57
+ projectTouch(options.lastAssistantMessage),
52
58
  notifyDesktop(options.sessionId),
53
59
  ]);
54
60
 
@@ -62,6 +68,8 @@ export async function runStopHandlers(
62
68
  "reflect-trigger",
63
69
  "self-model-trigger",
64
70
  "synthesis",
71
+ "auto-graduate",
72
+ "project-touch",
65
73
  "desktop-notify",
66
74
  ];
67
75
  for (let i = 0; i < results.length; i++) {
@@ -126,16 +126,20 @@ function stem(word: string): string {
126
126
  return stemOnce(stemOnce(word));
127
127
  }
128
128
 
129
+ /** Lowercase → strip punctuation → split → drop stop-words and shorts → stem → drop shorts.
130
+ * Returns the token stream WITH duplicates (needed for term-frequency counting). */
131
+ export function tokenize(text: string): string[] {
132
+ return text
133
+ .toLowerCase()
134
+ .replace(/[^a-z0-9\s-]/g, " ")
135
+ .split(/\s+/)
136
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
137
+ .map((w) => stem(w))
138
+ .filter((w) => w.length > 2);
139
+ }
140
+
129
141
  export function extractKeywords(text: string): Set<string> {
130
- return new Set(
131
- text
132
- .toLowerCase()
133
- .replace(/[^a-z0-9\s-]/g, " ")
134
- .split(/\s+/)
135
- .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
136
- .map((w) => stem(w))
137
- .filter((w) => w.length > 2)
138
- );
142
+ return new Set(tokenize(text));
139
143
  }
140
144
 
141
145
  /** Dice coefficient on keyword sets. Returns 0-1. */
@@ -8,9 +8,163 @@
8
8
  * not auto-extracted from transcripts.
9
9
  */
10
10
 
11
- import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { paths } from "./paths";
14
+ import { similarity } from "./text-similarity";
15
+
16
+ /** Dice-similarity floor for "principle already represented" — matches graduation.ts. */
17
+ const PRINCIPLE_DEDUP_THRESHOLD = 0.3;
18
+ const PRINCIPLES_PLACEHOLDER =
19
+ "*No crystallized principles yet. Observations accumulating.*";
20
+
21
+ function today(): string {
22
+ return new Date().toISOString().slice(0, 10);
23
+ }
24
+
25
+ function scaffoldFrame(domain: string): string {
26
+ const t = today();
27
+ return `# Frame: ${domain.charAt(0).toUpperCase() + domain.slice(1)}
28
+
29
+ ## Meta
30
+ - **Domain:** ${domain}
31
+ - **Observation Count:** 0
32
+ - **Last Updated:** ${t}
33
+
34
+ ---
35
+
36
+ ## Core Principles
37
+
38
+ ${PRINCIPLES_PLACEHOLDER}
39
+
40
+ ---
41
+
42
+ ## Contextual Rules
43
+
44
+ *None yet.*
45
+
46
+ ---
47
+
48
+ ## Anti-Patterns
49
+
50
+ *None yet.*
51
+
52
+ ---
53
+
54
+ ## Evolution Log
55
+ - ${t}: Frame scaffolded by auto-graduate
56
+ `;
57
+ }
58
+
59
+ /** Existing CRYSTAL principle texts in the frame content (both v4 heading + legacy bullet). */
60
+ function existingCrystalPrinciples(content: string): string[] {
61
+ const out: string[] = [];
62
+ for (const m of content.matchAll(/^### (.+?) \[CRYSTAL:\s*\d+%\]/gm)) {
63
+ if (m[1]) out.push(m[1].trim());
64
+ }
65
+ for (const line of content.split("\n")) {
66
+ const m = line.match(/^\s*-\s*(.+?)\s*\[CRYSTAL:\s*\d+%\]\s*$/);
67
+ if (m?.[1]) out.push(m[1].trim());
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export interface PromoteCrystalResult {
73
+ domain: string;
74
+ principle: string;
75
+ confidence: number;
76
+ framePath: string;
77
+ /** "duplicate" → a Dice-similar CRYSTAL line already existed; nothing written. */
78
+ skipped: "duplicate" | null;
79
+ }
80
+
81
+ /**
82
+ * Idempotently promote a graduated principle to a wisdom-frame CRYSTAL line.
83
+ *
84
+ * Content-dedup: if any existing CRYSTAL line in the frame is Dice-similar
85
+ * (≥0.3) to `principle`, the call is a no-op. Combined with the auto-graduate
86
+ * handler's TTL guard and state-dedup, this means N rapid calls with the same
87
+ * input produce ≤1 file write — the property the past failed attempt missed.
88
+ */
89
+ export function promoteCrystal(
90
+ domain: string,
91
+ principle: string,
92
+ confidence: number
93
+ ): PromoteCrystalResult {
94
+ const framesDir = paths.wisdom();
95
+ mkdirSync(framesDir, { recursive: true });
96
+ const framePath = resolve(framesDir, `${domain}.md`);
97
+
98
+ if (!existsSync(framePath)) {
99
+ writeFileSync(framePath, scaffoldFrame(domain));
100
+ }
101
+
102
+ let content = readFileSync(framePath, "utf-8");
103
+
104
+ for (const existing of existingCrystalPrinciples(content)) {
105
+ if (similarity(principle, existing) >= PRINCIPLE_DEDUP_THRESHOLD) {
106
+ return { domain, principle, confidence, framePath, skipped: "duplicate" };
107
+ }
108
+ }
109
+
110
+ const newLine = `### ${principle} [CRYSTAL: ${confidence}%]`;
111
+
112
+ if (content.includes(PRINCIPLES_PLACEHOLDER)) {
113
+ content = content.replace(PRINCIPLES_PLACEHOLDER, newLine);
114
+ } else {
115
+ content = content.replace(/(## Core Principles\n+)/, `$1${newLine}\n\n`);
116
+ }
117
+
118
+ content = content.replace(/(\*\*Last Updated:\*\*\s*)\S+/, `$1${today()}`);
119
+ const evolutionEntry = `- ${today()}: Auto-promoted to CRYSTAL ${confidence}% — ${principle}`;
120
+ content = content.replace(/(## Evolution Log\n)/, `$1${evolutionEntry}\n`);
121
+
122
+ writeFileSync(framePath, content);
123
+ return { domain, principle, confidence, framePath, skipped: null };
124
+ }
125
+
126
+ export interface FrameDoc {
127
+ domain: string;
128
+ principle: string;
129
+ body: string;
130
+ confidence: number;
131
+ }
132
+
133
+ /** Extract every CRYSTAL principle as a structured doc (domain + principle + surrounding body + confidence).
134
+ * Body is the first ~600 chars of the frame for ranking context.
135
+ * Used by the retrieval indexer; readFramePrinciples remains for SessionStart formatting. */
136
+ export function readFramesForRetrieval(): FrameDoc[] {
137
+ const framesDir = paths.wisdom();
138
+ const docs: FrameDoc[] = [];
139
+
140
+ if (!existsSync(framesDir)) return docs;
141
+
142
+ for (const file of readdirSync(framesDir).filter((f) => f.endsWith(".md"))) {
143
+ const domain = file.replace(".md", "");
144
+ const content = readFileSync(resolve(framesDir, file), "utf-8");
145
+ const body = content.slice(0, 600);
146
+
147
+ for (const match of content.matchAll(/^### (.+?) \[CRYSTAL:\s*(\d+)%\]/gm)) {
148
+ const name = match[1]?.trim();
149
+ const pct = parseInt(match[2] ?? "", 10);
150
+ if (name && Number.isFinite(pct) && pct >= 85) {
151
+ docs.push({ domain, principle: name, body, confidence: pct });
152
+ }
153
+ }
154
+
155
+ for (const line of content.split("\n")) {
156
+ const m = line.match(/^\s*-\s*(.+?)\s*\[CRYSTAL:\s*(\d+)%\]\s*$/);
157
+ if (!m) continue;
158
+ const name = m[1]?.trim();
159
+ const pct = parseInt(m[2] ?? "", 10);
160
+ if (name && Number.isFinite(pct) && pct >= 85) {
161
+ docs.push({ domain, principle: name, body, confidence: pct });
162
+ }
163
+ }
164
+ }
165
+
166
+ return docs;
167
+ }
14
168
 
15
169
  /** Extract CRYSTAL principles (≥85% confidence) from all frame files */
16
170
  export function readFramePrinciples(): string[] {