@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.
- package/mcp/dist/cli/actions.js +3 -0
- package/mcp/dist/cli/config.js +3 -3
- package/mcp/dist/cli/govern.js +18 -8
- package/mcp/dist/cli/hooks-context.js +1 -1
- package/mcp/dist/cli/hooks-session.js +18 -62
- package/mcp/dist/cli/namespaces.js +1 -1
- package/mcp/dist/cli/search.js +5 -5
- package/mcp/dist/cli-hooks-prompt.js +7 -3
- package/mcp/dist/cli-hooks-session-handlers.js +3 -15
- package/mcp/dist/cli-hooks-stop.js +10 -48
- package/mcp/dist/content/archive.js +8 -20
- package/mcp/dist/content/learning.js +29 -8
- package/mcp/dist/data/access.js +13 -4
- package/mcp/dist/finding/lifecycle.js +9 -3
- package/mcp/dist/governance/audit.js +13 -5
- package/mcp/dist/governance/policy.js +13 -0
- package/mcp/dist/governance/rbac.js +1 -1
- package/mcp/dist/governance/scores.js +2 -1
- package/mcp/dist/hooks.js +52 -6
- package/mcp/dist/index.js +1 -1
- package/mcp/dist/init/init.js +66 -45
- package/mcp/dist/init/shared.js +1 -1
- package/mcp/dist/init-bootstrap.js +0 -47
- package/mcp/dist/init-fresh.js +13 -18
- package/mcp/dist/init-uninstall.js +22 -0
- package/mcp/dist/init-walkthrough.js +19 -24
- package/mcp/dist/link/doctor.js +9 -0
- package/mcp/dist/package-metadata.js +1 -1
- package/mcp/dist/phren-art.js +4 -120
- package/mcp/dist/proactivity.js +1 -1
- package/mcp/dist/project-topics.js +16 -46
- package/mcp/dist/provider-adapters.js +1 -1
- package/mcp/dist/runtime-profile.js +1 -1
- package/mcp/dist/shared/data-utils.js +25 -0
- package/mcp/dist/shared/fragment-graph.js +4 -18
- package/mcp/dist/shared/index.js +14 -10
- package/mcp/dist/shared/ollama.js +23 -5
- package/mcp/dist/shared/process.js +24 -0
- package/mcp/dist/shared/retrieval.js +7 -4
- package/mcp/dist/shared/search-fallback.js +1 -0
- package/mcp/dist/shared.js +2 -1
- package/mcp/dist/shell/render.js +1 -1
- package/mcp/dist/skill/registry.js +1 -1
- package/mcp/dist/skill/state.js +0 -3
- package/mcp/dist/task/github.js +1 -0
- package/mcp/dist/task/lifecycle.js +1 -6
- package/mcp/dist/tools/config.js +415 -400
- package/mcp/dist/tools/finding.js +390 -373
- package/mcp/dist/tools/ops.js +372 -365
- package/mcp/dist/tools/search.js +495 -487
- package/mcp/dist/tools/session.js +3 -2
- package/mcp/dist/tools/skills.js +9 -0
- package/mcp/dist/ui/page.js +1 -1
- package/mcp/dist/ui/server.js +645 -1040
- package/mcp/dist/utils.js +12 -8
- package/package.json +1 -1
- package/mcp/dist/init-dryrun.js +0 -55
- package/mcp/dist/init-migrate.js +0 -51
- package/mcp/dist/init-walkthrough-merge.js +0 -90
package/mcp/dist/tools/search.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
if (
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
|
|
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:
|
|
315
|
+
return mcpResponse({ ok: true, message: `No results found since ${since}.`, data: { query, results: [] } });
|
|
368
316
|
}
|
|
369
317
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 =
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
}
|