@phren/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { mcpResponse } from "./mcp-types.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { isValidProjectName, errorMessage } from "./utils.js";
|
|
6
|
+
import { readFindings } from "./data-access.js";
|
|
7
|
+
import { debugLog, runtimeFile, DOC_TYPES, FINDING_TAGS, isMemoryScopeVisible, normalizeMemoryScope, } from "./shared.js";
|
|
8
|
+
import { FINDING_LIFECYCLE_STATUSES, parseFindingLifecycle, } from "./shared-content.js";
|
|
9
|
+
import { decodeStringRow, queryRows, queryDocRows, queryEntityLinks, logEntityMiss, extractSnippet, queryDocBySourceKey, normalizeMemoryId, } from "./shared-index.js";
|
|
10
|
+
import { runCustomHooks } from "./hooks.js";
|
|
11
|
+
import { entryScoreKey, getQualityMultiplier, getRetentionPolicy } from "./shared-governance.js";
|
|
12
|
+
import { callLlm } from "./content-dedup.js";
|
|
13
|
+
import { rankResults, searchKnowledgeRows, applyTrustFilter } from "./shared-retrieval.js";
|
|
14
|
+
import { parseSourceComment } from "./content-citation.js";
|
|
15
|
+
import { resolveActiveSessionScope } from "./mcp-session.js";
|
|
16
|
+
/**
|
|
17
|
+
* Q30: Log zero-result queries to .runtime/search-misses.jsonl.
|
|
18
|
+
* Strips PII-like tokens (emails, UUIDs, numbers) and keeps only query terms.
|
|
19
|
+
*/
|
|
20
|
+
export function logSearchMiss(phrenPath, query, project) {
|
|
21
|
+
try {
|
|
22
|
+
const sanitized = query
|
|
23
|
+
.replace(/\S+@\S+\.\S+/g, "<email>") // strip emails
|
|
24
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>") // strip UUIDs
|
|
25
|
+
.replace(/\b\d{3,}\b/g, "<num>") // strip long numbers
|
|
26
|
+
.trim();
|
|
27
|
+
if (!sanitized)
|
|
28
|
+
return;
|
|
29
|
+
const entry = JSON.stringify({
|
|
30
|
+
query: sanitized,
|
|
31
|
+
ts: Date.now(),
|
|
32
|
+
project: project ?? null,
|
|
33
|
+
});
|
|
34
|
+
const missFile = runtimeFile(phrenPath, "search-misses.jsonl");
|
|
35
|
+
fs.appendFileSync(missFile, entry + "\n");
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
39
|
+
process.stderr.write(`[phren] logSearchMiss: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const HISTORY_FINDING_STATUSES = new Set(["superseded", "retracted"]);
|
|
43
|
+
const DEGRADED_FINDING_STATUSES = new Set(["contradicted", "stale", "invalid_citation"]);
|
|
44
|
+
function findingRowKey(row) {
|
|
45
|
+
return row.path || `${row.project}/${row.filename}`;
|
|
46
|
+
}
|
|
47
|
+
function summarizeFindingStatuses(content, includeHistory, filterStatus) {
|
|
48
|
+
const statuses = content
|
|
49
|
+
.split("\n")
|
|
50
|
+
.filter(line => line.startsWith("- "))
|
|
51
|
+
.map(line => parseFindingLifecycle(line).status);
|
|
52
|
+
if (!statuses.length)
|
|
53
|
+
return null;
|
|
54
|
+
const deduped = [...new Set(statuses)];
|
|
55
|
+
const visible = deduped.filter(status => includeHistory || !HISTORY_FINDING_STATUSES.has(status));
|
|
56
|
+
if (!visible.length)
|
|
57
|
+
return null;
|
|
58
|
+
const filtered = filterStatus ? visible.filter(status => status === filterStatus) : visible;
|
|
59
|
+
if (!filtered.length)
|
|
60
|
+
return null;
|
|
61
|
+
const primaryStatus = filtered.includes("active") ? "active" : filtered[0];
|
|
62
|
+
return { statuses: filtered, primaryStatus };
|
|
63
|
+
}
|
|
64
|
+
function lifecycleSortBucket(summary) {
|
|
65
|
+
if (!summary)
|
|
66
|
+
return 1;
|
|
67
|
+
if (summary.statuses.includes("active"))
|
|
68
|
+
return 0;
|
|
69
|
+
if (summary.statuses.some(status => DEGRADED_FINDING_STATUSES.has(status)))
|
|
70
|
+
return 3;
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
function filterFindingsContentByScope(content, activeScope) {
|
|
74
|
+
const lines = content.split("\n");
|
|
75
|
+
const out = [];
|
|
76
|
+
let keepCitation = false;
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (line.startsWith("- ")) {
|
|
79
|
+
const source = parseSourceComment(line);
|
|
80
|
+
const itemScope = normalizeMemoryScope(source?.scope);
|
|
81
|
+
keepCitation = isMemoryScopeVisible(itemScope, activeScope);
|
|
82
|
+
if (keepCitation)
|
|
83
|
+
out.push(line);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (/^\s*<!--\s*phren:cite\s+\{.*\}\s*-->\s*$/.test(line.trim())) {
|
|
87
|
+
if (keepCitation)
|
|
88
|
+
out.push(line);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
keepCitation = false;
|
|
92
|
+
out.push(line);
|
|
93
|
+
}
|
|
94
|
+
return out.join("\n");
|
|
95
|
+
}
|
|
96
|
+
function taskLineScope(line) {
|
|
97
|
+
const scopeMatch = line.match(/<!--[^>]*\bscope:([^\s>]+)[^>]*-->/);
|
|
98
|
+
return normalizeMemoryScope(scopeMatch?.[1]);
|
|
99
|
+
}
|
|
100
|
+
function filterTaskContentByScope(content, activeScope) {
|
|
101
|
+
const lines = content.split("\n");
|
|
102
|
+
const out = [];
|
|
103
|
+
let keepContinuation = false;
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (line.startsWith("- ")) {
|
|
106
|
+
keepContinuation = isMemoryScopeVisible(taskLineScope(line), activeScope);
|
|
107
|
+
if (keepContinuation)
|
|
108
|
+
out.push(line);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (/^\s+/.test(line)) {
|
|
112
|
+
if (keepContinuation)
|
|
113
|
+
out.push(line);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
keepContinuation = false;
|
|
117
|
+
out.push(line);
|
|
118
|
+
}
|
|
119
|
+
return out.join("\n");
|
|
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
|
+
const id = normalizeMemoryId(rawId);
|
|
135
|
+
const match = id.match(/^mem:([^/]+)\/(.+)$/);
|
|
136
|
+
if (!match) {
|
|
137
|
+
return mcpResponse({ ok: false, error: `Invalid memory ID format "${rawId}". Expected mem:project/path/to/file.md.` });
|
|
138
|
+
}
|
|
139
|
+
const [, project] = match;
|
|
140
|
+
if (!isValidProjectName(project)) {
|
|
141
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
142
|
+
}
|
|
143
|
+
const db = ctx.db();
|
|
144
|
+
const doc = queryDocBySourceKey(db, phrenPath, id.slice(4));
|
|
145
|
+
if (!doc) {
|
|
146
|
+
return mcpResponse({ ok: false, error: `Memory not found: ${id}` });
|
|
147
|
+
}
|
|
148
|
+
// Extract metadata from filesystem and content
|
|
149
|
+
let updatedAt = null;
|
|
150
|
+
let createdAt = null;
|
|
151
|
+
try {
|
|
152
|
+
const stat = fs.statSync(doc.path);
|
|
153
|
+
updatedAt = stat.mtime.toISOString();
|
|
154
|
+
createdAt = stat.birthtime.toISOString();
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
158
|
+
process.stderr.write(`[phren] search_knowledge statFile: ${errorMessage(err)}\n`);
|
|
159
|
+
}
|
|
160
|
+
// Extract tags from content (e.g. [decision], [pitfall], [pattern])
|
|
161
|
+
const tagMatches = doc.content.match(/\[(decision|pitfall|pattern|tradeoff|architecture|bug)\]/gi);
|
|
162
|
+
const tags = tagMatches ? [...new Set(tagMatches.map(t => t.slice(1, -1).toLowerCase()))] : [];
|
|
163
|
+
// Get quality score if available
|
|
164
|
+
const scoreKey = entryScoreKey(doc.project, doc.filename, doc.content);
|
|
165
|
+
const qualityMultiplier = getQualityMultiplier(phrenPath, scoreKey);
|
|
166
|
+
return mcpResponse({
|
|
167
|
+
ok: true,
|
|
168
|
+
message: `[${id.slice(4)}] (${doc.type})\n\n${doc.content}`,
|
|
169
|
+
data: {
|
|
170
|
+
id,
|
|
171
|
+
project: doc.project,
|
|
172
|
+
filename: doc.filename,
|
|
173
|
+
type: doc.type,
|
|
174
|
+
content: doc.content,
|
|
175
|
+
path: doc.path,
|
|
176
|
+
created_at: createdAt,
|
|
177
|
+
updated_at: updatedAt,
|
|
178
|
+
tags: tags.length > 0 ? tags : undefined,
|
|
179
|
+
score: qualityMultiplier,
|
|
180
|
+
// Relevance metadata: rank and relevance_score are populated when
|
|
181
|
+
// the detail is fetched as part of a search result set. When fetched
|
|
182
|
+
// directly by ID they are not available.
|
|
183
|
+
rank: undefined,
|
|
184
|
+
relevance_score: undefined,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
server.registerTool("search_knowledge", {
|
|
189
|
+
title: "◆ phren · search",
|
|
190
|
+
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.",
|
|
191
|
+
inputSchema: z.object({
|
|
192
|
+
query: z.string().describe("Search query (supports FTS5 syntax: AND, OR, NOT, phrase matching with quotes)"),
|
|
193
|
+
limit: z.number().min(1).max(20).optional().describe("Max results to return (1-20, default 5)"),
|
|
194
|
+
project: z.string().optional().describe("Filter by project name."),
|
|
195
|
+
type: z.enum(DOC_TYPES)
|
|
196
|
+
.optional()
|
|
197
|
+
.describe("Filter by document type: claude, findings, reference, summary, task, skill"),
|
|
198
|
+
tag: z.preprocess(value => typeof value === "string" ? value.toLowerCase() : value, z.enum(FINDING_TAGS))
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("Filter findings by type tag: decision, pitfall, pattern, tradeoff, architecture, bug."),
|
|
201
|
+
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).'),
|
|
202
|
+
status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status: active, superseded, contradicted, stale, invalid_citation, or retracted."),
|
|
203
|
+
include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."),
|
|
204
|
+
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."),
|
|
205
|
+
}),
|
|
206
|
+
}, async ({ query, limit, project, type, tag, since, status, include_history, synthesize }) => {
|
|
207
|
+
try {
|
|
208
|
+
if (query.length > 1000)
|
|
209
|
+
return mcpResponse({ ok: false, error: "Search query exceeds 1000 character limit." });
|
|
210
|
+
const db = ctx.db();
|
|
211
|
+
const maxResults = limit ?? 5;
|
|
212
|
+
const filterType = type === "skills" ? "skill" : type;
|
|
213
|
+
const filterTag = tag?.toLowerCase();
|
|
214
|
+
const filterStatus = status;
|
|
215
|
+
const includeHistory = include_history ?? false;
|
|
216
|
+
const filterProject = project?.trim();
|
|
217
|
+
if (filterProject && !isValidProjectName(filterProject)) {
|
|
218
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
219
|
+
}
|
|
220
|
+
const fetchLimit = (filterTag || since || filterStatus) ? Math.min(maxResults * 5, 200) : maxResults;
|
|
221
|
+
const retrieval = await searchKnowledgeRows(db, {
|
|
222
|
+
query,
|
|
223
|
+
maxResults,
|
|
224
|
+
fetchLimit,
|
|
225
|
+
filterProject,
|
|
226
|
+
filterType,
|
|
227
|
+
phrenPath,
|
|
228
|
+
});
|
|
229
|
+
const safeQuery = retrieval.safeQuery;
|
|
230
|
+
if (!safeQuery)
|
|
231
|
+
return mcpResponse({ ok: false, error: "Search query is empty after sanitization." });
|
|
232
|
+
let rows = retrieval.rows;
|
|
233
|
+
const usedFallback = retrieval.usedFallback;
|
|
234
|
+
if (!rows || rows.length === 0) {
|
|
235
|
+
logSearchMiss(phrenPath, query, filterProject);
|
|
236
|
+
return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } });
|
|
237
|
+
}
|
|
238
|
+
const activeScope = resolveActiveSessionScope(phrenPath, filterProject);
|
|
239
|
+
if (activeScope) {
|
|
240
|
+
rows = rows
|
|
241
|
+
.map((row) => {
|
|
242
|
+
if (row.type === "findings") {
|
|
243
|
+
const filteredContent = filterFindingsContentByScope(row.content, activeScope);
|
|
244
|
+
const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- "));
|
|
245
|
+
return hasVisible ? { ...row, content: filteredContent } : null;
|
|
246
|
+
}
|
|
247
|
+
if (row.type === "task") {
|
|
248
|
+
const filteredContent = filterTaskContentByScope(row.content, activeScope);
|
|
249
|
+
const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- "));
|
|
250
|
+
return hasVisible ? { ...row, content: filteredContent } : null;
|
|
251
|
+
}
|
|
252
|
+
return row;
|
|
253
|
+
})
|
|
254
|
+
.filter((row) => Boolean(row));
|
|
255
|
+
if (rows.length === 0) {
|
|
256
|
+
logSearchMiss(phrenPath, query, filterProject);
|
|
257
|
+
return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Filter by observation tag if requested
|
|
261
|
+
if (filterTag && rows) {
|
|
262
|
+
const tagPattern = `[${filterTag.toLowerCase()}]`;
|
|
263
|
+
rows = rows.filter(row => row.content.toLowerCase().includes(tagPattern));
|
|
264
|
+
if (rows.length === 0) {
|
|
265
|
+
logSearchMiss(phrenPath, query, filterProject);
|
|
266
|
+
return mcpResponse({ ok: true, message: `No results found with tag [${filterTag}].`, data: { query, results: [] } });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Filter by since date if requested
|
|
270
|
+
if (since && rows) {
|
|
271
|
+
let sinceDate = null;
|
|
272
|
+
const daysMatch = since.match(/^(\d+)d$/);
|
|
273
|
+
if (daysMatch) {
|
|
274
|
+
sinceDate = new Date(Date.now() - parseInt(daysMatch[1], 10) * 86400000);
|
|
275
|
+
}
|
|
276
|
+
else if (/^\d{4}-\d{2}$/.test(since)) {
|
|
277
|
+
// Validate month is 01-12
|
|
278
|
+
const [, mm] = since.split("-");
|
|
279
|
+
const month = parseInt(mm, 10);
|
|
280
|
+
if (month < 1 || month > 12) {
|
|
281
|
+
return mcpResponse({ ok: false, error: `Invalid since value "${since}": month must be 01-12.` });
|
|
282
|
+
}
|
|
283
|
+
sinceDate = new Date(`${since}-01T00:00:00Z`);
|
|
284
|
+
}
|
|
285
|
+
else if (/^\d{4}-\d{2}-\d{2}$/.test(since)) {
|
|
286
|
+
// Validate month and day strictly (reject impossible dates like 2026-02-31)
|
|
287
|
+
const [, mm, dd] = since.split("-");
|
|
288
|
+
const month = parseInt(mm, 10);
|
|
289
|
+
const day = parseInt(dd, 10);
|
|
290
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
|
291
|
+
return mcpResponse({ ok: false, error: `Invalid since value "${since}": month or day out of range.` });
|
|
292
|
+
}
|
|
293
|
+
const candidate = new Date(`${since}T00:00:00Z`);
|
|
294
|
+
// new Date() normalizes impossible dates (e.g. Feb 31 → Mar 3); detect by comparing parsed month/day
|
|
295
|
+
if (candidate.getUTCMonth() + 1 !== month || candidate.getUTCDate() !== day) {
|
|
296
|
+
return mcpResponse({ ok: false, error: `Invalid since value "${since}": date does not exist on the calendar.` });
|
|
297
|
+
}
|
|
298
|
+
sinceDate = candidate;
|
|
299
|
+
}
|
|
300
|
+
else if (since) {
|
|
301
|
+
return mcpResponse({ ok: false, error: `Invalid since format "${since}". Use "7d", "YYYY-MM", or "YYYY-MM-DD".` });
|
|
302
|
+
}
|
|
303
|
+
if (sinceDate && !isNaN(sinceDate.getTime())) {
|
|
304
|
+
const sinceMs = sinceDate.getTime();
|
|
305
|
+
rows = rows.filter(row => {
|
|
306
|
+
const createdDates = [...row.content.matchAll(/<!-- created: (\d{4}-\d{2}-\d{2}) -->/g)];
|
|
307
|
+
if (createdDates.length === 0)
|
|
308
|
+
return true;
|
|
309
|
+
return createdDates.some(m => new Date(`${m[1]}T00:00:00Z`).getTime() >= sinceMs);
|
|
310
|
+
});
|
|
311
|
+
if (rows.length === 0) {
|
|
312
|
+
logSearchMiss(phrenPath, query, filterProject);
|
|
313
|
+
return mcpResponse({ ok: true, message: `No results found since ${since}.`, data: { query, results: [] } });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const lifecycleByRowKey = new Map();
|
|
318
|
+
if (rows) {
|
|
319
|
+
const filteredRows = [];
|
|
320
|
+
for (const row of rows) {
|
|
321
|
+
if (row.type !== "findings") {
|
|
322
|
+
if (filterStatus)
|
|
323
|
+
continue;
|
|
324
|
+
filteredRows.push(row);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const summary = summarizeFindingStatuses(row.content, includeHistory, filterStatus);
|
|
328
|
+
if (!summary)
|
|
329
|
+
continue;
|
|
330
|
+
lifecycleByRowKey.set(findingRowKey(row), summary);
|
|
331
|
+
filteredRows.push(row);
|
|
332
|
+
}
|
|
333
|
+
rows = filteredRows;
|
|
334
|
+
if (rows.length === 0) {
|
|
335
|
+
logSearchMiss(phrenPath, query, filterProject);
|
|
336
|
+
return mcpResponse({ ok: true, message: "No results found after lifecycle filters.", data: { query, results: [] } });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
rows = rankResults(rows, "general", null, filterProject ?? null, phrenPath, db, undefined, query, { skipTaskFilter: true, filterType: filterType ?? null });
|
|
340
|
+
// Apply trust filter — same as hook-prompt uses — to strip stale/low-confidence findings
|
|
341
|
+
try {
|
|
342
|
+
const policy = getRetentionPolicy(phrenPath);
|
|
343
|
+
const trustResult = applyTrustFilter(rows, policy.ttlDays, policy.minInjectConfidence, policy.decay, phrenPath);
|
|
344
|
+
rows = trustResult.rows;
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
debugLog(`search_knowledge trustFilter: ${errorMessage(err)}`);
|
|
348
|
+
}
|
|
349
|
+
rows = rows
|
|
350
|
+
.map((row, idx) => ({ row, idx }))
|
|
351
|
+
.sort((a, b) => {
|
|
352
|
+
const aBucket = a.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(a.row))) : 1;
|
|
353
|
+
const bBucket = b.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(b.row))) : 1;
|
|
354
|
+
if (aBucket !== bBucket)
|
|
355
|
+
return aBucket - bBucket;
|
|
356
|
+
return a.idx - b.idx;
|
|
357
|
+
})
|
|
358
|
+
.map(entry => entry.row);
|
|
359
|
+
if (rows.length > maxResults) {
|
|
360
|
+
rows = rows.slice(0, maxResults);
|
|
361
|
+
}
|
|
362
|
+
const results = rows.map((row) => {
|
|
363
|
+
const snippet = extractSnippet(row.content, query);
|
|
364
|
+
const lifecycle = row.type === "findings" ? lifecycleByRowKey.get(findingRowKey(row)) : undefined;
|
|
365
|
+
return {
|
|
366
|
+
project: row.project,
|
|
367
|
+
filename: row.filename,
|
|
368
|
+
type: row.type,
|
|
369
|
+
snippet,
|
|
370
|
+
path: row.path,
|
|
371
|
+
status: lifecycle?.primaryStatus,
|
|
372
|
+
statuses: lifecycle?.statuses,
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
let relatedFragments = [];
|
|
376
|
+
try {
|
|
377
|
+
const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
|
|
378
|
+
for (const term of terms) {
|
|
379
|
+
const links = queryEntityLinks(db, term);
|
|
380
|
+
if (links.related.length > 0) {
|
|
381
|
+
relatedFragments.push(...links.related);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
logEntityMiss(phrenPath, term, "search_knowledge", filterProject);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
relatedFragments = [...new Set(relatedFragments)].slice(0, 10);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
391
|
+
process.stderr.write(`[phren] fragment query: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
392
|
+
}
|
|
393
|
+
const formatted = results.map((r) => `### ${r.project}/${r.filename} (${r.type})\n${r.snippet}\n\n\`${r.path}\``);
|
|
394
|
+
// Memory synthesis: generate a concise paragraph from top results when requested
|
|
395
|
+
let synthesis;
|
|
396
|
+
if (synthesize && results.length > 0) {
|
|
397
|
+
try {
|
|
398
|
+
const synthKey = createHash("sha256").update([query, filterProject ?? "", filterType ?? "", filterTag ?? "", since ?? ""].join("|")).digest("hex").slice(0, 16);
|
|
399
|
+
const synthCachePath = runtimeFile(phrenPath, "synth-cache.json");
|
|
400
|
+
let synthCache = {};
|
|
401
|
+
try {
|
|
402
|
+
synthCache = JSON.parse(fs.readFileSync(synthCachePath, "utf8"));
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
406
|
+
process.stderr.write(`[phren] search_knowledge synthCacheRead: ${errorMessage(err)}\n`);
|
|
407
|
+
}
|
|
408
|
+
const cached = synthCache[synthKey];
|
|
409
|
+
const SYNTH_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
410
|
+
if (cached && Date.now() - cached.ts < SYNTH_CACHE_TTL_MS) {
|
|
411
|
+
synthesis = cached.result;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
const snippets = results.slice(0, 5).map((r, i) => `[${i + 1}] ${r.snippet}`).join("\n");
|
|
415
|
+
const synthPrompt = `Summarize these search results for "${query}" in 2-3 sentences. No headers, no lists. Plain paragraph only.\n\n${snippets}`;
|
|
416
|
+
synthesis = await callLlm(synthPrompt, undefined, 300);
|
|
417
|
+
if (synthesis) {
|
|
418
|
+
synthCache[synthKey] = { result: synthesis, ts: Date.now() };
|
|
419
|
+
// Trim cache to 100 entries
|
|
420
|
+
const cacheKeys = Object.keys(synthCache);
|
|
421
|
+
if (cacheKeys.length > 100) {
|
|
422
|
+
const oldest = cacheKeys.sort((a, b) => synthCache[a].ts - synthCache[b].ts).slice(0, cacheKeys.length - 100);
|
|
423
|
+
for (const k of oldest)
|
|
424
|
+
delete synthCache[k];
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
fs.writeFileSync(synthCachePath, JSON.stringify(synthCache));
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
431
|
+
process.stderr.write(`[phren] synthCache write: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
debugLog(`search synthesis failed: ${errorMessage(err)}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const fallbackNote = usedFallback ? " (keyword fallback)" : "";
|
|
441
|
+
const fragmentNote = relatedFragments.length > 0 ? `\n\nRelated fragments: ${relatedFragments.join(", ")}` : "";
|
|
442
|
+
const synthesisBlock = synthesis ? `\n\n${synthesis}\n\n---\n\n` : "\n\n";
|
|
443
|
+
runCustomHooks(phrenPath, "post-search", { PHREN_QUERY: query, PHREN_RESULT_COUNT: String(results.length) });
|
|
444
|
+
return mcpResponse({
|
|
445
|
+
ok: true,
|
|
446
|
+
message: `Found ${results.length} result(s) for "${query}"${fallbackNote}:${synthesisBlock}${formatted.join("\n\n---\n\n")}${fragmentNote}`,
|
|
447
|
+
data: { query, count: results.length, results, fallback: usedFallback, relatedFragments: relatedFragments.length > 0 ? relatedFragments : undefined, ...(synthesis ? { synthesis } : {}) },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
return mcpResponse({ ok: false, error: `Search error: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
server.registerTool("get_project_summary", {
|
|
455
|
+
title: "◆ phren · project",
|
|
456
|
+
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.",
|
|
457
|
+
inputSchema: z.object({
|
|
458
|
+
name: z.string().describe("Project name (e.g. 'my-app', 'backend', 'frontend')"),
|
|
459
|
+
}),
|
|
460
|
+
}, async ({ name }) => {
|
|
461
|
+
const db = ctx.db();
|
|
462
|
+
const docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [name]);
|
|
463
|
+
if (!docs) {
|
|
464
|
+
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
465
|
+
const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : [];
|
|
466
|
+
return mcpResponse({ ok: false, error: `Project "${name}" not found.`, data: { available: names } });
|
|
467
|
+
}
|
|
468
|
+
const summaryDoc = docs.find(doc => doc.type === "summary");
|
|
469
|
+
const claudeDoc = docs.find(doc => doc.type === "claude");
|
|
470
|
+
const indexedFiles = docs.map(doc => ({ filename: doc.filename, type: doc.type, path: doc.path }));
|
|
471
|
+
const parts = [`# ${name}`];
|
|
472
|
+
if (summaryDoc) {
|
|
473
|
+
parts.push(`\n## Summary\n${summaryDoc.content}`);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
parts.push("\n*No summary.md found for this project.*");
|
|
477
|
+
}
|
|
478
|
+
if (claudeDoc) {
|
|
479
|
+
parts.push(`\n## CLAUDE.md path\n\`${claudeDoc.path}\``);
|
|
480
|
+
}
|
|
481
|
+
const fileList = indexedFiles.map((f) => `- ${f.filename} (${f.type})`).join("\n");
|
|
482
|
+
parts.push(`\n## Indexed files\n${fileList}`);
|
|
483
|
+
return mcpResponse({
|
|
484
|
+
ok: true,
|
|
485
|
+
message: parts.join("\n"),
|
|
486
|
+
data: {
|
|
487
|
+
name,
|
|
488
|
+
summary: summaryDoc?.content ?? null,
|
|
489
|
+
claudeMdPath: claudeDoc?.path ?? null,
|
|
490
|
+
files: indexedFiles,
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
server.registerTool("list_projects", {
|
|
495
|
+
title: "◆ phren · projects",
|
|
496
|
+
description: "List all projects in the active phren profile with a brief summary of each. " +
|
|
497
|
+
"Shows which documentation files exist per project.",
|
|
498
|
+
inputSchema: z.object({
|
|
499
|
+
page: z.number().int().min(1).optional().describe("1-based page number (default 1)."),
|
|
500
|
+
page_size: z.number().int().min(1).max(50).optional().describe("Page size (default 20, max 50)."),
|
|
501
|
+
}),
|
|
502
|
+
}, async ({ page, page_size }) => {
|
|
503
|
+
const db = ctx.db();
|
|
504
|
+
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
505
|
+
if (!projectRows)
|
|
506
|
+
return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } });
|
|
507
|
+
const projects = projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]);
|
|
508
|
+
const pageSize = page_size ?? 20;
|
|
509
|
+
const pageNum = page ?? 1;
|
|
510
|
+
const start = Math.max(0, (pageNum - 1) * pageSize);
|
|
511
|
+
const end = start + pageSize;
|
|
512
|
+
const pageProjects = projects.slice(start, end);
|
|
513
|
+
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
|
|
514
|
+
if (pageNum > totalPages) {
|
|
515
|
+
return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` });
|
|
516
|
+
}
|
|
517
|
+
const badgeTypes = ["claude", "findings", "summary", "task"];
|
|
518
|
+
const badgeLabels = { claude: "CLAUDE.md", findings: "FINDINGS", summary: "summary", task: "task" };
|
|
519
|
+
const projectList = pageProjects.map((proj) => {
|
|
520
|
+
const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [proj]) ?? [];
|
|
521
|
+
const types = rows.map(row => row.type);
|
|
522
|
+
const summaryRow = rows.find(row => row.type === "summary");
|
|
523
|
+
const claudeRow = rows.find(row => row.type === "claude");
|
|
524
|
+
const source = summaryRow?.content ?? claudeRow?.content;
|
|
525
|
+
let brief = "";
|
|
526
|
+
if (source) {
|
|
527
|
+
const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#"));
|
|
528
|
+
brief = firstLine?.trim() || "";
|
|
529
|
+
}
|
|
530
|
+
const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]);
|
|
531
|
+
return { name: proj, brief, badges, fileCount: rows.length };
|
|
532
|
+
});
|
|
533
|
+
const lines = [`# Phren Projects (${projects.length})`];
|
|
534
|
+
if (profile)
|
|
535
|
+
lines.push(`Profile: ${profile}`);
|
|
536
|
+
lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`);
|
|
537
|
+
lines.push(`Path: ${phrenPath}\n`);
|
|
538
|
+
for (const p of projectList) {
|
|
539
|
+
lines.push(`## ${p.name}`);
|
|
540
|
+
if (p.brief)
|
|
541
|
+
lines.push(p.brief);
|
|
542
|
+
lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`);
|
|
543
|
+
}
|
|
544
|
+
return mcpResponse({
|
|
545
|
+
ok: true,
|
|
546
|
+
message: lines.join("\n"),
|
|
547
|
+
data: { projects: projectList, total: projects.length, page: pageNum, totalPages, pageSize },
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
server.registerTool("get_findings", {
|
|
551
|
+
title: "◆ phren · findings",
|
|
552
|
+
description: "List recent findings for a project without requiring a search query.",
|
|
553
|
+
inputSchema: z.object({
|
|
554
|
+
project: z.string().describe("Project name."),
|
|
555
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max rows to return (default 50)."),
|
|
556
|
+
include_superseded: z.boolean().optional().describe("If true, include findings that have been superseded by a newer finding (hidden by default)."),
|
|
557
|
+
include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."),
|
|
558
|
+
status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status."),
|
|
559
|
+
}),
|
|
560
|
+
}, async ({ project, limit, include_superseded, include_history, status }) => {
|
|
561
|
+
if (!isValidProjectName(project))
|
|
562
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
563
|
+
const includeHistory = include_history ?? include_superseded ?? false;
|
|
564
|
+
const result = readFindings(phrenPath, project, { includeArchived: includeHistory });
|
|
565
|
+
if (!result.ok)
|
|
566
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
567
|
+
const allItems = result.data;
|
|
568
|
+
let historyCount = allItems.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
|
|
569
|
+
if (!includeHistory) {
|
|
570
|
+
const withArchive = readFindings(phrenPath, project, { includeArchived: true });
|
|
571
|
+
if (withArchive.ok) {
|
|
572
|
+
historyCount = withArchive.data.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const visibleItems = includeHistory
|
|
576
|
+
? allItems
|
|
577
|
+
: allItems.filter(f => f.tier !== "archived" && !HISTORY_FINDING_STATUSES.has(f.status));
|
|
578
|
+
const filteredItems = status ? visibleItems.filter(f => f.status === status) : visibleItems;
|
|
579
|
+
if (!filteredItems.length) {
|
|
580
|
+
const msg = historyCount > 0 && !includeHistory
|
|
581
|
+
? `No findings found for "${project}" with current filters. ${historyCount} historical finding(s) hidden. Pass include_history=true to show history.`
|
|
582
|
+
: `No findings found for "${project}".`;
|
|
583
|
+
return mcpResponse({ ok: true, message: msg, data: { project, findings: [], total: 0, status: status ?? null, include_history: includeHistory, historyCount } });
|
|
584
|
+
}
|
|
585
|
+
const capped = filteredItems.slice(0, limit ?? 50).map(entry => ({
|
|
586
|
+
...entry,
|
|
587
|
+
lifecycle: {
|
|
588
|
+
status: entry.status,
|
|
589
|
+
status_updated: entry.status_updated,
|
|
590
|
+
status_reason: entry.status_reason,
|
|
591
|
+
status_ref: entry.status_ref,
|
|
592
|
+
},
|
|
593
|
+
}));
|
|
594
|
+
const lines = capped.map((entry) => {
|
|
595
|
+
const metadata = [];
|
|
596
|
+
metadata.push(`status=${entry.status}`);
|
|
597
|
+
if (entry.taskItem)
|
|
598
|
+
metadata.push(`task=${entry.taskItem}`);
|
|
599
|
+
if (entry.sessionId)
|
|
600
|
+
metadata.push(`session=${entry.sessionId.slice(0, 8)}`);
|
|
601
|
+
if (entry.scope)
|
|
602
|
+
metadata.push(`scope=${entry.scope}`);
|
|
603
|
+
if (entry.actor)
|
|
604
|
+
metadata.push(`actor=${entry.actor}`);
|
|
605
|
+
if (entry.tool)
|
|
606
|
+
metadata.push(`tool=${entry.tool}`);
|
|
607
|
+
if (entry.model)
|
|
608
|
+
metadata.push(`model=${entry.model}`);
|
|
609
|
+
if (entry.supersedes)
|
|
610
|
+
metadata.push(`supersedes="${entry.supersedes.slice(0, 30)}"`);
|
|
611
|
+
if (entry.supersededBy)
|
|
612
|
+
metadata.push(`superseded_by="${entry.supersededBy.slice(0, 30)}"`);
|
|
613
|
+
if (entry.contradicts?.length)
|
|
614
|
+
metadata.push(`contradicts=${entry.contradicts.length}`);
|
|
615
|
+
if (entry.tier === "archived")
|
|
616
|
+
metadata.push("tier=archived");
|
|
617
|
+
const idLabel = entry.stableId ? `${entry.id}|fid:${entry.stableId}` : entry.id;
|
|
618
|
+
return `- [${idLabel}] ${entry.date}: ${entry.text}${entry.confidence !== undefined ? ` [confidence ${entry.confidence.toFixed(2)}]` : ""}${metadata.length > 0 ? ` [${metadata.join(" ")}]` : ""}${entry.citation ? ` (${entry.citation})` : ""}`;
|
|
619
|
+
});
|
|
620
|
+
const hiddenHistoryCount = includeHistory ? 0 : historyCount;
|
|
621
|
+
const historyNote = hiddenHistoryCount > 0 ? ` (${hiddenHistoryCount} historical hidden)` : "";
|
|
622
|
+
return mcpResponse({
|
|
623
|
+
ok: true,
|
|
624
|
+
message: `Findings for ${project} (${capped.length}/${filteredItems.length})${historyNote}:\n` + lines.join("\n"),
|
|
625
|
+
data: { project, findings: capped, total: filteredItems.length, status: status ?? null, include_history: includeHistory, historyCount: hiddenHistoryCount },
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
}
|