@phren/cli 0.0.32 → 0.0.34

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 (59) hide show
  1. package/mcp/dist/cli/actions.js +3 -0
  2. package/mcp/dist/cli/config.js +3 -3
  3. package/mcp/dist/cli/govern.js +18 -8
  4. package/mcp/dist/cli/hooks-context.js +1 -1
  5. package/mcp/dist/cli/hooks-session.js +18 -62
  6. package/mcp/dist/cli/namespaces.js +1 -1
  7. package/mcp/dist/cli/search.js +5 -5
  8. package/mcp/dist/cli-hooks-prompt.js +7 -3
  9. package/mcp/dist/cli-hooks-session-handlers.js +3 -15
  10. package/mcp/dist/cli-hooks-stop.js +10 -48
  11. package/mcp/dist/content/archive.js +8 -20
  12. package/mcp/dist/content/learning.js +29 -8
  13. package/mcp/dist/data/access.js +13 -4
  14. package/mcp/dist/finding/lifecycle.js +9 -3
  15. package/mcp/dist/governance/audit.js +13 -5
  16. package/mcp/dist/governance/policy.js +13 -0
  17. package/mcp/dist/governance/rbac.js +1 -1
  18. package/mcp/dist/governance/scores.js +2 -1
  19. package/mcp/dist/hooks.js +52 -6
  20. package/mcp/dist/index.js +1 -1
  21. package/mcp/dist/init/init.js +66 -45
  22. package/mcp/dist/init/shared.js +1 -1
  23. package/mcp/dist/init-bootstrap.js +0 -47
  24. package/mcp/dist/init-fresh.js +13 -18
  25. package/mcp/dist/init-uninstall.js +22 -0
  26. package/mcp/dist/init-walkthrough.js +19 -24
  27. package/mcp/dist/link/doctor.js +9 -0
  28. package/mcp/dist/package-metadata.js +1 -1
  29. package/mcp/dist/phren-art.js +4 -120
  30. package/mcp/dist/proactivity.js +1 -1
  31. package/mcp/dist/project-topics.js +16 -46
  32. package/mcp/dist/provider-adapters.js +1 -1
  33. package/mcp/dist/runtime-profile.js +1 -1
  34. package/mcp/dist/shared/data-utils.js +25 -0
  35. package/mcp/dist/shared/fragment-graph.js +4 -18
  36. package/mcp/dist/shared/index.js +14 -10
  37. package/mcp/dist/shared/ollama.js +23 -5
  38. package/mcp/dist/shared/process.js +24 -0
  39. package/mcp/dist/shared/retrieval.js +7 -4
  40. package/mcp/dist/shared/search-fallback.js +1 -0
  41. package/mcp/dist/shared.js +2 -1
  42. package/mcp/dist/shell/render.js +1 -1
  43. package/mcp/dist/skill/registry.js +1 -1
  44. package/mcp/dist/skill/state.js +0 -3
  45. package/mcp/dist/task/github.js +1 -0
  46. package/mcp/dist/task/lifecycle.js +1 -6
  47. package/mcp/dist/tools/config.js +415 -400
  48. package/mcp/dist/tools/finding.js +390 -373
  49. package/mcp/dist/tools/ops.js +372 -365
  50. package/mcp/dist/tools/search.js +495 -487
  51. package/mcp/dist/tools/session.js +3 -2
  52. package/mcp/dist/tools/skills.js +9 -0
  53. package/mcp/dist/ui/page.js +1 -1
  54. package/mcp/dist/ui/server.js +645 -1040
  55. package/mcp/dist/utils.js +12 -8
  56. package/package.json +1 -1
  57. package/mcp/dist/init-dryrun.js +0 -55
  58. package/mcp/dist/init-migrate.js +0 -51
  59. package/mcp/dist/init-walkthrough-merge.js +0 -90
@@ -428,6 +428,28 @@ export async function runUninstall(opts = {}) {
428
428
  catch (err) {
429
429
  debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
430
430
  }
431
+ // Remove global CLAUDE.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
432
+ const globalClaudeLink = homePath(".claude", "CLAUDE.md");
433
+ try {
434
+ if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
435
+ fs.unlinkSync(globalClaudeLink);
436
+ log(` Removed global CLAUDE.md symlink (${globalClaudeLink})`);
437
+ }
438
+ }
439
+ catch {
440
+ // Does not exist or not a symlink — nothing to do
441
+ }
442
+ // Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
443
+ const copilotInstrLink = homePath(".github", "copilot-instructions.md");
444
+ try {
445
+ if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
446
+ fs.unlinkSync(copilotInstrLink);
447
+ log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
448
+ }
449
+ }
450
+ catch {
451
+ // Does not exist or not a symlink — nothing to do
452
+ }
431
453
  // Sweep agent skill directories for symlinks pointing into the phren store
