@tekmidian/pai 0.25.2 → 0.26.0
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/dist/auto-route-BWGvvpcP.mjs +86 -0
- package/dist/auto-route-BWGvvpcP.mjs.map +1 -0
- package/dist/cli/index.mjs +2 -2
- package/dist/cli/program.mjs +2 -2
- package/dist/clusters-BdvGIoD-.mjs +201 -0
- package/dist/clusters-BdvGIoD-.mjs.map +1 -0
- package/dist/daemon/index.mjs +6 -5
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BaBTOB1P.mjs +1356 -0
- package/dist/daemon-BaBTOB1P.mjs.map +1 -0
- package/dist/detector-DExEQ5cW.mjs +74 -0
- package/dist/detector-DExEQ5cW.mjs.map +1 -0
- package/dist/factory-CAy0N1xR.mjs +142 -0
- package/dist/factory-CAy0N1xR.mjs.map +1 -0
- package/dist/indexer-backend-vOJqSE0U.mjs +299 -0
- package/dist/indexer-backend-vOJqSE0U.mjs.map +1 -0
- package/dist/latent-ideas-DuM_kgkW.mjs +191 -0
- package/dist/latent-ideas-DuM_kgkW.mjs.map +1 -0
- package/dist/neighborhood-DSkvFMAv.mjs +135 -0
- package/dist/neighborhood-DSkvFMAv.mjs.map +1 -0
- package/dist/note-context-DrcY4cWm.mjs +126 -0
- package/dist/note-context-DrcY4cWm.mjs.map +1 -0
- package/dist/pick-FrfZ_iY7.mjs +13299 -0
- package/dist/pick-FrfZ_iY7.mjs.map +1 -0
- package/dist/postgres-DTyxU4B1.mjs +891 -0
- package/dist/postgres-DTyxU4B1.mjs.map +1 -0
- package/dist/query-feedback-BX5nSyRm.mjs +76 -0
- package/dist/query-feedback-BX5nSyRm.mjs.map +1 -0
- package/dist/router-B2xR3gVP.mjs +228 -0
- package/dist/router-B2xR3gVP.mjs.map +1 -0
- package/dist/sqlite--BBAyXLH.mjs +271 -0
- package/dist/sqlite--BBAyXLH.mjs.map +1 -0
- package/dist/themes-DICajLf-.mjs +148 -0
- package/dist/themes-DICajLf-.mjs.map +1 -0
- package/dist/tools-DMAQxlOk.mjs +1939 -0
- package/dist/tools-DMAQxlOk.mjs.map +1 -0
- package/dist/trace-DfyGmMG_.mjs +137 -0
- package/dist/trace-DfyGmMG_.mjs.map +1 -0
- package/dist/vault-indexer-Dt8qXP-w.mjs +536 -0
- package/dist/vault-indexer-Dt8qXP-w.mjs.map +1 -0
- package/dist/work-queue-worker-Bt_UcbMy.mjs +1856 -0
- package/dist/work-queue-worker-Bt_UcbMy.mjs.map +1 -0
- package/dist/zettelkasten-C8BikWss.mjs +1063 -0
- package/dist/zettelkasten-C8BikWss.mjs.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1939 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-95iHPtFO.mjs";
|
|
2
|
+
import { n as cosineSimilarity } from "./embeddings-Bn86ssxR.mjs";
|
|
3
|
+
import { t as STOP_WORDS } from "./stop-words-BaMEGVeY.mjs";
|
|
4
|
+
import { i as searchMemoryHybrid, n as populateSlugs, s as touchChunksLastAccessed } from "./search-CpTv1I24.mjs";
|
|
5
|
+
import { r as formatDetectionJson, t as detectProject } from "./detect-Bf2z-oKB.mjs";
|
|
6
|
+
import { a as kgContradictions, i as kgAdd, n as updateEntityFeedbackWeight, o as kgInvalidate, s as kgQuery, t as listKgEntities } from "./kg-entity-r8duqhi9.mjs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
//#region src/mcp/tools/types.ts
|
|
12
|
+
/**
|
|
13
|
+
* Shared types and project-row helpers used across all MCP tool handler modules.
|
|
14
|
+
*/
|
|
15
|
+
function lookupProjectId(registryDb, slug) {
|
|
16
|
+
const bySlug = registryDb.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
17
|
+
if (bySlug) return bySlug.id;
|
|
18
|
+
const byAlias = registryDb.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(slug);
|
|
19
|
+
if (byAlias) return byAlias.project_id;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function detectProjectFromPath(registryDb, fsPath) {
|
|
23
|
+
const resolved = resolve(fsPath);
|
|
24
|
+
const exact = registryDb.prepare("SELECT id, slug, display_name, root_path, type, status, created_at, updated_at FROM projects WHERE root_path = ?").get(resolved);
|
|
25
|
+
if (exact) return exact;
|
|
26
|
+
const all = registryDb.prepare("SELECT id, slug, display_name, root_path, type, status, created_at, updated_at FROM projects ORDER BY LENGTH(root_path) DESC").all();
|
|
27
|
+
for (const project of all) if (resolved.startsWith(project.root_path + "/") || resolved === project.root_path) return project;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function formatProject(registryDb, project) {
|
|
31
|
+
const sessionCount = registryDb.prepare("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?").get(project.id).n;
|
|
32
|
+
const lastSession = registryDb.prepare("SELECT date FROM sessions WHERE project_id = ? ORDER BY date DESC LIMIT 1").get(project.id);
|
|
33
|
+
const tags = registryDb.prepare(`SELECT t.name FROM tags t
|
|
34
|
+
JOIN project_tags pt ON pt.tag_id = t.id
|
|
35
|
+
WHERE pt.project_id = ?
|
|
36
|
+
ORDER BY t.name`).all(project.id).map((r) => r.name);
|
|
37
|
+
const aliases = registryDb.prepare("SELECT alias FROM aliases WHERE project_id = ? ORDER BY alias").all(project.id).map((r) => r.alias);
|
|
38
|
+
const lines = [
|
|
39
|
+
`slug: ${project.slug}`,
|
|
40
|
+
`display_name: ${project.display_name}`,
|
|
41
|
+
`root_path: ${project.root_path}`,
|
|
42
|
+
`type: ${project.type}`,
|
|
43
|
+
`status: ${project.status}`,
|
|
44
|
+
`sessions: ${sessionCount}`
|
|
45
|
+
];
|
|
46
|
+
if (lastSession) lines.push(`last_session: ${lastSession.date}`);
|
|
47
|
+
if (tags.length) lines.push(`tags: ${tags.join(", ")}`);
|
|
48
|
+
if (aliases.length) lines.push(`aliases: ${aliases.join(", ")}`);
|
|
49
|
+
if (project.obsidian_link) lines.push(`obsidian_link: ${project.obsidian_link}`);
|
|
50
|
+
if (project.archived_at) lines.push(`archived_at: ${new Date(project.archived_at).toISOString().slice(0, 10)}`);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/mcp/tools/memory.ts
|
|
56
|
+
/**
|
|
57
|
+
* MCP tool handlers: memory_search, memory_get
|
|
58
|
+
*/
|
|
59
|
+
async function toolMemorySearch(registryDb, federation, params, searchDefaults) {
|
|
60
|
+
try {
|
|
61
|
+
const projectIds = params.project ? (() => {
|
|
62
|
+
const id = lookupProjectId(registryDb, params.project);
|
|
63
|
+
return id != null ? [id] : [];
|
|
64
|
+
})() : void 0;
|
|
65
|
+
if (params.project && (!projectIds || projectIds.length === 0)) return {
|
|
66
|
+
content: [{
|
|
67
|
+
type: "text",
|
|
68
|
+
text: `Project not found: ${params.project}`
|
|
69
|
+
}],
|
|
70
|
+
isError: true
|
|
71
|
+
};
|
|
72
|
+
const mode = params.mode ?? searchDefaults?.mode ?? "keyword";
|
|
73
|
+
const snippetLength = params.snippetLength ?? searchDefaults?.snippetLength ?? 200;
|
|
74
|
+
const searchOpts = {
|
|
75
|
+
projectIds,
|
|
76
|
+
sources: params.sources,
|
|
77
|
+
maxResults: params.limit ?? searchDefaults?.defaultLimit ?? 5
|
|
78
|
+
};
|
|
79
|
+
let results;
|
|
80
|
+
const isBackend = (x) => "backendType" in x;
|
|
81
|
+
if (isBackend(federation)) if (mode === "keyword") results = await federation.searchKeyword(params.query, searchOpts);
|
|
82
|
+
else if (mode === "semantic" || mode === "hybrid") {
|
|
83
|
+
const { generateEmbedding } = await import("./embeddings-Bn86ssxR.mjs").then((n) => n.i);
|
|
84
|
+
const queryEmbedding = await generateEmbedding(params.query, true);
|
|
85
|
+
if (mode === "semantic") results = await federation.searchSemantic(queryEmbedding, searchOpts);
|
|
86
|
+
else {
|
|
87
|
+
const [kwResults, semResults] = await Promise.all([federation.searchKeyword(params.query, {
|
|
88
|
+
...searchOpts,
|
|
89
|
+
maxResults: 50
|
|
90
|
+
}), federation.searchSemantic(queryEmbedding, {
|
|
91
|
+
...searchOpts,
|
|
92
|
+
maxResults: 50
|
|
93
|
+
})]);
|
|
94
|
+
results = combineHybridResults(kwResults, semResults, searchOpts.maxResults ?? 10);
|
|
95
|
+
}
|
|
96
|
+
} else results = await federation.searchKeyword(params.query, searchOpts);
|
|
97
|
+
else {
|
|
98
|
+
const { searchMemory, searchMemorySemantic } = await import("./search-CpTv1I24.mjs").then((n) => n.o);
|
|
99
|
+
if (mode === "keyword") results = searchMemory(federation, params.query, searchOpts);
|
|
100
|
+
else if (mode === "semantic" || mode === "hybrid") {
|
|
101
|
+
const { generateEmbedding } = await import("./embeddings-Bn86ssxR.mjs").then((n) => n.i);
|
|
102
|
+
const queryEmbedding = await generateEmbedding(params.query, true);
|
|
103
|
+
if (mode === "semantic") results = searchMemorySemantic(federation, queryEmbedding, searchOpts);
|
|
104
|
+
else results = searchMemoryHybrid(federation, params.query, queryEmbedding, searchOpts);
|
|
105
|
+
} else results = searchMemory(federation, params.query, searchOpts);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const chunkIds = results.map((r) => r.chunkId).filter((id) => id != null);
|
|
109
|
+
if (chunkIds.length > 0) {
|
|
110
|
+
const rawDb = !isBackend(federation) ? federation : federation.getSqliteDb?.() ?? null;
|
|
111
|
+
if (rawDb) touchChunksLastAccessed(rawDb, chunkIds);
|
|
112
|
+
}
|
|
113
|
+
} catch {}
|
|
114
|
+
const shouldRerank = params.rerank ?? searchDefaults?.rerank ?? true;
|
|
115
|
+
if (shouldRerank && results.length > 0) {
|
|
116
|
+
const { rerankResults } = await import("./reranker-CMNZcfVx.mjs").then((n) => n.r);
|
|
117
|
+
results = await rerankResults(params.query, results, { topK: searchOpts.maxResults ?? 5 });
|
|
118
|
+
}
|
|
119
|
+
const recencyDays = params.recencyBoost ?? searchDefaults?.recencyBoostDays ?? 0;
|
|
120
|
+
if (recencyDays > 0 && results.length > 0) {
|
|
121
|
+
const { applyRecencyBoost } = await import("./search-CpTv1I24.mjs").then((n) => n.o);
|
|
122
|
+
results = applyRecencyBoost(results, recencyDays);
|
|
123
|
+
}
|
|
124
|
+
const withSlugs = populateSlugs(results, registryDb);
|
|
125
|
+
if (withSlugs.length === 0) return { content: [{
|
|
126
|
+
type: "text",
|
|
127
|
+
text: `No results found for query: "${params.query}" (mode: ${mode})`
|
|
128
|
+
}] };
|
|
129
|
+
const rerankLabel = shouldRerank ? " +rerank" : "";
|
|
130
|
+
const useCompact = params.format === "compact";
|
|
131
|
+
const formatted = withSlugs.map((r, i) => {
|
|
132
|
+
const slug = r.projectSlug ?? `project:${r.projectId}`;
|
|
133
|
+
const idPart = r.chunkId ? ` id=${r.chunkId}` : "";
|
|
134
|
+
if (useCompact) return `[${i + 1}]${idPart} ${slug} — ${r.path} L${r.startLine}-${r.endLine} score=${r.score.toFixed(3)}`;
|
|
135
|
+
const header = `[${i + 1}]${idPart} ${slug} — ${r.path} (lines ${r.startLine}-${r.endLine}) score=${r.score.toFixed(4)} tier=${r.tier} source=${r.source}`;
|
|
136
|
+
const raw = r.snippet.trim();
|
|
137
|
+
return `${header}\n${raw.length > snippetLength ? raw.slice(0, snippetLength) + "..." : raw}`;
|
|
138
|
+
}).join(useCompact ? "\n" : "\n\n---\n\n");
|
|
139
|
+
try {
|
|
140
|
+
const { saveQueryResult } = await import("./query-feedback-BX5nSyRm.mjs").then((n) => n.t);
|
|
141
|
+
saveQueryResult({
|
|
142
|
+
query: params.query,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
source: "memory_search",
|
|
145
|
+
sourceSlugs: withSlugs.slice(0, 5).map((r) => r.path),
|
|
146
|
+
answerPreview: withSlugs.slice(0, 3).map((r) => r.snippet.trim().slice(0, 150)).join(" | "),
|
|
147
|
+
resultCount: withSlugs.length
|
|
148
|
+
});
|
|
149
|
+
} catch {}
|
|
150
|
+
return { content: [{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: `Found ${withSlugs.length} result(s) for "${params.query}" (mode: ${mode}${rerankLabel}):\n\n${formatted}`
|
|
153
|
+
}] };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return {
|
|
156
|
+
content: [{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: `Search error: ${String(e)}`
|
|
159
|
+
}],
|
|
160
|
+
isError: true
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function toolMemoryGet(registryDb, params) {
|
|
165
|
+
try {
|
|
166
|
+
const projectId = lookupProjectId(registryDb, params.project);
|
|
167
|
+
if (projectId == null) return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: "text",
|
|
170
|
+
text: `Project not found: ${params.project}`
|
|
171
|
+
}],
|
|
172
|
+
isError: true
|
|
173
|
+
};
|
|
174
|
+
const project = registryDb.prepare("SELECT root_path FROM projects WHERE id = ?").get(projectId);
|
|
175
|
+
if (!project) return {
|
|
176
|
+
content: [{
|
|
177
|
+
type: "text",
|
|
178
|
+
text: `Project not found: ${params.project}`
|
|
179
|
+
}],
|
|
180
|
+
isError: true
|
|
181
|
+
};
|
|
182
|
+
const requestedPath = params.path;
|
|
183
|
+
if (requestedPath.includes("..") || isAbsolute(requestedPath)) return {
|
|
184
|
+
content: [{
|
|
185
|
+
type: "text",
|
|
186
|
+
text: `Invalid path: ${params.path} (must be a relative path within the project root, no ../ allowed)`
|
|
187
|
+
}],
|
|
188
|
+
isError: true
|
|
189
|
+
};
|
|
190
|
+
const fullPath = join(project.root_path, requestedPath);
|
|
191
|
+
const resolvedFull = resolve(fullPath);
|
|
192
|
+
const resolvedRoot = resolve(project.root_path);
|
|
193
|
+
if (!resolvedFull.startsWith(resolvedRoot + "/") && resolvedFull !== resolvedRoot) return {
|
|
194
|
+
content: [{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: `Path traversal blocked: ${params.path}`
|
|
197
|
+
}],
|
|
198
|
+
isError: true
|
|
199
|
+
};
|
|
200
|
+
if (!existsSync(fullPath)) return {
|
|
201
|
+
content: [{
|
|
202
|
+
type: "text",
|
|
203
|
+
text: `File not found: ${requestedPath} (project: ${params.project})`
|
|
204
|
+
}],
|
|
205
|
+
isError: true
|
|
206
|
+
};
|
|
207
|
+
const stat = statSync(fullPath);
|
|
208
|
+
if (stat.size > 5 * 1024 * 1024) return { content: [{
|
|
209
|
+
type: "text",
|
|
210
|
+
text: `Error: file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Maximum 5 MB.`
|
|
211
|
+
}] };
|
|
212
|
+
const allLines = readFileSync(fullPath, "utf8").split("\n");
|
|
213
|
+
const fromLine = (params.from ?? 1) - 1;
|
|
214
|
+
const toLine = params.lines != null ? Math.min(fromLine + params.lines, allLines.length) : allLines.length;
|
|
215
|
+
const text = allLines.slice(fromLine, toLine).join("\n");
|
|
216
|
+
return { content: [{
|
|
217
|
+
type: "text",
|
|
218
|
+
text: `${params.from != null ? `${params.project}/${requestedPath} (lines ${fromLine + 1}-${toLine}):` : `${params.project}/${requestedPath}:`}\n\n${text}`
|
|
219
|
+
}] };
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return {
|
|
222
|
+
content: [{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: `Read error: ${String(e)}`
|
|
225
|
+
}],
|
|
226
|
+
isError: true
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Combine keyword + semantic results using min-max normalized scoring.
|
|
232
|
+
* Mirrors the logic in searchMemoryHybrid() from memory/search.ts,
|
|
233
|
+
* but works on pre-computed result arrays so it works for any backend.
|
|
234
|
+
*/
|
|
235
|
+
function combineHybridResults(keywordResults, semanticResults, maxResults, keywordWeight = .5, semanticWeight = .5) {
|
|
236
|
+
if (keywordResults.length === 0 && semanticResults.length === 0) return [];
|
|
237
|
+
const keyFor = (r) => `${r.projectId}:${r.path}:${r.startLine}:${r.endLine}`;
|
|
238
|
+
function minMaxNormalize(items) {
|
|
239
|
+
if (items.length === 0) return /* @__PURE__ */ new Map();
|
|
240
|
+
const min = Math.min(...items.map((r) => r.score));
|
|
241
|
+
const range = Math.max(...items.map((r) => r.score)) - min;
|
|
242
|
+
const m = /* @__PURE__ */ new Map();
|
|
243
|
+
for (const r of items) m.set(keyFor(r), range === 0 ? 1 : (r.score - min) / range);
|
|
244
|
+
return m;
|
|
245
|
+
}
|
|
246
|
+
const kwNorm = minMaxNormalize(keywordResults);
|
|
247
|
+
const semNorm = minMaxNormalize(semanticResults);
|
|
248
|
+
const allKeys = new Set([...keywordResults.map(keyFor), ...semanticResults.map(keyFor)]);
|
|
249
|
+
const metaMap = /* @__PURE__ */ new Map();
|
|
250
|
+
for (const r of [...keywordResults, ...semanticResults]) metaMap.set(keyFor(r), r);
|
|
251
|
+
const combined = [];
|
|
252
|
+
for (const key of allKeys) {
|
|
253
|
+
const meta = metaMap.get(key);
|
|
254
|
+
const kwScore = kwNorm.get(key) ?? 0;
|
|
255
|
+
const semScore = semNorm.get(key) ?? 0;
|
|
256
|
+
const combinedScore = keywordWeight * kwScore + semanticWeight * semScore;
|
|
257
|
+
combined.push({
|
|
258
|
+
...meta,
|
|
259
|
+
score: combinedScore,
|
|
260
|
+
combinedScore
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return combined.sort((a, b) => b.score - a.score).slice(0, maxResults).map(({ combinedScore: _unused, ...r }) => r);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/mcp/tools/projects.ts
|
|
268
|
+
/**
|
|
269
|
+
* MCP tool handlers: project_info, project_list, project_detect,
|
|
270
|
+
* project_health, project_todo
|
|
271
|
+
*/
|
|
272
|
+
function toolProjectInfo(registryDb, params) {
|
|
273
|
+
try {
|
|
274
|
+
let project = null;
|
|
275
|
+
if (params.slug) {
|
|
276
|
+
const projectId = lookupProjectId(registryDb, params.slug);
|
|
277
|
+
if (projectId != null) project = registryDb.prepare("SELECT id, slug, display_name, root_path, type, status, created_at, updated_at, archived_at, parent_id, obsidian_link FROM projects WHERE id = ?").get(projectId);
|
|
278
|
+
} else project = detectProjectFromPath(registryDb, process.cwd());
|
|
279
|
+
if (!project) return {
|
|
280
|
+
content: [{
|
|
281
|
+
type: "text",
|
|
282
|
+
text: params.slug ? `Project not found: ${params.slug}` : `No PAI project found matching the current directory: ${process.cwd()}`
|
|
283
|
+
}],
|
|
284
|
+
isError: !params.slug
|
|
285
|
+
};
|
|
286
|
+
return { content: [{
|
|
287
|
+
type: "text",
|
|
288
|
+
text: formatProject(registryDb, project)
|
|
289
|
+
}] };
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return {
|
|
292
|
+
content: [{
|
|
293
|
+
type: "text",
|
|
294
|
+
text: `project_info error: ${String(e)}`
|
|
295
|
+
}],
|
|
296
|
+
isError: true
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function toolProjectList(registryDb, params) {
|
|
301
|
+
try {
|
|
302
|
+
const conditions = [];
|
|
303
|
+
const queryParams = [];
|
|
304
|
+
if (params.status) {
|
|
305
|
+
conditions.push("p.status = ?");
|
|
306
|
+
queryParams.push(params.status);
|
|
307
|
+
}
|
|
308
|
+
if (params.tag) {
|
|
309
|
+
conditions.push("p.id IN (SELECT pt.project_id FROM project_tags pt JOIN tags t ON pt.tag_id = t.id WHERE t.name = ?)");
|
|
310
|
+
queryParams.push(params.tag);
|
|
311
|
+
}
|
|
312
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
313
|
+
const limit = params.limit ?? 50;
|
|
314
|
+
queryParams.push(limit);
|
|
315
|
+
const projects = registryDb.prepare(`SELECT p.id, p.slug, p.display_name, p.root_path, p.type, p.status, p.updated_at
|
|
316
|
+
FROM projects p
|
|
317
|
+
${where}
|
|
318
|
+
ORDER BY p.updated_at DESC
|
|
319
|
+
LIMIT ?`).all(...queryParams);
|
|
320
|
+
if (projects.length === 0) return { content: [{
|
|
321
|
+
type: "text",
|
|
322
|
+
text: "No projects found matching the given filters."
|
|
323
|
+
}] };
|
|
324
|
+
const lines = projects.map((p) => `${p.slug} [${p.status}] ${p.root_path} (updated: ${new Date(p.updated_at).toISOString().slice(0, 10)})`);
|
|
325
|
+
return { content: [{
|
|
326
|
+
type: "text",
|
|
327
|
+
text: `${projects.length} project(s):\n\n${lines.join("\n")}`
|
|
328
|
+
}] };
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return {
|
|
331
|
+
content: [{
|
|
332
|
+
type: "text",
|
|
333
|
+
text: `project_list error: ${String(e)}`
|
|
334
|
+
}],
|
|
335
|
+
isError: true
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function toolProjectDetect(registryDb, params) {
|
|
340
|
+
try {
|
|
341
|
+
const detection = detectProject(registryDb, params.cwd);
|
|
342
|
+
if (!detection) return { content: [{
|
|
343
|
+
type: "text",
|
|
344
|
+
text: `No registered project found for path: ${params.cwd ?? process.cwd()}\n\nRun 'pai project add .' to register this directory.`
|
|
345
|
+
}] };
|
|
346
|
+
return { content: [{
|
|
347
|
+
type: "text",
|
|
348
|
+
text: formatDetectionJson(detection)
|
|
349
|
+
}] };
|
|
350
|
+
} catch (e) {
|
|
351
|
+
return {
|
|
352
|
+
content: [{
|
|
353
|
+
type: "text",
|
|
354
|
+
text: `project_detect error: ${String(e)}`
|
|
355
|
+
}],
|
|
356
|
+
isError: true
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function toolProjectHealth(registryDb, params) {
|
|
361
|
+
try {
|
|
362
|
+
const { existsSync: fsExists, readdirSync, statSync } = await import("node:fs");
|
|
363
|
+
const { join: pathJoin, basename: pathBasename } = await import("node:path");
|
|
364
|
+
const { homedir } = await import("node:os");
|
|
365
|
+
const { encodeDir: enc } = await import("./utils-BAxjW3j8.mjs").then((n) => n.g);
|
|
366
|
+
const rows = registryDb.prepare(`SELECT p.id, p.slug, p.display_name, p.root_path, p.encoded_dir, p.status, p.type,
|
|
367
|
+
(SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
|
|
368
|
+
FROM projects p
|
|
369
|
+
ORDER BY p.slug ASC`).all();
|
|
370
|
+
const home = homedir();
|
|
371
|
+
const claudeProjects = pathJoin(home, ".claude", "projects");
|
|
372
|
+
function suggestMoved(rootPath) {
|
|
373
|
+
const name = pathBasename(rootPath);
|
|
374
|
+
return [
|
|
375
|
+
pathJoin(home, "dev", name),
|
|
376
|
+
pathJoin(home, "dev", "ai", name),
|
|
377
|
+
pathJoin(home, "Desktop", name),
|
|
378
|
+
pathJoin(home, "Projects", name)
|
|
379
|
+
].find((c) => fsExists(c));
|
|
380
|
+
}
|
|
381
|
+
function hasClaudeNotes(encodedDir) {
|
|
382
|
+
if (!fsExists(claudeProjects)) return false;
|
|
383
|
+
try {
|
|
384
|
+
for (const entry of readdirSync(claudeProjects)) {
|
|
385
|
+
if (entry !== encodedDir && !entry.startsWith(encodedDir)) continue;
|
|
386
|
+
const full = pathJoin(claudeProjects, entry);
|
|
387
|
+
try {
|
|
388
|
+
if (!statSync(full).isDirectory()) continue;
|
|
389
|
+
} catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (fsExists(pathJoin(full, "Notes"))) return true;
|
|
393
|
+
}
|
|
394
|
+
} catch {}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
function findTodoForProject(rootPath) {
|
|
398
|
+
for (const rel of [
|
|
399
|
+
"Notes/TODO.md",
|
|
400
|
+
".claude/Notes/TODO.md",
|
|
401
|
+
"tasks/todo.md",
|
|
402
|
+
"TODO.md"
|
|
403
|
+
]) {
|
|
404
|
+
const full = pathJoin(rootPath, rel);
|
|
405
|
+
if (fsExists(full)) try {
|
|
406
|
+
const raw = readFileSync(full, "utf8");
|
|
407
|
+
return {
|
|
408
|
+
found: true,
|
|
409
|
+
path: rel,
|
|
410
|
+
has_continue: /^## Continue$/m.test(raw)
|
|
411
|
+
};
|
|
412
|
+
} catch {
|
|
413
|
+
return {
|
|
414
|
+
found: true,
|
|
415
|
+
path: rel,
|
|
416
|
+
has_continue: false
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
found: false,
|
|
422
|
+
path: null,
|
|
423
|
+
has_continue: false
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const results = rows.map((p) => {
|
|
427
|
+
const pathExists = fsExists(p.root_path);
|
|
428
|
+
let health;
|
|
429
|
+
let suggestedPath = null;
|
|
430
|
+
if (pathExists) health = "active";
|
|
431
|
+
else {
|
|
432
|
+
suggestedPath = suggestMoved(p.root_path) ?? null;
|
|
433
|
+
health = suggestedPath ? "stale" : "dead";
|
|
434
|
+
}
|
|
435
|
+
const todo = pathExists ? findTodoForProject(p.root_path) : {
|
|
436
|
+
found: false,
|
|
437
|
+
path: null,
|
|
438
|
+
has_continue: false
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
slug: p.slug,
|
|
442
|
+
display_name: p.display_name,
|
|
443
|
+
root_path: p.root_path,
|
|
444
|
+
status: p.status,
|
|
445
|
+
type: p.type,
|
|
446
|
+
session_count: p.session_count,
|
|
447
|
+
health,
|
|
448
|
+
suggested_path: suggestedPath,
|
|
449
|
+
has_claude_notes: hasClaudeNotes(p.encoded_dir),
|
|
450
|
+
todo
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
const filtered = !params.category || params.category === "all" ? results : results.filter((r) => r.health === params.category);
|
|
454
|
+
const summary = {
|
|
455
|
+
total: rows.length,
|
|
456
|
+
active: results.filter((r) => r.health === "active").length,
|
|
457
|
+
stale: results.filter((r) => r.health === "stale").length,
|
|
458
|
+
dead: results.filter((r) => r.health === "dead").length
|
|
459
|
+
};
|
|
460
|
+
return { content: [{
|
|
461
|
+
type: "text",
|
|
462
|
+
text: JSON.stringify({
|
|
463
|
+
summary,
|
|
464
|
+
projects: filtered
|
|
465
|
+
}, null, 2)
|
|
466
|
+
}] };
|
|
467
|
+
} catch (e) {
|
|
468
|
+
return {
|
|
469
|
+
content: [{
|
|
470
|
+
type: "text",
|
|
471
|
+
text: `project_health error: ${String(e)}`
|
|
472
|
+
}],
|
|
473
|
+
isError: true
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* TODO candidate locations searched in priority order.
|
|
479
|
+
* Returns the first one that exists, along with its label.
|
|
480
|
+
*/
|
|
481
|
+
const TODO_LOCATIONS = [
|
|
482
|
+
{
|
|
483
|
+
rel: "Notes/TODO.md",
|
|
484
|
+
label: "Notes/TODO.md"
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
rel: ".claude/Notes/TODO.md",
|
|
488
|
+
label: ".claude/Notes/TODO.md"
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
rel: "tasks/todo.md",
|
|
492
|
+
label: "tasks/todo.md"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
rel: "TODO.md",
|
|
496
|
+
label: "TODO.md"
|
|
497
|
+
}
|
|
498
|
+
];
|
|
499
|
+
/**
|
|
500
|
+
* Given TODO file content, extract and surface the ## Continue section first,
|
|
501
|
+
* then return the remaining content. Returns an object with:
|
|
502
|
+
* continueSection: string | null
|
|
503
|
+
* fullContent: string
|
|
504
|
+
* hasContinue: boolean
|
|
505
|
+
*/
|
|
506
|
+
function parseTodoContent(raw) {
|
|
507
|
+
const lines = raw.split("\n");
|
|
508
|
+
const continueIdx = lines.findIndex((l) => l.trim() === "## Continue");
|
|
509
|
+
if (continueIdx === -1) return {
|
|
510
|
+
continueSection: null,
|
|
511
|
+
fullContent: raw,
|
|
512
|
+
hasContinue: false
|
|
513
|
+
};
|
|
514
|
+
let endIdx = lines.length;
|
|
515
|
+
for (let i = continueIdx + 1; i < lines.length; i++) {
|
|
516
|
+
const trimmed = lines[i].trim();
|
|
517
|
+
if (trimmed === "---" || trimmed.startsWith("##") && trimmed !== "## Continue") {
|
|
518
|
+
endIdx = i;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
continueSection: lines.slice(continueIdx, endIdx).join("\n").trim(),
|
|
524
|
+
fullContent: raw,
|
|
525
|
+
hasContinue: true
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function toolProjectTodo(registryDb, params) {
|
|
529
|
+
try {
|
|
530
|
+
let rootPath;
|
|
531
|
+
let projectSlug;
|
|
532
|
+
if (params.project) {
|
|
533
|
+
const projectId = lookupProjectId(registryDb, params.project);
|
|
534
|
+
if (projectId == null) return {
|
|
535
|
+
content: [{
|
|
536
|
+
type: "text",
|
|
537
|
+
text: `Project not found: ${params.project}`
|
|
538
|
+
}],
|
|
539
|
+
isError: true
|
|
540
|
+
};
|
|
541
|
+
const row = registryDb.prepare("SELECT root_path, slug FROM projects WHERE id = ?").get(projectId);
|
|
542
|
+
if (!row) return {
|
|
543
|
+
content: [{
|
|
544
|
+
type: "text",
|
|
545
|
+
text: `Project not found: ${params.project}`
|
|
546
|
+
}],
|
|
547
|
+
isError: true
|
|
548
|
+
};
|
|
549
|
+
rootPath = row.root_path;
|
|
550
|
+
projectSlug = row.slug;
|
|
551
|
+
} else {
|
|
552
|
+
const project = detectProjectFromPath(registryDb, process.cwd());
|
|
553
|
+
if (!project) return { content: [{
|
|
554
|
+
type: "text",
|
|
555
|
+
text: `No PAI project found matching the current directory: ${process.cwd()}\n\nProvide a project slug or run 'pai project add .' to register this directory.`
|
|
556
|
+
}] };
|
|
557
|
+
rootPath = project.root_path;
|
|
558
|
+
projectSlug = project.slug;
|
|
559
|
+
}
|
|
560
|
+
for (const loc of TODO_LOCATIONS) {
|
|
561
|
+
const fullPath = join(rootPath, loc.rel);
|
|
562
|
+
if (existsSync(fullPath)) {
|
|
563
|
+
const { continueSection, fullContent, hasContinue } = parseTodoContent(readFileSync(fullPath, "utf8"));
|
|
564
|
+
let output;
|
|
565
|
+
if (hasContinue && continueSection) output = [
|
|
566
|
+
`TODO found: ${projectSlug}/${loc.label}`,
|
|
567
|
+
"",
|
|
568
|
+
"=== CONTINUE SECTION (surfaced first) ===",
|
|
569
|
+
continueSection,
|
|
570
|
+
"",
|
|
571
|
+
"=== FULL TODO CONTENT ===",
|
|
572
|
+
fullContent
|
|
573
|
+
].join("\n");
|
|
574
|
+
else output = [
|
|
575
|
+
`TODO found: ${projectSlug}/${loc.label}`,
|
|
576
|
+
"",
|
|
577
|
+
fullContent
|
|
578
|
+
].join("\n");
|
|
579
|
+
return { content: [{
|
|
580
|
+
type: "text",
|
|
581
|
+
text: output
|
|
582
|
+
}] };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const searched = TODO_LOCATIONS.map((l) => ` ${rootPath}/${l.rel}`).join("\n");
|
|
586
|
+
return { content: [{
|
|
587
|
+
type: "text",
|
|
588
|
+
text: [
|
|
589
|
+
`No TODO.md found for project: ${projectSlug}`,
|
|
590
|
+
"",
|
|
591
|
+
"Searched locations (in order):",
|
|
592
|
+
searched,
|
|
593
|
+
"",
|
|
594
|
+
"Create a TODO with: echo '## Tasks\\n- [ ] First task' > Notes/TODO.md"
|
|
595
|
+
].join("\n")
|
|
596
|
+
}] };
|
|
597
|
+
} catch (e) {
|
|
598
|
+
return {
|
|
599
|
+
content: [{
|
|
600
|
+
type: "text",
|
|
601
|
+
text: `project_todo error: ${String(e)}`
|
|
602
|
+
}],
|
|
603
|
+
isError: true
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/mcp/tools/sessions.ts
|
|
610
|
+
function toolSessionList(registryDb, params) {
|
|
611
|
+
try {
|
|
612
|
+
const projectId = lookupProjectId(registryDb, params.project);
|
|
613
|
+
if (projectId == null) return {
|
|
614
|
+
content: [{
|
|
615
|
+
type: "text",
|
|
616
|
+
text: `Project not found: ${params.project}`
|
|
617
|
+
}],
|
|
618
|
+
isError: true
|
|
619
|
+
};
|
|
620
|
+
const conditions = ["project_id = ?"];
|
|
621
|
+
const queryParams = [projectId];
|
|
622
|
+
if (params.status) {
|
|
623
|
+
conditions.push("status = ?");
|
|
624
|
+
queryParams.push(params.status);
|
|
625
|
+
}
|
|
626
|
+
const limit = params.limit ?? 10;
|
|
627
|
+
queryParams.push(limit);
|
|
628
|
+
const sessions = registryDb.prepare(`SELECT number, date, title, filename, status
|
|
629
|
+
FROM sessions
|
|
630
|
+
WHERE ${conditions.join(" AND ")}
|
|
631
|
+
ORDER BY number DESC
|
|
632
|
+
LIMIT ?`).all(...queryParams);
|
|
633
|
+
if (sessions.length === 0) return { content: [{
|
|
634
|
+
type: "text",
|
|
635
|
+
text: `No sessions found for project: ${params.project}`
|
|
636
|
+
}] };
|
|
637
|
+
const lines = sessions.map((s) => `#${String(s.number).padStart(4, "0")} ${s.date} [${s.status}] ${s.title}\n file: Notes/${s.filename}`);
|
|
638
|
+
return { content: [{
|
|
639
|
+
type: "text",
|
|
640
|
+
text: `${sessions.length} session(s) for ${params.project}:\n\n${lines.join("\n\n")}`
|
|
641
|
+
}] };
|
|
642
|
+
} catch (e) {
|
|
643
|
+
return {
|
|
644
|
+
content: [{
|
|
645
|
+
type: "text",
|
|
646
|
+
text: `session_list error: ${String(e)}`
|
|
647
|
+
}],
|
|
648
|
+
isError: true
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Automatically suggest which project a session belongs to.
|
|
654
|
+
*
|
|
655
|
+
* Strategy (in priority order):
|
|
656
|
+
* 1. path — exact or parent-directory match in the project registry
|
|
657
|
+
* 2. marker — walk up from cwd looking for Notes/PAI.md
|
|
658
|
+
* 3. topic — BM25 keyword search against memory (requires context)
|
|
659
|
+
*
|
|
660
|
+
* Call this at session start (e.g., from CLAUDE.md or a session-start hook)
|
|
661
|
+
* to automatically route the session to the correct project.
|
|
662
|
+
*/
|
|
663
|
+
async function toolSessionRoute(registryDb, federation, params) {
|
|
664
|
+
try {
|
|
665
|
+
const { autoRoute, formatAutoRouteJson } = await import("./auto-route-BWGvvpcP.mjs");
|
|
666
|
+
const result = await autoRoute(registryDb, federation, params.cwd, params.context);
|
|
667
|
+
if (!result) return { content: [{
|
|
668
|
+
type: "text",
|
|
669
|
+
text: [
|
|
670
|
+
`No project match found for: ${params.cwd ?? process.cwd()}`,
|
|
671
|
+
"",
|
|
672
|
+
"Tried: path match, PAI.md marker walk" + (params.context ? ", topic detection" : ""),
|
|
673
|
+
"",
|
|
674
|
+
"Run 'pai project add .' to register this directory,",
|
|
675
|
+
"or provide conversation context for topic-based routing."
|
|
676
|
+
].join("\n")
|
|
677
|
+
}] };
|
|
678
|
+
return { content: [{
|
|
679
|
+
type: "text",
|
|
680
|
+
text: formatAutoRouteJson(result)
|
|
681
|
+
}] };
|
|
682
|
+
} catch (e) {
|
|
683
|
+
return {
|
|
684
|
+
content: [{
|
|
685
|
+
type: "text",
|
|
686
|
+
text: `session_route error: ${String(e)}`
|
|
687
|
+
}],
|
|
688
|
+
isError: true
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/mcp/tools/registry.ts
|
|
695
|
+
function toolRegistrySearch(registryDb, params) {
|
|
696
|
+
try {
|
|
697
|
+
const q = `%${params.query}%`;
|
|
698
|
+
const projects = registryDb.prepare(`SELECT id, slug, display_name, root_path, type, status, updated_at
|
|
699
|
+
FROM projects
|
|
700
|
+
WHERE slug LIKE ?
|
|
701
|
+
OR display_name LIKE ?
|
|
702
|
+
OR root_path LIKE ?
|
|
703
|
+
ORDER BY updated_at DESC
|
|
704
|
+
LIMIT 20`).all(q, q, q);
|
|
705
|
+
if (projects.length === 0) return { content: [{
|
|
706
|
+
type: "text",
|
|
707
|
+
text: `No projects found matching: "${params.query}"`
|
|
708
|
+
}] };
|
|
709
|
+
const lines = projects.map((p) => `${p.slug} [${p.status}] ${p.root_path}`);
|
|
710
|
+
return { content: [{
|
|
711
|
+
type: "text",
|
|
712
|
+
text: `${projects.length} match(es) for "${params.query}":\n\n${lines.join("\n")}`
|
|
713
|
+
}] };
|
|
714
|
+
} catch (e) {
|
|
715
|
+
return {
|
|
716
|
+
content: [{
|
|
717
|
+
type: "text",
|
|
718
|
+
text: `registry_search error: ${String(e)}`
|
|
719
|
+
}],
|
|
720
|
+
isError: true
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/mcp/tools/zettel.ts
|
|
727
|
+
async function toolZettelExplore(backend, params) {
|
|
728
|
+
try {
|
|
729
|
+
const { zettelExplore } = await import("./zettelkasten-C8BikWss.mjs");
|
|
730
|
+
const result = await zettelExplore(backend, {
|
|
731
|
+
startNote: params.start_note,
|
|
732
|
+
depth: params.depth,
|
|
733
|
+
direction: params.direction,
|
|
734
|
+
mode: params.mode
|
|
735
|
+
});
|
|
736
|
+
return { content: [{
|
|
737
|
+
type: "text",
|
|
738
|
+
text: JSON.stringify(result, null, 2)
|
|
739
|
+
}] };
|
|
740
|
+
} catch (e) {
|
|
741
|
+
return {
|
|
742
|
+
content: [{
|
|
743
|
+
type: "text",
|
|
744
|
+
text: `zettel_explore error: ${String(e)}`
|
|
745
|
+
}],
|
|
746
|
+
isError: true
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async function toolZettelHealth(backend, params) {
|
|
751
|
+
try {
|
|
752
|
+
const { zettelHealth } = await import("./zettelkasten-C8BikWss.mjs");
|
|
753
|
+
const result = await zettelHealth(backend, {
|
|
754
|
+
scope: params.scope,
|
|
755
|
+
projectPath: params.project_path,
|
|
756
|
+
recentDays: params.recent_days,
|
|
757
|
+
include: params.include
|
|
758
|
+
});
|
|
759
|
+
return { content: [{
|
|
760
|
+
type: "text",
|
|
761
|
+
text: JSON.stringify(result, null, 2)
|
|
762
|
+
}] };
|
|
763
|
+
} catch (e) {
|
|
764
|
+
return {
|
|
765
|
+
content: [{
|
|
766
|
+
type: "text",
|
|
767
|
+
text: `zettel_health error: ${String(e)}`
|
|
768
|
+
}],
|
|
769
|
+
isError: true
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async function toolZettelSurprise(backend, params) {
|
|
774
|
+
try {
|
|
775
|
+
const { zettelSurprise } = await import("./zettelkasten-C8BikWss.mjs");
|
|
776
|
+
const results = await zettelSurprise(backend, {
|
|
777
|
+
referencePath: params.reference_path,
|
|
778
|
+
vaultProjectId: params.vault_project_id,
|
|
779
|
+
limit: params.limit,
|
|
780
|
+
minSimilarity: params.min_similarity,
|
|
781
|
+
minGraphDistance: params.min_graph_distance
|
|
782
|
+
});
|
|
783
|
+
return { content: [{
|
|
784
|
+
type: "text",
|
|
785
|
+
text: JSON.stringify(results, null, 2)
|
|
786
|
+
}] };
|
|
787
|
+
} catch (e) {
|
|
788
|
+
return {
|
|
789
|
+
content: [{
|
|
790
|
+
type: "text",
|
|
791
|
+
text: `zettel_surprise error: ${String(e)}`
|
|
792
|
+
}],
|
|
793
|
+
isError: true
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
async function toolZettelSuggest(backend, params) {
|
|
798
|
+
try {
|
|
799
|
+
const { zettelSuggest } = await import("./zettelkasten-C8BikWss.mjs");
|
|
800
|
+
const results = await zettelSuggest(backend, {
|
|
801
|
+
notePath: params.note_path,
|
|
802
|
+
vaultProjectId: params.vault_project_id,
|
|
803
|
+
limit: params.limit,
|
|
804
|
+
excludeLinked: params.exclude_linked
|
|
805
|
+
});
|
|
806
|
+
return { content: [{
|
|
807
|
+
type: "text",
|
|
808
|
+
text: JSON.stringify(results, null, 2)
|
|
809
|
+
}] };
|
|
810
|
+
} catch (e) {
|
|
811
|
+
return {
|
|
812
|
+
content: [{
|
|
813
|
+
type: "text",
|
|
814
|
+
text: `zettel_suggest error: ${String(e)}`
|
|
815
|
+
}],
|
|
816
|
+
isError: true
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
async function toolZettelConverse(backend, params) {
|
|
821
|
+
try {
|
|
822
|
+
const { zettelConverse } = await import("./zettelkasten-C8BikWss.mjs");
|
|
823
|
+
const result = await zettelConverse(backend, {
|
|
824
|
+
question: params.question,
|
|
825
|
+
vaultProjectId: params.vault_project_id,
|
|
826
|
+
depth: params.depth,
|
|
827
|
+
limit: params.limit
|
|
828
|
+
});
|
|
829
|
+
try {
|
|
830
|
+
const { saveQueryResult } = await import("./query-feedback-BX5nSyRm.mjs").then((n) => n.t);
|
|
831
|
+
saveQueryResult({
|
|
832
|
+
query: params.question,
|
|
833
|
+
timestamp: Date.now(),
|
|
834
|
+
source: "zettel_converse",
|
|
835
|
+
sourceSlugs: result.relevantNotes.slice(0, 5).map((n) => n.path),
|
|
836
|
+
answerPreview: result.relevantNotes.slice(0, 3).map((n) => {
|
|
837
|
+
return `${n.title ?? "(untitled)"}: ${n.snippet.trim().slice(0, 100)}`;
|
|
838
|
+
}).join(" | "),
|
|
839
|
+
resultCount: result.relevantNotes.length
|
|
840
|
+
});
|
|
841
|
+
} catch {}
|
|
842
|
+
return { content: [{
|
|
843
|
+
type: "text",
|
|
844
|
+
text: JSON.stringify(result, null, 2)
|
|
845
|
+
}] };
|
|
846
|
+
} catch (e) {
|
|
847
|
+
return {
|
|
848
|
+
content: [{
|
|
849
|
+
type: "text",
|
|
850
|
+
text: `zettel_converse error: ${String(e)}`
|
|
851
|
+
}],
|
|
852
|
+
isError: true
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
async function toolZettelThemes(backend, params) {
|
|
857
|
+
try {
|
|
858
|
+
const { zettelThemes } = await import("./zettelkasten-C8BikWss.mjs");
|
|
859
|
+
const result = await zettelThemes(backend, {
|
|
860
|
+
vaultProjectId: params.vault_project_id,
|
|
861
|
+
lookbackDays: params.lookback_days,
|
|
862
|
+
minClusterSize: params.min_cluster_size,
|
|
863
|
+
maxThemes: params.max_themes,
|
|
864
|
+
similarityThreshold: params.similarity_threshold
|
|
865
|
+
});
|
|
866
|
+
return { content: [{
|
|
867
|
+
type: "text",
|
|
868
|
+
text: JSON.stringify(result, null, 2)
|
|
869
|
+
}] };
|
|
870
|
+
} catch (e) {
|
|
871
|
+
return {
|
|
872
|
+
content: [{
|
|
873
|
+
type: "text",
|
|
874
|
+
text: `zettel_themes error: ${String(e)}`
|
|
875
|
+
}],
|
|
876
|
+
isError: true
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
async function toolZettelGodNotes(backend, params) {
|
|
881
|
+
try {
|
|
882
|
+
const { zettelGodNotes } = await import("./zettelkasten-C8BikWss.mjs");
|
|
883
|
+
const result = await zettelGodNotes(backend, {
|
|
884
|
+
limit: params.limit,
|
|
885
|
+
minInbound: params.min_inbound
|
|
886
|
+
});
|
|
887
|
+
return { content: [{
|
|
888
|
+
type: "text",
|
|
889
|
+
text: JSON.stringify(result, null, 2)
|
|
890
|
+
}] };
|
|
891
|
+
} catch (e) {
|
|
892
|
+
return {
|
|
893
|
+
content: [{
|
|
894
|
+
type: "text",
|
|
895
|
+
text: `zettel_god_notes error: ${String(e)}`
|
|
896
|
+
}],
|
|
897
|
+
isError: true
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
async function toolZettelCommunities(backend, params) {
|
|
902
|
+
try {
|
|
903
|
+
const { zettelCommunities } = await import("./zettelkasten-C8BikWss.mjs");
|
|
904
|
+
const result = await zettelCommunities(backend, {
|
|
905
|
+
minSize: params.min_size,
|
|
906
|
+
maxCommunities: params.max_communities,
|
|
907
|
+
resolution: params.resolution
|
|
908
|
+
});
|
|
909
|
+
return { content: [{
|
|
910
|
+
type: "text",
|
|
911
|
+
text: JSON.stringify(result, null, 2)
|
|
912
|
+
}] };
|
|
913
|
+
} catch (e) {
|
|
914
|
+
return {
|
|
915
|
+
content: [{
|
|
916
|
+
type: "text",
|
|
917
|
+
text: `zettel_communities error: ${String(e)}`
|
|
918
|
+
}],
|
|
919
|
+
isError: true
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region src/mcp/tools/kg.ts
|
|
926
|
+
async function toolKgAdd(pool, params) {
|
|
927
|
+
try {
|
|
928
|
+
if (!params.subject || !params.predicate || !params.object) return {
|
|
929
|
+
content: [{
|
|
930
|
+
type: "text",
|
|
931
|
+
text: "kg_add error: subject, predicate, and object are required"
|
|
932
|
+
}],
|
|
933
|
+
isError: true
|
|
934
|
+
};
|
|
935
|
+
const triple = await kgAdd(pool, params);
|
|
936
|
+
return { content: [{
|
|
937
|
+
type: "text",
|
|
938
|
+
text: JSON.stringify(triple, null, 2)
|
|
939
|
+
}] };
|
|
940
|
+
} catch (e) {
|
|
941
|
+
return {
|
|
942
|
+
content: [{
|
|
943
|
+
type: "text",
|
|
944
|
+
text: `kg_add error: ${String(e)}`
|
|
945
|
+
}],
|
|
946
|
+
isError: true
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async function toolKgQuery(pool, params) {
|
|
951
|
+
try {
|
|
952
|
+
const asOf = params.as_of ? new Date(params.as_of) : void 0;
|
|
953
|
+
if (asOf && isNaN(asOf.getTime())) return {
|
|
954
|
+
content: [{
|
|
955
|
+
type: "text",
|
|
956
|
+
text: `kg_query error: invalid as_of date: ${params.as_of}`
|
|
957
|
+
}],
|
|
958
|
+
isError: true
|
|
959
|
+
};
|
|
960
|
+
const triples = await kgQuery(pool, {
|
|
961
|
+
subject: params.subject,
|
|
962
|
+
predicate: params.predicate,
|
|
963
|
+
object: params.object,
|
|
964
|
+
project_id: params.project_id,
|
|
965
|
+
as_of: asOf,
|
|
966
|
+
include_invalidated: params.include_invalidated
|
|
967
|
+
});
|
|
968
|
+
return { content: [{
|
|
969
|
+
type: "text",
|
|
970
|
+
text: JSON.stringify(triples, null, 2)
|
|
971
|
+
}] };
|
|
972
|
+
} catch (e) {
|
|
973
|
+
return {
|
|
974
|
+
content: [{
|
|
975
|
+
type: "text",
|
|
976
|
+
text: `kg_query error: ${String(e)}`
|
|
977
|
+
}],
|
|
978
|
+
isError: true
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async function toolKgInvalidate(pool, params) {
|
|
983
|
+
try {
|
|
984
|
+
if (params.triple_id === void 0 || params.triple_id === null) return {
|
|
985
|
+
content: [{
|
|
986
|
+
type: "text",
|
|
987
|
+
text: "kg_invalidate error: triple_id is required"
|
|
988
|
+
}],
|
|
989
|
+
isError: true
|
|
990
|
+
};
|
|
991
|
+
await kgInvalidate(pool, params.triple_id);
|
|
992
|
+
return { content: [{
|
|
993
|
+
type: "text",
|
|
994
|
+
text: JSON.stringify({
|
|
995
|
+
invalidated: true,
|
|
996
|
+
triple_id: params.triple_id
|
|
997
|
+
})
|
|
998
|
+
}] };
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
return {
|
|
1001
|
+
content: [{
|
|
1002
|
+
type: "text",
|
|
1003
|
+
text: `kg_invalidate error: ${String(e)}`
|
|
1004
|
+
}],
|
|
1005
|
+
isError: true
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async function toolKgContradictions(pool, params) {
|
|
1010
|
+
try {
|
|
1011
|
+
if (!params.subject) return {
|
|
1012
|
+
content: [{
|
|
1013
|
+
type: "text",
|
|
1014
|
+
text: "kg_contradictions error: subject is required"
|
|
1015
|
+
}],
|
|
1016
|
+
isError: true
|
|
1017
|
+
};
|
|
1018
|
+
const contradictions = await kgContradictions(pool, params.subject);
|
|
1019
|
+
return { content: [{
|
|
1020
|
+
type: "text",
|
|
1021
|
+
text: JSON.stringify(contradictions, null, 2)
|
|
1022
|
+
}] };
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
return {
|
|
1025
|
+
content: [{
|
|
1026
|
+
type: "text",
|
|
1027
|
+
text: `kg_contradictions error: ${String(e)}`
|
|
1028
|
+
}],
|
|
1029
|
+
isError: true
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/memory/wakeup.ts
|
|
1036
|
+
/**
|
|
1037
|
+
* Wake-up context system — progressive context loading inspired by mempalace.
|
|
1038
|
+
*
|
|
1039
|
+
* Layers:
|
|
1040
|
+
* L0 Identity (~100 tokens) — user identity from ~/.pai/identity.txt. Always loaded.
|
|
1041
|
+
* L1 Essential Story (~500-800t) — top session notes for the project, key lines extracted.
|
|
1042
|
+
* L2 On-Demand — triggered by topic queries (handled by memory_search).
|
|
1043
|
+
* L3 Deep Search — unlimited federated memory search (memory_search tool).
|
|
1044
|
+
*/
|
|
1045
|
+
/** Maximum tokens for the L1 essential story block. Approx 4 chars/token. */
|
|
1046
|
+
const L1_TOKEN_BUDGET = 800;
|
|
1047
|
+
L1_TOKEN_BUDGET * 4;
|
|
1048
|
+
/** Maximum session notes to scan when building L1. */
|
|
1049
|
+
const L1_MAX_NOTES = 10;
|
|
1050
|
+
/** Sections to extract from session notes (in priority order). */
|
|
1051
|
+
const EXTRACT_SECTIONS = [
|
|
1052
|
+
"Work Done",
|
|
1053
|
+
"Key Decisions",
|
|
1054
|
+
"Next Steps",
|
|
1055
|
+
"Checkpoint"
|
|
1056
|
+
];
|
|
1057
|
+
/** Identity file location. */
|
|
1058
|
+
const IDENTITY_FILE = join(homedir(), ".pai", "identity.txt");
|
|
1059
|
+
/**
|
|
1060
|
+
* Load L0 identity from ~/.pai/identity.txt.
|
|
1061
|
+
* Returns the file content, or an empty string if the file does not exist.
|
|
1062
|
+
* Never throws.
|
|
1063
|
+
*/
|
|
1064
|
+
function loadL0Identity() {
|
|
1065
|
+
if (!existsSync(IDENTITY_FILE)) return "";
|
|
1066
|
+
try {
|
|
1067
|
+
return readFileSync(IDENTITY_FILE, "utf-8").trim();
|
|
1068
|
+
} catch {
|
|
1069
|
+
return "";
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Find the Notes directory for a project given its root_path from the registry.
|
|
1074
|
+
* Checks local Notes/ first, then central ~/.claude/projects/... path.
|
|
1075
|
+
*/
|
|
1076
|
+
function findNotesDirForProject(rootPath) {
|
|
1077
|
+
const localCandidates = [
|
|
1078
|
+
join(rootPath, "Notes"),
|
|
1079
|
+
join(rootPath, "notes"),
|
|
1080
|
+
join(rootPath, ".claude", "Notes")
|
|
1081
|
+
];
|
|
1082
|
+
for (const p of localCandidates) if (existsSync(p)) return p;
|
|
1083
|
+
const encoded = rootPath.replace(/\//g, "-").replace(/\./g, "-").replace(/ /g, "-");
|
|
1084
|
+
const centralNotes = join(homedir(), ".claude", "projects", encoded, "Notes");
|
|
1085
|
+
if (existsSync(centralNotes)) return centralNotes;
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Recursively find all .md session note files in a Notes directory.
|
|
1090
|
+
* Handles both flat layout (Notes/*.md) and month-subdirectory layout
|
|
1091
|
+
* (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename
|
|
1092
|
+
* (note numbers are monotonically increasing, so lexicographic = newest-last,
|
|
1093
|
+
* so we reverse).
|
|
1094
|
+
*/
|
|
1095
|
+
function findSessionNotes(notesDir) {
|
|
1096
|
+
const result = [];
|
|
1097
|
+
const scanDir = (dir) => {
|
|
1098
|
+
if (!existsSync(dir)) return;
|
|
1099
|
+
let entries;
|
|
1100
|
+
try {
|
|
1101
|
+
entries = readdirSync(dir, { withFileTypes: true }).map((e) => ({
|
|
1102
|
+
name: e.name,
|
|
1103
|
+
isDir: e.isDirectory()
|
|
1104
|
+
}));
|
|
1105
|
+
} catch {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
for (const entry of entries) {
|
|
1109
|
+
const fullPath = join(dir, entry.name);
|
|
1110
|
+
if (entry.isDir) scanDir(fullPath);
|
|
1111
|
+
else if (entry.name.match(/^\d{3,4}[\s_-].*\.md$/)) result.push(fullPath);
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
scanDir(notesDir);
|
|
1115
|
+
const dateOf = (p) => basename(p).match(/(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
|
|
1116
|
+
const numberOf = (p) => parseInt(basename(p).match(/^(\d+)/)?.[1] ?? "0", 10);
|
|
1117
|
+
const mtimeOf = (p) => {
|
|
1118
|
+
try {
|
|
1119
|
+
return statSync(p).mtimeMs;
|
|
1120
|
+
} catch {
|
|
1121
|
+
return 0;
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
result.sort((a, b) => {
|
|
1125
|
+
const dateA = dateOf(a);
|
|
1126
|
+
const dateB = dateOf(b);
|
|
1127
|
+
if (dateA !== dateB) {
|
|
1128
|
+
if (!dateA) return 1;
|
|
1129
|
+
if (!dateB) return -1;
|
|
1130
|
+
return dateB.localeCompare(dateA);
|
|
1131
|
+
}
|
|
1132
|
+
const byNumber = numberOf(b) - numberOf(a);
|
|
1133
|
+
if (byNumber !== 0) return byNumber;
|
|
1134
|
+
return mtimeOf(b) - mtimeOf(a);
|
|
1135
|
+
});
|
|
1136
|
+
return result;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Extract the most important lines from a session note.
|
|
1140
|
+
* Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.
|
|
1141
|
+
* Returns a condensed string under maxChars.
|
|
1142
|
+
*/
|
|
1143
|
+
function extractKeyLines(content, maxChars) {
|
|
1144
|
+
const lines = content.split("\n");
|
|
1145
|
+
const selected = [];
|
|
1146
|
+
let inTargetSection = false;
|
|
1147
|
+
let currentSection = "";
|
|
1148
|
+
let charCount = 0;
|
|
1149
|
+
for (const line of lines) {
|
|
1150
|
+
const h2Match = line.match(/^## (.+)$/);
|
|
1151
|
+
const h3Match = line.match(/^### (.+)$/);
|
|
1152
|
+
if (h2Match) {
|
|
1153
|
+
currentSection = h2Match[1];
|
|
1154
|
+
inTargetSection = EXTRACT_SECTIONS.some((s) => currentSection.toLowerCase().includes(s.toLowerCase()));
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (h3Match) {
|
|
1158
|
+
if (inTargetSection) {
|
|
1159
|
+
const label = `[${h3Match[1]}]`;
|
|
1160
|
+
if (charCount + label.length < maxChars) {
|
|
1161
|
+
selected.push(label);
|
|
1162
|
+
charCount += label.length + 1;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (!inTargetSection) continue;
|
|
1168
|
+
const trimmed = line.trim();
|
|
1169
|
+
if (!trimmed || trimmed.startsWith("<!--") || trimmed === "---") continue;
|
|
1170
|
+
if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.match(/^\d+\./) || trimmed.startsWith("**")) {
|
|
1171
|
+
if (charCount + trimmed.length + 1 > maxChars) break;
|
|
1172
|
+
selected.push(trimmed);
|
|
1173
|
+
charCount += trimmed.length + 1;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return selected.join("\n");
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Build the L1 essential story block.
|
|
1180
|
+
*
|
|
1181
|
+
* Reads the most recent session notes for the project and extracts the key
|
|
1182
|
+
* lines (Work Done, Key Decisions, Next Steps) within the token budget.
|
|
1183
|
+
*
|
|
1184
|
+
* @param rootPath The project root path (from the registry).
|
|
1185
|
+
* @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).
|
|
1186
|
+
* @returns Formatted L1 block, or empty string if no notes found.
|
|
1187
|
+
*/
|
|
1188
|
+
function buildL1EssentialStory(rootPath, tokenBudget = L1_TOKEN_BUDGET) {
|
|
1189
|
+
const charBudget = tokenBudget * 4;
|
|
1190
|
+
const notesDir = findNotesDirForProject(rootPath);
|
|
1191
|
+
if (!notesDir) return "";
|
|
1192
|
+
const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);
|
|
1193
|
+
if (noteFiles.length === 0) return "";
|
|
1194
|
+
const sections = [];
|
|
1195
|
+
let remaining = charBudget;
|
|
1196
|
+
for (const noteFile of noteFiles) {
|
|
1197
|
+
if (remaining <= 50) break;
|
|
1198
|
+
let content;
|
|
1199
|
+
try {
|
|
1200
|
+
content = readFileSync(noteFile, "utf-8");
|
|
1201
|
+
} catch {
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const name = basename(noteFile);
|
|
1205
|
+
const titleMatch = name.match(/^\d+ - (\d{4}-\d{2}-\d{2}) - (.+)\.md$/);
|
|
1206
|
+
const dateLabel = titleMatch ? titleMatch[1] : "";
|
|
1207
|
+
const titleLabel = titleMatch ? titleMatch[2] : name.replace(/^\d+ - /, "").replace(/\.md$/, "");
|
|
1208
|
+
const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);
|
|
1209
|
+
const extracted = extractKeyLines(content, perNoteChars);
|
|
1210
|
+
if (!extracted) continue;
|
|
1211
|
+
const noteBlock = `[${dateLabel} - ${titleLabel}]\n${extracted}`;
|
|
1212
|
+
sections.push(noteBlock);
|
|
1213
|
+
remaining -= noteBlock.length + 1;
|
|
1214
|
+
}
|
|
1215
|
+
if (sections.length === 0) return "";
|
|
1216
|
+
return sections.join("\n\n");
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Build the combined wake-up context block (L0 + L1).
|
|
1220
|
+
*
|
|
1221
|
+
* Returns a formatted string suitable for injection as a system-reminder,
|
|
1222
|
+
* or an empty string if both layers are empty.
|
|
1223
|
+
*
|
|
1224
|
+
* @param rootPath Project root path for L1 note lookup. Optional.
|
|
1225
|
+
* @param tokenBudget L1 token budget. Default 800.
|
|
1226
|
+
*/
|
|
1227
|
+
function buildWakeupContext(rootPath, tokenBudget = L1_TOKEN_BUDGET) {
|
|
1228
|
+
const identity = loadL0Identity();
|
|
1229
|
+
const essentialStory = rootPath ? buildL1EssentialStory(rootPath, tokenBudget) : "";
|
|
1230
|
+
if (!identity && !essentialStory) return "";
|
|
1231
|
+
const parts = [];
|
|
1232
|
+
if (identity) parts.push(`## L0 Identity\n\n${identity}`);
|
|
1233
|
+
if (essentialStory) parts.push(`## L1 Essential Story\n\n${essentialStory}`);
|
|
1234
|
+
return parts.join("\n\n");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
//#endregion
|
|
1238
|
+
//#region src/mcp/tools/wakeup.ts
|
|
1239
|
+
const DEFAULT_TOKEN_BUDGET = 800;
|
|
1240
|
+
function toolMemoryWakeup(registryDb, params) {
|
|
1241
|
+
try {
|
|
1242
|
+
const tokenBudget = params.token_budget ?? DEFAULT_TOKEN_BUDGET;
|
|
1243
|
+
let rootPath;
|
|
1244
|
+
if (params.project) {
|
|
1245
|
+
const bySlug = registryDb.prepare("SELECT root_path FROM projects WHERE slug = ?").get(params.project);
|
|
1246
|
+
if (bySlug) rootPath = bySlug.root_path;
|
|
1247
|
+
else {
|
|
1248
|
+
const detected = detectProjectFromPath(registryDb, params.project);
|
|
1249
|
+
if (detected) rootPath = detected.root_path;
|
|
1250
|
+
}
|
|
1251
|
+
} else {
|
|
1252
|
+
const detected = detectProjectFromPath(registryDb, process.cwd());
|
|
1253
|
+
if (detected) rootPath = detected.root_path;
|
|
1254
|
+
}
|
|
1255
|
+
const wakeupBlock = buildWakeupContext(rootPath, tokenBudget);
|
|
1256
|
+
if (!wakeupBlock) return { content: [{
|
|
1257
|
+
type: "text",
|
|
1258
|
+
text: "No wake-up context available. Create ~/.pai/identity.txt for L0 identity, or ensure session notes exist for L1 story."
|
|
1259
|
+
}] };
|
|
1260
|
+
return { content: [{
|
|
1261
|
+
type: "text",
|
|
1262
|
+
text: `WAKEUP CONTEXT\n\n${wakeupBlock}`
|
|
1263
|
+
}] };
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
return {
|
|
1266
|
+
content: [{
|
|
1267
|
+
type: "text",
|
|
1268
|
+
text: `Wakeup context error: ${String(e)}`
|
|
1269
|
+
}],
|
|
1270
|
+
isError: true
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
//#endregion
|
|
1276
|
+
//#region src/memory/taxonomy.ts
|
|
1277
|
+
/**
|
|
1278
|
+
* Build a taxonomy of stored memory — what projects exist, how much is stored,
|
|
1279
|
+
* and what has been active recently.
|
|
1280
|
+
*
|
|
1281
|
+
* Registry queries (projects, sessions) are synchronous (better-sqlite3).
|
|
1282
|
+
* Storage backend queries (files, chunks) are async.
|
|
1283
|
+
*/
|
|
1284
|
+
async function getTaxonomy(registryDb, storage, options = {}) {
|
|
1285
|
+
const includeArchived = options.include_archived ?? false;
|
|
1286
|
+
const limit = options.limit ?? 50;
|
|
1287
|
+
const statusFilter = includeArchived ? "status IN ('active', 'archived', 'migrating')" : "status = 'active'";
|
|
1288
|
+
const projectRows = registryDb.prepare(`SELECT id, slug, display_name, status, created_at, updated_at
|
|
1289
|
+
FROM projects
|
|
1290
|
+
WHERE ${statusFilter}
|
|
1291
|
+
ORDER BY updated_at DESC
|
|
1292
|
+
LIMIT ?`).all(limit);
|
|
1293
|
+
if (projectRows.length === 0) return {
|
|
1294
|
+
projects: [],
|
|
1295
|
+
totals: {
|
|
1296
|
+
projects: 0,
|
|
1297
|
+
sessions: 0,
|
|
1298
|
+
notes: 0,
|
|
1299
|
+
chunks: 0
|
|
1300
|
+
},
|
|
1301
|
+
recent_activity: []
|
|
1302
|
+
};
|
|
1303
|
+
const projectIds = projectRows.map((p) => p.id);
|
|
1304
|
+
const sessionCountsByProject = /* @__PURE__ */ new Map();
|
|
1305
|
+
const lastSessionDateByProject = /* @__PURE__ */ new Map();
|
|
1306
|
+
for (const projectId of projectIds) {
|
|
1307
|
+
const countRow = registryDb.prepare("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?").get(projectId);
|
|
1308
|
+
sessionCountsByProject.set(projectId, countRow.n);
|
|
1309
|
+
const lastRow = registryDb.prepare("SELECT date FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(projectId);
|
|
1310
|
+
lastSessionDateByProject.set(projectId, lastRow?.date ?? null);
|
|
1311
|
+
}
|
|
1312
|
+
const tagsByProject = /* @__PURE__ */ new Map();
|
|
1313
|
+
for (const projectId of projectIds) {
|
|
1314
|
+
const tags = registryDb.prepare(`SELECT t.name
|
|
1315
|
+
FROM tags t
|
|
1316
|
+
JOIN project_tags pt ON pt.tag_id = t.id
|
|
1317
|
+
WHERE pt.project_id = ?
|
|
1318
|
+
ORDER BY t.name`).all(projectId);
|
|
1319
|
+
tagsByProject.set(projectId, tags.map((t) => t.name));
|
|
1320
|
+
}
|
|
1321
|
+
const noteCountsByProject = /* @__PURE__ */ new Map();
|
|
1322
|
+
const chunkCountsByProject = /* @__PURE__ */ new Map();
|
|
1323
|
+
const isBackend = (x) => x.backendType === "sqlite";
|
|
1324
|
+
if (isBackend(storage)) {
|
|
1325
|
+
const rawDb = storage.getRawDb?.();
|
|
1326
|
+
if (rawDb) for (const projectId of projectIds) {
|
|
1327
|
+
const noteRow = rawDb.prepare("SELECT COUNT(*) AS n FROM memory_files WHERE project_id = ?").get(projectId);
|
|
1328
|
+
noteCountsByProject.set(projectId, noteRow.n);
|
|
1329
|
+
const chunkRow = rawDb.prepare("SELECT COUNT(*) AS n FROM memory_chunks WHERE project_id = ?").get(projectId);
|
|
1330
|
+
chunkCountsByProject.set(projectId, chunkRow.n);
|
|
1331
|
+
}
|
|
1332
|
+
} else for (const projectId of projectIds) {
|
|
1333
|
+
noteCountsByProject.set(projectId, 0);
|
|
1334
|
+
chunkCountsByProject.set(projectId, 0);
|
|
1335
|
+
}
|
|
1336
|
+
const stats = await storage.getStats();
|
|
1337
|
+
const totalProjects = registryDb.prepare(`SELECT COUNT(*) AS n FROM projects WHERE ${statusFilter}`).get().n;
|
|
1338
|
+
const totalSessions = registryDb.prepare("SELECT COUNT(*) AS n FROM sessions").get().n;
|
|
1339
|
+
const recentActivity = registryDb.prepare(`SELECT s.date, s.title, p.slug
|
|
1340
|
+
FROM sessions s
|
|
1341
|
+
JOIN projects p ON p.id = s.project_id
|
|
1342
|
+
WHERE p.${statusFilter.replace("status", "p.status")}
|
|
1343
|
+
ORDER BY s.created_at DESC
|
|
1344
|
+
LIMIT 10`).all().map((row) => ({
|
|
1345
|
+
project_slug: row.slug,
|
|
1346
|
+
action: `session: ${row.title || "(untitled)"}`,
|
|
1347
|
+
timestamp: row.date
|
|
1348
|
+
}));
|
|
1349
|
+
return {
|
|
1350
|
+
projects: projectRows.map((row) => ({
|
|
1351
|
+
slug: row.slug,
|
|
1352
|
+
display_name: row.display_name,
|
|
1353
|
+
session_count: sessionCountsByProject.get(row.id) ?? 0,
|
|
1354
|
+
note_count: noteCountsByProject.get(row.id) ?? 0,
|
|
1355
|
+
last_activity: lastSessionDateByProject.get(row.id) ?? null,
|
|
1356
|
+
top_tags: tagsByProject.get(row.id) ?? []
|
|
1357
|
+
})),
|
|
1358
|
+
totals: {
|
|
1359
|
+
projects: totalProjects,
|
|
1360
|
+
sessions: totalSessions,
|
|
1361
|
+
notes: stats.files,
|
|
1362
|
+
chunks: stats.chunks
|
|
1363
|
+
},
|
|
1364
|
+
recent_activity: recentActivity
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
//#endregion
|
|
1369
|
+
//#region src/mcp/tools/taxonomy.ts
|
|
1370
|
+
async function toolMemoryTaxonomy(registryDb, storage, params = {}) {
|
|
1371
|
+
try {
|
|
1372
|
+
const result = await getTaxonomy(registryDb, storage, {
|
|
1373
|
+
include_archived: params.include_archived,
|
|
1374
|
+
limit: params.limit
|
|
1375
|
+
});
|
|
1376
|
+
const lines = [];
|
|
1377
|
+
lines.push(`PAI Memory Taxonomy — ${result.totals.projects} project(s), ${result.totals.sessions} session(s), ${result.totals.notes} indexed file(s), ${result.totals.chunks} chunk(s)`);
|
|
1378
|
+
lines.push("");
|
|
1379
|
+
if (result.projects.length === 0) lines.push("No active projects found.");
|
|
1380
|
+
else {
|
|
1381
|
+
lines.push("Projects:");
|
|
1382
|
+
for (const p of result.projects) {
|
|
1383
|
+
const tagStr = p.top_tags.length > 0 ? ` [${p.top_tags.join(", ")}]` : "";
|
|
1384
|
+
const activityStr = p.last_activity ? ` last: ${p.last_activity}` : "";
|
|
1385
|
+
lines.push(` ${p.slug} — ${p.display_name} sessions=${p.session_count}` + (p.note_count > 0 ? ` files=${p.note_count}` : "") + activityStr + tagStr);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (result.recent_activity.length > 0) {
|
|
1389
|
+
lines.push("");
|
|
1390
|
+
lines.push("Recent activity (last 10 sessions across all projects):");
|
|
1391
|
+
for (const a of result.recent_activity) lines.push(` ${a.timestamp} ${a.project_slug} ${a.action}`);
|
|
1392
|
+
}
|
|
1393
|
+
return { content: [{
|
|
1394
|
+
type: "text",
|
|
1395
|
+
text: lines.join("\n")
|
|
1396
|
+
}] };
|
|
1397
|
+
} catch (e) {
|
|
1398
|
+
return {
|
|
1399
|
+
content: [{
|
|
1400
|
+
type: "text",
|
|
1401
|
+
text: `memory_taxonomy error: ${String(e)}`
|
|
1402
|
+
}],
|
|
1403
|
+
isError: true
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
//#endregion
|
|
1409
|
+
//#region src/memory/tunnels.ts
|
|
1410
|
+
/**
|
|
1411
|
+
* tunnels.ts — cross-project concept detection ("palace graph / tunnel detection")
|
|
1412
|
+
*
|
|
1413
|
+
* A "tunnel" is a concept (word or short phrase) that appears in chunks from
|
|
1414
|
+
* at least two distinct projects. These serendipitous cross-project connections
|
|
1415
|
+
* are surfaced so the user can discover unexpected relationships between their
|
|
1416
|
+
* work streams.
|
|
1417
|
+
*
|
|
1418
|
+
* Algorithm:
|
|
1419
|
+
* 1. Pull the top-N most frequent significant terms from memory_chunks via BM25 FTS.
|
|
1420
|
+
* We use the FTS5 vocab table (if available) or fall back to term frequency
|
|
1421
|
+
* aggregation over the raw text via a trigram approach.
|
|
1422
|
+
* 2. For each candidate term, count how many distinct projects have at least one
|
|
1423
|
+
* chunk containing it and aggregate occurrence stats.
|
|
1424
|
+
* 3. Filter by min_projects and min_occurrences, sort by project breadth then
|
|
1425
|
+
* frequency, return top limit results.
|
|
1426
|
+
*
|
|
1427
|
+
* Backend support:
|
|
1428
|
+
* - SQLite — uses `memory_fts` MATCH to count per-project occurrences.
|
|
1429
|
+
* - Postgres — uses `memory_chunks` tsvector + ts_stat for term extraction and
|
|
1430
|
+
* per-project term frequency counting via plainto_tsquery.
|
|
1431
|
+
*/
|
|
1432
|
+
/**
|
|
1433
|
+
* Extract candidate terms from the SQLite FTS5 index using the vocabulary
|
|
1434
|
+
* approach: iterate the fts5vocab table (if it exists) for the most common
|
|
1435
|
+
* terms, then per-term count distinct projects.
|
|
1436
|
+
*/
|
|
1437
|
+
async function findTunnelsSqlite(db, slugMap, opts) {
|
|
1438
|
+
const projectIds = Object.keys(slugMap).map(Number);
|
|
1439
|
+
if (projectIds.length < 2) return {
|
|
1440
|
+
tunnels: [],
|
|
1441
|
+
projects_analyzed: projectIds.length,
|
|
1442
|
+
total_concepts_evaluated: 0
|
|
1443
|
+
};
|
|
1444
|
+
let candidateTerms = [];
|
|
1445
|
+
try {
|
|
1446
|
+
candidateTerms = db.prepare(`SELECT term, SUM(doc) AS doc_count, SUM(cnt) AS total_cnt
|
|
1447
|
+
FROM memory_fts_v
|
|
1448
|
+
GROUP BY term
|
|
1449
|
+
HAVING SUM(cnt) >= ?
|
|
1450
|
+
ORDER BY SUM(doc) DESC
|
|
1451
|
+
LIMIT 500`).all(opts.min_occurrences).map((r) => r.term).filter((t) => t.length >= 3 && !STOP_WORDS.has(t));
|
|
1452
|
+
} catch {
|
|
1453
|
+
const sampleRows = db.prepare(`SELECT LOWER(text) AS text FROM memory_chunks
|
|
1454
|
+
WHERE LENGTH(text) > 20
|
|
1455
|
+
ORDER BY RANDOM()
|
|
1456
|
+
LIMIT 2000`).all();
|
|
1457
|
+
const freq = /* @__PURE__ */ new Map();
|
|
1458
|
+
for (const { text } of sampleRows) {
|
|
1459
|
+
const tokens = text.split(/[\s\p{P}]+/u).filter(Boolean).filter((t) => t.length >= 3 && !STOP_WORDS.has(t));
|
|
1460
|
+
for (const t of tokens) freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
1461
|
+
}
|
|
1462
|
+
candidateTerms = [...freq.entries()].filter(([, n]) => n >= opts.min_occurrences).sort((a, b) => b[1] - a[1]).slice(0, 200).map(([t]) => t);
|
|
1463
|
+
}
|
|
1464
|
+
if (candidateTerms.length === 0) return {
|
|
1465
|
+
tunnels: [],
|
|
1466
|
+
projects_analyzed: projectIds.length,
|
|
1467
|
+
total_concepts_evaluated: 0
|
|
1468
|
+
};
|
|
1469
|
+
const tunnels = [];
|
|
1470
|
+
for (const term of candidateTerms) try {
|
|
1471
|
+
const rows = db.prepare(`SELECT c.project_id, COUNT(*) AS cnt,
|
|
1472
|
+
MIN(c.updated_at) AS first_seen,
|
|
1473
|
+
MAX(c.updated_at) AS last_seen
|
|
1474
|
+
FROM memory_fts f
|
|
1475
|
+
JOIN memory_chunks c ON c.id = f.id
|
|
1476
|
+
WHERE memory_fts MATCH ?
|
|
1477
|
+
AND c.project_id IN (${projectIds.map(() => "?").join(", ")})
|
|
1478
|
+
GROUP BY c.project_id`).all(`"${term.replace(/"/g, "\"\"")}"`, ...projectIds);
|
|
1479
|
+
if (rows.length < opts.min_projects) continue;
|
|
1480
|
+
const totalOccurrences = rows.reduce((s, r) => s + Number(r.cnt), 0);
|
|
1481
|
+
if (totalOccurrences < opts.min_occurrences) continue;
|
|
1482
|
+
const projects = rows.map((r) => slugMap[r.project_id] ?? String(r.project_id)).filter(Boolean);
|
|
1483
|
+
const firstSeen = Math.min(...rows.map((r) => r.first_seen));
|
|
1484
|
+
const lastSeen = Math.max(...rows.map((r) => r.last_seen));
|
|
1485
|
+
tunnels.push({
|
|
1486
|
+
concept: term,
|
|
1487
|
+
projects,
|
|
1488
|
+
occurrences: totalOccurrences,
|
|
1489
|
+
first_seen: firstSeen,
|
|
1490
|
+
last_seen: lastSeen
|
|
1491
|
+
});
|
|
1492
|
+
} catch {
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
tunnels.sort((a, b) => {
|
|
1496
|
+
const byProjects = b.projects.length - a.projects.length;
|
|
1497
|
+
if (byProjects !== 0) return byProjects;
|
|
1498
|
+
return b.occurrences - a.occurrences;
|
|
1499
|
+
});
|
|
1500
|
+
return {
|
|
1501
|
+
tunnels: tunnels.slice(0, opts.limit),
|
|
1502
|
+
projects_analyzed: projectIds.length,
|
|
1503
|
+
total_concepts_evaluated: candidateTerms.length
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Use Postgres ts_stat() + plainto_tsquery to efficiently find terms that
|
|
1508
|
+
* appear across multiple projects.
|
|
1509
|
+
*/
|
|
1510
|
+
async function findTunnelsPostgres(pool, slugMap, opts) {
|
|
1511
|
+
const projectIds = Object.keys(slugMap).map(Number);
|
|
1512
|
+
if (projectIds.length < 2) return {
|
|
1513
|
+
tunnels: [],
|
|
1514
|
+
projects_analyzed: projectIds.length,
|
|
1515
|
+
total_concepts_evaluated: 0
|
|
1516
|
+
};
|
|
1517
|
+
let candidateTerms = (await pool.query(`SELECT word, ndoc, nentry
|
|
1518
|
+
FROM ts_stat(
|
|
1519
|
+
'SELECT to_tsvector(''simple'', text) FROM memory_chunks WHERE project_id = ANY($1)'
|
|
1520
|
+
)
|
|
1521
|
+
WHERE length(word) >= 3
|
|
1522
|
+
AND nentry >= $2
|
|
1523
|
+
ORDER BY ndoc DESC
|
|
1524
|
+
LIMIT 500`, [projectIds, opts.min_occurrences])).rows.map((r) => r.word).filter((t) => !STOP_WORDS.has(t));
|
|
1525
|
+
if (candidateTerms.length === 0) return {
|
|
1526
|
+
tunnels: [],
|
|
1527
|
+
projects_analyzed: projectIds.length,
|
|
1528
|
+
total_concepts_evaluated: 0
|
|
1529
|
+
};
|
|
1530
|
+
candidateTerms = candidateTerms.slice(0, 200);
|
|
1531
|
+
const valuesClause = candidateTerms.map((t, i) => `($${i + 2}::text)`).join(", ");
|
|
1532
|
+
const batchResult = await pool.query(`SELECT v.concept, c.project_id::text, COUNT(*) AS cnt,
|
|
1533
|
+
MIN(c.updated_at) AS first_seen,
|
|
1534
|
+
MAX(c.updated_at) AS last_seen
|
|
1535
|
+
FROM (VALUES ${valuesClause}) AS v(concept)
|
|
1536
|
+
JOIN memory_chunks c
|
|
1537
|
+
ON to_tsvector('simple', c.text) @@ plainto_tsquery('simple', v.concept)
|
|
1538
|
+
AND c.project_id = ANY($1)
|
|
1539
|
+
GROUP BY v.concept, c.project_id`, [projectIds, ...candidateTerms]);
|
|
1540
|
+
const byConceptMap = /* @__PURE__ */ new Map();
|
|
1541
|
+
for (const row of batchResult.rows) {
|
|
1542
|
+
const existing = byConceptMap.get(row.concept) ?? {
|
|
1543
|
+
projects: /* @__PURE__ */ new Set(),
|
|
1544
|
+
occurrences: 0,
|
|
1545
|
+
firstSeen: Infinity,
|
|
1546
|
+
lastSeen: -Infinity
|
|
1547
|
+
};
|
|
1548
|
+
existing.projects.add(parseInt(row.project_id, 10));
|
|
1549
|
+
existing.occurrences += parseInt(row.cnt, 10);
|
|
1550
|
+
const fs = parseInt(row.first_seen, 10);
|
|
1551
|
+
const ls = parseInt(row.last_seen, 10);
|
|
1552
|
+
if (fs < existing.firstSeen) existing.firstSeen = fs;
|
|
1553
|
+
if (ls > existing.lastSeen) existing.lastSeen = ls;
|
|
1554
|
+
byConceptMap.set(row.concept, existing);
|
|
1555
|
+
}
|
|
1556
|
+
const tunnels = [];
|
|
1557
|
+
for (const [concept, data] of byConceptMap) {
|
|
1558
|
+
if (data.projects.size < opts.min_projects) continue;
|
|
1559
|
+
if (data.occurrences < opts.min_occurrences) continue;
|
|
1560
|
+
const projects = [...data.projects].map((id) => slugMap[id] ?? String(id)).filter(Boolean);
|
|
1561
|
+
tunnels.push({
|
|
1562
|
+
concept,
|
|
1563
|
+
projects,
|
|
1564
|
+
occurrences: data.occurrences,
|
|
1565
|
+
first_seen: data.firstSeen === Infinity ? 0 : data.firstSeen,
|
|
1566
|
+
last_seen: data.lastSeen === -Infinity ? 0 : data.lastSeen
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
tunnels.sort((a, b) => {
|
|
1570
|
+
const byProjects = b.projects.length - a.projects.length;
|
|
1571
|
+
if (byProjects !== 0) return byProjects;
|
|
1572
|
+
return b.occurrences - a.occurrences;
|
|
1573
|
+
});
|
|
1574
|
+
return {
|
|
1575
|
+
tunnels: tunnels.slice(0, opts.limit),
|
|
1576
|
+
projects_analyzed: projectIds.length,
|
|
1577
|
+
total_concepts_evaluated: candidateTerms.length
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Find cross-project concept tunnels.
|
|
1582
|
+
*
|
|
1583
|
+
* Works with both SQLite and Postgres storage backends.
|
|
1584
|
+
* Requires the `registryDb` (better-sqlite3) for project slug resolution.
|
|
1585
|
+
*
|
|
1586
|
+
* @param backend Active PAI storage backend.
|
|
1587
|
+
* @param registryDb Registry database for project slug resolution.
|
|
1588
|
+
* @param options Filter and limit options.
|
|
1589
|
+
*/
|
|
1590
|
+
async function findTunnels(backend, registryDb, options) {
|
|
1591
|
+
const opts = {
|
|
1592
|
+
min_projects: options?.min_projects ?? 2,
|
|
1593
|
+
min_occurrences: options?.min_occurrences ?? 3,
|
|
1594
|
+
limit: options?.limit ?? 20
|
|
1595
|
+
};
|
|
1596
|
+
const projectRows = registryDb.prepare("SELECT id, slug FROM projects WHERE status != 'archived'").all();
|
|
1597
|
+
const slugMap = {};
|
|
1598
|
+
for (const { id, slug } of projectRows) slugMap[id] = slug;
|
|
1599
|
+
if (backend.backendType === "postgres") {
|
|
1600
|
+
const pool = backend.getPool?.();
|
|
1601
|
+
if (!pool) throw new Error("findTunnels: Postgres backend does not expose getPool()");
|
|
1602
|
+
return findTunnelsPostgres(pool, slugMap, opts);
|
|
1603
|
+
}
|
|
1604
|
+
const rawDb = backend.getRawDb?.();
|
|
1605
|
+
if (!rawDb) throw new Error("findTunnels: SQLite backend does not expose getRawDb()");
|
|
1606
|
+
return findTunnelsSqlite(rawDb, slugMap, opts);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
//#endregion
|
|
1610
|
+
//#region src/mcp/tools/tunnels.ts
|
|
1611
|
+
async function toolMemoryTunnels(registryDb, backend, params) {
|
|
1612
|
+
try {
|
|
1613
|
+
const result = await findTunnels(backend, registryDb, {
|
|
1614
|
+
min_projects: params.min_projects,
|
|
1615
|
+
min_occurrences: params.min_occurrences,
|
|
1616
|
+
limit: params.limit
|
|
1617
|
+
});
|
|
1618
|
+
return { content: [{
|
|
1619
|
+
type: "text",
|
|
1620
|
+
text: JSON.stringify(result, null, 2)
|
|
1621
|
+
}] };
|
|
1622
|
+
} catch (e) {
|
|
1623
|
+
return {
|
|
1624
|
+
content: [{
|
|
1625
|
+
type: "text",
|
|
1626
|
+
text: `memory_tunnels error: ${String(e)}`
|
|
1627
|
+
}],
|
|
1628
|
+
isError: true
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
//#endregion
|
|
1634
|
+
//#region src/mcp/tools/feedback.ts
|
|
1635
|
+
const FEEDBACK_ALPHA = .1;
|
|
1636
|
+
/**
|
|
1637
|
+
* Apply relevance feedback to memory chunks and associated KG entities.
|
|
1638
|
+
*
|
|
1639
|
+
* @param db Federation SQLite database
|
|
1640
|
+
* @param params Feedback parameters
|
|
1641
|
+
*/
|
|
1642
|
+
function toolMemoryFeedback(db, params) {
|
|
1643
|
+
try {
|
|
1644
|
+
if (!Array.isArray(params.chunk_ids) || params.chunk_ids.length === 0) return {
|
|
1645
|
+
content: [{
|
|
1646
|
+
type: "text",
|
|
1647
|
+
text: "memory_feedback error: chunk_ids must be a non-empty array"
|
|
1648
|
+
}],
|
|
1649
|
+
isError: true
|
|
1650
|
+
};
|
|
1651
|
+
const rating = params.rating;
|
|
1652
|
+
if (typeof rating !== "number" || rating < 1 || rating > 5) return {
|
|
1653
|
+
content: [{
|
|
1654
|
+
type: "text",
|
|
1655
|
+
text: "memory_feedback error: rating must be a number between 1 and 5"
|
|
1656
|
+
}],
|
|
1657
|
+
isError: true
|
|
1658
|
+
};
|
|
1659
|
+
const normalizedRating = (rating - 1) / 4;
|
|
1660
|
+
const placeholders = params.chunk_ids.map(() => "?").join(", ");
|
|
1661
|
+
const chunks = db.prepare(`SELECT id, text, relevance_score FROM memory_chunks WHERE id IN (${placeholders})`).all(...params.chunk_ids);
|
|
1662
|
+
if (chunks.length === 0) return {
|
|
1663
|
+
content: [{
|
|
1664
|
+
type: "text",
|
|
1665
|
+
text: "memory_feedback: no matching chunks found"
|
|
1666
|
+
}],
|
|
1667
|
+
isError: true
|
|
1668
|
+
};
|
|
1669
|
+
let updatedChunks = 0;
|
|
1670
|
+
const combinedText = [];
|
|
1671
|
+
const updateStmt = db.prepare("UPDATE memory_chunks SET relevance_score = ? WHERE id = ?");
|
|
1672
|
+
for (const chunk of chunks) {
|
|
1673
|
+
const oldScore = chunk.relevance_score ?? .5;
|
|
1674
|
+
const newScore = oldScore + FEEDBACK_ALPHA * (normalizedRating - oldScore);
|
|
1675
|
+
updateStmt.run(newScore, chunk.id);
|
|
1676
|
+
updatedChunks++;
|
|
1677
|
+
combinedText.push(chunk.text);
|
|
1678
|
+
}
|
|
1679
|
+
const entities = listKgEntities(db, params.tenant_id ?? "default", void 0, 500);
|
|
1680
|
+
const text = combinedText.join("\n").toLowerCase();
|
|
1681
|
+
let updatedEntities = 0;
|
|
1682
|
+
for (const entity of entities) if (text.includes(entity.name.toLowerCase())) {
|
|
1683
|
+
updateEntityFeedbackWeight(db, entity.entity_id, normalizedRating, FEEDBACK_ALPHA);
|
|
1684
|
+
updatedEntities++;
|
|
1685
|
+
}
|
|
1686
|
+
return { content: [{
|
|
1687
|
+
type: "text",
|
|
1688
|
+
text: JSON.stringify({
|
|
1689
|
+
updated_chunks: updatedChunks,
|
|
1690
|
+
updated_entities: updatedEntities,
|
|
1691
|
+
rating,
|
|
1692
|
+
normalized_rating: normalizedRating
|
|
1693
|
+
})
|
|
1694
|
+
}] };
|
|
1695
|
+
} catch (e) {
|
|
1696
|
+
return {
|
|
1697
|
+
content: [{
|
|
1698
|
+
type: "text",
|
|
1699
|
+
text: `memory_feedback error: ${String(e)}`
|
|
1700
|
+
}],
|
|
1701
|
+
isError: true
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
//#endregion
|
|
1707
|
+
//#region src/memory/kg-search.ts
|
|
1708
|
+
/**
|
|
1709
|
+
* Find entity names (from the federation.db kg_entities table) that appear
|
|
1710
|
+
* as substrings in the given text chunks.
|
|
1711
|
+
*
|
|
1712
|
+
* Returns a deduplicated list of matching entity names.
|
|
1713
|
+
*/
|
|
1714
|
+
function extractEntityMentions(federationDb, chunks, tenantId = "default") {
|
|
1715
|
+
const entities = listKgEntities(federationDb, tenantId, void 0, 500);
|
|
1716
|
+
if (entities.length === 0) return [];
|
|
1717
|
+
const combinedText = chunks.map((c) => c.snippet).join("\n").toLowerCase();
|
|
1718
|
+
const matched = /* @__PURE__ */ new Set();
|
|
1719
|
+
for (const entity of entities) if (combinedText.includes(entity.name.toLowerCase())) matched.add(entity.name);
|
|
1720
|
+
return Array.from(matched);
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Perform BFS over kg_triples to find neighbors of the given entity names.
|
|
1724
|
+
*
|
|
1725
|
+
* For each entity name, fetches triples where the entity is the subject
|
|
1726
|
+
* or the object (bi-directional expansion). Supports 1 or 2 hop depths.
|
|
1727
|
+
*
|
|
1728
|
+
* Returns all unique triples found in the BFS expansion.
|
|
1729
|
+
*/
|
|
1730
|
+
async function bfsExpand(pool, entityNames, hops, projectId) {
|
|
1731
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1732
|
+
const tripleMap = /* @__PURE__ */ new Map();
|
|
1733
|
+
const frontier = new Set(entityNames);
|
|
1734
|
+
for (let hop = 0; hop < hops; hop++) {
|
|
1735
|
+
const nextFrontier = /* @__PURE__ */ new Set();
|
|
1736
|
+
for (const name of frontier) {
|
|
1737
|
+
const asSubject = await kgQuery(pool, {
|
|
1738
|
+
subject: name,
|
|
1739
|
+
project_id: projectId
|
|
1740
|
+
});
|
|
1741
|
+
const asObject = await kgQuery(pool, {
|
|
1742
|
+
object: name,
|
|
1743
|
+
project_id: projectId
|
|
1744
|
+
});
|
|
1745
|
+
for (const triple of [...asSubject, ...asObject]) if (!seen.has(triple.id)) {
|
|
1746
|
+
seen.add(triple.id);
|
|
1747
|
+
tripleMap.set(triple.id, triple);
|
|
1748
|
+
nextFrontier.add(triple.subject);
|
|
1749
|
+
nextFrontier.add(triple.object);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
frontier.clear();
|
|
1753
|
+
for (const name of nextFrontier) if (!entityNames.includes(name)) {
|
|
1754
|
+
frontier.add(name);
|
|
1755
|
+
entityNames.push(name);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
return Array.from(tripleMap.values());
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Score each triple by cosine similarity to the query embedding.
|
|
1762
|
+
*
|
|
1763
|
+
* The triple text representation is: "subject predicate object"
|
|
1764
|
+
* We embed this on-the-fly using generateEmbedding(), which may use a cache.
|
|
1765
|
+
*
|
|
1766
|
+
* Returns triples sorted by relevance score descending.
|
|
1767
|
+
*/
|
|
1768
|
+
async function rerankTriples(triples, queryVec, maxTriples) {
|
|
1769
|
+
if (triples.length === 0) return [];
|
|
1770
|
+
const { generateEmbedding } = await import("./embeddings-Bn86ssxR.mjs").then((n) => n.i);
|
|
1771
|
+
return (await Promise.all(triples.map(async (triple) => {
|
|
1772
|
+
const text = `${triple.subject} ${triple.predicate} ${triple.object}`;
|
|
1773
|
+
try {
|
|
1774
|
+
const score = cosineSimilarity(queryVec, await generateEmbedding(text, false));
|
|
1775
|
+
return {
|
|
1776
|
+
...triple,
|
|
1777
|
+
relevanceScore: score
|
|
1778
|
+
};
|
|
1779
|
+
} catch {
|
|
1780
|
+
return {
|
|
1781
|
+
...triple,
|
|
1782
|
+
relevanceScore: 0
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
}))).sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, maxTriples);
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Graph-completion retrieval combining vector search with KG neighborhood BFS.
|
|
1789
|
+
*
|
|
1790
|
+
* @param federationDb SQLite federation.db (for chunk/entity lookup)
|
|
1791
|
+
* @param pool Postgres connection pool (for kg_triples queries)
|
|
1792
|
+
* @param queryVec Pre-computed embedding for the search query
|
|
1793
|
+
* @param opts Configuration options
|
|
1794
|
+
*/
|
|
1795
|
+
async function graphCompletionSearch(federationDb, pool, queryVec, opts = {}) {
|
|
1796
|
+
const { seedCount = 50, hops = 1, maxTriples = 20, projectId, tenantId = "default", searchOpts } = opts;
|
|
1797
|
+
const seedSearchOpts = {
|
|
1798
|
+
...searchOpts,
|
|
1799
|
+
maxResults: seedCount,
|
|
1800
|
+
projectIds: projectId != null ? [projectId] : searchOpts?.projectIds
|
|
1801
|
+
};
|
|
1802
|
+
let seedChunks = [];
|
|
1803
|
+
try {
|
|
1804
|
+
const { searchMemorySemantic } = await import("./search-CpTv1I24.mjs").then((n) => n.o);
|
|
1805
|
+
seedChunks = searchMemorySemantic(federationDb, queryVec, seedSearchOpts);
|
|
1806
|
+
} catch (e) {
|
|
1807
|
+
process.stderr.write(`[kg-search] Phase 1 seed search error: ${e}\n`);
|
|
1808
|
+
}
|
|
1809
|
+
if (seedChunks.length === 0) return {
|
|
1810
|
+
triples: [],
|
|
1811
|
+
seedChunks: [],
|
|
1812
|
+
expandedEntities: []
|
|
1813
|
+
};
|
|
1814
|
+
const expandedEntities = extractEntityMentions(federationDb, seedChunks, tenantId);
|
|
1815
|
+
if (expandedEntities.length === 0) return {
|
|
1816
|
+
triples: [],
|
|
1817
|
+
seedChunks,
|
|
1818
|
+
expandedEntities: []
|
|
1819
|
+
};
|
|
1820
|
+
let expandedTriples = [];
|
|
1821
|
+
try {
|
|
1822
|
+
expandedTriples = await bfsExpand(pool, [...expandedEntities], hops, projectId);
|
|
1823
|
+
} catch (e) {
|
|
1824
|
+
process.stderr.write(`[kg-search] Phase 3 BFS expansion error: ${e}\n`);
|
|
1825
|
+
}
|
|
1826
|
+
if (expandedTriples.length === 0) return {
|
|
1827
|
+
triples: [],
|
|
1828
|
+
seedChunks,
|
|
1829
|
+
expandedEntities
|
|
1830
|
+
};
|
|
1831
|
+
let rankedTriples = [];
|
|
1832
|
+
try {
|
|
1833
|
+
rankedTriples = await rerankTriples(expandedTriples, queryVec, maxTriples);
|
|
1834
|
+
} catch (e) {
|
|
1835
|
+
process.stderr.write(`[kg-search] Phase 4 rerank error: ${e}\n`);
|
|
1836
|
+
rankedTriples = expandedTriples.slice(0, maxTriples).map((t) => ({
|
|
1837
|
+
...t,
|
|
1838
|
+
relevanceScore: 0
|
|
1839
|
+
}));
|
|
1840
|
+
}
|
|
1841
|
+
return {
|
|
1842
|
+
triples: rankedTriples,
|
|
1843
|
+
seedChunks,
|
|
1844
|
+
expandedEntities
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
//#endregion
|
|
1849
|
+
//#region src/mcp/tools/kg-search.ts
|
|
1850
|
+
/**
|
|
1851
|
+
* Graph-completion search: vector search + KG neighborhood BFS.
|
|
1852
|
+
*
|
|
1853
|
+
* @param federationDb SQLite federation.db for chunk/entity operations
|
|
1854
|
+
* @param pool Postgres pool for kg_triples BFS expansion
|
|
1855
|
+
* @param params Tool parameters
|
|
1856
|
+
*/
|
|
1857
|
+
async function toolMemoryKgSearch(federationDb, pool, params) {
|
|
1858
|
+
try {
|
|
1859
|
+
if (!params.query || typeof params.query !== "string" || params.query.trim() === "") return {
|
|
1860
|
+
content: [{
|
|
1861
|
+
type: "text",
|
|
1862
|
+
text: "memory_kg_search error: query is required"
|
|
1863
|
+
}],
|
|
1864
|
+
isError: true
|
|
1865
|
+
};
|
|
1866
|
+
const { generateEmbedding } = await import("./embeddings-Bn86ssxR.mjs").then((n) => n.i);
|
|
1867
|
+
const result = await graphCompletionSearch(federationDb, pool, await generateEmbedding(params.query, true), {
|
|
1868
|
+
seedCount: params.wide_k ?? 50,
|
|
1869
|
+
hops: params.neighborhood_depth ?? 1,
|
|
1870
|
+
maxTriples: params.top_k ?? 20,
|
|
1871
|
+
projectId: params.project_id,
|
|
1872
|
+
tenantId: params.tenant_id ?? "default"
|
|
1873
|
+
});
|
|
1874
|
+
if (result.triples.length === 0) return { content: [{
|
|
1875
|
+
type: "text",
|
|
1876
|
+
text: `No KG triples found for query: "${params.query}"\nSeed chunks: ${result.seedChunks.length}, Expanded entities: ${result.expandedEntities.length}`
|
|
1877
|
+
}] };
|
|
1878
|
+
const tripleLines = result.triples.map((t, i) => `[${i + 1}] (score=${t.relevanceScore.toFixed(4)}) ${t.subject} — ${t.predicate} — ${t.object}`).join("\n");
|
|
1879
|
+
const entityList = result.expandedEntities.slice(0, 10).join(", ");
|
|
1880
|
+
return { content: [{
|
|
1881
|
+
type: "text",
|
|
1882
|
+
text: [
|
|
1883
|
+
`Found ${result.triples.length} KG triple(s) for "${params.query}"`,
|
|
1884
|
+
`Seed chunks: ${result.seedChunks.length}`,
|
|
1885
|
+
`Expanded entities (${result.expandedEntities.length}): ${entityList}`,
|
|
1886
|
+
"",
|
|
1887
|
+
tripleLines
|
|
1888
|
+
].join("\n")
|
|
1889
|
+
}] };
|
|
1890
|
+
} catch (e) {
|
|
1891
|
+
return {
|
|
1892
|
+
content: [{
|
|
1893
|
+
type: "text",
|
|
1894
|
+
text: `memory_kg_search error: ${String(e)}`
|
|
1895
|
+
}],
|
|
1896
|
+
isError: true
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
//#endregion
|
|
1902
|
+
//#region src/mcp/tools.ts
|
|
1903
|
+
var tools_exports = /* @__PURE__ */ __exportAll({
|
|
1904
|
+
combineHybridResults: () => combineHybridResults,
|
|
1905
|
+
detectProjectFromPath: () => detectProjectFromPath,
|
|
1906
|
+
formatProject: () => formatProject,
|
|
1907
|
+
lookupProjectId: () => lookupProjectId,
|
|
1908
|
+
toolKgAdd: () => toolKgAdd,
|
|
1909
|
+
toolKgContradictions: () => toolKgContradictions,
|
|
1910
|
+
toolKgInvalidate: () => toolKgInvalidate,
|
|
1911
|
+
toolKgQuery: () => toolKgQuery,
|
|
1912
|
+
toolMemoryFeedback: () => toolMemoryFeedback,
|
|
1913
|
+
toolMemoryGet: () => toolMemoryGet,
|
|
1914
|
+
toolMemoryKgSearch: () => toolMemoryKgSearch,
|
|
1915
|
+
toolMemorySearch: () => toolMemorySearch,
|
|
1916
|
+
toolMemoryTaxonomy: () => toolMemoryTaxonomy,
|
|
1917
|
+
toolMemoryTunnels: () => toolMemoryTunnels,
|
|
1918
|
+
toolMemoryWakeup: () => toolMemoryWakeup,
|
|
1919
|
+
toolProjectDetect: () => toolProjectDetect,
|
|
1920
|
+
toolProjectHealth: () => toolProjectHealth,
|
|
1921
|
+
toolProjectInfo: () => toolProjectInfo,
|
|
1922
|
+
toolProjectList: () => toolProjectList,
|
|
1923
|
+
toolProjectTodo: () => toolProjectTodo,
|
|
1924
|
+
toolRegistrySearch: () => toolRegistrySearch,
|
|
1925
|
+
toolSessionList: () => toolSessionList,
|
|
1926
|
+
toolSessionRoute: () => toolSessionRoute,
|
|
1927
|
+
toolZettelCommunities: () => toolZettelCommunities,
|
|
1928
|
+
toolZettelConverse: () => toolZettelConverse,
|
|
1929
|
+
toolZettelExplore: () => toolZettelExplore,
|
|
1930
|
+
toolZettelGodNotes: () => toolZettelGodNotes,
|
|
1931
|
+
toolZettelHealth: () => toolZettelHealth,
|
|
1932
|
+
toolZettelSuggest: () => toolZettelSuggest,
|
|
1933
|
+
toolZettelSurprise: () => toolZettelSurprise,
|
|
1934
|
+
toolZettelThemes: () => toolZettelThemes
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
//#endregion
|
|
1938
|
+
export { toolMemoryWakeup as a, toolSessionRoute as c, toolProjectInfo as d, toolProjectList as f, toolMemorySearch as h, toolMemoryTaxonomy as i, toolProjectDetect as l, toolMemoryGet as m, toolMemoryKgSearch as n, toolRegistrySearch as o, toolProjectTodo as p, toolMemoryFeedback as r, toolSessionList as s, tools_exports as t, toolProjectHealth as u };
|
|
1939
|
+
//# sourceMappingURL=tools-DMAQxlOk.mjs.map
|