@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
@@ -17,7 +17,7 @@ import { buildSourceDocKey, queryDocBySourceKey, queryDocRows, } from "../index-
17
17
  import { classifyTopicForText, readProjectTopics, } from "../project-topics.js";
18
18
  export { porterStem } from "./stemmer.js";
19
19
  export { cosineFallback } from "./search-fallback.js";
20
- export { queryFragmentLinks, queryFragmentLinks as queryEntityLinks, getFragmentBoostDocs, getFragmentBoostDocs as getEntityBoostDocs, ensureGlobalEntitiesTable, queryCrossProjectFragments, logFragmentMiss, logFragmentMiss as logEntityMiss, extractFragmentNames, extractFragmentNames as extractEntityNames, } from "./fragment-graph.js";
20
+ export { queryFragmentLinks, getFragmentBoostDocs, ensureGlobalEntitiesTable, queryCrossProjectFragments, logFragmentMiss, extractFragmentNames, } from "./fragment-graph.js";
21
21
  export { buildSourceDocKey, decodeFiniteNumber, decodeStringRow, extractSnippet, getDocSourceKey, normalizeMemoryId, queryDocBySourceKey, queryDocRows, queryRows, rowToDoc, rowToDocWithRowid, } from "../index-query.js";
22
22
  // ── Async embedding queue ───────────────────────────────────────────────────
23
23
  const _embQueue = new Map();
@@ -165,6 +165,7 @@ function _resolveImportsRecursive(content, phrenPath, seen, depth) {
165
165
  * The import path is resolved relative to the phren root (e.g. `shared/foo.md` -> `~/.phren/global/shared/foo.md`).
166
166
  * Circular imports are detected and skipped. Depth is capped to prevent runaway recursion.
167
167
  */
168
+ /** @internal Exported for tests. */
168
169
  export function resolveImports(content, phrenPath) {
169
170
  return _resolveImportsRecursive(content, phrenPath, new Set(), 0);
170
171
  }
@@ -324,7 +325,7 @@ function loadHashMap(phrenPath) {
324
325
  }
325
326
  return { hashes: {} };
326
327
  }