432
454
  if (phrenPath) {
433
455
  try {
@@ -300,31 +300,26 @@ export async function runWalkthrough(phrenPath) {
300
300
  log(" Change later: set PHREN_OLLAMA_URL=off to disable");
301
301
  let ollamaEnabled = false;
302
302
  try {
303
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("./shared/ollama.js");
304
- if (getOllamaUrl()) {
305
- const ollamaUp = await checkOllamaAvailable();
306
- if (ollamaUp) {
307
- const modelReady = await checkModelAvailable();
308
- if (modelReady) {
309
- log(" Ollama detected with nomic-embed-text ready.");
310
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
311
- }
312
- else {
313
- log(" Ollama detected, but nomic-embed-text is not pulled yet.");
314
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
315
- if (ollamaEnabled) {
316
- log(" Run after init: ollama pull nomic-embed-text");
317
- }
318
- }
303
+ const { checkOllamaStatus } = await import("./shared/ollama.js");
304
+ const status = await checkOllamaStatus();
305
+ if (status === "ready") {
306
+ log(" Ollama detected with nomic-embed-text ready.");
307
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
308
+ }
309
+ else if (status === "no_model") {
310
+ log(" Ollama detected, but nomic-embed-text is not pulled yet.");
311
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
312
+ if (ollamaEnabled) {
313
+ log(" Run after init: ollama pull nomic-embed-text");
319
314
  }
320
- else {
321
- log(" Ollama not detected. Install it to enable semantic search:");
322
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
323
- ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
324
- if (ollamaEnabled) {
325
- log(style.success(" Semantic search enabled — will activate once Ollama is running."));
326
- log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
327
- }
315
+ }
316
+ else if (status === "not_running") {
317
+ log(" Ollama not detected. Install it to enable semantic search:");
318
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
319
+ ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
320
+ if (ollamaEnabled) {
321
+ log(style.success(" Semantic search enabled will activate once Ollama is running."));
322
+ log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
328
323
  }
329
324
  }
330
325
  }
@@ -402,6 +402,15 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
402
402
  : `${tool} wrapper missing or not first in PATH`,
403
403
  });
404
404
  }
405
+ // Check phren CLI wrapper
406
+ const phrenCliActive = isWrapperActive("phren");
407
+ checks.push({
408
+ name: "wrapper:phren-cli",
409
+ ok: phrenCliActive,
410
+ detail: phrenCliActive
411
+ ? "phren CLI wrapper active via ~/.local/bin/phren"
412
+ : "phren CLI wrapper missing — run init to install, or npm i -g @phren/cli",
413
+ });
405
414
  if (fix) {
406
415
  const repaired = repairPreexistingInstall(phrenPath);
407
416
  const details = [];
@@ -3,7 +3,7 @@ import * as path from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
5
  export const ROOT = path.join(__dirname, "..", "..");
6
- export const PACKAGE_JSON_PATH = path.join(ROOT, "package.json");
6
+ const PACKAGE_JSON_PATH = path.join(ROOT, "package.json");
7
7
  function readPackageJson() {
8
8
  return JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8"));
9
9
  }
@@ -1,20 +1,9 @@
1
1
  /**
2
- * Phren character ASCII/Unicode art, animation engine, and spinner for CLI presence.
2
+ * Phren character ASCII/Unicode art 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.
10
6
  */
11
- const ESC = "\x1b[";
12
- const RESET = `${ESC}0m`;
13
- const PURPLE = `${ESC}35m`; // magenta — body
14
- const BRIGHT_PURPLE = `${ESC}95m`; // bright magenta — highlights
15
- const CYAN = `${ESC}96m`; // bright cyan — sparkle
16
- const DARK_PURPLE = `${ESC}38;5;57m`; // deep purple — shadow/outline
17
- // ── Art constants (24px wide, truecolor half-blocks) ─────────────────────────
18
7
  /**
19
8
  * Phren truecolor art (24px wide, generated from phren-transparent.png).
20
9
  * Uses half-block ▀ with RGB foreground+background for pixel-faithful rendering.
@@ -35,32 +24,18 @@ export const PHREN_ART = [
35
24
  " ",
36
25
  ];
37
26
  // ── Sparkle row: the cyan pixels at row 2 ────────────────────────────────────
38
- // Sparkle uses ▄ half-blocks with cyan truecolor. For animation we cycle through
39
- // decorative unicode characters at different brightness levels.
40
27
  const SPARKLE_ROW = 2;
41
- const SPARKLE_CHARS = ["\u2726", "\u2727", "\u2736", " "]; // ✦ ✧ ✶ (dim/blank)
42
- // ── Eye detection: dark navy pixels in row 6 (fg R<30, G<45, B<120) ──────────
43
- // Row 6 has two eye pixels at segments 1 and 5 (visual positions 7 and 11).
44
- // When blinking, we replace their dark fg color with the surrounding body purple.
28
+ const SPARKLE_CHARS = ["\u2726", "\u2727", "\u2736", " "];
29
+ // ── Eye detection: dark navy pixels in row 6 ─────────────────────────────────
45
30
  const EYE_ROW = 6;
46
- // Dark-pixel fg threshold
47
31
  const EYE_R_MAX = 30;
48
32
  const EYE_G_MAX = 45;
49
33
  const EYE_B_MAX = 120;
50
- // Body-purple color to use when "closing" eyes (average of surrounding pixels)
51
34
  const BLINK_COLOR = "146;130;250";
52
- /**
53
- * Flip a single art line horizontally. Reverses the order of colored pixel
54
- * segments and swaps leading/trailing whitespace so the character faces right.
55
- * Half-block characters (▀ ▄) are horizontally symmetric so no char swap needed.
56
- */
57
35
  function flipLine(line) {
58
36
  const stripped = line.replace(/\x1b\[[^m]*m/g, "");
59
37
  const leadSpaces = stripped.match(/^( *)/)[1].length;
60
38
  const trailSpaces = stripped.match(/( *)$/)[1].length;
61
- // Parse pixel segments: each is one or two ANSI color codes followed by a block char.
62
- // We strip any leading reset (\x1b[0m) from captured codes — it's an artifact from
63
- // the original per-pixel reset pattern and will be re-added during reassembly.
64
39
  const pixels = [];
65
40
  const pixelRegex = /((?:\x1b\[[^m]*m)+)([\u2580\u2584])/g;
66
41
  let match;
@@ -70,7 +45,7 @@ function flipLine(line) {
70
45
  pixels.push({ codes, char: match[2] });
71
46
  }
72
47
  if (pixels.length === 0)
73
- return line; // blank or space-only line
48
+ return line;
74
49
  const reversed = [...pixels].reverse();
75
50
  const newLead = " ".repeat(trailSpaces);
76
51
  const newTrail = " ".repeat(leadSpaces);
@@ -81,26 +56,14 @@ function flipLine(line) {
81
56
  result += newTrail;
82
57
  return result;
83
58
  }
84
- /**
85
- * Generate horizontally flipped art (facing right).
86
- */
87
59
  function generateFlippedArt(art) {
88
60
  return art.map(flipLine);
89
61
  }
90
- /** Pre-computed right-facing art */
91
62
  export const PHREN_ART_RIGHT = generateFlippedArt(PHREN_ART);
92
- /** Random integer in [min, max] inclusive */
93
63
  function randInt(min, max) {
94
64
  return Math.floor(Math.random() * (max - min + 1)) + min;
95
65
  }
96
- /**
97
- * Replace eye pixels on a single line with the blink color.
98
- * Eye pixels are identified by their dark navy fg color (R<30, G<45, B<120).
99
- * We replace the fg color code while preserving the bg code and character.
100
- */
101
66
  function applyBlinkToLine(line) {
102
- // Match ANSI color sequences: each pixel is \e[38;2;R;G;Bm (optionally with \e[48;2;...m) then a block char
103
- // We scan for fg codes that match the eye threshold and replace them with the blink color
104
67
  return line.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (full, rStr, gStr, bStr) => {
105
68
  const r = Number(rStr);
106
69
  const g = Number(gStr);
@@ -111,44 +74,24 @@ function applyBlinkToLine(line) {
111
74
  return full;
112
75
  });
113
76
  }
114
- /**
115
- * Replace the sparkle pixels on the sparkle row with the current sparkle character.
116
- * The sparkle row has two cyan ▄ half-blocks. During sparkle animation we replace
117
- * them with decorative Unicode characters from SPARKLE_CHARS.
118
- */
119
77
  function applySparkleToLine(line, frame, active) {
120
78
  if (!active)
121
79
  return line;
122
80
  const sparkleChar = SPARKLE_CHARS[frame % SPARKLE_CHARS.length];
123
81
  if (sparkleChar === " ") {
124
- // Dim: replace the cyan-colored segments with spaces (they disappear)
125
82
  return line.replace(/\x1b\[38;2;\d+;2\d\d;2\d\dm[\u2580\u2584]\x1b\[0m/g, " ");
126
83
  }
127
- // Replace the half-block characters with the sparkle unicode char, keeping the cyan color
128
84
  return line.replace(/(\x1b\[38;2;\d+;2\d\d;2\d\dm)[\u2580\u2584](\x1b\[0m)/g, `$1${sparkleChar}$2`);
129
85
  }
130
- /**
131
- * Apply lean (horizontal shift) to a line.
132
- * Positive offset = shift right (prepend spaces, trim from end).
133
- * Negative offset = shift left (trim from start, append spaces).
134
- */
135
86
  function applyLean(line, offset) {
136
87
  if (offset === 0)
137
88
  return line;
138
89
  if (offset > 0) {
139
- // Shift right: prepend spaces
140
90
  return " ".repeat(offset) + line;
141
91
  }
142
- // Shift left: remove leading spaces (up to |offset|)
143
92
  const trimCount = Math.min(-offset, line.match(/^( *)/)[1].length);
144
93
  return line.slice(trimCount);
145
94
  }
146
- /**
147
- * Create an animated phren character controller.
148
- *
149
- * @param options.facing - 'left' (default, original) or 'right' (flipped)
150
- * @param options.size - art width; unused but reserved for future scaling (default 24)
151
- */
152
95
  export function createPhrenAnimator(options) {
153
96
  const facing = options?.facing ?? "left";
154
97
  const baseArt = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
@@ -164,22 +107,18 @@ export function createPhrenAnimator(options) {
164
107
  const t = setTimeout(fn, ms);
165
108
  timers.push(t);
166
109
  }
167
- // ── Bob animation: toggles bobUp every ~500ms ──────────────────────────
168
110
  function scheduleBob() {
169
111
  scheduleTimer(() => {
170
112
  state.bobUp = !state.bobUp;
171
113
  scheduleBob();
172
114
  }, 500);
173
115
  }
174
- // ── Blink animation: eyes close for 150ms, random 2-8s intervals ──────
175
116
  function scheduleBlink() {
176
117
  const interval = randInt(2000, 8000);
177
118
  scheduleTimer(() => {
178
- // Perform blink
179
119
  state.isBlinking = true;
180
120
  scheduleTimer(() => {
181
121
  state.isBlinking = false;
182
- // 30% chance of double-blink
183
122
  if (Math.random() < 0.3) {
184
123
  scheduleTimer(() => {
185
124
  state.isBlinking = true;
@@ -195,9 +134,7 @@ export function createPhrenAnimator(options) {
195
134
  }, 150);
196
135
  }, interval);
197
136
  }
198
- // ── Sparkle animation: fast cycle during bursts, long pauses between ───
199
137
  function scheduleSparkle() {
200
- // Wait 1-5 seconds before next sparkle burst
201
138
  const pause = randInt(1000, 5000);
202
139
  scheduleTimer(() => {
203
140
  state.sparkleActive = true;
@@ -207,7 +144,6 @@ export function createPhrenAnimator(options) {
207
144
  }
208
145
  function sparkleStep(step) {
209
146
  if (step >= SPARKLE_CHARS.length) {
210
- // Burst complete
211
147
  state.sparkleActive = false;
212
148
  scheduleSparkle();
213
149
  return;
@@ -217,7 +153,6 @@ export function createPhrenAnimator(options) {
217
153
  sparkleStep(step + 1);
218
154
  }, 200);
219
155
  }
220
- // ── Lean animation: shift 1 col left or right every 4-10s, hold 1-2s ──
221
156
  function scheduleLean() {
222
157
  const interval = randInt(4000, 10000);
223
158
  scheduleTimer(() => {
@@ -234,19 +169,15 @@ export function createPhrenAnimator(options) {
234
169
  getFrame() {
235
170
  let lines = baseArt.map((line, i) => {
236
171
  let result = line;
237
- // Apply blink to the eye row
238
172
  if (state.isBlinking && i === EYE_ROW) {
239
173
  result = applyBlinkToLine(result);
240
174
  }
241
- // Apply sparkle to sparkle row
242
175
  if (i === SPARKLE_ROW) {
243
176
  result = applySparkleToLine(result, state.sparkleFrame, state.sparkleActive);
244
177
  }
245
- // Apply lean
246
178
  result = applyLean(result, state.leanOffset);
247
179
  return result;
248
180
  });
249
- // Apply bob: when bobUp, prepend a blank line (shift everything down visually)
250
181
  if (state.bobUp) {
251
182
  lines = ["", ...lines.slice(0, -1)];
252
183
  }
@@ -266,56 +197,9 @@ export function createPhrenAnimator(options) {
266
197
  },
267
198
  };
268
199
  }
269
- // ── Startup frames (pre-baked, no timers) ────────────────────────────────────
270
- /**
271
- * Returns 4 pre-baked animation frames for shell startup display.
272
- * No timers needed — the caller cycles through them manually.
273
- *
274
- * Frames: [neutral, bob-up, neutral, bob-down(sparkle)]
275
- *
276
- * @param facing - 'left' (default) or 'right'
277
- */
278
- export function getPhrenStartupFrames(facing) {
279
- const art = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
280
- // Frame 0: neutral
281
- const frame0 = [...art];
282
- // Frame 1: bob up (prepend blank line, drop last line)
283
- const frame1 = ["", ...art.slice(0, -1)];
284
- // Frame 2: neutral (same as frame 0)
285
- const frame2 = [...art];
286
- // Frame 3: bob down with sparkle burst — shift down by removing first line, append blank
287
- const frame3WithSparkle = art.map((line, i) => {
288
- if (i === SPARKLE_ROW) {
289
- return applySparkleToLine(line, 0, true); // ✦ sparkle
290
- }
291
- return line;
292
- });
293
- const frame3 = [...frame3WithSparkle.slice(1), ""];
294
- return [frame0, frame1, frame2, frame3];
295
- }
296
- // ── Legacy exports (unchanged) ───────────────────────────────────────────────
297
- /** Single-line compact phren for inline use */
298
- export const PHREN_INLINE = `${PURPLE}◆${RESET}`;
299
- /** Phren spinner frames for search/sync operations — cycles through in purple */
300
- export const PHREN_SPINNER_FRAMES = [
301
- `${BRIGHT_PURPLE}◆${RESET}`,
302
- `${PURPLE}◇${RESET}`,
303
- `${CYAN}✦${RESET}`,
304
- `${PURPLE}✧${RESET}`,
305
- `${BRIGHT_PURPLE}◆${RESET}`,
306
- `${DARK_PURPLE}◇${RESET}`,
307
- ];
308
- /** Default spinner interval in ms */
309
- export const PHREN_SPINNER_INTERVAL_MS = 120;
310
200
  /**
311
201
  * Return the phren art as a single string, optionally indented.
312
202
  */
313
203
  export function renderPhrenArt(indent = "") {
314
204
  return PHREN_ART.map(line => indent + line).join("\n");
315
205
  }
316
- /**
317
- * Get a spinner frame by index (wraps around automatically).
318
- */
319
- export function spinnerFrame(tick) {
320
- return PHREN_SPINNER_FRAMES[tick % PHREN_SPINNER_FRAMES.length];
321
- }
@@ -21,7 +21,7 @@ function resolveProactivityPhrenPath(explicitPhrenPath) {
21
21
  return explicitPhrenPath ?? findPhrenPath();
22
22
  }
23
23
  /** Read per-user preferences from ~/.phren/.users/<actor>/preferences.json. Actor from PHREN_ACTOR env var. */
24
- export function readUserPreferences(explicitPhrenPath) {
24
+ function readUserPreferences(explicitPhrenPath) {
25
25
  const phrenPath = resolveProactivityPhrenPath(explicitPhrenPath);
26
26
  if (!phrenPath)
27
27
  return {};
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import { debugLog } from "./shared.js";
5
5
  import { withFileLock } from "./shared/governance.js";
6
6
  import { STOP_WORDS, errorMessage, extractKeywords, isValidProjectName, safeProjectPath } from "./utils.js";
7
+ import { walkDirectory } from "./shared/data-utils.js";
7
8
  const TOPIC_CONFIG_FILENAME = "topic-config.json";
8
9
  const AUTO_TOPIC_MARKER_RE = /^<!--\s*phren:auto-topic(?:\s+slug=([a-z0-9_-]+))?\s*-->$/;
9
10
  const ARCHIVED_SECTION_RE = /^## Archived (\d{4}-\d{2}-\d{2})$/;
@@ -303,7 +304,7 @@ function normalizeKeyword(raw) {
303
304
  .replace(/\s+/g, " ")
304
305
  .trim();
305
306
  }
306
- export function normalizeTopicSlug(raw) {
307
+ function normalizeTopicSlug(raw) {
307
308
  return raw
308
309
  .trim()
309
310
  .toLowerCase()
@@ -384,10 +385,7 @@ function topicConfigPath(phrenPath, project) {
384
385
  function projectDirPath(phrenPath, project) {
385
386
  return safeProjectPath(phrenPath, project);
386
387
  }
387
- export function topicReferenceDir(phrenPath, project) {
388
- return safeProjectPath(phrenPath, project, "reference", "topics");
389
- }
390
- export function topicReferenceRelativePath(slug) {
388
+ function topicReferenceRelativePath(slug) {
391
389
  return path.posix.join("reference", "topics", `${slug}.md`);
392
390
  }
393
391
  export function topicReferencePath(phrenPath, project, slug) {
@@ -575,7 +573,7 @@ export function readProjectTopics(phrenPath, project) {
575
573
  }
576
574
  return { source: "custom", topics: normalized, domain: typeof parsed.domain === "string" ? parsed.domain : undefined };
577
575
  }
578
- export function readPinnedTopics(phrenPath, project) {
576
+ function readPinnedTopics(phrenPath, project) {
579
577
  const configPath = topicConfigPath(phrenPath, project);
580
578
  if (!configPath || !fs.existsSync(configPath))
581
579
  return [];
@@ -675,27 +673,14 @@ function normalizeBullet(line) {
675
673
  }
676
674
  function collectArchivedBulletsRecursively(dirPath) {
677
675
  const bullets = new Set();
678
- if (!fs.existsSync(dirPath))
679
- return bullets;
680
- const stack = [dirPath];
681
- while (stack.length > 0) {
682
- const current = stack.pop();
683
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
684
- const fullPath = path.join(current, entry.name);
685
- if (entry.isDirectory()) {
686
- stack.push(fullPath);
687
- continue;
688
- }
689
- if (!entry.isFile() || !entry.name.endsWith(".md"))
676
+ for (const filePath of walkDirectory(dirPath)) {
677
+ const content = fs.readFileSync(filePath, "utf8");
678
+ for (const line of content.split("\n")) {
679
+ if (!line.startsWith("- "))
690
680
  continue;
691
- const content = fs.readFileSync(fullPath, "utf8");
692
- for (const line of content.split("\n")) {
693
- if (!line.startsWith("- "))
694
- continue;
695
- const normalized = normalizeBullet(line);
696
- if (normalized)
697
- bullets.add(normalized);
698
- }
681
+ const normalized = normalizeBullet(line);
682
+ if (normalized)
683
+ bullets.add(normalized);
699
684
  }
700
685
  }
701
686
  return bullets;
@@ -797,23 +782,7 @@ function parseLegacyTopicEntries(content, project) {
797
782
  return { slug: fallbackSlug, entries };
798
783
  }
799
784
  function readReferenceMarkdownFiles(referenceDir) {
800
- if (!fs.existsSync(referenceDir))
801
- return [];
802
- const files = [];
803
- const stack = [referenceDir];
804
- while (stack.length > 0) {
805
- const current = stack.pop();
806
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
807
- const fullPath = path.join(current, entry.name);
808
- if (entry.isDirectory()) {
809
- stack.push(fullPath);
810
- continue;
811
- }
812
- if (entry.isFile() && entry.name.endsWith(".md"))
813
- files.push(fullPath);
814
- }
815
- }
816
- return files.sort();
785
+ return walkDirectory(referenceDir).sort();
817
786
  }
818
787
  function relativeToProject(projectDir, filePath) {
819
788
  return path.relative(projectDir, filePath).replace(/\\/g, "/");
@@ -835,7 +804,7 @@ function safeStatIso(filePath) {
835
804
  return "";
836
805
  }
837
806
  }
838
- export function listProjectTopicDocs(phrenPath, project, topics) {
807
+ function listProjectTopicDocs(phrenPath, project, topics) {
839
808
  const projectDir = projectDirPath(phrenPath, project);
840
809
  if (!projectDir)
841
810
  return [];
@@ -885,7 +854,7 @@ export function listProjectReferenceDocs(phrenPath, project, topics) {
885
854
  }
886
855
  return { topicDocs, otherDocs };
887
856
  }
888
- export function listLegacyTopicDocs(phrenPath, project) {
857
+ function listLegacyTopicDocs(phrenPath, project) {
889
858
  const projectDir = projectDirPath(phrenPath, project);
890
859
  const referenceDir = safeProjectPath(phrenPath, project, "reference");
891
860
  if (!projectDir || !referenceDir || !fs.existsSync(referenceDir))
@@ -1042,6 +1011,7 @@ export function suggestTopics(phrenPath, project, topics) {
1042
1011
  }
1043
1012
  return deduped;
1044
1013
  }
1014
+ /** @internal Exported for tests. */
1045
1015
  export const suggestProjectTopics = suggestTopics;
1046
1016
  export function getProjectTopicsResponse(phrenPath, project) {
1047
1017
  const { source, topics } = readProjectTopics(phrenPath, project);
@@ -1054,7 +1024,7 @@ export function getProjectTopicsResponse(phrenPath, project) {
1054
1024
  topicDocs: listProjectTopicDocs(phrenPath, project, topics),
1055
1025
  };
1056
1026
  }
1057
- export function resolveReferenceContentPath(phrenPath, project, file) {
1027
+ function resolveReferenceContentPath(phrenPath, project, file) {
1058
1028
  if (!isValidProjectName(project) || !file || file.includes("\0"))
1059
1029
  return null;
1060
1030
  if (!file.endsWith(".md"))
@@ -35,7 +35,7 @@ function normalizeWindowsPathToWsl(input) {
35
35
  function uniqStrings(values) {
36
36
  return Array.from(new Set(values.filter((value) => Boolean(value && value.trim()))));
37
37
  }
38
- export function pickExistingFile(candidates) {
38
+ function pickExistingFile(candidates) {
39
39
  for (const candidate of candidates) {
40
40
  if (fs.existsSync(candidate))
41
41
  return candidate;
@@ -1,5 +1,5 @@
1
1
  import { resolveActiveProfile } from "./profile-store.js";
2
- export function requestedProfileFromEnv() {
2
+ function requestedProfileFromEnv() {
3
3
  const profile = (process.env.PHREN_PROFILE)?.trim();
4
4
  return profile ? profile : undefined;
5
5
  }
@@ -15,6 +15,31 @@ export function withSafeLock(filePath, fn) {
15
15
  throw err;
16
16
  }
17
17
  }
18
+ /**
19
+ * Recursively walk a directory and return paths of files matching an optional filter.
20
+ * Defaults to `.md` files only. Uses an iterative stack to avoid recursion limits.
21
+ */
22
+ export function walkDirectory(root, filter) {
23
+ const accept = filter ?? ((name) => name.endsWith(".md"));
24
+ const results = [];
25
+ if (!fs.existsSync(root))
26
+ return results;
27
+ const stack = [root];
28
+ while (stack.length > 0) {
29
+ const current = stack.pop();
30
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
31
+ const fullPath = path.join(current, entry.name);
32
+ if (entry.isDirectory()) {
33
+ stack.push(fullPath);
34
+ continue;
35
+ }
36
+ if (entry.isFile() && accept(entry.name)) {
37
+ results.push(fullPath);
38
+ }
39
+ }
40
+ }
41
+ return results;
42
+ }
18
43
  export function ensureProject(phrenPath, project) {
19
44
  if (!isValidProjectName(project))
20
45
  return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
@@ -4,8 +4,10 @@ import { runtimeFile } from "../shared.js";
4
4
  import { logger } from "../logger.js";
5
5
  import { UNIVERSAL_TECH_TERMS_RE } from "../phren-core.js";
6
6
  import { errorMessage } from "../utils.js";
7
+ /** @internal Exported for tests. */
7
8
  export function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
8
- /** Escape SQL LIKE wildcard characters so user input is treated literally. */
9
+ /** Escape SQL LIKE wildcard characters so user input is treated literally.
10
+ * @internal Exported for tests. */
9
11
  export function escapeLike(s) { return s.replace(/[%_\\]/g, '\\$&'); }
10
12
  /**
11
13
  * Log fragment resolution misses to .runtime/fragment-misses.jsonl.
@@ -41,8 +43,6 @@ export function logFragmentMiss(phrenPath, name, context, project) {
41
43
  // Best-effort logging; don't let miss tracking break the caller.
42
44
  }
43
45
  }
44
- /** @deprecated Use logFragmentMiss instead */
45
- export const logEntityMiss = logFragmentMiss;
46
46
  // Use the shared universal starter set. Framework/tool specifics are learned
47
47
  // dynamically per project via extractDynamicFragments() in content-dedup.ts.
48
48
  const PROSE_FRAGMENT_PATTERN = UNIVERSAL_TECH_TERMS_RE;
@@ -110,8 +110,6 @@ export function extractFragmentNames(content) {
110
110
  }
111
111
  return [...found];
112
112
  }
113
- /** @deprecated Use extractFragmentNames instead */
114
- export const extractEntityNames = extractFragmentNames;
115
113
  function getOrCreateFragment(db, name, type) {
116
114
  try {
117
115
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [name, type, new Date().toISOString().slice(0, 10)]);
@@ -190,8 +188,6 @@ export function beginUserFragmentBuildCache(phrenPath, projects) {
190
188
  }
191
189
  }
192
190
  }
193
- /** @deprecated Use beginUserFragmentBuildCache instead */
194
- export const beginUserEntityBuildCache = beginUserFragmentBuildCache;
195
191
  /** End a build-scoped cache created by beginUserFragmentBuildCache(). */
196
192
  export function endUserFragmentBuildCache(phrenPath) {
197
193
  const prefix = `${phrenPath}/`;
@@ -202,8 +198,6 @@ export function endUserFragmentBuildCache(phrenPath) {
202
198
  if (_activeBuildCacheKeyPrefix === prefix)
203
199
  _activeBuildCacheKeyPrefix = null;
204
200
  }
205
- /** @deprecated Use endUserFragmentBuildCache instead */
206
- export const endUserEntityBuildCache = endUserFragmentBuildCache;
207
201
  function parseUserDefinedFragments(phrenPath, project) {
208
202
  const claudeMdPath = `${phrenPath}/${project}/CLAUDE.md`;
209
203
  const cacheKey = `${phrenPath}/${project}`;
@@ -238,13 +232,11 @@ function parseUserDefinedFragments(phrenPath, project) {
238
232
  }
239
233
  }
240
234
  /** Clear the user fragment cache (call between index builds). */
241
- export function clearUserFragmentCache() {
235
+ function clearUserFragmentCache() {
242
236
  _userFragmentCache.clear();
243
237
  _buildUserFragmentCache.clear();
244
238
  _activeBuildCacheKeyPrefix = null;
245
239
  }
246
- /** @deprecated Use clearUserFragmentCache instead */
247
- export const clearUserEntityCache = clearUserFragmentCache;
248
240
  // Words that commonly start sentences or appear in titles — not fragment names
249
241
  const SENTENCE_START_WORDS = new Set([
250
242
  "the", "this", "that", "these", "those", "when", "where", "which", "while",
@@ -362,8 +354,6 @@ export function extractAndLinkFragments(db, content, sourceDoc, phrenPath) {
362
354
  }
363
355
  }
364
356
  }
365
- /** @deprecated Use extractAndLinkFragments instead */
366
- export const extractAndLinkEntities = extractAndLinkFragments;
367
357
  /**
368
358
  * Query related fragments for a given name.
369
359
  */
@@ -389,8 +379,6 @@ export function queryFragmentLinks(db, name) {
389
379
  }
390
380
  return { related };
391
381
  }
392
- /** @deprecated Use queryFragmentLinks instead */
393
- export const queryEntityLinks = queryFragmentLinks;
394
382
  /**
395
383
  * Query cross-project fragment relationships.
396
384
  * Returns projects and docs that share fragments with the given query.
@@ -444,5 +432,3 @@ export function getFragmentBoostDocs(db, query) {
444
432
  return new Set();
445
433
  }
446
434
  }
447
- /** @deprecated Use getFragmentBoostDocs instead */
448
- export const getEntityBoostDocs = getFragmentBoostDocs;