portable-agent-layer 0.9.0 → 0.10.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.
@@ -43,3 +43,9 @@ Do not create projects for one-off questions or quick fixes.
43
43
  - When a session produces reusable insights → session learning
44
44
  - When something fails significantly (rating < 6) → failure capture
45
45
  - Do NOT write memories about trivial exchanges or things already captured in TELOS.
46
+
47
+ ## Opinion Tracking
48
+
49
+ PAL tracks confidence-scored opinions about the user. When you notice the user confirming or contradicting a behavioral pattern, update it via `bun run tool:opinion`. Run `bun run tool:opinion -- --help` for full usage and examples. Opinions at ≥85% confidence are automatically injected into every session context.
50
+
51
+ {{STEERING_RULES}}
@@ -0,0 +1,23 @@
1
+ ## Steering Rules
2
+
3
+ Behavioral directives — act on these, don't just know them.
4
+
5
+ **Surgical fixes only.** When debugging, make precise corrections to the broken behavior. Never delete or rearchitect components as a fix. If you believe a component is the root cause, explain your reasoning and ask before removing it.
6
+
7
+ **Never assert without verification.** Don't say something "is" a certain way unless you've verified it with your tools. After making changes, verify the result before claiming success. Evidence required — tests, diffs, tool output. Never "Done!" without proof.
8
+
9
+ **First principles over bolt-ons.** Most problems are symptoms. Understand → Simplify → Reduce → Add (last resort). Don't accrue technical debt through band-aid solutions.
10
+
11
+ **Read before modifying.** Understand existing code, imports, and patterns before suggesting changes.
12
+
13
+ **One change when debugging.** Isolate, verify, proceed. Don't change multiple things at once.
14
+
15
+ **Minimal scope.** Only change what was asked. No bonus refactoring, no extra cleanup, no unsolicited improvements.
16
+
17
+ **Ask before destructive actions.** Deletes, force pushes, production deploys — always ask first.
18
+
19
+ **Plan means stop.** "Create a plan" = present and STOP. No execution without approval.
20
+
21
+ **Error recovery.** When told you did something wrong — review the session, identify the violation, fix it, then explain what happened and capture the learning. Don't ask "What did I do wrong?"
22
+
23
+ **Act on what you know.** When tracked opinions or relationship notes reveal user preferences, apply them to your behavior. If you know the user prefers concise responses, be concise. If they prefer manual commits, never offer to commit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,6 +49,7 @@
49
49
  "ai:pdf-download": "bun run src/tools/pdf-download.ts",
50
50
  "ai:youtube-analyze": "bun run src/tools/youtube-analyze.ts",
51
51
  "tool:analyze": "bun run src/tools/analyze.ts",
52
+ "tool:opinion": "bun run src/tools/opinion.ts",
52
53
  "tool:reflect": "bun run src/tools/relationship-reflect.ts",
53
54
  "tool:export": "bun run src/tools/export.ts",
54
55
  "tool:import": "bun run src/tools/import.ts",
@@ -10,12 +10,10 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import { stringify } from "../lib/frontmatter";
14
13
  import { inference } from "../lib/inference";
15
- import { categorizeLearning } from "../lib/learning-category";
16
- import { ensureDir, paths } from "../lib/paths";
14
+ import { paths } from "../lib/paths";
17
15
  import { emitRating } from "../lib/signals";
18
- import { fileTimestamp, monthPath, now } from "../lib/time";
16
+ import { now } from "../lib/time";
19
17
  import { logTokenUsage } from "../lib/token-usage";
20
18
 
21
19
  /** Read cached last assistant response (written by StopOrchestrator), looked up by session */
@@ -237,37 +235,6 @@ const MIN_CONFIDENCE = 0.5;
237
235
 
238
236
  // ── Rating Handling ──
239
237
 
