@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
@@ -6,7 +6,7 @@ import { isValidProjectName, errorMessage } from "../utils.js";
6
6
  import { readFindings } from "../data/access.js";
7
7
  import { debugLog, runtimeFile, DOC_TYPES, FINDING_TAGS, isMemoryScopeVisible, normalizeMemoryScope, } from "../shared.js";
8
8
  import { FINDING_LIFECYCLE_STATUSES, parseFindingLifecycle, } from "../shared/content.js";
9
- import { decodeStringRow, queryRows, queryDocRows, queryEntityLinks, logEntityMiss, extractSnippet, queryDocBySourceKey, normalizeMemoryId, } from "../shared/index.js";
9
+ import { decodeStringRow, queryRows, queryDocRows, queryFragmentLinks, logFragmentMiss, extractSnippet, queryDocBySourceKey, normalizeMemoryId, } from "../shared/index.js";
10
10
  import { runCustomHooks } from "../hooks.js";
11
11
  import { entryScoreKey, getQualityMultiplier, getRetentionPolicy } from "../shared/governance.js";
12
12
  import { callLlm } from "../content/dedup.js";
@@ -118,412 +118,537 @@ function filterTaskContentByScope(content, activeScope) {
118
118
  }
119
119
  return out.join("\n");
120
120
  }
