@phren/cli 0.0.4 → 0.0.6

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.
@@ -169,7 +169,7 @@ function findMostRecentSummaryWithProject(phrenPath) {
169
169
  if (fs.existsSync(fastPath)) {
170
170
  const data = JSON.parse(fs.readFileSync(fastPath, "utf-8"));
171
171
  if (data.summary)
172
- return { summary: data.summary, project: data.project };
172
+ return { summary: data.summary, project: data.project, endedAt: data.endedAt };
173
173
  }
174
174
  }
175
175
  catch (err) {
@@ -181,7 +181,7 @@ function findMostRecentSummaryWithProject(phrenPath) {
181
181
  if (results.length === 0)
182
182
  return { summary: null };
183
183
  const best = results[0]; // already sorted newest-mtime-first
184
- return { summary: best.data.summary, project: best.data.project };
184
+ return { summary: best.data.summary, project: best.data.project, endedAt: best.data.endedAt };
185
185
  }
186
186
  /** Resolve session file from an explicit sessionId or a previously-bound connectionId. */
187
187
  function resolveSessionFile(phrenPath, sessionId, connectionId) {
@@ -338,6 +338,45 @@ function hasCompletedTasksInSession(phrenPath, sessionId, project) {
338
338
  const artifacts = getSessionArtifacts(phrenPath, sessionId, project);
339
339
  return artifacts.tasks.some((task) => task.section === "Done" && task.checked);
340
340
  }
341
+ /** Compute what changed since the last session ended. */
342
+ export function computeSessionDiff(phrenPath, project, lastSessionEnd) {
343
+ const projectDir = path.join(phrenPath, project);
344
+ const findingsPath = path.join(projectDir, "FINDINGS.md");
345
+ if (!fs.existsSync(findingsPath))
346
+ return { newFindings: 0, superseded: 0, tasksCompleted: 0 };
347
+ const content = fs.readFileSync(findingsPath, "utf8");
348
+ const lines = content.split("\n");
349
+ let currentDate = null;
350
+ let newFindings = 0;
351
+ let superseded = 0;
352
+ const cutoff = lastSessionEnd.slice(0, 10);
353
+ for (const line of lines) {
354
+ const dateMatch = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
355
+ if (dateMatch) {
356
+ currentDate = dateMatch[1];
357
+ continue;
358
+ }
359
+ if (!line.startsWith("- ") || !currentDate)
360
+ continue;
361
+ if (currentDate >= cutoff) {
362
+ newFindings++;
363
+ if (line.includes('status "superseded"'))
364
+ superseded++;
365
+ }
366
+ }
367
+ // Count completed tasks since last session
368
+ const tasksPath = path.join(projectDir, "TASKS.md");
369
+ let tasksCompleted = 0;
370
+ if (fs.existsSync(tasksPath)) {
371
+ const taskContent = fs.readFileSync(tasksPath, "utf8");
372
+ const doneMatch = taskContent.match(/## Done[\s\S]*/);
373
+ if (doneMatch) {
374
+ const doneLines = doneMatch[0].split("\n").filter(l => l.startsWith("- "));
375
+ tasksCompleted = doneLines.length;
376
+ }
377
+ }
378
+ return { newFindings, superseded, tasksCompleted };
379
+ }
341
380
  export function register(server, ctx) {
342
381
  const { phrenPath } = ctx;
343
382
  server.registerTool("session_start", {
@@ -364,6 +403,7 @@ export function register(server, ctx) {
364
403
  const priorEnded = prior ? null : findMostRecentSummaryWithProject(phrenPath);
365
404
  const priorSummary = prior?.summary ?? priorEnded?.summary ?? null;
366
405
  const priorProject = prior?.project ?? priorEnded?.project;
406
+ const priorEndedAt = prior?.endedAt ?? priorEnded?.endedAt;
367
407
  // Create new session with unique ID in its own file
368
408
  const sessionId = crypto.randomUUID();
369
409
  const next = {
@@ -447,6 +487,25 @@ export function register(server, ctx) {
447
487
  debugError("session_start checkpointsRead", err);
448
488
  }
449
489
  }
490
+ // Compute context diff since last session
491
+ if (activeProject && isValidProjectName(activeProject) && priorEndedAt) {
492
+ try {
493
+ const diff = computeSessionDiff(phrenPath, activeProject, priorEndedAt);
494
+ if (diff.newFindings > 0 || diff.superseded > 0 || diff.tasksCompleted > 0) {
495
+ const diffParts = [];
496
+ if (diff.newFindings > 0)
497
+ diffParts.push(`${diff.newFindings} new finding${diff.newFindings === 1 ? "" : "s"}`);
498
+ if (diff.superseded > 0)
499
+ diffParts.push(`${diff.superseded} superseded`);
500
+ if (diff.tasksCompleted > 0)
501
+ diffParts.push(`${diff.tasksCompleted} task${diff.tasksCompleted === 1 ? "" : "s"} in done`);
502
+ parts.push(`## Since last session\n${diffParts.join(", ")}.`);
503
+ }
504
+ }
505
+ catch (err) {
506
+ debugError("session_start contextDiff", err);
507
+ }
508
+ }
450
509
  const message = parts.length > 0
451
510
  ? `Session started (${sessionId.slice(0, 8)}).\n\n${parts.join("\n\n")}`
452
511
  : `Session started (${sessionId.slice(0, 8)}). No prior context found.`;
@@ -8,7 +8,7 @@ import { readCustomHooks } from "./hooks.js";
8
8
  import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
9
9
  import { getAllSkills } from "./skill-registry.js";
10
10
  import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
11
- import { buildIndex, queryRows } from "./shared-index.js";
11
+ import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
12
12
  import { readProjectTopics, classifyTopicForText } from "./project-topics.js";
13
13
  import { entryScoreKey } from "./governance-scores.js";
14
14
  function extractGithubUrl(content) {
@@ -264,17 +264,16 @@ export async function buildGraph(phrenPath, profile, focusProject) {
264
264
  catch {
265
265
  // task loading failed — continue with other data sources
266
266
  }
267
- // ── Fragments (entity graph) ───────────────────────────────────────
267
+ // ── Fragments (fragment graph) ──────────────────────────────────────
268
268
  let db = null;
269
269
  try {
270
270
  db = await buildIndex(phrenPath, profile);
271
271
  const rows = queryRows(db, `SELECT e.id, e.name, e.type, COUNT(DISTINCT el.source_doc) as ref_count
272
272
  FROM entities e JOIN entity_links el ON el.target_id = e.id WHERE e.type != 'document'
273
273
  GROUP BY e.id, e.name, e.type ORDER BY ref_count DESC LIMIT 500`, []);
274
- const refRows = queryRows(db, `SELECT e.id, el.source_doc, d.content, d.filename
274
+ const refRows = queryRows(db, `SELECT e.id, el.source_doc
275
275
  FROM entities e
276
276
  JOIN entity_links el ON el.target_id = e.id
277
- LEFT JOIN docs d ON d.source_key = el.source_doc
278
277
  WHERE e.type != 'document'`, []);
279
278
  const refsByEntity = new Map();
280
279
  const seenEntityDoc = new Set();
@@ -291,8 +290,9 @@ export async function buildGraph(phrenPath, profile, focusProject) {
291
290
  continue;
292
291
  seenEntityDoc.add(entityDocKey);
293
292
  const project = projectFromSourceDoc(doc);
294
- const content = typeof row[2] === "string" ? row[2] : "";
295
- const filename = typeof row[3] === "string" ? row[3] : "";
293
+ const docRow = queryDocBySourceKey(db, phrenPath, doc);
294
+ const content = docRow?.content ?? "";
295
+ const filename = docRow?.filename ?? "";
296
296
  const scoreKey = project && filename && content ? entryScoreKey(project, filename, content) : undefined;
297
297
  const refs = refsByEntity.get(entityId) ?? [];
298
298
  refs.push({ doc, project, scoreKey });
@@ -326,7 +326,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
326
326
  entityType: type,
327
327
  refDocs: refs,
328
328
  });
329
- // Link entity to each project it appears in
329
+ // Link fragment to each project it appears in
330
330
  const linkedProjects = new Set();
331
331
  for (const ref of refs) {
332
332
  if (ref.project && projectSet.has(ref.project))
@@ -339,7 +339,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
339
339
  }
340
340
  }
341
341
  catch {
342
- // entity loading failed — continue with other data sources
342
+ // fragment loading failed — continue with other data sources
343
343
  }
344
344
  finally {
345
345
  if (db) {
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Phren character ASCII/Unicode art and spinner for CLI presence.
2
+ * Phren character ASCII/Unicode art, animation engine, and spinner for CLI presence.
3
3
  *
4
4
  * Based on the pixel art: purple 8-bit brain with diamond eyes,
5
5
  * smile, little legs, and cyan sparkle.
6
+ *
7
+ * Animation system provides lifelike movement through composable effects:
8
+ * bob, blink, sparkle, and lean — all driven by setTimeout with randomized
9
+ * intervals for organic timing.
6
10
  */
7
11
  const ESC = "\x1b[";
8
12
  const RESET = `${ESC}0m`;
@@ -14,6 +18,7 @@ const DARK_PURPLE = `${ESC}38;5;57m`; // deep purple — shadow/outline
14
18
  const LIGHT_PURPLE = `${ESC}38;5;141m`; // lavender — brain highlights
15
19
  const MID_PURPLE = `${ESC}38;5;98m`; // mid tone
16
20
  const NAVY = `${ESC}38;5;18m`; // darkest outline
21
+ // ── Art constants (24px wide, truecolor half-blocks) ─────────────────────────
17
22
  /**
18
23
  * Phren truecolor art (24px wide, generated from phren-transparent.png).
19
24
  * Uses half-block ▀ with RGB foreground+background for pixel-faithful rendering.
@@ -33,6 +38,268 @@ export const PHREN_ART = [
33
38
  " ",
34
39
  " ",
35
40
  ];
41
+ /** The art width in visible columns */
42
+ const ART_WIDTH = 24;
43
+ // ── Sparkle row: the cyan pixels at row 2 ────────────────────────────────────
44
+ // Sparkle uses ▄ half-blocks with cyan truecolor. For animation we cycle through
45
+ // decorative unicode characters at different brightness levels.
46
+ const SPARKLE_ROW = 2;
47
+ const SPARKLE_CHARS = ["\u2726", "\u2727", "\u2736", " "]; // ✦ ✧ ✶ (dim/blank)
48
+ // ── Eye detection: dark navy pixels in row 6 (fg R<30, G<45, B<120) ──────────
49
+ // Row 6 has two eye pixels at segments 1 and 5 (visual positions 7 and 11).
50
+ // When blinking, we replace their dark fg color with the surrounding body purple.
51
+ const EYE_ROW = 6;
52
+ // Dark-pixel fg threshold
53
+ const EYE_R_MAX = 30;
54
+ const EYE_G_MAX = 45;
55
+ const EYE_B_MAX = 120;
56
+ // Body-purple color to use when "closing" eyes (average of surrounding pixels)
57
+ const BLINK_COLOR = "146;130;250";
58
+ /**
59
+ * Flip a single art line horizontally. Reverses the order of colored pixel
60
+ * segments and swaps leading/trailing whitespace so the character faces right.
61
+ * Half-block characters (▀ ▄) are horizontally symmetric so no char swap needed.
62
+ */
63
+ function flipLine(line) {
64
+ const stripped = line.replace(/\x1b\[[^m]*m/g, "");
65
+ const leadSpaces = stripped.match(/^( *)/)[1].length;
66
+ const trailSpaces = stripped.match(/( *)$/)[1].length;
67
+ // Parse pixel segments: each is one or two ANSI color codes followed by a block char.
68
+ // We strip any leading reset (\x1b[0m) from captured codes — it's an artifact from
69
+ // the original per-pixel reset pattern and will be re-added during reassembly.
70
+ const pixels = [];
71
+ const pixelRegex = /((?:\x1b\[[^m]*m)+)([\u2580\u2584])/g;
72
+ let match;
73
+ while ((match = pixelRegex.exec(line)) !== null) {
74
+ const codes = match[1].replace(/\x1b\[0m/g, "");
75
+ if (codes)
76
+ pixels.push({ codes, char: match[2] });
77
+ }
78
+ if (pixels.length === 0)
79
+ return line; // blank or space-only line
80
+ const reversed = [...pixels].reverse();
81
+ const newLead = " ".repeat(trailSpaces);
82
+ const newTrail = " ".repeat(leadSpaces);
83
+ let result = newLead;
84
+ for (const px of reversed) {
85
+ result += px.codes + px.char + "\x1b[0m";
86
+ }
87
+ result += newTrail;
88
+ return result;
89
+ }
90
+ /**
91
+ * Generate horizontally flipped art (facing right).
92
+ */
93
+ function generateFlippedArt(art) {
94
+ return art.map(flipLine);
95
+ }
96
+ /** Pre-computed right-facing art */
97
+ export const PHREN_ART_RIGHT = generateFlippedArt(PHREN_ART);
98
+ /** Random integer in [min, max] inclusive */
99
+ function randInt(min, max) {
100
+ return Math.floor(Math.random() * (max - min + 1)) + min;
101
+ }
102
+ /**
103
+ * Replace eye pixels on a single line with the blink color.
104
+ * Eye pixels are identified by their dark navy fg color (R<30, G<45, B<120).
105
+ * We replace the fg color code while preserving the bg code and character.
106
+ */
107
+ function applyBlinkToLine(line) {
108
+ // Match ANSI color sequences: each pixel is \e[38;2;R;G;Bm (optionally with \e[48;2;...m) then a block char
109
+ // We scan for fg codes that match the eye threshold and replace them with the blink color
110
+ return line.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (full, rStr, gStr, bStr) => {
111
+ const r = Number(rStr);
112
+ const g = Number(gStr);
113
+ const b = Number(bStr);
114
+ if (r < EYE_R_MAX && g < EYE_G_MAX && b < EYE_B_MAX) {
115
+ return `\x1b[38;2;${BLINK_COLOR}m`;
116
+ }
117
+ return full;
118
+ });
119
+ }
120
+ /**
121
+ * Replace the sparkle pixels on the sparkle row with the current sparkle character.
122
+ * The sparkle row has two cyan ▄ half-blocks. During sparkle animation we replace
123
+ * them with decorative Unicode characters from SPARKLE_CHARS.
124
+ */
125
+ function applySparkleToLine(line, frame, active) {
126
+ if (!active)
127
+ return line;
128
+ const sparkleChar = SPARKLE_CHARS[frame % SPARKLE_CHARS.length];
129
+ if (sparkleChar === " ") {
130
+ // Dim: replace the cyan-colored segments with spaces (they disappear)
131
+ return line.replace(/\x1b\[38;2;\d+;2\d\d;2\d\dm[\u2580\u2584]\x1b\[0m/g, " ");
132
+ }
133
+ // Replace the half-block characters with the sparkle unicode char, keeping the cyan color
134
+ return line.replace(/(\x1b\[38;2;\d+;2\d\d;2\d\dm)[\u2580\u2584](\x1b\[0m)/g, `$1${sparkleChar}$2`);
135
+ }
136
+ /**
137
+ * Apply lean (horizontal shift) to a line.
138
+ * Positive offset = shift right (prepend spaces, trim from end).
139
+ * Negative offset = shift left (trim from start, append spaces).
140
+ */
141
+ function applyLean(line, offset) {
142
+ if (offset === 0)
143
+ return line;
144
+ if (offset > 0) {
145
+ // Shift right: prepend spaces
146
+ return " ".repeat(offset) + line;
147
+ }
148
+ // Shift left: remove leading spaces (up to |offset|)
149
+ const trimCount = Math.min(-offset, line.match(/^( *)/)[1].length);
150
+ return line.slice(trimCount);
151
+ }
152
+ /**
153
+ * Create an animated phren character controller.
154
+ *
155
+ * @param options.facing - 'left' (default, original) or 'right' (flipped)
156
+ * @param options.size - art width; unused but reserved for future scaling (default 24)
157
+ */
158
+ export function createPhrenAnimator(options) {
159
+ const facing = options?.facing ?? "left";
160
+ const baseArt = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
161
+ const state = {
162
+ bobUp: false,
163
+ isBlinking: false,
164
+ sparkleFrame: 0,
165
+ sparkleActive: false,
166
+ leanOffset: 0,
167
+ };
168
+ const timers = [];
169
+ function scheduleTimer(fn, ms) {
170
+ const t = setTimeout(fn, ms);
171
+ timers.push(t);
172
+ }
173
+ // ── Bob animation: toggles bobUp every ~500ms ──────────────────────────
174
+ function scheduleBob() {
175
+ scheduleTimer(() => {
176
+ state.bobUp = !state.bobUp;
177
+ scheduleBob();
178
+ }, 500);
179
+ }
180
+ // ── Blink animation: eyes close for 150ms, random 2-8s intervals ──────
181
+ function scheduleBlink() {
182
+ const interval = randInt(2000, 8000);
183
+ scheduleTimer(() => {
184
+ // Perform blink
185
+ state.isBlinking = true;
186
+ scheduleTimer(() => {
187
+ state.isBlinking = false;
188
+ // 30% chance of double-blink
189
+ if (Math.random() < 0.3) {
190
+ scheduleTimer(() => {
191
+ state.isBlinking = true;
192
+ scheduleTimer(() => {
193
+ state.isBlinking = false;
194
+ scheduleBlink();
195
+ }, 150);
196
+ }, 200);
197
+ }
198
+ else {
199
+ scheduleBlink();
200
+ }
201
+ }, 150);
202
+ }, interval);
203
+ }
204
+ // ── Sparkle animation: fast cycle during bursts, long pauses between ───
205
+ function scheduleSparkle() {
206
+ // Wait 1-5 seconds before next sparkle burst
207
+ const pause = randInt(1000, 5000);
208
+ scheduleTimer(() => {
209
+ state.sparkleActive = true;
210
+ state.sparkleFrame = 0;
211
+ sparkleStep(0);
212
+ }, pause);
213
+ }
214
+ function sparkleStep(step) {
215
+ if (step >= SPARKLE_CHARS.length) {
216
+ // Burst complete
217
+ state.sparkleActive = false;
218
+ scheduleSparkle();
219
+ return;
220
+ }
221
+ state.sparkleFrame = step;
222
+ scheduleTimer(() => {
223
+ sparkleStep(step + 1);
224
+ }, 200);
225
+ }
226
+ // ── Lean animation: shift 1 col left or right every 4-10s, hold 1-2s ──
227
+ function scheduleLean() {
228
+ const interval = randInt(4000, 10000);
229
+ scheduleTimer(() => {
230
+ const direction = Math.random() < 0.5 ? -1 : 1;
231
+ state.leanOffset = direction;
232
+ const holdTime = randInt(1000, 2000);
233
+ scheduleTimer(() => {
234
+ state.leanOffset = 0;
235
+ scheduleLean();
236
+ }, holdTime);
237
+ }, interval);
238
+ }
239
+ return {
240
+ getFrame() {
241
+ let lines = baseArt.map((line, i) => {
242
+ let result = line;
243
+ // Apply blink to the eye row
244
+ if (state.isBlinking && i === EYE_ROW) {
245
+ result = applyBlinkToLine(result);
246
+ }
247
+ // Apply sparkle to sparkle row
248
+ if (i === SPARKLE_ROW) {
249
+ result = applySparkleToLine(result, state.sparkleFrame, state.sparkleActive);
250
+ }
251
+ // Apply lean
252
+ result = applyLean(result, state.leanOffset);
253
+ return result;
254
+ });
255
+ // Apply bob: when bobUp, prepend a blank line (shift everything down visually)
256
+ if (state.bobUp) {
257
+ lines = ["", ...lines.slice(0, -1)];
258
+ }
259
+ return lines;
260
+ },
261
+ start() {
262
+ scheduleBob();
263
+ scheduleBlink();
264
+ scheduleSparkle();
265
+ scheduleLean();
266
+ },
267
+ stop() {
268
+ for (const t of timers) {
269
+ clearTimeout(t);
270
+ }
271
+ timers.length = 0;
272
+ },
273
+ };
274
+ }
275
+ // ── Startup frames (pre-baked, no timers) ────────────────────────────────────
276
+ /**
277
+ * Returns 4 pre-baked animation frames for shell startup display.
278
+ * No timers needed — the caller cycles through them manually.
279
+ *
280
+ * Frames: [neutral, bob-up, neutral, bob-down(sparkle)]
281
+ *
282
+ * @param facing - 'left' (default) or 'right'
283
+ */
284
+ export function getPhrenStartupFrames(facing) {
285
+ const art = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
286
+ // Frame 0: neutral
287
+ const frame0 = [...art];
288
+ // Frame 1: bob up (prepend blank line, drop last line)
289
+ const frame1 = ["", ...art.slice(0, -1)];
290
+ // Frame 2: neutral (same as frame 0)
291
+ const frame2 = [...art];
292
+ // Frame 3: bob down with sparkle burst — shift down by removing first line, append blank
293
+ const frame3WithSparkle = art.map((line, i) => {
294
+ if (i === SPARKLE_ROW) {
295
+ return applySparkleToLine(line, 0, true); // ✦ sparkle
296
+ }
297
+ return line;
298
+ });
299
+ const frame3 = [...frame3WithSparkle.slice(1), ""];
300
+ return [frame0, frame1, frame2, frame3];
301
+ }
302
+ // ── Legacy exports (unchanged) ───────────────────────────────────────────────
36
303
  /** Single-line compact phren for inline use */
37
304
  export const PHREN_INLINE = `${PURPLE}◆${RESET}`;
38
305
  /** Phren spinner frames for search/sync operations — cycles through in purple */
@@ -1,6 +1,6 @@
1
1
  // Shared Phren result types, validation tags, and low-level helpers.
2
2
  /**
3
- * Minimal cross-domain starter set for entity/conflict detection.
3
+ * Minimal cross-domain starter set for fragment/conflict detection.
4
4
  *
5
5
  * Kept intentionally small: only terms that are genuinely universal across
6
6
  * disciplines (languages, infra primitives, version control). Framework-specific
@@ -9,8 +9,8 @@
9
9
  */
10
10
  export const UNIVERSAL_TECH_TERMS_RE = /\b(Python|Rust|Go|Java|TypeScript|JavaScript|Docker|Kubernetes|AWS|GCP|Azure|SQL|Git)\b/gi;
11
11
  /**
12
- * Additional entity patterns beyond CamelCase and acronyms.
13
- * Each pattern has a named group so callers can identify the entity type.
12
+ * Additional fragment patterns beyond CamelCase and acronyms.
13
+ * Each pattern has a named group so callers can identify the fragment type.
14
14
  */
15
15
  export const EXTRA_ENTITY_PATTERNS = [
16
16
  // Semantic version numbers: v1.2.3, 2.0.0-beta.1
@@ -91,7 +91,7 @@ export const FINDING_TAGS = FINDING_TYPES;
91
91
  /** Canonical set of known observation tags — derived from FINDING_TYPES */
92
92
  export const KNOWN_OBSERVATION_TAGS = new Set(FINDING_TYPES);
93
93
  /** Document types in the FTS index */
94
- export const DOC_TYPES = ["claude", "findings", "reference", "skills", "summary", "task", "changelog", "canonical", "memory-queue", "skill", "other"];
94
+ export const DOC_TYPES = ["claude", "findings", "reference", "skills", "summary", "task", "changelog", "canonical", "review-queue", "skill", "other"];
95
95
  // ── Cache eviction helper ────────────────────────────────────────────────────
96
96
  const CACHE_MAX = 1000;
97
97
  const CACHE_EVICT = 100;
@@ -433,7 +433,7 @@ export function computePhrenLiveStateToken(phrenPath) {
433
433
  for (const projectDir of projectDirs) {
434
434
  const project = path.basename(projectDir);
435
435
  parts.push(`project:${project}`);
436
- for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md", "tasks.md", "MEMORY_QUEUE.md", "CANONICAL_MEMORIES.md", "topic-config.json", "phren.project.yaml"]) {
436
+ for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md", "tasks.md", "review.md", "truths.md", "topic-config.json", "phren.project.yaml"]) {
437
437
  pushFileToken(parts, path.join(projectDir, file));
438
438
  }
439
439
  pushDirTokens(parts, path.join(projectDir, "reference"));
@@ -209,7 +209,7 @@ function buildProjectCard(dir) {
209
209
  .split("\n")
210
210
  .map((line) => line.trim())
211
211
  .find((line) => line && !line.startsWith("#")) || "";
212
- const docs = ["CLAUDE.md", "FINDINGS.md", "summary.md", "MEMORY_QUEUE.md"]
212
+ const docs = ["CLAUDE.md", "FINDINGS.md", "summary.md", "review.md"]
213
213
  .filter((file) => fs.existsSync(path.join(dir, file)));
214
214
  const taskFile = TASK_FILE_ALIASES.find((file) => fs.existsSync(path.join(dir, file)));
215
215
  if (taskFile)
@@ -0,0 +1,147 @@
1
+ // query-correlation.ts — Lightweight query-to-finding correlation tracker.
2
+ // Tracks which queries led to which documents being selected (and later rated "helpful"),
3
+ // then uses that data to pre-warm results for recurring query patterns.
4
+ //
5
+ // Gated behind PHREN_FEATURE_QUERY_CORRELATION env var (disabled by default).
6
+ // Storage: JSONL append to .runtime/query-correlations.jsonl, last-500 window.
7
+ import * as fs from "fs";
8
+ import { runtimeFile, debugLog } from "./shared.js";
9
+ import { isFeatureEnabled, errorMessage } from "./utils.js";
10
+ const CORRELATION_FILENAME = "query-correlations.jsonl";
11
+ const RECENT_WINDOW = 500;
12
+ const MIN_TOKEN_OVERLAP = 2;
13
+ const MIN_TOKEN_LENGTH = 3;
14
+ /**
15
+ * Check if query correlation feature is enabled via env var.
16
+ */
17
+ export function isQueryCorrelationEnabled() {
18
+ return isFeatureEnabled("PHREN_FEATURE_QUERY_CORRELATION", false);
19
+ }
20
+ /**
21
+ * Log query-to-finding correlations after snippet selection.
22
+ * Called from handleHookPrompt after selectSnippets.
23
+ */
24
+ export function logCorrelations(phrenPath, keywords, selected, sessionId) {
25
+ if (!isQueryCorrelationEnabled())
26
+ return;
27
+ if (!selected.length || !keywords.trim())
28
+ return;
29
+ try {
30
+ const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
31
+ const lines = [];
32
+ for (const sel of selected) {
33
+ const entry = {
34
+ timestamp: new Date().toISOString(),
35
+ keywords: keywords.slice(0, 200),
36
+ project: sel.doc.project,
37
+ filename: sel.doc.filename,
38
+ sessionId,
39
+ };
40
+ lines.push(JSON.stringify(entry));
41
+ }
42
+ fs.appendFileSync(correlationFile, lines.join("\n") + "\n");
43
+ }
44
+ catch (err) {
45
+ debugLog(`query-correlation log failed: ${errorMessage(err)}`);
46
+ }
47
+ }
48
+ /**
49
+ * Mark correlations from a session as "helpful" when positive feedback is received.
50
+ * This retroactively stamps entries so that future correlation lookups weight them higher.
51
+ */
52
+ export function markCorrelationsHelpful(phrenPath, sessionId, docKey) {
53
+ if (!isQueryCorrelationEnabled())
54
+ return;
55
+ try {
56
+ const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
57
+ if (!fs.existsSync(correlationFile))
58
+ return;
59
+ const raw = fs.readFileSync(correlationFile, "utf8");
60
+ const lines = raw.split("\n").filter(Boolean);
61
+ let modified = false;
62
+ const updated = lines.map((line) => {
63
+ try {
64
+ const entry = JSON.parse(line);
65
+ if (entry.sessionId === sessionId &&
66
+ `${entry.project}/${entry.filename}` === docKey &&
67
+ !entry.helpful) {
68
+ entry.helpful = true;
69
+ modified = true;
70
+ return JSON.stringify(entry);
71
+ }
72
+ }
73
+ catch {
74
+ // keep original line
75
+ }
76
+ return line;
77
+ });
78
+ if (modified) {
79
+ fs.writeFileSync(correlationFile, updated.join("\n") + "\n");
80
+ }
81
+ }
82
+ catch (err) {
83
+ debugLog(`query-correlation mark-helpful failed: ${errorMessage(err)}`);
84
+ }
85
+ }
86
+ /**
87
+ * Tokenize a keyword string for overlap comparison.
88
+ */
89
+ function tokenize(text) {
90
+ return new Set(text
91
+ .toLowerCase()
92
+ .split(/\s+/)
93
+ .filter((w) => w.length >= MIN_TOKEN_LENGTH));
94
+ }
95
+ /**
96
+ * Find documents that historically correlate with the given query keywords.
97
+ * Returns doc keys (project/filename) sorted by correlation strength.
98
+ *
99
+ * Only looks at the last RECENT_WINDOW entries for performance.
100
+ * Entries marked "helpful" get a 2x weight boost.
101
+ */
102
+ export function getCorrelatedDocs(phrenPath, keywords, limit = 3) {
103
+ if (!isQueryCorrelationEnabled())
104
+ return [];
105
+ try {
106
+ const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
107
+ if (!fs.existsSync(correlationFile))
108
+ return [];
109
+ const raw = fs.readFileSync(correlationFile, "utf8");
110
+ const lines = raw.split("\n").filter(Boolean);
111
+ // Only look at last RECENT_WINDOW entries to keep it fast
112
+ const recent = lines.slice(-RECENT_WINDOW);
113
+ const queryTokens = tokenize(keywords);
114
+ if (queryTokens.size === 0)
115
+ return [];
116
+ const docScores = new Map();
117
+ for (const line of recent) {
118
+ try {
119
+ const entry = JSON.parse(line);
120
+ const entryTokens = tokenize(entry.keywords);
121
+ // Calculate overlap between current query and past query
122
+ let overlap = 0;
123
+ for (const t of queryTokens) {
124
+ if (entryTokens.has(t))
125
+ overlap++;
126
+ }
127
+ if (overlap >= MIN_TOKEN_OVERLAP) {
128
+ const key = `${entry.project}/${entry.filename}`;
129
+ // Helpful entries get a 2x weight boost
130
+ const weight = entry.helpful ? overlap * 2 : overlap;
131
+ docScores.set(key, (docScores.get(key) ?? 0) + weight);
132
+ }
133
+ }
134
+ catch {
135
+ // skip malformed lines
136
+ }
137
+ }
138
+ return [...docScores.entries()]
139
+ .sort((a, b) => b[1] - a[1])
140
+ .slice(0, limit)
141
+ .map(([key]) => key);
142
+ }
143
+ catch (err) {
144
+ debugLog(`query-correlation lookup failed: ${errorMessage(err)}`);
145
+ return [];
146
+ }
147
+ }
@@ -3,6 +3,6 @@ export { checkConsolidationNeeded, validateFindingsFormat, stripTaskDoneSection,
3
3
  export { filterTrustedFindings, filterTrustedFindingsDetailed, } from "./content-citation.js";
4
4
  export { scanForSecrets, resolveCoref, isDuplicateFinding, detectConflicts, extractDynamicEntities, checkSemanticDedup, checkSemanticConflicts, } from "./content-dedup.js";
5
5
  export { countActiveFindings, autoArchiveToReference, } from "./content-archive.js";
6
- export { upsertCanonical, addFindingToFile, addFindingsToFile, } from "./content-learning.js";
7
- export { FINDING_LIFECYCLE_STATUSES, parseFindingLifecycle, buildLifecycleComments, isInactiveFindingLine, } from "./finding-lifecycle.js";
6
+ export { upsertCanonical, addFindingToFile, addFindingsToFile, autoDetectFindingType, } from "./content-learning.js";
7
+ export { FINDING_LIFECYCLE_STATUSES, FINDING_TYPE_DECAY, extractFindingType, parseFindingLifecycle, buildLifecycleComments, isInactiveFindingLine, } from "./finding-lifecycle.js";
8
8
  export { METADATA_REGEX, parseStatus, parseStatusField, parseSupersession, parseSupersedesRef, parseContradiction, parseAllContradictions, parseFindingId, parseCreatedDate, isCitationLine, isArchiveStart, isArchiveEnd, stripLifecycleMetadata, stripRelationMetadata, stripAllMetadata, stripComments, addMetadata, } from "./content-metadata.js";