327
- function saveHashMap(phrenPath, hashes) {
328
+ function saveHashMap(phrenPath, hashes, knownPaths) {
328
329
  const runtimeDir = path.join(phrenPath, ".runtime");
329
330
  try {
330
331
  fs.mkdirSync(runtimeDir, { recursive: true });
@@ -343,9 +344,11 @@ function saveHashMap(phrenPath, hashes) {
343
344
  logger.debug("saveHashMap readExisting", errorMessage(err));
344
345
  }
345
346
  const merged = { ...existing, ...hashes };
346
- // Remove entries for paths that no longer exist on disk
347
+ // Remove entries for paths that no longer exist. When knownPaths is provided
348
+ // (build passes supply the full file list), use set membership instead of
349
+ // hitting the filesystem — avoids N sync stat calls inside the lock.
347
350
  for (const filePath of Object.keys(merged)) {
348
- if (!fs.existsSync(filePath)) {
351
+ if (knownPaths ? !knownPaths.has(filePath) : !fs.existsSync(filePath)) {
349
352
  delete merged[filePath];
350
353
  }
351
354
  }
@@ -815,7 +818,8 @@ function mergeManualLinks(db, phrenPath) {
815
818
  }
816
819
  async function buildIndexImpl(phrenPath, profile) {
817
820
  const t0 = Date.now();
818
- beginUserFragmentBuildCache(phrenPath, getProjectDirs(phrenPath, profile).map(dir => path.basename(dir)));
821
+ const projectDirs = getProjectDirs(phrenPath, profile);
822
+ beginUserFragmentBuildCache(phrenPath, projectDirs.map(dir => path.basename(dir)));
819
823
  try {
820
824
  // ── Cache dir + hash sentinel ─────────────────────────────────────────────
821
825
  let userSuffix;
@@ -1021,7 +1025,7 @@ async function buildIndexImpl(phrenPath, profile) {
1021
1025
  throw err;
1022
1026
  }
1023
1027
  }
1024
- saveHashMap(phrenPath, currentHashes);
1028
+ saveHashMap(phrenPath, currentHashes, new Set(Object.keys(currentHashes)));
1025
1029
  touchSentinel(phrenPath);
1026
1030
  invalidateDfCache();
1027
1031
  // Save updated cache
@@ -1120,19 +1124,19 @@ async function buildIndexImpl(phrenPath, profile) {
1120
1124
  // Always merge manual links (survive rebuild)
1121
1125
  mergeManualLinks(db, phrenPath);
1122
1126
  // ── Finalize: persist hashes, save cache, log ─────────────────────────────
1123
- saveHashMap(phrenPath, newHashes);
1127
+ saveHashMap(phrenPath, newHashes, new Set(Object.keys(newHashes)));
1124
1128
  touchSentinel(phrenPath);
1125
1129
  invalidateDfCache();
1126
1130
  const buildMs = Date.now() - t0;
1127
- debugLog(`Built FTS index: ${fileCount} files from ${getProjectDirs(phrenPath, profile).length} projects in ${buildMs}ms`);
1131
+ debugLog(`Built FTS index: ${fileCount} files from ${projectDirs.length} projects in ${buildMs}ms`);
1128
1132
  if ((process.env.PHREN_DEBUG))
1129
- console.error(`Indexed ${fileCount} files from ${getProjectDirs(phrenPath, profile).length} projects`);
1133
+ console.error(`Indexed ${fileCount} files from ${projectDirs.length} projects`);
1130
1134
  appendIndexEvent(phrenPath, {
1131
1135
  event: "build_index",
1132
1136
  cache: "miss",
1133
1137
  hash: hash.slice(0, 12),
1134
1138
  files: fileCount,
1135
- projects: getProjectDirs(phrenPath, profile).length,
1139
+ projects: projectDirs.length,
1136
1140
  elapsedMs: buildMs,
1137
1141
  profile: profile || "",
1138
1142
  });
@@ -3,6 +3,11 @@ const DEFAULT_OLLAMA_URL = "http://localhost:11434";
3
3
  const DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
4
4
  const DEFAULT_EXTRACT_MODEL = "llama3.2";
5
5
  const MAX_EMBED_INPUT_CHARS = 6000;
6
+ const CLOUD_EMBEDDING_TIMEOUT_MS = 15_000;
7
+ const OLLAMA_HEALTH_TIMEOUT_MS = 2_000;
8
+ const OLLAMA_EMBEDDING_TIMEOUT_MS = 10_000;
9
+ const OLLAMA_GENERATE_TIMEOUT_MS = 60_000;
10
+ /** @internal Exported for tests. */
6
11
  export function prepareEmbeddingInput(text) {
7
12
  return text
8
13
  .replace(/<!--[\s\S]*?-->/g, " ")
@@ -44,7 +49,7 @@ async function embedTextCloud(input, baseUrl, model, apiKey) {
44
49
  headers["Authorization"] = `Bearer ${apiKey}`;
45
50
  try {
46
51
  const controller = new AbortController();
47
- const id = setTimeout(() => controller.abort(), 15000);
52
+ const id = setTimeout(() => controller.abort(), CLOUD_EMBEDDING_TIMEOUT_MS);
48
53
  const res = await fetch(`${baseUrl}/embeddings`, {
49
54
  method: "POST",
50
55
  headers,
@@ -85,7 +90,7 @@ export async function checkOllamaAvailable(url) {
85
90
  return false;
86
91
  try {
87
92
  const controller = new AbortController();
88
- const id = setTimeout(() => controller.abort(), 2000);
93
+ const id = setTimeout(() => controller.abort(), OLLAMA_HEALTH_TIMEOUT_MS);
89
94
  const res = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal });
90
95
  clearTimeout(id);
91
96
  return res.ok;
@@ -104,7 +109,7 @@ export async function checkModelAvailable(model, url) {
104
109
  const modelName = model ?? getEmbeddingModel();
105
110
  try {
106
111
  const controller = new AbortController();
107
- const id = setTimeout(() => controller.abort(), 2000);
112
+ const id = setTimeout(() => controller.abort(), OLLAMA_HEALTH_TIMEOUT_MS);
108
113
  const res = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal });
109
114
  clearTimeout(id);
110
115
  if (!res.ok)
@@ -131,7 +136,7 @@ export async function embedText(text, model, url) {
131
136
  return null;
132
137
  try {
133
138
  const controller = new AbortController();
134
- const id = setTimeout(() => controller.abort(), 10000);
139
+ const id = setTimeout(() => controller.abort(), OLLAMA_EMBEDDING_TIMEOUT_MS);
135
140
  const res = await fetch(`${baseUrl}/api/embed`, {
136
141
  method: "POST",
137
142
  headers: { "Content-Type": "application/json" },
@@ -158,7 +163,7 @@ export async function generateText(prompt, model, url) {
158
163
  const modelName = model ?? getExtractModel();
159
164
  try {
160
165
  const controller = new AbortController();
161
- const id = setTimeout(() => controller.abort(), 60000);
166
+ const id = setTimeout(() => controller.abort(), OLLAMA_GENERATE_TIMEOUT_MS);
162
167
  const res = await fetch(`${baseUrl}/api/generate`, {
163
168
  method: "POST",
164
169
  headers: { "Content-Type": "application/json" },
@@ -178,4 +183,17 @@ export async function generateText(prompt, model, url) {
178
183
  return null;
179
184
  }
180
185
  }
186
+ /**
187
+ * Probe Ollama availability and model readiness in one call.
188
+ * Returns a status enum so callers can branch on it without repeating the check logic.
189
+ */
190
+ export async function checkOllamaStatus() {
191
+ if (!getOllamaUrl())
192
+ return "disabled";
193
+ const ollamaUp = await checkOllamaAvailable();
194
+ if (!ollamaUp)
195
+ return "not_running";
196
+ const modelReady = await checkModelAvailable();
197
+ return modelReady ? "ready" : "no_model";
198
+ }
181
199
  export { cosineSimilarity } from "../embedding.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared process helpers for spawning detached child processes.
3
+ */
4
+ import { spawn } from "child_process";
5
+ import { resolveRuntimeProfile } from "../runtime-profile.js";
6
+ /**
7
+ * Spawn a detached child process with the standard phren environment.
8
+ * When logFd is provided, stdout/stderr are redirected to that fd.
9
+ * When omitted, all stdio is ignored.
10
+ * Returns the ChildProcess so callers can attach `.unref()` or `.on("exit", ...)`.
11
+ */
12
+ export function spawnDetachedChild(args, opts) {
13
+ return spawn(process.execPath, args, {
14
+ cwd: opts.cwd ?? process.cwd(),
15
+ detached: true,
16
+ stdio: opts.logFd !== undefined ? ["ignore", opts.logFd, opts.logFd] : "ignore",
17
+ env: {
18
+ ...process.env,
19
+ PHREN_PATH: opts.phrenPath,
20
+ PHREN_PROFILE: resolveRuntimeProfile(opts.phrenPath),
21
+ ...opts.extraEnv,
22
+ },
23
+ });
24
+ }
@@ -1,6 +1,6 @@
1
1
  // shared-retrieval.ts — shared retrieval core used by hooks and MCP search.
2
2
  import { getQualityMultiplier, entryScoreKey, } from "./governance.js";
3
- import { queryDocRows, queryRows, cosineFallback, extractSnippet, getDocSourceKey, getEntityBoostDocs, decodeFiniteNumber, rowToDocWithRowid, buildIndex, } from "./index.js";
3
+ import { queryDocRows, queryRows, cosineFallback, extractSnippet, getDocSourceKey, getFragmentBoostDocs, decodeFiniteNumber, rowToDocWithRowid, buildIndex, } from "./index.js";
4
4
  import { filterTrustedFindingsDetailed, } from "./content.js";
5
5
  import { parseCitationComment } from "../content/citation.js";
6
6
  import { getHighImpactFindings } from "../finding/impact.js";
@@ -183,6 +183,7 @@ const RRF_K = 60;
183
183
  * Documents appearing in multiple tiers get a higher combined score.
184
184
  * Formula: score(d) = Σ 1/(k + rank_i) for each tier i containing d, where k=60 (standard).
185
185
  */
186
+ /** @internal Exported for tests. */
186
187
  export function rrfMerge(tiers, k = RRF_K) {
187
188
  const scores = new Map();
188
189
  const docs = new Map();
@@ -267,6 +268,7 @@ function semanticFallbackDocs(db, prompt, project) {
267
268
  .map((x) => x.doc);
268
269
  return scored;
269
270
  }
271
+ /** @internal Exported for tests. */
270
272
  export function shouldRunVectorExpansion(rows, prompt, desiredResults = VECTOR_FALLBACK_SKIP_COUNT) {
271
273
  if (!rows || rows.length === 0)
272
274
  return true;
@@ -492,7 +494,7 @@ export async function searchKnowledgeRows(db, options) {
492
494
  * Parse PHREN_FEDERATION_PATHS env var and return valid, distinct paths.
493
495
  * Paths are colon-separated. The local phrenPath is excluded to avoid duplicate results.
494
496
  */
495
- export function parseFederationPaths(localPhrenPath) {
497
+ function parseFederationPaths(localPhrenPath) {
496
498
  const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
497
499
  if (!raw.trim())
498
500
  return [];
@@ -624,7 +626,7 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
624
626
  if (canonicalRows)
625
627
  ranked = [...canonicalRows, ...ranked];
626
628
  }
627
- const entityBoost = query ? getEntityBoostDocs(db, query) : new Set();
629
+ const entityBoost = query ? getFragmentBoostDocs(db, query) : new Set();
628
630
  const entityBoostPaths = new Set();
629
631
  for (const doc of ranked) {
630
632
  // Use getDocSourceKey to build the full project/relFile key, matching what
@@ -735,7 +737,8 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
735
737
  }
736
738
  return ranked;
737
739
  }
738
- /** Mark snippet lines with stale citations (cited file missing or line content changed). */
740
+ /** Mark snippet lines with stale citations (cited file missing or line content changed).
741
+ * @internal Exported for tests. */
739
742
  export function markStaleCitations(snippet) {
740
743
  const lines = snippet.split("\n");
741
744
  const result = [];
@@ -17,6 +17,7 @@ const COSINE_WINDOW_COUNT = 4;
17
17
  function splitPathSegments(filePath) {
18
18
  return filePath.split(/[\\/]+/).filter(Boolean);
19
19
  }
20
+ /** @internal Exported for tests. */
20
21
  export function deriveVectorDocIdentity(phrenPath, fullPath) {
21
22
  const normalizedPhrenPath = phrenPath.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
22
23
  const normalizedFullPath = fullPath.replace(/[\\/]+/g, "/");
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import { debugLog, runtimeFile } from "./phren-paths.js";
4
4
  import { errorMessage } from "./utils.js";
5
5
  import { withFileLock } from "./governance/locks.js";
6
+ const MAX_LOG_LINES = 1000;
6
7
  export { HOOK_TOOL_NAMES, hookConfigPath } from "./provider-adapters.js";
7
8
  export { EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, PhrenError, phrenOk, phrenErr, forwardErr, parsePhrenErrorCode, isRecord, withDefaults, FINDING_TYPES, FINDING_TAGS, KNOWN_OBSERVATION_TAGS, DOC_TYPES, capCache, RESERVED_PROJECT_DIR_NAMES, } from "./phren-core.js";
8
9
  export { ROOT_MANIFEST_FILENAME, homeDir, homePath, expandHomePath, defaultPhrenPath, rootManifestPath, readRootManifest, writeRootManifest, resolveInstallContext, findNearestPhrenPath, isProjectLocalMode, runtimeDir, tryUnlink, sessionsDir, runtimeFile, installPreferencesFile, runtimeHealthFile, shellStateFile, sessionMetricsFile, memoryScoresFile, memoryUsageLogFile, sessionMarker, debugLog, appendIndexEvent, resolveFindingsPath, findPhrenPath, ensurePhrenPath, findPhrenPathWithArg, normalizeProjectNameForCreate, findProjectNameCaseInsensitive, findArchivedProjectNameCaseInsensitive, getProjectDirs, collectNativeMemoryFiles, computePhrenLiveStateToken, getPhrenPath, qualityMarkers, atomicWriteText, } from "./phren-paths.js";
@@ -39,7 +40,7 @@ export function appendAuditLog(phrenPath, event, details) {
39
40
  if (stat.size > 1_000_000) {
40
41
  const content = fs.readFileSync(logPath, "utf8");
41
42
  const lines = content.split("\n");
42
- fs.writeFileSync(logPath, lines.slice(-500).join("\n") + "\n");
43
+ fs.writeFileSync(logPath, lines.slice(-MAX_LOG_LINES).join("\n") + "\n");
43
44
  }
44
45
  });
45
46
  }
@@ -37,7 +37,7 @@ export function separator(width = 50) {
37
37
  export function stripAnsi(s) {
38
38
  return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
39
39
  }
40
- export function visibleWidth(s) {
40
+ function visibleWidth(s) {
41
41
  return stripAnsi(s).length;
42
42
  }
43
43
  export function padToWidth(s, width) {
@@ -223,7 +223,7 @@ export function getAllSkills(phrenPath, profile) {
223
223
  }
224
224
  return all;
225
225
  }
226
- export function getLocalSkills(phrenPath, scope) {
226
+ function getLocalSkills(phrenPath, scope) {
227
227
  if (scope.toLowerCase() === "global")
228
228
  return getGlobalSkills(phrenPath);
229
229
  return getProjectLocalSkills(phrenPath, scope);
@@ -23,6 +23,3 @@ export function setSkillEnabled(phrenPath, scope, name, enabled) {
23
23
  disabledSkills: Object.keys(disabled).length ? disabled : undefined,
24
24
  });
25
25
  }
26
- export function getSkillStateKey(scope, name) {
27
- return skillStateKey(scope, name);
28
- }
@@ -16,6 +16,7 @@ export function parseGithubIssueUrl(url) {
16
16
  url: match[0],
17
17
  };
18
18
  }
19
+ /** @internal Exported for tests. */
19
20
  export function extractGithubRepoFromText(content) {
20
21
  const match = content.match(GITHUB_REPO_URL);
21
22
  return match?.[1];
@@ -80,7 +80,7 @@ function clearTaskSessionState(phrenPath, sessionId) {
80
80
  debugLog(`task lifecycle clear session ${sessionId}: ${errorMessage(err)}`);
81
81
  }
82
82
  }
83
- export function getTaskMode(phrenPath) {
83
+ function getTaskMode(phrenPath) {
84
84
  return getWorkflowPolicy(phrenPath).taskMode;
85
85
  }
86
86
  function isActionablePrompt(prompt, intent) {
@@ -330,11 +330,6 @@ export function finalizeTaskSession(args) {
330
330
  return;
331
331
  }
332
332
  }
333
- export function clearTaskSession(phrenPath, sessionId) {
334
- if (!sessionId)
335
- return;
336
- clearTaskSessionState(phrenPath, sessionId);
337
- }
338
333
  /**
339
334
  * Return the active TaskItem tracked for a session+project, if any.
340
335
  * Used by mcp-finding.ts to link findings to active tasks.