121
- export function register(server, ctx) {
122
- const { phrenPath, profile } = ctx;
123
- server.registerTool("get_memory_detail", {
124
- title: "◆ phren · memory detail",
125
- description: "Fetch the full content of a specific memory entry by its ID. Use this after receiving a compact " +
126
- "memory index from the hook-prompt (when PHREN_FEATURE_PROGRESSIVE_DISCLOSURE is enabled). " +
127
- "The id format is `mem:project/path/to/file.md` as shown in the memory index.",
128
- inputSchema: z.object({
129
- id: z.string().describe("Memory ID in the format `mem:project/path/to/file.md` (e.g. `mem:my-app/reference/api/auth.md`). " +
130
- "Returned by the hook-prompt compact index when PHREN_FEATURE_PROGRESSIVE_DISCLOSURE=1."),
131
- }),
132
- }, async ({ id: rawId }) => {
133
- // Normalize ID: decode URL encoding and normalize path separators
134
- let id;
135
- try {
136
- id = normalizeMemoryId(rawId);
137
- }
138
- catch {
139
- return mcpResponse({ ok: false, error: `Invalid memory ID format: "${rawId}" contains malformed URL encoding.` });
140
- }
141
- const match = id.match(/^mem:([^/]+)\/(.+)$/);
142
- if (!match) {
143
- return mcpResponse({ ok: false, error: `Invalid memory ID format "${rawId}". Expected mem:project/path/to/file.md.` });
144
- }
145
- const [, project] = match;
146
- if (!isValidProjectName(project)) {
147
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
148
- }
121
+ // ── Handlers ─────────────────────────────────────────────────────────────────
122
+ async function handleGetMemoryDetail(ctx, { id: rawId }) {
123
+ const { phrenPath } = ctx;
124
+ // Normalize ID: decode URL encoding and normalize path separators
125
+ let id;
126
+ try {
127
+ id = normalizeMemoryId(rawId);
128
+ }
129
+ catch {
130
+ return mcpResponse({ ok: false, error: `Invalid memory ID format: "${rawId}" contains malformed URL encoding.` });
131
+ }
132
+ const match = id.match(/^mem:([^/]+)\/(.+)$/);
133
+ if (!match) {
134
+ return mcpResponse({ ok: false, error: `Invalid memory ID format "${rawId}". Expected mem:project/path/to/file.md.` });
135
+ }
136
+ const [, project] = match;
137
+ if (!isValidProjectName(project)) {
138
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
139
+ }
140
+ const db = ctx.db();
141
+ const doc = queryDocBySourceKey(db, phrenPath, id.slice(4));
142
+ if (!doc) {
143
+ return mcpResponse({ ok: false, error: `Memory not found: ${id}` });
144
+ }
145
+ // Extract metadata from filesystem and content
146
+ let updatedAt = null;
147
+ let createdAt = null;
148
+ try {
149
+ const stat = fs.statSync(doc.path);
150
+ updatedAt = stat.mtime.toISOString();
151
+ createdAt = stat.birthtime.toISOString();
152
+ }
153
+ catch (err) {
154
+ logger.debug("search", `search_knowledge statFile: ${errorMessage(err)}`);
155
+ }
156
+ // Extract tags from content (e.g. [decision], [pitfall], [pattern])
157
+ const tagMatches = doc.content.match(/\[(decision|pitfall|pattern|tradeoff|architecture|bug)\]/gi);
158
+ const tags = tagMatches ? [...new Set(tagMatches.map(t => t.slice(1, -1).toLowerCase()))] : [];
159
+ // Get quality score if available
160
+ const scoreKey = entryScoreKey(doc.project, doc.filename, doc.content);
161
+ const qualityMultiplier = getQualityMultiplier(phrenPath, scoreKey);
162
+ return mcpResponse({
163
+ ok: true,
164
+ message: `[${id.slice(4)}] (${doc.type})\n\n${doc.content}`,
165
+ data: {
166
+ id,
167
+ project: doc.project,
168
+ filename: doc.filename,
169
+ type: doc.type,
170
+ content: doc.content,
171
+ path: doc.path,
172
+ created_at: createdAt,
173
+ updated_at: updatedAt,
174
+ tags: tags.length > 0 ? tags : undefined,
175
+ score: qualityMultiplier,
176
+ rank: undefined,
177
+ relevance_score: undefined,
178
+ },
179
+ });
180
+ }
181
+ async function handleSearchKnowledge(ctx, { query, limit, project, type, tag, since, status, include_history, synthesize }) {
182
+ const { phrenPath } = ctx;
183
+ try {
184
+ if (query.length > 1000)
185
+ return mcpResponse({ ok: false, error: "Search query exceeds 1000 character limit." });
149
186
  const db = ctx.db();
150
- const doc = queryDocBySourceKey(db, phrenPath, id.slice(4));
151
- if (!doc) {
152
- return mcpResponse({ ok: false, error: `Memory not found: ${id}` });
153
- }
154
- // Extract metadata from filesystem and content
155
- let updatedAt = null;
156
- let createdAt = null;
157
- try {
158
- const stat = fs.statSync(doc.path);
159
- updatedAt = stat.mtime.toISOString();
160
- createdAt = stat.birthtime.toISOString();
161
- }
162
- catch (err) {
163
- logger.debug("search", `search_knowledge statFile: ${errorMessage(err)}`);
187
+ const maxResults = limit ?? 5;
188
+ const filterType = type === "skills" ? "skill" : type;
189
+ const filterTag = tag?.toLowerCase();
190
+ const filterStatus = status;
191
+ const includeHistory = include_history ?? false;
192
+ const filterProject = project?.trim();
193
+ if (filterProject && !isValidProjectName(filterProject)) {
194
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
164
195
  }
165
- // Extract tags from content (e.g. [decision], [pitfall], [pattern])
166
- const tagMatches = doc.content.match(/\[(decision|pitfall|pattern|tradeoff|architecture|bug)\]/gi);
167
- const tags = tagMatches ? [...new Set(tagMatches.map(t => t.slice(1, -1).toLowerCase()))] : [];
168
- // Get quality score if available
169
- const scoreKey = entryScoreKey(doc.project, doc.filename, doc.content);
170
- const qualityMultiplier = getQualityMultiplier(phrenPath, scoreKey);
171
- return mcpResponse({
172
- ok: true,
173
- message: `[${id.slice(4)}] (${doc.type})\n\n${doc.content}`,
174
- data: {
175
- id,
176
- project: doc.project,
177
- filename: doc.filename,
178
- type: doc.type,
179
- content: doc.content,
180
- path: doc.path,
181
- created_at: createdAt,
182
- updated_at: updatedAt,
183
- tags: tags.length > 0 ? tags : undefined,
184
- score: qualityMultiplier,
185
- // Relevance metadata: rank and relevance_score are populated when
186
- // the detail is fetched as part of a search result set. When fetched
187
- // directly by ID they are not available.
188
- rank: undefined,
189
- relevance_score: undefined,
190
- },
196
+ const fetchLimit = (filterTag || since || filterStatus) ? Math.min(maxResults * 5, 200) : maxResults;
197
+ const retrieval = await searchKnowledgeRows(db, {
198
+ query,
199
+ maxResults,
200
+ fetchLimit,
201
+ filterProject,
202
+ filterType,
203
+ phrenPath,
191
204
  });
192
- });
193
- server.registerTool("search_knowledge", {
194
- title: " phren · search",
195
- description: "Search the user's phren. Call this at the start of any session to get project context, and any time the user asks about their codebase, stack, architecture, past decisions, commands, conventions, or findings. Prefer this over asking the user to re-explain things they've already told phren.",
196
- inputSchema: z.object({
197
- query: z.string().describe("Search query (supports FTS5 syntax: AND, OR, NOT, phrase matching with quotes)"),
198
- limit: z.number().min(1).max(20).optional().describe("Max results to return (1-20, default 5)"),
199
- project: z.string().optional().describe("Filter by project name."),
200
- type: z.enum(DOC_TYPES)
201
- .optional()
202
- .describe("Filter by document type: claude, findings, reference, summary, task, skill"),
203
- tag: z.preprocess(value => typeof value === "string" ? value.toLowerCase() : value, z.enum(FINDING_TAGS))
204
- .optional()
205
- .describe("Filter findings by type tag: decision, pitfall, pattern, tradeoff, architecture, bug."),
206
- since: z.string().optional().describe('Filter findings by creation date. Formats: "7d" (last 7 days), "30d" (last 30 days), "YYYY-MM" (since start of month), "YYYY-MM-DD" (since date).'),
207
- status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status: active, superseded, contradicted, stale, invalid_citation, or retracted."),
208
- include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."),
209
- synthesize: z.boolean().optional().describe("When true, generate a short synthesis paragraph from the top results using an LLM. Requires PHREN_LLM_ENDPOINT, ANTHROPIC_API_KEY, or OPENAI_API_KEY."),
210
- }),
211
- }, async ({ query, limit, project, type, tag, since, status, include_history, synthesize }) => {
212
- try {
213
- if (query.length > 1000)
214
- return mcpResponse({ ok: false, error: "Search query exceeds 1000 character limit." });
215
- const db = ctx.db();
216
- const maxResults = limit ?? 5;
217
- const filterType = type === "skills" ? "skill" : type;
218
- const filterTag = tag?.toLowerCase();
219
- const filterStatus = status;
220
- const includeHistory = include_history ?? false;
221
- const filterProject = project?.trim();
222
- if (filterProject && !isValidProjectName(filterProject)) {
223
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
224
- }
225
- const fetchLimit = (filterTag || since || filterStatus) ? Math.min(maxResults * 5, 200) : maxResults;
226
- const retrieval = await searchKnowledgeRows(db, {
227
- query,
228
- maxResults,
229
- fetchLimit,
230
- filterProject,
231
- filterType,
232
- phrenPath,
233
- });
234
- const safeQuery = retrieval.safeQuery;
235
- if (!safeQuery)
236
- return mcpResponse({ ok: false, error: "Search query is empty after sanitization." });
237
- let rows = retrieval.rows ?? [];
238
- const usedFallback = retrieval.usedFallback;
239
- // Merge federated store results when PHREN_FEDERATION_PATHS is set and no project filter
240
- // (federation is global by nature — per-project filter only makes sense within a single store)
241
- if (!filterProject) {
242
- try {
243
- const federatedRows = await searchFederatedStores(phrenPath, {
244
- query,
245
- maxResults,
246
- fetchLimit,
247
- filterType,
205
+ const safeQuery = retrieval.safeQuery;
206
+ if (!safeQuery)
207
+ return mcpResponse({ ok: false, error: "Search query is empty after sanitization." });
208
+ let rows = retrieval.rows ?? [];
209
+ const usedFallback = retrieval.usedFallback;
210
+ // Merge federated store results when PHREN_FEDERATION_PATHS is set and no project filter
211
+ // (federation is global by nature — per-project filter only makes sense within a single store)
212
+ if (!filterProject) {
213
+ try {
214
+ const federatedRows = await searchFederatedStores(phrenPath, {
215
+ query,
216
+ maxResults,
217
+ fetchLimit,
218
+ filterType,
219
+ });
220
+ if (federatedRows.length > 0) {
221
+ // Dedup by path to avoid duplicates if stores share files
222
+ const localPaths = new Set(rows.map((r) => r.path || `${r.project}/${r.filename}`));
223
+ const uniqueFederated = federatedRows.filter((r) => {
224
+ const key = r.path || `${r.project}/${r.filename}`;
225
+ return !localPaths.has(key);
248
226
  });
249
- if (federatedRows.length > 0) {
250
- // Dedup by path to avoid duplicates if stores share files
251
- const localPaths = new Set(rows.map((r) => r.path || `${r.project}/${r.filename}`));
252
- const uniqueFederated = federatedRows.filter((r) => {
253
- const key = r.path || `${r.project}/${r.filename}`;
254
- return !localPaths.has(key);
255
- });
256
- rows = [...rows, ...uniqueFederated];
257
- }
227
+ rows = [...rows, ...uniqueFederated];
258
228
  }
259
- catch (err) {
260
- if (process.env.PHREN_DEBUG) {
261
- logger.debug("search", `search_knowledge federation: ${errorMessage(err)}`);
262
- }
229
+ }
230
+ catch (err) {
231
+ if (process.env.PHREN_DEBUG) {
232
+ logger.debug("search", `search_knowledge federation: ${errorMessage(err)}`);
263
233
  }
264
234
  }
235
+ }
236
+ if (rows.length === 0) {
237
+ logSearchMiss(phrenPath, query, filterProject);
238
+ return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } });
239
+ }
240
+ const activeScope = resolveActiveSessionScope(phrenPath, filterProject);
241
+ if (activeScope) {
242
+ rows = rows
243
+ .map((row) => {
244
+ if (row.type === "findings") {
245
+ const filteredContent = filterFindingsContentByScope(row.content, activeScope);
246
+ const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- "));
247
+ return hasVisible ? { ...row, content: filteredContent } : null;
248
+ }
249
+ if (row.type === "task") {
250
+ const filteredContent = filterTaskContentByScope(row.content, activeScope);
251
+ const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- "));
252
+ return hasVisible ? { ...row, content: filteredContent } : null;
253
+ }
254
+ return row;
255
+ })
256
+ .filter((row) => Boolean(row));
265
257
  if (rows.length === 0) {
266
258
  logSearchMiss(phrenPath, query, filterProject);
267
259
  return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } });
268
260
  }
269
- const activeScope = resolveActiveSessionScope(phrenPath, filterProject);
270
- if (activeScope) {
271
- rows = rows
272
- .map((row) => {
273
- if (row.type === "findings") {
274
- const filteredContent = filterFindingsContentByScope(row.content, activeScope);
275
- const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- "));
276
- return hasVisible ? { ...row, content: filteredContent } : null;
277
- }
278
- if (row.type === "task") {
279
- const filteredContent = filterTaskContentByScope(row.content, activeScope);
280
- const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- "));
281
- return hasVisible ? { ...row, content: filteredContent } : null;
282
- }
283
- return row;
284
- })
285
- .filter((row) => Boolean(row));
286
- if (rows.length === 0) {
287
- logSearchMiss(phrenPath, query, filterProject);
288
- return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } });
289
- }
261
+ }
262
+ // Filter by observation tag if requested
263
+ if (filterTag && rows) {
264
+ const tagPattern = `[${filterTag.toLowerCase()}]`;
265
+ rows = rows.filter(row => row.content.toLowerCase().includes(tagPattern));
266
+ if (rows.length === 0) {
267
+ logSearchMiss(phrenPath, query, filterProject);
268
+ return mcpResponse({ ok: true, message: `No results found with tag [${filterTag}].`, data: { query, results: [] } });
290
269
  }
291
- // Filter by observation tag if requested
292
- if (filterTag && rows) {
293
- const tagPattern = `[${filterTag.toLowerCase()}]`;
294
- rows = rows.filter(row => row.content.toLowerCase().includes(tagPattern));
295
- if (rows.length === 0) {
296
- logSearchMiss(phrenPath, query, filterProject);
297
- return mcpResponse({ ok: true, message: `No results found with tag [${filterTag}].`, data: { query, results: [] } });
298
- }
270
+ }
271
+ // Filter by since date if requested
272
+ if (since && rows) {
273
+ let sinceDate = null;
274
+ const daysMatch = since.match(/^(\d+)d$/);
275
+ if (daysMatch) {
276
+ sinceDate = new Date(Date.now() - parseInt(daysMatch[1], 10) * 86400000);
299
277
  }
300
- // Filter by since date if requested
301
- if (since && rows) {
302
- let sinceDate = null;
303
- const daysMatch = since.match(/^(\d+)d$/);
304
- if (daysMatch) {
305
- sinceDate = new Date(Date.now() - parseInt(daysMatch[1], 10) * 86400000);
278
+ else if (/^\d{4}-\d{2}$/.test(since)) {
279
+ // Validate month is 01-12
280
+ const [, mm] = since.split("-");
281
+ const month = parseInt(mm, 10);
282
+ if (month < 1 || month > 12) {
283
+ return mcpResponse({ ok: false, error: `Invalid since value "${since}": month must be 01-12.` });
306
284
  }
307
- else if (/^\d{4}-\d{2}$/.test(since)) {
308
- // Validate month is 01-12
309
- const [, mm] = since.split("-");
310
- const month = parseInt(mm, 10);
311
- if (month < 1 || month > 12) {
312
- return mcpResponse({ ok: false, error: `Invalid since value "${since}": month must be 01-12.` });
313
- }
314
- sinceDate = new Date(`${since}-01T00:00:00Z`);
315
- }
316
- else if (/^\d{4}-\d{2}-\d{2}$/.test(since)) {
317
- // Validate month and day strictly (reject impossible dates like 2026-02-31)
318
- const [, mm, dd] = since.split("-");
319
- const month = parseInt(mm, 10);
320
- const day = parseInt(dd, 10);
321
- if (month < 1 || month > 12 || day < 1 || day > 31) {
322
- return mcpResponse({ ok: false, error: `Invalid since value "${since}": month or day out of range.` });
323
- }
324
- const candidate = new Date(`${since}T00:00:00Z`);
325
- // new Date() normalizes impossible dates (e.g. Feb 31 → Mar 3); detect by comparing parsed month/day
326
- if (candidate.getUTCMonth() + 1 !== month || candidate.getUTCDate() !== day) {
327
- return mcpResponse({ ok: false, error: `Invalid since value "${since}": date does not exist on the calendar.` });
328
- }
329
- sinceDate = candidate;
330
- }
331
- else if (since) {
332
- return mcpResponse({ ok: false, error: `Invalid since format "${since}". Use "7d", "YYYY-MM", or "YYYY-MM-DD".` });
285
+ sinceDate = new Date(`${since}-01T00:00:00Z`);
286
+ }
287
+ else if (/^\d{4}-\d{2}-\d{2}$/.test(since)) {
288
+ // Validate month and day strictly (reject impossible dates like 2026-02-31)
289
+ const [, mm, dd] = since.split("-");
290
+ const month = parseInt(mm, 10);
291
+ const day = parseInt(dd, 10);
292
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
293
+ return mcpResponse({ ok: false, error: `Invalid since value "${since}": month or day out of range.` });
333
294
  }
334
- if (sinceDate && !isNaN(sinceDate.getTime())) {
335
- const sinceMs = sinceDate.getTime();
336
- rows = rows.filter(row => {
337
- const createdDates = [...row.content.matchAll(/<!-- created: (\d{4}-\d{2}-\d{2}) -->/g)];
338
- if (createdDates.length === 0)
339
- return true;
340
- return createdDates.some(m => new Date(`${m[1]}T00:00:00Z`).getTime() >= sinceMs);
341
- });
342
- if (rows.length === 0) {
343
- logSearchMiss(phrenPath, query, filterProject);
344
- return mcpResponse({ ok: true, message: `No results found since ${since}.`, data: { query, results: [] } });
345
- }
295
+ const candidate = new Date(`${since}T00:00:00Z`);
296
+ // new Date() normalizes impossible dates (e.g. Feb 31 → Mar 3); detect by comparing parsed month/day
297
+ if (candidate.getUTCMonth() + 1 !== month || candidate.getUTCDate() !== day) {
298
+ return mcpResponse({ ok: false, error: `Invalid since value "${since}": date does not exist on the calendar.` });
346
299
  }
300
+ sinceDate = candidate;
347
301
  }
348
- const lifecycleByRowKey = new Map();
349
- if (rows) {
350
- const filteredRows = [];
351
- for (const row of rows) {
352
- if (row.type !== "findings") {
353
- if (filterStatus)
354
- continue;
355
- filteredRows.push(row);
356
- continue;
357
- }
358
- const summary = summarizeFindingStatuses(row.content, includeHistory, filterStatus);
359
- if (!summary)
360
- continue;
361
- lifecycleByRowKey.set(findingRowKey(row), summary);
362
- filteredRows.push(row);
363
- }
364
- rows = filteredRows;
302
+ else if (since) {
303
+ return mcpResponse({ ok: false, error: `Invalid since format "${since}". Use "7d", "YYYY-MM", or "YYYY-MM-DD".` });
304
+ }
305
+ if (sinceDate && !isNaN(sinceDate.getTime())) {
306
+ const sinceMs = sinceDate.getTime();
307
+ rows = rows.filter(row => {
308
+ const createdDates = [...row.content.matchAll(/<!-- created: (\d{4}-\d{2}-\d{2}) -->/g)];
309
+ if (createdDates.length === 0)
310
+ return true;
311
+ return createdDates.some(m => new Date(`${m[1]}T00:00:00Z`).getTime() >= sinceMs);
312
+ });
365
313
  if (rows.length === 0) {
366
314
  logSearchMiss(phrenPath, query, filterProject);
367
- return mcpResponse({ ok: true, message: "No results found after lifecycle filters.", data: { query, results: [] } });
315
+ return mcpResponse({ ok: true, message: `No results found since ${since}.`, data: { query, results: [] } });
368
316
  }
369
317
  }
370
- rows = rankResults(rows, "general", null, filterProject ?? null, phrenPath, db, undefined, query, { skipTaskFilter: true, filterType: filterType ?? null });
371
- // Apply trust filter — same as hook-prompt uses — to strip stale/low-confidence findings
372
- try {
373
- const policy = getRetentionPolicy(phrenPath);
374
- const trustResult = applyTrustFilter(rows, policy.ttlDays, policy.minInjectConfidence, policy.decay, phrenPath);
375
- rows = trustResult.rows;
376
- }
377
- catch (err) {
378
- debugLog(`search_knowledge trustFilter: ${errorMessage(err)}`);
318
+ }
319
+ const lifecycleByRowKey = new Map();
320
+ if (rows) {
321
+ const filteredRows = [];
322
+ for (const row of rows) {
323
+ if (row.type !== "findings") {
324
+ if (filterStatus)
325
+ continue;
326
+ filteredRows.push(row);
327
+ continue;
328
+ }
329
+ const summary = summarizeFindingStatuses(row.content, includeHistory, filterStatus);
330
+ if (!summary)
331
+ continue;
332
+ lifecycleByRowKey.set(findingRowKey(row), summary);
333
+ filteredRows.push(row);
379
334
  }
380
- rows = rows
381
- .map((row, idx) => ({ row, idx }))
382
- .sort((a, b) => {
383
- const aBucket = a.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(a.row))) : 1;
384
- const bBucket = b.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(b.row))) : 1;
385
- if (aBucket !== bBucket)
386
- return aBucket - bBucket;
387
- return a.idx - b.idx;
388
- })
389
- .map(entry => entry.row);
390
- if (rows.length > maxResults) {
391
- rows = rows.slice(0, maxResults);
335
+ rows = filteredRows;
336
+ if (rows.length === 0) {
337
+ logSearchMiss(phrenPath, query, filterProject);
338
+ return mcpResponse({ ok: true, message: "No results found after lifecycle filters.", data: { query, results: [] } });
392
339
  }
393
- const results = rows.map((row) => {
394
- const snippet = extractSnippet(row.content, query);
395
- const lifecycle = row.type === "findings" ? lifecycleByRowKey.get(findingRowKey(row)) : undefined;
396
- const federationSource = "federationSource" in row ? row.federationSource : undefined;
397
- return {
398
- project: row.project,
399
- filename: row.filename,
400
- type: row.type,
401
- snippet,
402
- path: row.path,
403
- status: lifecycle?.primaryStatus,
404
- statuses: lifecycle?.statuses,
405
- ...(federationSource ? { federation_source: federationSource } : {}),
406
- };
407
- });
408
- let relatedFragments = [];
409
- try {
410
- const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
411
- for (const term of terms) {
412
- const links = queryEntityLinks(db, term);
413
- if (links.related.length > 0) {
414
- relatedFragments.push(...links.related);
415
- }
416
- else {
417
- logEntityMiss(phrenPath, term, "search_knowledge", filterProject);
418
- }
340
+ }
341
+ rows = rankResults(rows, "general", null, filterProject ?? null, phrenPath, db, undefined, query, { skipTaskFilter: true, filterType: filterType ?? null });
342
+ // Apply trust filter same as hook-prompt uses — to strip stale/low-confidence findings
343
+ try {
344
+ const policy = getRetentionPolicy(phrenPath);
345
+ const trustResult = applyTrustFilter(rows, policy.ttlDays, policy.minInjectConfidence, policy.decay, phrenPath);
346
+ rows = trustResult.rows;
347
+ }
348
+ catch (err) {
349
+ debugLog(`search_knowledge trustFilter: ${errorMessage(err)}`);
350
+ }
351
+ rows = rows
352
+ .map((row, idx) => ({ row, idx }))
353
+ .sort((a, b) => {
354
+ const aBucket = a.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(a.row))) : 1;
355
+ const bBucket = b.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(b.row))) : 1;
356
+ if (aBucket !== bBucket)
357
+ return aBucket - bBucket;
358
+ return a.idx - b.idx;
359
+ })
360
+ .map(entry => entry.row);
361
+ if (rows.length > maxResults) {
362
+ rows = rows.slice(0, maxResults);
363
+ }
364
+ const results = rows.map((row) => {
365
+ const snippet = extractSnippet(row.content, query);
366
+ const lifecycle = row.type === "findings" ? lifecycleByRowKey.get(findingRowKey(row)) : undefined;
367
+ const federationSource = "federationSource" in row ? row.federationSource : undefined;
368
+ return {
369
+ project: row.project,
370
+ filename: row.filename,
371
+ type: row.type,
372
+ snippet,
373
+ path: row.path,
374
+ status: lifecycle?.primaryStatus,
375
+ statuses: lifecycle?.statuses,
376
+ ...(federationSource ? { federation_source: federationSource } : {}),
377
+ };
378
+ });
379
+ let relatedFragments = [];
380
+ try {
381
+ const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
382
+ for (const term of terms) {
383
+ const links = queryFragmentLinks(db, term);
384
+ if (links.related.length > 0) {
385
+ relatedFragments.push(...links.related);
386
+ }
387
+ else {
388
+ logFragmentMiss(phrenPath, term, "search_knowledge", filterProject);
419
389
  }
420
- relatedFragments = [...new Set(relatedFragments)].slice(0, 10);
421
- }
422
- catch (err) {
423
- logger.debug("search", `fragment query: ${errorMessage(err)}`);
424
390
  }
425
- const formatted = results.map((r) => {
426
- const fedNote = r.federation_source ? ` [from: ${r.federation_source}]` : "";
427
- return `### ${r.project}/${r.filename} (${r.type})${fedNote}\n${r.snippet}\n\n\`${r.path}\``;
428
- });
429
- // Memory synthesis: generate a concise paragraph from top results when requested
430
- let synthesis;
431
- if (synthesize && results.length > 0) {
391
+ relatedFragments = [...new Set(relatedFragments)].slice(0, 10);
392
+ }
393
+ catch (err) {
394
+ logger.debug("search", `fragment query: ${errorMessage(err)}`);
395
+ }
396
+ const formatted = results.map((r) => {
397
+ const fedNote = r.federation_source ? ` [from: ${r.federation_source}]` : "";
398
+ return `### ${r.project}/${r.filename} (${r.type})${fedNote}\n${r.snippet}\n\n\`${r.path}\``;
399
+ });
400
+ // Memory synthesis: generate a concise paragraph from top results when requested
401
+ let synthesis;
402
+ if (synthesize && results.length > 0) {
403
+ try {
404
+ const synthKey = createHash("sha256").update([query, filterProject ?? "", filterType ?? "", filterTag ?? "", since ?? ""].join("|")).digest("hex").slice(0, 16);
405
+ const synthCachePath = runtimeFile(phrenPath, "synth-cache.json");
406
+ let synthCache = {};
432
407
  try {
433
- const synthKey = createHash("sha256").update([query, filterProject ?? "", filterType ?? "", filterTag ?? "", since ?? ""].join("|")).digest("hex").slice(0, 16);
434
- const synthCachePath = runtimeFile(phrenPath, "synth-cache.json");
435
- let synthCache = {};
436
- try {
437
- synthCache = JSON.parse(fs.readFileSync(synthCachePath, "utf8"));
438
- }
439
- catch (err) {
440
- logger.debug("search", `search_knowledge synthCacheRead: ${errorMessage(err)}`);
441
- }
442
- const cached = synthCache[synthKey];
443
- const SYNTH_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
444
- if (cached && Date.now() - cached.ts < SYNTH_CACHE_TTL_MS) {
445
- synthesis = cached.result;
446
- }
447
- else {
448
- const snippets = results.slice(0, 5).map((r, i) => `[${i + 1}] ${r.snippet}`).join("\n");
449
- const synthPrompt = `Summarize these search results for "${query}" in 2-3 sentences. No headers, no lists. Plain paragraph only.\n\n${snippets}`;
450
- synthesis = await callLlm(synthPrompt, undefined, 300);
451
- if (synthesis) {
452
- synthCache[synthKey] = { result: synthesis, ts: Date.now() };
453
- // Trim cache to 100 entries
454
- const cacheKeys = Object.keys(synthCache);
455
- if (cacheKeys.length > 100) {
456
- const oldest = cacheKeys.sort((a, b) => synthCache[a].ts - synthCache[b].ts).slice(0, cacheKeys.length - 100);
457
- for (const k of oldest)
458
- delete synthCache[k];
459
- }
460
- try {
461
- fs.writeFileSync(synthCachePath, JSON.stringify(synthCache));
462
- }
463
- catch (err) {
464
- logger.debug("search", `synthCache write: ${errorMessage(err)}`);
465
- }
466
- }
467
- }
408
+ synthCache = JSON.parse(fs.readFileSync(synthCachePath, "utf8"));
468
409
  }
469
410
  catch (err) {
470
- debugLog(`search synthesis failed: ${errorMessage(err)}`);
411
+ logger.debug("search", `search_knowledge synthCacheRead: ${errorMessage(err)}`);
412
+ }
413
+ const cached = synthCache[synthKey];
414
+ const SYNTH_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
415
+ if (cached && Date.now() - cached.ts < SYNTH_CACHE_TTL_MS) {
416
+ synthesis = cached.result;
417
+ }
418
+ else {
419
+ const snippets = results.slice(0, 5).map((r, i) => `[${i + 1}] ${r.snippet}`).join("\n");
420
+ const synthPrompt = `Summarize these search results for "${query}" in 2-3 sentences. No headers, no lists. Plain paragraph only.\n\n${snippets}`;
421
+ synthesis = await callLlm(synthPrompt, undefined, 300);
422
+ if (synthesis) {
423
+ synthCache[synthKey] = { result: synthesis, ts: Date.now() };
424
+ // Trim cache to 100 entries
425
+ const cacheKeys = Object.keys(synthCache);
426
+ if (cacheKeys.length > 100) {
427
+ const oldest = cacheKeys.sort((a, b) => synthCache[a].ts - synthCache[b].ts).slice(0, cacheKeys.length - 100);
428
+ for (const k of oldest)
429
+ delete synthCache[k];
430
+ }
431
+ try {
432
+ fs.writeFileSync(synthCachePath, JSON.stringify(synthCache));
433
+ }
434
+ catch (err) {
435
+ logger.debug("search", `synthCache write: ${errorMessage(err)}`);
436
+ }
437
+ }
471
438
  }
472
439
  }
473
- const fallbackNote = usedFallback ? " (keyword fallback)" : "";
474
- const fragmentNote = relatedFragments.length > 0 ? `\n\nRelated fragments: ${relatedFragments.join(", ")}` : "";
475
- const synthesisBlock = synthesis ? `\n\n${synthesis}\n\n---\n\n` : "\n\n";
476
- runCustomHooks(phrenPath, "post-search", { PHREN_QUERY: query, PHREN_RESULT_COUNT: String(results.length) });
477
- return mcpResponse({
478
- ok: true,
479
- message: `Found ${results.length} result(s) for "${query}"${fallbackNote}:${synthesisBlock}${formatted.join("\n\n---\n\n")}${fragmentNote}`,
480
- data: { query, count: results.length, results, fallback: usedFallback, relatedFragments: relatedFragments.length > 0 ? relatedFragments : undefined, ...(synthesis ? { synthesis } : {}) },
481
- });
440
+ catch (err) {
441
+ debugLog(`search synthesis failed: ${errorMessage(err)}`);
442
+ }
482
443
  }
483
- catch (err) {
484
- return mcpResponse({ ok: false, error: `Search error: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
444
+ const fallbackNote = usedFallback ? " (keyword fallback)" : "";
445
+ const fragmentNote = relatedFragments.length > 0 ? `\n\nRelated fragments: ${relatedFragments.join(", ")}` : "";
446
+ const synthesisBlock = synthesis ? `\n\n${synthesis}\n\n---\n\n` : "\n\n";
447
+ runCustomHooks(phrenPath, "post-search", { PHREN_QUERY: query, PHREN_RESULT_COUNT: String(results.length) });
448
+ return mcpResponse({
449
+ ok: true,
450
+ message: `Found ${results.length} result(s) for "${query}"${fallbackNote}:${synthesisBlock}${formatted.join("\n\n---\n\n")}${fragmentNote}`,
451
+ data: { query, count: results.length, results, fallback: usedFallback, relatedFragments: relatedFragments.length > 0 ? relatedFragments : undefined, ...(synthesis ? { synthesis } : {}) },
452
+ });
453
+ }
454
+ catch (err) {
455
+ return mcpResponse({ ok: false, error: `Search error: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
456
+ }
457
+ }
458
+ async function handleGetProjectSummary(ctx, { name }) {
459
+ const { phrenPath } = ctx;
460
+ const db = ctx.db();
461
+ const docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [name]);
462
+ if (!docs) {
463
+ const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
464
+ const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : [];
465
+ return mcpResponse({ ok: false, error: `Project "${name}" not found.`, data: { available: names } });
466
+ }
467
+ const summaryDoc = docs.find(doc => doc.type === "summary");
468
+ const claudeDoc = docs.find(doc => doc.type === "claude");
469
+ const indexedFiles = docs.map(doc => ({ filename: doc.filename, type: doc.type, path: doc.path }));
470
+ const parts = [`# ${name}`];
471
+ if (summaryDoc) {
472
+ parts.push(`\n## Summary\n${summaryDoc.content}`);
473
+ }
474
+ else {
475
+ parts.push("\n*No summary.md found for this project.*");
476
+ }
477
+ if (claudeDoc) {
478
+ parts.push(`\n## CLAUDE.md path\n\`${claudeDoc.path}\``);
479
+ }
480
+ const fileList = indexedFiles.map((f) => `- ${f.filename} (${f.type})`).join("\n");
481
+ parts.push(`\n## Indexed files\n${fileList}`);
482
+ return mcpResponse({
483
+ ok: true,
484
+ message: parts.join("\n"),
485
+ data: {
486
+ name,
487
+ summary: summaryDoc?.content ?? null,
488
+ claudeMdPath: claudeDoc?.path ?? null,
489
+ files: indexedFiles,
490
+ },
491
+ });
492
+ }
493
+ async function handleListProjects(ctx, { page, page_size }) {
494
+ const { phrenPath, profile } = ctx;
495
+ const db = ctx.db();
496
+ const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
497
+ if (!projectRows)
498
+ return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } });
499
+ const projects = projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]);
500
+ const pageSize = page_size ?? 20;
501
+ const pageNum = page ?? 1;
502
+ const start = Math.max(0, (pageNum - 1) * pageSize);
503
+ const end = start + pageSize;
504
+ const pageProjects = projects.slice(start, end);
505
+ const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
506
+ if (pageNum > totalPages) {
507
+ return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` });
508
+ }
509
+ const badgeTypes = ["claude", "findings", "summary", "task"];
510
+ const badgeLabels = { claude: "CLAUDE.md", findings: "FINDINGS", summary: "summary", task: "task" };
511
+ const projectList = pageProjects.map((proj) => {
512
+ const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [proj]) ?? [];
513
+ const types = rows.map(row => row.type);
514
+ const summaryRow = rows.find(row => row.type === "summary");
515
+ const claudeRow = rows.find(row => row.type === "claude");
516
+ const source = summaryRow?.content ?? claudeRow?.content;
517
+ let brief = "";
518
+ if (source) {
519
+ const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#"));
520
+ brief = firstLine?.trim() || "";
485
521
  }
522
+ const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]);
523
+ return { name: proj, brief, badges, fileCount: rows.length };
524
+ });
525
+ const lines = [`# Phren Projects (${projects.length})`];
526
+ if (profile)
527
+ lines.push(`Profile: ${profile}`);
528
+ lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`);
529
+ lines.push(`Path: ${phrenPath}\n`);
530
+ for (const p of projectList) {
531
+ lines.push(`## ${p.name}`);
532
+ if (p.brief)
533
+ lines.push(p.brief);
534
+ lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`);
535
+ }
536
+ return mcpResponse({
537
+ ok: true,
538
+ message: lines.join("\n"),
539
+ data: { projects: projectList, total: projects.length, page: pageNum, totalPages, pageSize },
486
540
  });
541
+ }
542
+ async function handleGetFindings(ctx, { project, limit, include_superseded, include_history, status }) {
543
+ const { phrenPath } = ctx;
544
+ if (!isValidProjectName(project))
545
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
546
+ const includeHistory = include_history ?? include_superseded ?? false;
547
+ // Always read with archive so we can compute historyCount without a second read
548
+ const result = readFindings(phrenPath, project, { includeArchived: true });
549
+ if (!result.ok)
550
+ return mcpResponse({ ok: false, error: result.error });
551
+ const allItems = result.data;
552
+ const historyCount = allItems.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
553
+ const visibleItems = includeHistory
554
+ ? allItems
555
+ : allItems.filter(f => f.tier !== "archived" && !HISTORY_FINDING_STATUSES.has(f.status));
556
+ // Apply scope filter: only show findings visible to the active scope
557
+ const activeScope = resolveActiveSessionScope(phrenPath, project);
558
+ const scopedItems = activeScope
559
+ ? visibleItems.filter(f => {
560
+ const itemScope = normalizeMemoryScope(f.scope);
561
+ return isMemoryScopeVisible(itemScope, activeScope);
562
+ })
563
+ : visibleItems;
564
+ const filteredItems = status ? scopedItems.filter(f => f.status === status) : scopedItems;
565
+ if (!filteredItems.length) {
566
+ const msg = historyCount > 0 && !includeHistory
567
+ ? `No findings found for "${project}" with current filters. ${historyCount} historical finding(s) hidden. Pass include_history=true to show history.`
568
+ : `No findings found for "${project}".`;
569
+ return mcpResponse({ ok: true, message: msg, data: { project, findings: [], total: 0, status: status ?? null, include_history: includeHistory, historyCount } });
570
+ }
571
+ const capped = filteredItems.slice(0, limit ?? 50).map(entry => ({
572
+ ...entry,
573
+ lifecycle: {
574
+ status: entry.status,
575
+ status_updated: entry.status_updated,
576
+ status_reason: entry.status_reason,
577
+ status_ref: entry.status_ref,
578
+ },
579
+ }));
580
+ const lines = capped.map((entry) => {
581
+ const metadata = [];
582
+ metadata.push(`status=${entry.status}`);
583
+ if (entry.taskItem)
584
+ metadata.push(`task=${entry.taskItem}`);
585
+ if (entry.sessionId)
586
+ metadata.push(`session=${entry.sessionId.slice(0, 8)}`);
587
+ if (entry.scope)
588
+ metadata.push(`scope=${entry.scope}`);
589
+ if (entry.actor)
590
+ metadata.push(`actor=${entry.actor}`);
591
+ if (entry.tool)
592
+ metadata.push(`tool=${entry.tool}`);
593
+ if (entry.model)
594
+ metadata.push(`model=${entry.model}`);
595
+ if (entry.supersedes)
596
+ metadata.push(`supersedes="${entry.supersedes.slice(0, 30)}"`);
597
+ if (entry.supersededBy)
598
+ metadata.push(`superseded_by="${entry.supersededBy.slice(0, 30)}"`);
599
+ if (entry.contradicts?.length)
600
+ metadata.push(`contradicts=${entry.contradicts.length}`);
601
+ if (entry.tier === "archived")
602
+ metadata.push("tier=archived");
603
+ const idLabel = entry.stableId ? `${entry.id}|fid:${entry.stableId}` : entry.id;
604
+ return `- [${idLabel}] ${entry.date}: ${entry.text}${entry.confidence !== undefined ? ` [confidence ${entry.confidence.toFixed(2)}]` : ""}${metadata.length > 0 ? ` [${metadata.join(" ")}]` : ""}${entry.citation ? ` (${entry.citation})` : ""}`;
605
+ });
606
+ const hiddenHistoryCount = includeHistory ? 0 : historyCount;
607
+ const historyNote = hiddenHistoryCount > 0 ? ` (${hiddenHistoryCount} historical hidden)` : "";
608
+ return mcpResponse({
609
+ ok: true,
610
+ message: `Findings for ${project} (${capped.length}/${filteredItems.length})${historyNote}:\n` + lines.join("\n"),
611
+ data: { project, findings: capped, total: filteredItems.length, status: status ?? null, include_history: includeHistory, historyCount: hiddenHistoryCount },
612
+ });
613
+ }
614
+ // ── Registration ─────────────────────────────────────────────────────────────
615
+ export function register(server, ctx) {
616
+ server.registerTool("get_memory_detail", {
617
+ title: "◆ phren · memory detail",
618
+ description: "Fetch the full content of a specific memory entry by its ID. Use this after receiving a compact " +
619
+ "memory index from the hook-prompt (when PHREN_FEATURE_PROGRESSIVE_DISCLOSURE is enabled). " +
620
+ "The id format is `mem:project/path/to/file.md` as shown in the memory index.",
621
+ inputSchema: z.object({
622
+ id: z.string().describe("Memory ID in the format `mem:project/path/to/file.md` (e.g. `mem:my-app/reference/api/auth.md`). " +
623
+ "Returned by the hook-prompt compact index when PHREN_FEATURE_PROGRESSIVE_DISCLOSURE=1."),
624
+ }),
625
+ }, (params) => handleGetMemoryDetail(ctx, params));
626
+ server.registerTool("search_knowledge", {
627
+ title: "◆ phren · search",
628
+ description: "Search the user's phren. Call this at the start of any session to get project context, and any time the user asks about their codebase, stack, architecture, past decisions, commands, conventions, or findings. Prefer this over asking the user to re-explain things they've already told phren.",
629
+ inputSchema: z.object({
630
+ query: z.string().describe("Search query (supports FTS5 syntax: AND, OR, NOT, phrase matching with quotes)"),
631
+ limit: z.number().min(1).max(20).optional().describe("Max results to return (1-20, default 5)"),
632
+ project: z.string().optional().describe("Filter by project name."),
633
+ type: z.enum(DOC_TYPES)
634
+ .optional()
635
+ .describe("Filter by document type: claude, findings, reference, summary, task, skill"),
636
+ tag: z.preprocess(value => typeof value === "string" ? value.toLowerCase() : value, z.enum(FINDING_TAGS))
637
+ .optional()
638
+ .describe("Filter findings by type tag: decision, pitfall, pattern, tradeoff, architecture, bug."),
639
+ since: z.string().optional().describe('Filter findings by creation date. Formats: "7d" (last 7 days), "30d" (last 30 days), "YYYY-MM" (since start of month), "YYYY-MM-DD" (since date).'),
640
+ status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status: active, superseded, contradicted, stale, invalid_citation, or retracted."),
641
+ include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."),
642
+ synthesize: z.boolean().optional().describe("When true, generate a short synthesis paragraph from the top results using an LLM. Requires PHREN_LLM_ENDPOINT, ANTHROPIC_API_KEY, or OPENAI_API_KEY."),
643
+ }),
644
+ }, (params) => handleSearchKnowledge(ctx, params));
487
645
  server.registerTool("get_project_summary", {
488
646
  title: "◆ phren · project",
489
647
  description: "Get a project's summary card and available docs. Call this when starting work on a specific project to orient yourself: what it is, the stack, current status, and how to run it.",
490
648
  inputSchema: z.object({
491
649
  name: z.string().describe("Project name (e.g. 'my-app', 'backend', 'frontend')"),
492
650
  }),
493
- }, async ({ name }) => {
494
- const db = ctx.db();
495
- const docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [name]);
496
- if (!docs) {
497
- const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
498
- const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : [];
499
- return mcpResponse({ ok: false, error: `Project "${name}" not found.`, data: { available: names } });
500
- }
501
- const summaryDoc = docs.find(doc => doc.type === "summary");
502
- const claudeDoc = docs.find(doc => doc.type === "claude");
503
- const indexedFiles = docs.map(doc => ({ filename: doc.filename, type: doc.type, path: doc.path }));
504
- const parts = [`# ${name}`];
505
- if (summaryDoc) {
506
- parts.push(`\n## Summary\n${summaryDoc.content}`);
507
- }
508
- else {
509
- parts.push("\n*No summary.md found for this project.*");
510
- }
511
- if (claudeDoc) {
512
- parts.push(`\n## CLAUDE.md path\n\`${claudeDoc.path}\``);
513
- }
514
- const fileList = indexedFiles.map((f) => `- ${f.filename} (${f.type})`).join("\n");
515
- parts.push(`\n## Indexed files\n${fileList}`);
516
- return mcpResponse({
517
- ok: true,
518
- message: parts.join("\n"),
519
- data: {
520
- name,
521
- summary: summaryDoc?.content ?? null,
522
- claudeMdPath: claudeDoc?.path ?? null,
523
- files: indexedFiles,
524
- },
525
- });
526
- });
651
+ }, (params) => handleGetProjectSummary(ctx, params));
527
652
  server.registerTool("list_projects", {
528
653
  title: "◆ phren · projects",
529
654
  description: "List all projects in the active phren profile with a brief summary of each. " +
@@ -532,54 +657,7 @@ export function register(server, ctx) {
532
657
  page: z.number().int().min(1).optional().describe("1-based page number (default 1)."),
533
658
  page_size: z.number().int().min(1).max(50).optional().describe("Page size (default 20, max 50)."),
534
659
  }),
535
- }, async ({ page, page_size }) => {
536
- const db = ctx.db();
537
- const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
538
- if (!projectRows)
539
- return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } });
540
- const projects = projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]);
541
- const pageSize = page_size ?? 20;
542
- const pageNum = page ?? 1;
543
- const start = Math.max(0, (pageNum - 1) * pageSize);
544
- const end = start + pageSize;
545
- const pageProjects = projects.slice(start, end);
546
- const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
547
- if (pageNum > totalPages) {
548
- return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` });
549
- }
550
- const badgeTypes = ["claude", "findings", "summary", "task"];
551
- const badgeLabels = { claude: "CLAUDE.md", findings: "FINDINGS", summary: "summary", task: "task" };
552
- const projectList = pageProjects.map((proj) => {
553
- const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [proj]) ?? [];
554
- const types = rows.map(row => row.type);
555
- const summaryRow = rows.find(row => row.type === "summary");
556
- const claudeRow = rows.find(row => row.type === "claude");
557
- const source = summaryRow?.content ?? claudeRow?.content;
558
- let brief = "";
559
- if (source) {
560
- const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#"));
561
- brief = firstLine?.trim() || "";
562
- }
563
- const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]);
564
- return { name: proj, brief, badges, fileCount: rows.length };
565
- });
566
- const lines = [`# Phren Projects (${projects.length})`];
567
- if (profile)
568
- lines.push(`Profile: ${profile}`);
569
- lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`);
570
- lines.push(`Path: ${phrenPath}\n`);
571
- for (const p of projectList) {
572
- lines.push(`## ${p.name}`);
573
- if (p.brief)
574
- lines.push(p.brief);
575
- lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`);
576
- }
577
- return mcpResponse({
578
- ok: true,
579
- message: lines.join("\n"),
580
- data: { projects: projectList, total: projects.length, page: pageNum, totalPages, pageSize },
581
- });
582
- });
660
+ }, (params) => handleListProjects(ctx, params));
583
661
  server.registerTool("get_findings", {
584
662
  title: "◆ phren · findings",
585
663
  description: "List recent findings for a project without requiring a search query.",
@@ -590,75 +668,5 @@ export function register(server, ctx) {
590
668
  include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."),
591
669
  status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status."),
592
670
  }),
593
- }, async ({ project, limit, include_superseded, include_history, status }) => {
594
- if (!isValidProjectName(project))
595
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
596
- const includeHistory = include_history ?? include_superseded ?? false;
597
- // Always read with archive so we can compute historyCount without a second read
598
- const result = readFindings(phrenPath, project, { includeArchived: true });
599
- if (!result.ok)
600
- return mcpResponse({ ok: false, error: result.error });
601
- const allItems = result.data;
602
- const historyCount = allItems.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
603
- const visibleItems = includeHistory
604
- ? allItems
605
- : allItems.filter(f => f.tier !== "archived" && !HISTORY_FINDING_STATUSES.has(f.status));
606
- // Apply scope filter: only show findings visible to the active scope
607
- const activeScope = resolveActiveSessionScope(phrenPath, project);
608
- const scopedItems = activeScope
609
- ? visibleItems.filter(f => {
610
- const itemScope = normalizeMemoryScope(f.scope);
611
- return isMemoryScopeVisible(itemScope, activeScope);
612
- })
613
- : visibleItems;
614
- const filteredItems = status ? scopedItems.filter(f => f.status === status) : scopedItems;
615
- if (!filteredItems.length) {
616
- const msg = historyCount > 0 && !includeHistory
617
- ? `No findings found for "${project}" with current filters. ${historyCount} historical finding(s) hidden. Pass include_history=true to show history.`
618
- : `No findings found for "${project}".`;
619
- return mcpResponse({ ok: true, message: msg, data: { project, findings: [], total: 0, status: status ?? null, include_history: includeHistory, historyCount } });
620
- }
621
- const capped = filteredItems.slice(0, limit ?? 50).map(entry => ({
622
- ...entry,
623
- lifecycle: {
624
- status: entry.status,
625
- status_updated: entry.status_updated,
626
- status_reason: entry.status_reason,
627
- status_ref: entry.status_ref,
628
- },
629
- }));
630
- const lines = capped.map((entry) => {
631
- const metadata = [];
632
- metadata.push(`status=${entry.status}`);
633
- if (entry.taskItem)
634
- metadata.push(`task=${entry.taskItem}`);
635
- if (entry.sessionId)
636
- metadata.push(`session=${entry.sessionId.slice(0, 8)}`);
637
- if (entry.scope)
638
- metadata.push(`scope=${entry.scope}`);
639
- if (entry.actor)
640
- metadata.push(`actor=${entry.actor}`);
641
- if (entry.tool)
642
- metadata.push(`tool=${entry.tool}`);
643
- if (entry.model)
644
- metadata.push(`model=${entry.model}`);
645
- if (entry.supersedes)
646
- metadata.push(`supersedes="${entry.supersedes.slice(0, 30)}"`);
647
- if (entry.supersededBy)
648
- metadata.push(`superseded_by="${entry.supersededBy.slice(0, 30)}"`);
649
- if (entry.contradicts?.length)
650
- metadata.push(`contradicts=${entry.contradicts.length}`);
651
- if (entry.tier === "archived")
652
- metadata.push("tier=archived");
653
- const idLabel = entry.stableId ? `${entry.id}|fid:${entry.stableId}` : entry.id;
654
- return `- [${idLabel}] ${entry.date}: ${entry.text}${entry.confidence !== undefined ? ` [confidence ${entry.confidence.toFixed(2)}]` : ""}${metadata.length > 0 ? ` [${metadata.join(" ")}]` : ""}${entry.citation ? ` (${entry.citation})` : ""}`;
655
- });
656
- const hiddenHistoryCount = includeHistory ? 0 : historyCount;
657
- const historyNote = hiddenHistoryCount > 0 ? ` (${hiddenHistoryCount} historical hidden)` : "";
658
- return mcpResponse({
659
- ok: true,
660
- message: `Findings for ${project} (${capped.length}/${filteredItems.length})${historyNote}:\n` + lines.join("\n"),
661
- data: { project, findings: capped, total: filteredItems.length, status: status ?? null, include_history: includeHistory, historyCount: hiddenHistoryCount },
662
- });
663
- });
671
+ }, (params) => handleGetFindings(ctx, params));
664
672
  }