240
- function writeLearningMarkdown(
241
- rating: number,
242
- source: string,
243
- context: string,
244
- detailedContext: string,
245
- responsePreview: string
246
- ): void {
247
- const category = categorizeLearning(context, detailedContext);
248
- const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
249
- const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
250
-
251
- const meta: Record<string, unknown> = {
252
- title: context.slice(0, 100) || "(low rating)",
253
- category,
254
- date: new Date().toISOString().slice(0, 10),
255
- rating,
256
- source,
257
- };
258
-
259
- const body = [
260
- "## Context",
261
- context || "*(unavailable)*",
262
- "",
263
- ...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
264
- "## Last Response",
265
- responsePreview || "*(unavailable)*",
266
- ].join("\n");
267
-
268
- writeFileSync(resolve(dir, filename), stringify(meta, body), "utf-8");
269
- }
270
-
271
238
  function handleRating(
272
239
  rating: number,
273
240
  context: string,
@@ -279,8 +246,8 @@ function handleRating(
279
246
  const responsePreview = getLastResponse(sessionId).slice(0, 500);
280
247
  emitRating(rating, context, source, responsePreview);
281
248
 
282
- if (rating <= 3) {
283
- // Deep failure — write pending file for Stop handler with full transcript
249
+ if (rating <= 4) {
250
+ // Low rating — write pending file for Stop handler with full transcript
284
251
  const userPreview = userMessage?.slice(0, 400);
285
252
  writeFileSync(
286
253
  resolve(paths.state(), "pending-failure.json"),
@@ -299,16 +266,6 @@ function handleRating(
299
266
  ),
300
267
  "utf-8"
301
268
  );
302
- // No learning markdown for ≤3 — failure capture covers it with richer analysis + tags
303
- } else if (rating < 5) {
304
- // Low but not critical — write learning markdown
305
- writeLearningMarkdown(
306
- rating,
307
- source,
308
- context,
309
- detailedContext ?? "",
310
- responsePreview
311
- );
312
269
  }
313
270
  }
314
271
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Auto-trigger for relationship reflect — runs when conditions are met:
3
+ * - 7+ days since last reflect
4
+ * - 10+ new relationship notes since last reflect
5
+ *
6
+ * Spawns `bun run tool:reflect` as a detached background process.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { logDebug } from "../lib/log";
12
+ import { getLastReflectDate } from "../lib/opinions";
13
+ import { palPkg, paths } from "../lib/paths";
14
+
15
+ const MIN_DAYS_BETWEEN = 7;
16
+ const MIN_NEW_NOTES = 10;
17
+
18
+ function countNotesSince(since: string): number {
19
+ const relDir = paths.relationship();
20
+ if (!existsSync(relDir)) return 0;
21
+
22
+ let count = 0;
23
+ try {
24
+ for (const monthDir of readdirSync(relDir)) {
25
+ if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
26
+ const monthPath = resolve(relDir, monthDir);
27
+ try {
28
+ for (const file of readdirSync(monthPath)) {
29
+ if (!file.endsWith(".md")) continue;
30
+ const dateStr = file.replace(".md", "");
31
+ if (dateStr > since) {
32
+ const content = readFileSync(resolve(monthPath, file), "utf-8");
33
+ count += (content.match(/^- [OBW]/gm) || []).length;
34
+ }
35
+ }
36
+ } catch {
37
+ /* skip */
38
+ }
39
+ }
40
+ } catch {
41
+ /* non-critical */
42
+ }
43
+ return count;
44
+ }
45
+
46
+ export async function checkReflectTrigger(): Promise<void> {
47
+ const lastReflect = getLastReflectDate();
48
+ const now = new Date();
49
+
50
+ // Trigger if either condition is met (OR logic)
51
+ let timeThreshold = !lastReflect;
52
+ if (lastReflect) {
53
+ const daysSince =
54
+ (now.getTime() - new Date(lastReflect).getTime()) / (1000 * 60 * 60 * 24);
55
+ timeThreshold = daysSince >= MIN_DAYS_BETWEEN;
56
+ }
57
+
58
+ const newNotes = countNotesSince(lastReflect || "2000-01-01");
59
+ const volumeThreshold = newNotes >= MIN_NEW_NOTES;
60
+
61
+ if (!timeThreshold && !volumeThreshold) {
62
+ logDebug("reflect-trigger", `Skipping: ${newNotes} notes, time threshold not met`);
63
+ return;
64
+ }
65
+
66
+ logDebug(
67
+ "reflect-trigger",
68
+ `Triggering: ${newNotes} new notes, last: ${lastReflect || "never"}`
69
+ );
70
+
71
+ try {
72
+ const proc = Bun.spawn(["bun", "run", "tool:reflect"], {
73
+ cwd: palPkg(),
74
+ stdout: "ignore",
75
+ stderr: "ignore",
76
+ stdin: "ignore",
77
+ });
78
+ proc.unref();
79
+ logDebug("reflect-trigger", "Spawned reflect in background");
80
+ } catch (err) {
81
+ logDebug("reflect-trigger", `Failed to spawn: ${err}`);
82
+ }
83
+ }
@@ -20,8 +20,9 @@ const OBSERVATION_SCHEMA = {
20
20
  properties: {
21
21
  type: {
22
22
  type: "string",
23
- enum: ["O", "W"],
24
- description: "O=opinion/preference, W=factual observation",
23
+ enum: ["O", "W", "B"],
24
+ description:
25
+ "O=opinion/preference, W=factual observation, B=belief/behavioral pattern",
25
26
  },
26
27
  text: { type: "string" },
27
28
  confidence: { type: "number" },
@@ -74,8 +75,10 @@ export async function captureRelationship(
74
75
  const result = await inference({
75
76
  system:
76
77
  "You analyze user messages from an AI coding session to extract relationship observations. " +
77
- "Focus on: preferences (how they like to work), corrections (what they pushed back on), " +
78
- "frustrations, positive reactions, communication style patterns. " +
78
+ "Types: O=opinions/preferences (how they like to work, what they want), " +
79
+ "B=beliefs/behavioral patterns (how they approach problems, decision-making style, recurring habits), " +
80
+ "W=world facts (their situation, projects, tools they use). " +
81
+ "Focus on: preferences, corrections, frustrations, positive reactions, communication style, problem-solving approach. " +
79
82
  "Return 0-3 observations. If nothing notable, return empty observations array. Be concise.",
80
83
  user: `User messages from this session:\n${userMessages.map((m, i) => `${i + 1}. ${m}`).join("\n")}`,
81
84
  maxTokens: 300,
@@ -93,7 +96,7 @@ export async function captureRelationship(
93
96
 
94
97
  try {
95
98
  const parsed = JSON.parse(result.output) as {
96
- observations: Array<{ type: "O" | "W"; text: string; confidence: number }>;
99
+ observations: Array<{ type: "O" | "W" | "B"; text: string; confidence: number }>;
97
100
  };
98
101
 
99
102
  logDebug("relationship", `Parsed ${parsed.observations?.length ?? 0} observations`);
@@ -30,14 +30,16 @@ export async function captureSessionName(
30
30
  ): Promise<void> {
31
31
  if (!sessionId) return;
32
32
 
33
- // Skip if this session is already named
33
+ // Skip if this session is already named (non-untitled)
34
34
  const names = readSessionNames();
35
- if (names[sessionId]) return;
35
+ const existing = names[sessionId];
36
+ if (existing && existing !== "untitled session") return;
36
37
 
37
- // 1. Instant deterministic name from keywords
38
- const fallback = extractFallbackName(message);
39
- writeSessionName(sessionId, fallback);
40
- logDebug("session-name", `Deterministic name: "${fallback}"`);
38
+ // Try deterministic name from this message's keywords
39
+ const name = extractFallbackName(message);
40
+ if (name === "untitled session") return; // not enough keywords yet
41
+ writeSessionName(sessionId, name);
42
+ logDebug("session-name", `Named from prompt: "${name}"`);
41
43
 
42
44
  // TODO: re-enable when a consumer exists (tab titles, dashboard)
43
45
  // // 2. Spawn detached background process to upgrade with inference
@@ -154,6 +154,7 @@ export async function captureWorkLearning(
154
154
  title,
155
155
  category,
156
156
  date: new Date().toISOString().slice(0, 10),
157
+ cwd: process.cwd(),
157
158
  };
158
159
  if (sessionId) meta.session = sessionId;
159
160
 
@@ -3,7 +3,11 @@
3
3
  * Replaces the old work.ts handler.
4
4
  */
5
5
 
6
- import { readSessionNames } from "../lib/session-names";
6
+ import {
7
+ extractFallbackName,
8
+ readSessionNames,
9
+ writeSessionName,
10
+ } from "../lib/session-names";
7
11
  import { now } from "../lib/time";
8
12
  import {
9
13
  extractContent,
@@ -29,9 +33,18 @@ export async function captureWorkSession(
29
33
 
30
34
  const id = sessionId || `session-${Date.now()}`;
31
35
 
32
- // Look up session name
36
+ // Name the session if still untitled and enough messages
33
37
  const names = readSessionNames();
34
- const name = names[id] || "untitled session";
38
+ let name = names[id] || "";
39
+ if ((!name || name === "untitled session") && messages.length >= 6) {
40
+ const userTexts = messages
41
+ .filter((m) => m.role === "user")
42
+ .map((m) => extractContent(m))
43
+ .join(" ");
44
+ name = extractFallbackName(userTexts);
45
+ if (name !== "untitled session") writeSessionName(id, name);
46
+ }
47
+ if (!name || name === "untitled session") name = "untitled session";
35
48
 
36
49
  // Extract content
37
50
  const lastUser = extractLastUser(messages);
@@ -72,7 +72,11 @@ export function needsRebuild(): boolean {
72
72
  const outputMtime = statSync(outputPath).mtimeMs;
73
73
 
74
74
  // Collect source files: template + setup.json + all telos/*.md
75
- const sources: string[] = [TEMPLATE_PATH, resolve(paths.state(), "setup.json")];
75
+ const sources: string[] = [
76
+ TEMPLATE_PATH,
77
+ resolve(dirname(TEMPLATE_PATH), "STEERING-RULES.md"),
78
+ resolve(paths.state(), "setup.json"),
79
+ ];
76
80
 
77
81
  const telosDir = paths.telos();
78
82
  if (existsSync(telosDir)) {
@@ -105,10 +109,16 @@ export function buildClaudeMd(): string {
105
109
  const setupPrompt = state ? buildSetupPrompt(state) : null;
106
110
  const telos = loadTelos();
107
111
 
112
+ const steeringPath = resolve(dirname(TEMPLATE_PATH), "STEERING-RULES.md");
113
+ const steeringRules = existsSync(steeringPath)
114
+ ? readFileSync(steeringPath, "utf-8").trim()
115
+ : "";
116
+
108
117
  return template
109
118
  .replace("{{SETUP_PROMPT}}", setupPrompt ? `${setupPrompt}\n` : "")
110
119
  .replace("{{TELOS}}", telos ? `${telos}\n` : "")
111
- .replace("{{MEMORY_PATHS}}", memoryPaths());
120
+ .replace("{{MEMORY_PATHS}}", memoryPaths())
121
+ .replace("{{STEERING_RULES}}", steeringRules);
112
122
  }
113
123
 
114
124
  /** Regenerate AGENTS.md if any source file is newer, and ensure CLAUDE.md symlink exists. Returns true if rebuilt. */
@@ -7,6 +7,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { parse } from "./frontmatter";
9
9
  import { readFailures, readLearnings } from "./learning-store";
10
+ import { loadOpinionContext } from "./opinions";
10
11
  import { paths } from "./paths";
11
12
  import { loadRecentNotes } from "./relationship";
12
13
  import { readSessionNames } from "./session-names";
@@ -62,22 +63,21 @@ export function countSignals(filename: string): number {
62
63
  /** Load structured session history + project dashboard */
63
64
  export function loadActiveWork(): { text: string; summary: string | null } | null {
64
65
  try {
65
- const recent = recentSessions(48);
66
+ const cwd = process.cwd();
67
+ const allRecent = recentSessions(48);
66
68
  const projects = activeProjects();
67
69
  const stale = staleProjects(7);
68
70
 
69
- if (recent.length === 0 && projects.length === 0) return null;
71
+ if (allRecent.length === 0 && projects.length === 0) return null;
70
72
 
71
73
  const lines: string[] = [];
72
74
 
73
- if (recent.length > 0) {
75
+ if (allRecent.length > 0) {
74
76
  lines.push("## Recent Work (last 48h)");
75
- for (const s of recent.slice(-10).reverse()) {
77
+ for (const s of allRecent.slice(-10).reverse()) {
76
78
  const ago = formatAgo(s.ts);
77
- lines.push(`- [${s.status}] ${s.name} ${ago}`);
78
- if (s.handoff) {
79
- lines.push(` Handoff: ${s.handoff.split("\n")[0].slice(0, 120)}`);
80
- }
79
+ const here = s.cwd === cwd ? " *" : "";
80
+ lines.push(`- [${s.status}] ${s.name} — ${ago}${here}`);
81
81
  }
82
82
  }
83
83
 
@@ -106,7 +106,8 @@ export function loadActiveWork(): { text: string; summary: string | null } | nul
106
106
  }
107
107
 
108
108
  // Summary from most recent session
109
- const last = recent.length > 0 ? recent[recent.length - 1] : null;
109
+ const cwdSessions = allRecent.filter((s) => s.cwd === cwd);
110
+ const last = cwdSessions.length > 0 ? cwdSessions[cwdSessions.length - 1] : null;
110
111
  const summary = last?.summary?.slice(0, 60) || null;
111
112
 
112
113
  return {
@@ -215,26 +216,33 @@ export function loadWisdomContext(): string {
215
216
  }
216
217
  }
217
218
 
218
- /** Load recent session learning files as digest, split by category */
219
+ /** Load recent session learning files as digest, with detail for current project */
219
220
  export function loadLearningDigest(): string {
220
221
  try {
221
- const entries = readLearnings(paths.sessionLearning(), 6);
222
+ const cwd = process.cwd();
223
+ const entries = readLearnings(paths.sessionLearning(), 10);
222
224
  if (entries.length === 0) return "";
223
225
 
224
- const approach = entries.filter((e) => e.category !== "system").slice(0, 2);
225
- const system = entries.filter((e) => e.category === "system").slice(0, 2);
226
+ const thisProject = entries.filter((e) => e.cwd === cwd).slice(0, 4);
227
+ const other = entries.filter((e) => e.cwd !== cwd).slice(0, 3);
228
+
229
+ if (thisProject.length === 0 && other.length === 0) return "";
226
230
 
227
- if (approach.length === 0 && system.length === 0) return "";
231
+ const lines: string[] = [];
228
232
 
229
- const lines: string[] = ["## Recent Session Learnings"];
230
- if (approach.length > 0) {
231
- lines.push("### Approach");
232
- for (const e of approach) lines.push(`- **Title:** ${e.title}`);
233
+ if (thisProject.length > 0) {
234
+ lines.push("## This Project — Recent Sessions");
235
+ for (const e of thisProject) {
236
+ lines.push(`- **${e.title}**`);
237
+ if (e.insights) lines.push(` ${e.insights.split("\n")[0].slice(0, 150)}`);
238
+ }
233
239
  }
234
- if (system.length > 0) {
235
- lines.push("### System");
236
- for (const e of system) lines.push(`- **Title:** ${e.title}`);
240
+
241
+ if (other.length > 0) {
242
+ lines.push(thisProject.length > 0 ? "" : "", "## Other Recent Learnings");
243
+ for (const e of other) lines.push(`- ${e.title}`);
237
244
  }
245
+
238
246
  return lines.join("\n");
239
247
  } catch {
240
248
  return "";
@@ -347,8 +355,10 @@ export function buildSystemReminder(): string {
347
355
  const trends = loadSignalTrends();
348
356
  const failures = loadFailurePatterns();
349
357
  const synthesis = loadSynthesisRecommendations();
358
+ const opinions = loadOpinionContext();
350
359
  const parts: string[] = [];
351
360
  if (wisdom) parts.push(wisdom);
361
+ if (opinions) parts.push(opinions);
352
362
  if (relationship) parts.push(relationship);
353
363
  if (digest) parts.push(digest);
354
364
  if (synthesis) parts.push(synthesis);
@@ -2,7 +2,7 @@
2
2
  * Unified Learning Analysis — graduation + ratings summary in one pipeline.
3
3
  *
4
4
  * Reads failures and session learnings via learning-store, detects recurring
5
- * patterns via Jaccard similarity on context text, and generates a ratings summary
5
+ * patterns via Dice similarity on context text, and generates a ratings summary
6
6
  * with recommendations via Haiku inference.
7
7
  *
8
8
  * A pattern qualifies for graduation when it appears 3+ times across different sessions.
@@ -13,20 +13,20 @@
13
13
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
15
  import {
16
- extractKeywords,
17
16
  type FailureEntry,
18
17
  type LearningEntry,
19
18
  readFailures,
20
19
  readLearnings,
21
- similarity,
22
20
  } from "./learning-store";
23
21
  import { logDebug } from "./log";
24
22
  import { ensureDir, paths } from "./paths";
23
+ import { extractKeywords, similarity } from "./text-similarity";
25
24
 
26
25
  // ── Types ──
27
26
 
28
27
  export interface AnalysisEntry {
29
28
  source: string;
29
+ path: string;
30
30
  text: string;
31
31
  date: string;
32
32
  }
@@ -86,7 +86,7 @@ function classifyDomain(text: string): string {
86
86
  // ── Data Collection ──
87
87
 
88
88
  const MIN_TEXT_LENGTH = 30;
89
- export const SIMILARITY_THRESHOLD = 0.35;
89
+ export const SIMILARITY_THRESHOLD = 0.3;
90
90
  const MIN_OCCURRENCES = 3;
91
91
 
92
92
  function toAnalysisEntries(
@@ -99,6 +99,7 @@ function toAnalysisEntries(
99
99
  if (f.context.length >= MIN_TEXT_LENGTH) {
100
100
  entries.push({
101
101
  source: `failure:${f.slug}`,
102
+ path: f.path,
102
103
  text: f.context.slice(0, 300),
103
104
  date: f.date,
104
105
  });
@@ -110,6 +111,7 @@ function toAnalysisEntries(
110
111
  if (text.length >= MIN_TEXT_LENGTH) {
111
112
  entries.push({
112
113
  source: `learning:${l.filename}`,
114
+ path: l.path,
113
115
  text: text.slice(0, 300),
114
116
  date: l.date,
115
117
  });
@@ -13,6 +13,7 @@ import { parse } from "./frontmatter";
13
13
 
14
14
  export interface FailureEntry {
15
15
  slug: string;
16
+ path: string;
16
17
  rating: number;
17
18
  context: string;
18
19
  principle: string;
@@ -22,11 +23,13 @@ export interface FailureEntry {
22
23
 
23
24
  export interface LearningEntry {
24
25
  filename: string;
26
+ path: string;
25
27
  title: string;
26
28
  category: string;
27
29
  principle: string;
28
30
  date: string;
29
31
  insights: string;
32
+ cwd: string;
30
33
  }
31
34
 
32
35
  // ── Shared Directory Walker ──
@@ -79,6 +82,7 @@ export function readFailures(baseDir: string, limit?: number): FailureEntry[] {
79
82
 
80
83
  entries.push({
81
84
  slug: meta.slug || slug,
85
+ path: capturePath,
82
86
  rating: meta.rating ?? 0,
83
87
  context: meta.context,
84
88
  principle: meta.principle || "",
@@ -119,6 +123,7 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
119
123
  category?: string;
120
124
  principle?: string;
121
125
  date?: string;
126
+ cwd?: string;
122
127
  }>(content);
123
128
 
124
129
  if (!meta.title) continue;
@@ -127,11 +132,13 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
127
132
 
128
133
  entries.push({
129
134
  filename: file,
135
+ path: resolve(monthDir, file),
130
136
  title: meta.title,
131
137
  category: meta.category || "algorithm",
132
138
  principle: meta.principle || "",
133
139
  date: meta.date || "",
134
140
  insights: insightsMatch?.[1]?.trim() || "",
141
+ cwd: meta.cwd || "",
135
142
  });
136
143
 
137
144
  if (limit && entries.length >= limit) return entries;
@@ -146,120 +153,3 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
146
153
 
147
154
  return entries;
148
155
  }
149
-
150
- // ── Text Similarity (Jaccard on keywords) ──
151
-
152
- const STOP_WORDS = new Set([
153
- "the",
154
- "a",
155
- "an",
156
- "is",
157
- "was",
158
- "are",
159
- "were",
160
- "be",
161
- "been",
162
- "being",
163
- "have",
164
- "has",
165
- "had",
166
- "do",
167
- "does",
168
- "did",
169
- "will",
170
- "would",
171
- "could",
172
- "should",
173
- "may",
174
- "might",
175
- "can",
176
- "shall",
177
- "to",
178
- "of",
179
- "in",
180
- "for",
181
- "on",
182
- "with",
183
- "at",
184
- "by",
185
- "from",
186
- "as",
187
- "into",
188
- "through",
189
- "during",
190
- "before",
191
- "after",
192
- "and",
193
- "but",
194
- "or",
195
- "nor",
196
- "not",
197
- "no",
198
- "so",
199
- "if",
200
- "then",
201
- "than",
202
- "that",
203
- "this",
204
- "it",
205
- "its",
206
- "i",
207
- "you",
208
- "he",
209
- "she",
210
- "we",
211
- "they",
212
- "my",
213
- "your",
214
- "his",
215
- "her",
216
- "our",
217
- "their",
218
- "what",
219
- "which",
220
- "who",
221
- "when",
222
- "where",
223
- "how",
224
- "all",
225
- "each",
226
- "both",
227
- "few",
228
- "more",
229
- "most",
230
- "other",
231
- "some",
232
- "such",
233
- "up",
234
- "out",
235
- "about",
236
- "just",
237
- "also",
238
- "very",
239
- "too",
240
- "only",
241
- "own",
242
- ]);
243
-
244
- export function extractKeywords(text: string): Set<string> {
245
- return new Set(
246
- text
247
- .toLowerCase()
248
- .replace(/[^a-z0-9\s-]/g, " ")
249
- .split(/\s+/)
250
- .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
251
- );
252
- }
253
-
254
- export function similarity(a: string, b: string): number {
255
- const ka = extractKeywords(a);
256
- const kb = extractKeywords(b);
257
- if (ka.size === 0 || kb.size === 0) return 0;
258
-
259
- let intersection = 0;
260
- for (const w of ka) {
261
- if (kb.has(w)) intersection++;
262
- }
263
- const union = new Set([...ka, ...kb]).size;
264
- return union > 0 ? intersection / union : 0;
265
- }