@tekmidian/pai 0.1.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.
Files changed (66) hide show
  1. package/ARCHITECTURE.md +567 -0
  2. package/FEATURE.md +108 -0
  3. package/LICENSE +21 -0
  4. package/README.md +101 -0
  5. package/dist/auto-route-D7W6RE06.mjs +86 -0
  6. package/dist/auto-route-D7W6RE06.mjs.map +1 -0
  7. package/dist/cli/index.d.mts +1 -0
  8. package/dist/cli/index.mjs +5927 -0
  9. package/dist/cli/index.mjs.map +1 -0
  10. package/dist/config-DBh1bYM2.mjs +151 -0
  11. package/dist/config-DBh1bYM2.mjs.map +1 -0
  12. package/dist/daemon/index.d.mts +1 -0
  13. package/dist/daemon/index.mjs +56 -0
  14. package/dist/daemon/index.mjs.map +1 -0
  15. package/dist/daemon-mcp/index.d.mts +1 -0
  16. package/dist/daemon-mcp/index.mjs +185 -0
  17. package/dist/daemon-mcp/index.mjs.map +1 -0
  18. package/dist/daemon-v5O897D4.mjs +773 -0
  19. package/dist/daemon-v5O897D4.mjs.map +1 -0
  20. package/dist/db-4lSqLFb8.mjs +199 -0
  21. package/dist/db-4lSqLFb8.mjs.map +1 -0
  22. package/dist/db-BcDxXVBu.mjs +110 -0
  23. package/dist/db-BcDxXVBu.mjs.map +1 -0
  24. package/dist/detect-BHqYcjJ1.mjs +86 -0
  25. package/dist/detect-BHqYcjJ1.mjs.map +1 -0
  26. package/dist/detector-DKA83aTZ.mjs +74 -0
  27. package/dist/detector-DKA83aTZ.mjs.map +1 -0
  28. package/dist/embeddings-mfqv-jFu.mjs +91 -0
  29. package/dist/embeddings-mfqv-jFu.mjs.map +1 -0
  30. package/dist/factory-BDAiKtYR.mjs +42 -0
  31. package/dist/factory-BDAiKtYR.mjs.map +1 -0
  32. package/dist/index.d.mts +307 -0
  33. package/dist/index.d.mts.map +1 -0
  34. package/dist/index.mjs +11 -0
  35. package/dist/indexer-B20bPHL-.mjs +677 -0
  36. package/dist/indexer-B20bPHL-.mjs.map +1 -0
  37. package/dist/indexer-backend-BXaocO5r.mjs +360 -0
  38. package/dist/indexer-backend-BXaocO5r.mjs.map +1 -0
  39. package/dist/ipc-client-DPy7s3iu.mjs +156 -0
  40. package/dist/ipc-client-DPy7s3iu.mjs.map +1 -0
  41. package/dist/mcp/index.d.mts +1 -0
  42. package/dist/mcp/index.mjs +373 -0
  43. package/dist/mcp/index.mjs.map +1 -0
  44. package/dist/migrate-Bwj7qPaE.mjs +241 -0
  45. package/dist/migrate-Bwj7qPaE.mjs.map +1 -0
  46. package/dist/pai-marker-DX_mFLum.mjs +186 -0
  47. package/dist/pai-marker-DX_mFLum.mjs.map +1 -0
  48. package/dist/postgres-Ccvpc6fC.mjs +335 -0
  49. package/dist/postgres-Ccvpc6fC.mjs.map +1 -0
  50. package/dist/rolldown-runtime-95iHPtFO.mjs +18 -0
  51. package/dist/schemas-DjdwzIQ8.mjs +3405 -0
  52. package/dist/schemas-DjdwzIQ8.mjs.map +1 -0
  53. package/dist/search-PjftDxxs.mjs +282 -0
  54. package/dist/search-PjftDxxs.mjs.map +1 -0
  55. package/dist/sqlite-CHUrNtbI.mjs +90 -0
  56. package/dist/sqlite-CHUrNtbI.mjs.map +1 -0
  57. package/dist/tools-CLK4080-.mjs +805 -0
  58. package/dist/tools-CLK4080-.mjs.map +1 -0
  59. package/dist/utils-DEWdIFQ0.mjs +160 -0
  60. package/dist/utils-DEWdIFQ0.mjs.map +1 -0
  61. package/package.json +72 -0
  62. package/templates/README.md +181 -0
  63. package/templates/agent-prefs.example.md +362 -0
  64. package/templates/claude-md.template.md +733 -0
  65. package/templates/pai-project.template.md +13 -0
  66. package/templates/voices.example.json +251 -0
@@ -0,0 +1,805 @@
1
+ import { i as searchMemoryHybrid, n as populateSlugs } from "./search-PjftDxxs.mjs";
2
+ import { r as formatDetectionJson, t as detectProject } from "./detect-BHqYcjJ1.mjs";
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+
6
+ //#region src/mcp/tools.ts
7
+ /**
8
+ * PAI Knowledge OS — Pure tool handler functions (shared by daemon + legacy MCP server)
9
+ *
10
+ * Each function accepts pre-opened database handles and raw params, executes
11
+ * the tool logic, and returns an MCP-style content array.
12
+ *
13
+ * This module does NOT import indexAll() — indexing is handled by the daemon
14
+ * on its own schedule. The search hot path is pure DB read.
15
+ */
16
+ function lookupProjectId(registryDb, slug) {
17
+ const bySlug = registryDb.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
18
+ if (bySlug) return bySlug.id;
19
+ const byAlias = registryDb.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(slug);
20
+ if (byAlias) return byAlias.project_id;
21
+ return null;
22
+ }
23
+ function detectProjectFromPath(registryDb, fsPath) {
24
+ const resolved = resolve(fsPath);
25
+ const exact = registryDb.prepare("SELECT id, slug, display_name, root_path, type, status, created_at, updated_at FROM projects WHERE root_path = ?").get(resolved);
26
+ if (exact) return exact;
27
+ 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();
28
+ for (const project of all) if (resolved.startsWith(project.root_path + "/") || resolved === project.root_path) return project;
29
+ return null;
30
+ }
31
+ function formatProject(registryDb, project) {
32
+ const sessionCount = registryDb.prepare("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?").get(project.id).n;
33
+ const lastSession = registryDb.prepare("SELECT date FROM sessions WHERE project_id = ? ORDER BY date DESC LIMIT 1").get(project.id);
34
+ const tags = registryDb.prepare(`SELECT t.name FROM tags t
35
+ JOIN project_tags pt ON pt.tag_id = t.id
36
+ WHERE pt.project_id = ?
37
+ ORDER BY t.name`).all(project.id).map((r) => r.name);
38
+ const aliases = registryDb.prepare("SELECT alias FROM aliases WHERE project_id = ? ORDER BY alias").all(project.id).map((r) => r.alias);
39
+ const lines = [
40
+ `slug: ${project.slug}`,
41
+ `display_name: ${project.display_name}`,
42
+ `root_path: ${project.root_path}`,
43
+ `type: ${project.type}`,
44
+ `status: ${project.status}`,
45
+ `sessions: ${sessionCount}`
46
+ ];
47
+ if (lastSession) lines.push(`last_session: ${lastSession.date}`);
48
+ if (tags.length) lines.push(`tags: ${tags.join(", ")}`);
49
+ if (aliases.length) lines.push(`aliases: ${aliases.join(", ")}`);
50
+ if (project.obsidian_link) lines.push(`obsidian_link: ${project.obsidian_link}`);
51
+ if (project.archived_at) lines.push(`archived_at: ${new Date(project.archived_at).toISOString().slice(0, 10)}`);
52
+ return lines.join("\n");
53
+ }
54
+ async function toolMemorySearch(registryDb, federation, params) {
55
+ try {
56
+ const projectIds = params.project ? (() => {
57
+ const id = lookupProjectId(registryDb, params.project);
58
+ return id != null ? [id] : [];
59
+ })() : void 0;
60
+ if (params.project && (!projectIds || projectIds.length === 0)) return {
61
+ content: [{
62
+ type: "text",
63
+ text: `Project not found: ${params.project}`
64
+ }],
65
+ isError: true
66
+ };
67
+ const mode = params.mode ?? "keyword";
68
+ const snippetLength = params.snippetLength ?? 200;
69
+ const searchOpts = {
70
+ projectIds,
71
+ sources: params.sources,
72
+ maxResults: params.limit ?? 5
73
+ };
74
+ let results;
75
+ const isBackend = (x) => "backendType" in x;
76
+ if (isBackend(federation)) if (mode === "keyword") results = await federation.searchKeyword(params.query, searchOpts);
77
+ else if (mode === "semantic" || mode === "hybrid") {
78
+ const { generateEmbedding } = await import("./embeddings-mfqv-jFu.mjs").then((n) => n.i);
79
+ const queryEmbedding = await generateEmbedding(params.query, true);
80
+ if (mode === "semantic") results = await federation.searchSemantic(queryEmbedding, searchOpts);
81
+ else {
82
+ const [kwResults, semResults] = await Promise.all([federation.searchKeyword(params.query, {
83
+ ...searchOpts,
84
+ maxResults: 50
85
+ }), federation.searchSemantic(queryEmbedding, {
86
+ ...searchOpts,
87
+ maxResults: 50
88
+ })]);
89
+ results = combineHybridResults(kwResults, semResults, searchOpts.maxResults ?? 10);
90
+ }
91
+ } else results = await federation.searchKeyword(params.query, searchOpts);
92
+ else {
93
+ const { searchMemory, searchMemorySemantic } = await import("./search-PjftDxxs.mjs").then((n) => n.o);
94
+ if (mode === "keyword") results = searchMemory(federation, params.query, searchOpts);
95
+ else if (mode === "semantic" || mode === "hybrid") {
96
+ const { generateEmbedding } = await import("./embeddings-mfqv-jFu.mjs").then((n) => n.i);
97
+ const queryEmbedding = await generateEmbedding(params.query, true);
98
+ if (mode === "semantic") results = searchMemorySemantic(federation, queryEmbedding, searchOpts);
99
+ else results = searchMemoryHybrid(federation, params.query, queryEmbedding, searchOpts);
100
+ } else results = searchMemory(federation, params.query, searchOpts);
101
+ }
102
+ const withSlugs = populateSlugs(results, registryDb);
103
+ if (withSlugs.length === 0) return { content: [{
104
+ type: "text",
105
+ text: `No results found for query: "${params.query}" (mode: ${mode})`
106
+ }] };
107
+ const formatted = withSlugs.map((r, i) => {
108
+ const header = `[${i + 1}] ${r.projectSlug ?? `project:${r.projectId}`} — ${r.path} (lines ${r.startLine}-${r.endLine}) score=${r.score.toFixed(4)} tier=${r.tier} source=${r.source}`;
109
+ const raw = r.snippet.trim();
110
+ return `${header}\n${raw.length > snippetLength ? raw.slice(0, snippetLength) + "..." : raw}`;
111
+ }).join("\n\n---\n\n");
112
+ return { content: [{
113
+ type: "text",
114
+ text: `Found ${withSlugs.length} result(s) for "${params.query}" (mode: ${mode}):\n\n${formatted}`
115
+ }] };
116
+ } catch (e) {
117
+ return {
118
+ content: [{
119
+ type: "text",
120
+ text: `Search error: ${String(e)}`
121
+ }],
122
+ isError: true
123
+ };
124
+ }
125
+ }
126
+ function toolMemoryGet(registryDb, params) {
127
+ try {
128
+ const projectId = lookupProjectId(registryDb, params.project);
129
+ if (projectId == null) return {
130
+ content: [{
131
+ type: "text",
132
+ text: `Project not found: ${params.project}`
133
+ }],
134
+ isError: true
135
+ };
136
+ const project = registryDb.prepare("SELECT root_path FROM projects WHERE id = ?").get(projectId);
137
+ if (!project) return {
138
+ content: [{
139
+ type: "text",
140
+ text: `Project not found: ${params.project}`
141
+ }],
142
+ isError: true
143
+ };
144
+ const requestedPath = params.path;
145
+ if (requestedPath.includes("..") || isAbsolute(requestedPath)) return {
146
+ content: [{
147
+ type: "text",
148
+ text: `Invalid path: ${params.path} (must be a relative path within the project root, no ../ allowed)`
149
+ }],
150
+ isError: true
151
+ };
152
+ const fullPath = join(project.root_path, requestedPath);
153
+ const resolvedFull = resolve(fullPath);
154
+ const resolvedRoot = resolve(project.root_path);
155
+ if (!resolvedFull.startsWith(resolvedRoot + "/") && resolvedFull !== resolvedRoot) return {
156
+ content: [{
157
+ type: "text",
158
+ text: `Path traversal blocked: ${params.path}`
159
+ }],
160
+ isError: true
161
+ };
162
+ if (!existsSync(fullPath)) return {
163
+ content: [{
164
+ type: "text",
165
+ text: `File not found: ${requestedPath} (project: ${params.project})`
166
+ }],
167
+ isError: true
168
+ };
169
+ const stat = statSync(fullPath);
170
+ if (stat.size > 5 * 1024 * 1024) return { content: [{
171
+ type: "text",
172
+ text: `Error: file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Maximum 5 MB.`
173
+ }] };
174
+ const allLines = readFileSync(fullPath, "utf8").split("\n");
175
+ const fromLine = (params.from ?? 1) - 1;
176
+ const toLine = params.lines != null ? Math.min(fromLine + params.lines, allLines.length) : allLines.length;
177
+ const text = allLines.slice(fromLine, toLine).join("\n");
178
+ return { content: [{
179
+ type: "text",
180
+ text: `${params.from != null ? `${params.project}/${requestedPath} (lines ${fromLine + 1}-${toLine}):` : `${params.project}/${requestedPath}:`}\n\n${text}`
181
+ }] };
182
+ } catch (e) {
183
+ return {
184
+ content: [{
185
+ type: "text",
186
+ text: `Read error: ${String(e)}`
187
+ }],
188
+ isError: true
189
+ };
190
+ }
191
+ }
192
+ function toolProjectInfo(registryDb, params) {
193
+ try {
194
+ let project = null;
195
+ if (params.slug) {
196
+ const projectId = lookupProjectId(registryDb, params.slug);
197
+ 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);
198
+ } else project = detectProjectFromPath(registryDb, process.cwd());
199
+ if (!project) return {
200
+ content: [{
201
+ type: "text",
202
+ text: params.slug ? `Project not found: ${params.slug}` : `No PAI project found matching the current directory: ${process.cwd()}`
203
+ }],
204
+ isError: !params.slug
205
+ };
206
+ return { content: [{
207
+ type: "text",
208
+ text: formatProject(registryDb, project)
209
+ }] };
210
+ } catch (e) {
211
+ return {
212
+ content: [{
213
+ type: "text",
214
+ text: `project_info error: ${String(e)}`
215
+ }],
216
+ isError: true
217
+ };
218
+ }
219
+ }
220
+ function toolProjectList(registryDb, params) {
221
+ try {
222
+ const conditions = [];
223
+ const queryParams = [];
224
+ if (params.status) {
225
+ conditions.push("p.status = ?");
226
+ queryParams.push(params.status);
227
+ }
228
+ if (params.tag) {
229
+ 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 = ?)");
230
+ queryParams.push(params.tag);
231
+ }
232
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
233
+ const limit = params.limit ?? 50;
234
+ queryParams.push(limit);
235
+ const projects = registryDb.prepare(`SELECT p.id, p.slug, p.display_name, p.root_path, p.type, p.status, p.updated_at
236
+ FROM projects p
237
+ ${where}
238
+ ORDER BY p.updated_at DESC
239
+ LIMIT ?`).all(...queryParams);
240
+ if (projects.length === 0) return { content: [{
241
+ type: "text",
242
+ text: "No projects found matching the given filters."
243
+ }] };
244
+ const lines = projects.map((p) => `${p.slug} [${p.status}] ${p.root_path} (updated: ${new Date(p.updated_at).toISOString().slice(0, 10)})`);
245
+ return { content: [{
246
+ type: "text",
247
+ text: `${projects.length} project(s):\n\n${lines.join("\n")}`
248
+ }] };
249
+ } catch (e) {
250
+ return {
251
+ content: [{
252
+ type: "text",
253
+ text: `project_list error: ${String(e)}`
254
+ }],
255
+ isError: true
256
+ };
257
+ }
258
+ }
259
+ function toolSessionList(registryDb, params) {
260
+ try {
261
+ const projectId = lookupProjectId(registryDb, params.project);
262
+ if (projectId == null) return {
263
+ content: [{
264
+ type: "text",
265
+ text: `Project not found: ${params.project}`
266
+ }],
267
+ isError: true
268
+ };
269
+ const conditions = ["project_id = ?"];
270
+ const queryParams = [projectId];
271
+ if (params.status) {
272
+ conditions.push("status = ?");
273
+ queryParams.push(params.status);
274
+ }
275
+ const limit = params.limit ?? 10;
276
+ queryParams.push(limit);
277
+ const sessions = registryDb.prepare(`SELECT number, date, title, filename, status
278
+ FROM sessions
279
+ WHERE ${conditions.join(" AND ")}
280
+ ORDER BY number DESC
281
+ LIMIT ?`).all(...queryParams);
282
+ if (sessions.length === 0) return { content: [{
283
+ type: "text",
284
+ text: `No sessions found for project: ${params.project}`
285
+ }] };
286
+ const lines = sessions.map((s) => `#${String(s.number).padStart(4, "0")} ${s.date} [${s.status}] ${s.title}\n file: Notes/${s.filename}`);
287
+ return { content: [{
288
+ type: "text",
289
+ text: `${sessions.length} session(s) for ${params.project}:\n\n${lines.join("\n\n")}`
290
+ }] };
291
+ } catch (e) {
292
+ return {
293
+ content: [{
294
+ type: "text",
295
+ text: `session_list error: ${String(e)}`
296
+ }],
297
+ isError: true
298
+ };
299
+ }
300
+ }
301
+ function toolRegistrySearch(registryDb, params) {
302
+ try {
303
+ const q = `%${params.query}%`;
304
+ const projects = registryDb.prepare(`SELECT id, slug, display_name, root_path, type, status, updated_at
305
+ FROM projects
306
+ WHERE slug LIKE ?
307
+ OR display_name LIKE ?
308
+ OR root_path LIKE ?
309
+ ORDER BY updated_at DESC
310
+ LIMIT 20`).all(q, q, q);
311
+ if (projects.length === 0) return { content: [{
312
+ type: "text",
313
+ text: `No projects found matching: "${params.query}"`
314
+ }] };
315
+ const lines = projects.map((p) => `${p.slug} [${p.status}] ${p.root_path}`);
316
+ return { content: [{
317
+ type: "text",
318
+ text: `${projects.length} match(es) for "${params.query}":\n\n${lines.join("\n")}`
319
+ }] };
320
+ } catch (e) {
321
+ return {
322
+ content: [{
323
+ type: "text",
324
+ text: `registry_search error: ${String(e)}`
325
+ }],
326
+ isError: true
327
+ };
328
+ }
329
+ }
330
+ function toolProjectDetect(registryDb, params) {
331
+ try {
332
+ const detection = detectProject(registryDb, params.cwd);
333
+ if (!detection) return { content: [{
334
+ type: "text",
335
+ text: `No registered project found for path: ${params.cwd ?? process.cwd()}\n\nRun 'pai project add .' to register this directory.`
336
+ }] };
337
+ return { content: [{
338
+ type: "text",
339
+ text: formatDetectionJson(detection)
340
+ }] };
341
+ } catch (e) {
342
+ return {
343
+ content: [{
344
+ type: "text",
345
+ text: `project_detect error: ${String(e)}`
346
+ }],
347
+ isError: true
348
+ };
349
+ }
350
+ }
351
+ async function toolProjectHealth(registryDb, params) {
352
+ try {
353
+ const { existsSync: fsExists, readdirSync, statSync } = await import("node:fs");
354
+ const { join: pathJoin, basename: pathBasename } = await import("node:path");
355
+ const { homedir } = await import("node:os");
356
+ const { encodeDir: enc } = await import("./utils-DEWdIFQ0.mjs").then((n) => n.h);
357
+ const rows = registryDb.prepare(`SELECT p.id, p.slug, p.display_name, p.root_path, p.encoded_dir, p.status, p.type,
358
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
359
+ FROM projects p
360
+ ORDER BY p.slug ASC`).all();
361
+ const home = homedir();
362
+ const claudeProjects = pathJoin(home, ".claude", "projects");
363
+ function suggestMoved(rootPath) {
364
+ const name = pathBasename(rootPath);
365
+ return [
366
+ pathJoin(home, "dev", name),
367
+ pathJoin(home, "dev", "ai", name),
368
+ pathJoin(home, "Desktop", name),
369
+ pathJoin(home, "Projects", name)
370
+ ].find((c) => fsExists(c));
371
+ }
372
+ function hasClaudeNotes(encodedDir) {
373
+ if (!fsExists(claudeProjects)) return false;
374
+ try {
375
+ for (const entry of readdirSync(claudeProjects)) {
376
+ if (entry !== encodedDir && !entry.startsWith(encodedDir)) continue;
377
+ const full = pathJoin(claudeProjects, entry);
378
+ try {
379
+ if (!statSync(full).isDirectory()) continue;
380
+ } catch {
381
+ continue;
382
+ }
383
+ if (fsExists(pathJoin(full, "Notes"))) return true;
384
+ }
385
+ } catch {}
386
+ return false;
387
+ }
388
+ function findTodoForProject(rootPath) {
389
+ for (const rel of [
390
+ "Notes/TODO.md",
391
+ ".claude/Notes/TODO.md",
392
+ "tasks/todo.md",
393
+ "TODO.md"
394
+ ]) {
395
+ const full = pathJoin(rootPath, rel);
396
+ if (fsExists(full)) try {
397
+ const raw = readFileSync(full, "utf8");
398
+ return {
399
+ found: true,
400
+ path: rel,
401
+ has_continue: /^## Continue$/m.test(raw)
402
+ };
403
+ } catch {
404
+ return {
405
+ found: true,
406
+ path: rel,
407
+ has_continue: false
408
+ };
409
+ }
410
+ }
411
+ return {
412
+ found: false,
413
+ path: null,
414
+ has_continue: false
415
+ };
416
+ }
417
+ const results = rows.map((p) => {
418
+ const pathExists = fsExists(p.root_path);
419
+ let health;
420
+ let suggestedPath = null;
421
+ if (pathExists) health = "active";
422
+ else {
423
+ suggestedPath = suggestMoved(p.root_path) ?? null;
424
+ health = suggestedPath ? "stale" : "dead";
425
+ }
426
+ const todo = pathExists ? findTodoForProject(p.root_path) : {
427
+ found: false,
428
+ path: null,
429
+ has_continue: false
430
+ };
431
+ return {
432
+ slug: p.slug,
433
+ display_name: p.display_name,
434
+ root_path: p.root_path,
435
+ status: p.status,
436
+ type: p.type,
437
+ session_count: p.session_count,
438
+ health,
439
+ suggested_path: suggestedPath,
440
+ has_claude_notes: hasClaudeNotes(p.encoded_dir),
441
+ todo
442
+ };
443
+ });
444
+ const filtered = !params.category || params.category === "all" ? results : results.filter((r) => r.health === params.category);
445
+ const summary = {
446
+ total: rows.length,
447
+ active: results.filter((r) => r.health === "active").length,
448
+ stale: results.filter((r) => r.health === "stale").length,
449
+ dead: results.filter((r) => r.health === "dead").length
450
+ };
451
+ return { content: [{
452
+ type: "text",
453
+ text: JSON.stringify({
454
+ summary,
455
+ projects: filtered
456
+ }, null, 2)
457
+ }] };
458
+ } catch (e) {
459
+ return {
460
+ content: [{
461
+ type: "text",
462
+ text: `project_health error: ${String(e)}`
463
+ }],
464
+ isError: true
465
+ };
466
+ }
467
+ }
468
+ /**
469
+ * TODO candidate locations searched in priority order.
470
+ * Returns the first one that exists, along with its label.
471
+ */
472
+ const TODO_LOCATIONS = [
473
+ {
474
+ rel: "Notes/TODO.md",
475
+ label: "Notes/TODO.md"
476
+ },
477
+ {
478
+ rel: ".claude/Notes/TODO.md",
479
+ label: ".claude/Notes/TODO.md"
480
+ },
481
+ {
482
+ rel: "tasks/todo.md",
483
+ label: "tasks/todo.md"
484
+ },
485
+ {
486
+ rel: "TODO.md",
487
+ label: "TODO.md"
488
+ }
489
+ ];
490
+ /**
491
+ * Given TODO file content, extract and surface the ## Continue section first,
492
+ * then return the remaining content. Returns an object with:
493
+ * continueSection: string | null
494
+ * fullContent: string
495
+ * hasContinue: boolean
496
+ */
497
+ function parseTodoContent(raw) {
498
+ const lines = raw.split("\n");
499
+ const continueIdx = lines.findIndex((l) => l.trim() === "## Continue");
500
+ if (continueIdx === -1) return {
501
+ continueSection: null,
502
+ fullContent: raw,
503
+ hasContinue: false
504
+ };
505
+ let endIdx = lines.length;
506
+ for (let i = continueIdx + 1; i < lines.length; i++) {
507
+ const trimmed = lines[i].trim();
508
+ if (trimmed === "---" || trimmed.startsWith("##") && trimmed !== "## Continue") {
509
+ endIdx = i;
510
+ break;
511
+ }
512
+ }
513
+ return {
514
+ continueSection: lines.slice(continueIdx, endIdx).join("\n").trim(),
515
+ fullContent: raw,
516
+ hasContinue: true
517
+ };
518
+ }
519
+ function toolProjectTodo(registryDb, params) {
520
+ try {
521
+ let rootPath;
522
+ let projectSlug;
523
+ if (params.project) {
524
+ const projectId = lookupProjectId(registryDb, params.project);
525
+ if (projectId == null) return {
526
+ content: [{
527
+ type: "text",
528
+ text: `Project not found: ${params.project}`
529
+ }],
530
+ isError: true
531
+ };
532
+ const row = registryDb.prepare("SELECT root_path, slug FROM projects WHERE id = ?").get(projectId);
533
+ if (!row) return {
534
+ content: [{
535
+ type: "text",
536
+ text: `Project not found: ${params.project}`
537
+ }],
538
+ isError: true
539
+ };
540
+ rootPath = row.root_path;
541
+ projectSlug = row.slug;
542
+ } else {
543
+ const project = detectProjectFromPath(registryDb, process.cwd());
544
+ if (!project) return { content: [{
545
+ type: "text",
546
+ 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.`
547
+ }] };
548
+ rootPath = project.root_path;
549
+ projectSlug = project.slug;
550
+ }
551
+ for (const loc of TODO_LOCATIONS) {
552
+ const fullPath = join(rootPath, loc.rel);
553
+ if (existsSync(fullPath)) {
554
+ const { continueSection, fullContent, hasContinue } = parseTodoContent(readFileSync(fullPath, "utf8"));
555
+ let output;
556
+ if (hasContinue && continueSection) output = [
557
+ `TODO found: ${projectSlug}/${loc.label}`,
558
+ "",
559
+ "=== CONTINUE SECTION (surfaced first) ===",
560
+ continueSection,
561
+ "",
562
+ "=== FULL TODO CONTENT ===",
563
+ fullContent
564
+ ].join("\n");
565
+ else output = [
566
+ `TODO found: ${projectSlug}/${loc.label}`,
567
+ "",
568
+ fullContent
569
+ ].join("\n");
570
+ return { content: [{
571
+ type: "text",
572
+ text: output
573
+ }] };
574
+ }
575
+ }
576
+ const searched = TODO_LOCATIONS.map((l) => ` ${rootPath}/${l.rel}`).join("\n");
577
+ return { content: [{
578
+ type: "text",
579
+ text: [
580
+ `No TODO.md found for project: ${projectSlug}`,
581
+ "",
582
+ "Searched locations (in order):",
583
+ searched,
584
+ "",
585
+ "Create a TODO with: echo '## Tasks\\n- [ ] First task' > Notes/TODO.md"
586
+ ].join("\n")
587
+ }] };
588
+ } catch (e) {
589
+ return {
590
+ content: [{
591
+ type: "text",
592
+ text: `project_todo error: ${String(e)}`
593
+ }],
594
+ isError: true
595
+ };
596
+ }
597
+ }
598
+ /**
599
+ * Handle notification config queries and updates via the daemon IPC.
600
+ * Falls back gracefully if the daemon is not running.
601
+ */
602
+ async function toolNotificationConfig(params) {
603
+ try {
604
+ const { PaiClient } = await import("./ipc-client-DPy7s3iu.mjs").then((n) => n.n);
605
+ const client = new PaiClient();
606
+ if (params.action === "get") {
607
+ const { config, activeChannels } = await client.getNotificationConfig();
608
+ return { content: [{
609
+ type: "text",
610
+ text: [
611
+ `mode: ${config.mode}`,
612
+ `active_channels: ${activeChannels.join(", ") || "(none)"}`,
613
+ "",
614
+ "channels:",
615
+ ...Object.entries(config.channels).map(([ch, cfg]) => {
616
+ return ` ${ch}: ${cfg.enabled ? "enabled" : "disabled"}`;
617
+ }),
618
+ "",
619
+ "routing:",
620
+ ...Object.entries(config.routing).map(([event, channels]) => ` ${event}: ${channels.join(", ") || "(none)"}`)
621
+ ].join("\n")
622
+ }] };
623
+ }
624
+ if (params.action === "set") {
625
+ if (!params.mode && !params.channels && !params.routing) return {
626
+ content: [{
627
+ type: "text",
628
+ text: "notification_config set: provide at least one of mode, channels, or routing."
629
+ }],
630
+ isError: true
631
+ };
632
+ return { content: [{
633
+ type: "text",
634
+ text: `Notification config updated. Mode: ${(await client.setNotificationConfig({
635
+ mode: params.mode,
636
+ channels: params.channels,
637
+ routing: params.routing
638
+ })).config.mode}`
639
+ }] };
640
+ }
641
+ if (params.action === "send") {
642
+ if (!params.message) return {
643
+ content: [{
644
+ type: "text",
645
+ text: "notification_config send: message is required."
646
+ }],
647
+ isError: true
648
+ };
649
+ const result = await client.sendNotification({
650
+ event: params.event ?? "info",
651
+ message: params.message,
652
+ title: params.title
653
+ });
654
+ return { content: [{
655
+ type: "text",
656
+ text: [
657
+ `mode: ${result.mode}`,
658
+ `attempted: ${result.channelsAttempted.join(", ") || "(none)"}`,
659
+ `succeeded: ${result.channelsSucceeded.join(", ") || "(none)"}`,
660
+ ...result.channelsFailed.length > 0 ? [`failed: ${result.channelsFailed.join(", ")}`] : []
661
+ ].join("\n")
662
+ }] };
663
+ }
664
+ return {
665
+ content: [{
666
+ type: "text",
667
+ text: `Unknown action: ${String(params.action)}. Use "get", "set", or "send".`
668
+ }],
669
+ isError: true
670
+ };
671
+ } catch (e) {
672
+ return {
673
+ content: [{
674
+ type: "text",
675
+ text: `notification_config error: ${String(e)}`
676
+ }],
677
+ isError: true
678
+ };
679
+ }
680
+ }
681
+ /**
682
+ * Detect whether recent conversation context has shifted to a different project.
683
+ * Uses memory_search to find which project best matches the context, then
684
+ * compares against the current project.
685
+ *
686
+ * Calls the daemon via IPC so it has access to the storage backend.
687
+ * Falls back gracefully if the daemon is not running.
688
+ */
689
+ async function toolTopicDetect(params) {
690
+ try {
691
+ const { PaiClient } = await import("./ipc-client-DPy7s3iu.mjs").then((n) => n.n);
692
+ const result = await new PaiClient().topicCheck({
693
+ context: params.context,
694
+ currentProject: params.current_project,
695
+ threshold: params.threshold
696
+ });
697
+ const lines = [
698
+ `shifted: ${result.shifted}`,
699
+ `current_project: ${result.currentProject ?? "(none)"}`,
700
+ `suggested_project: ${result.suggestedProject ?? "(none)"}`,
701
+ `confidence: ${result.confidence.toFixed(3)}`,
702
+ `chunks_scored: ${result.chunkCount}`
703
+ ];
704
+ if (result.topProjects.length > 0) {
705
+ lines.push("");
706
+ lines.push("top_matches:");
707
+ for (const p of result.topProjects) lines.push(` ${p.slug}: ${(p.score * 100).toFixed(1)}%`);
708
+ }
709
+ if (result.shifted) {
710
+ lines.push("");
711
+ lines.push(`TOPIC SHIFT DETECTED: conversation appears to be about "${result.suggestedProject}" (confidence: ${(result.confidence * 100).toFixed(0)}%), not "${result.currentProject}".`);
712
+ }
713
+ return { content: [{
714
+ type: "text",
715
+ text: lines.join("\n")
716
+ }] };
717
+ } catch (e) {
718
+ return {
719
+ content: [{
720
+ type: "text",
721
+ text: `topic_detect error: ${String(e)}`
722
+ }],
723
+ isError: true
724
+ };
725
+ }
726
+ }
727
+ /**
728
+ * Automatically suggest which project a session belongs to.
729
+ *
730
+ * Strategy (in priority order):
731
+ * 1. path — exact or parent-directory match in the project registry
732
+ * 2. marker — walk up from cwd looking for Notes/PAI.md
733
+ * 3. topic — BM25 keyword search against memory (requires context)
734
+ *
735
+ * Call this at session start (e.g., from CLAUDE.md or a session-start hook)
736
+ * to automatically route the session to the correct project.
737
+ */
738
+ async function toolSessionRoute(registryDb, federation, params) {
739
+ try {
740
+ const { autoRoute, formatAutoRouteJson } = await import("./auto-route-D7W6RE06.mjs");
741
+ const result = await autoRoute(registryDb, federation, params.cwd, params.context);
742
+ if (!result) return { content: [{
743
+ type: "text",
744
+ text: [
745
+ `No project match found for: ${params.cwd ?? process.cwd()}`,
746
+ "",
747
+ "Tried: path match, PAI.md marker walk" + (params.context ? ", topic detection" : ""),
748
+ "",
749
+ "Run 'pai project add .' to register this directory,",
750
+ "or provide conversation context for topic-based routing."
751
+ ].join("\n")
752
+ }] };
753
+ return { content: [{
754
+ type: "text",
755
+ text: formatAutoRouteJson(result)
756
+ }] };
757
+ } catch (e) {
758
+ return {
759
+ content: [{
760
+ type: "text",
761
+ text: `session_route error: ${String(e)}`
762
+ }],
763
+ isError: true
764
+ };
765
+ }
766
+ }
767
+ /**
768
+ * Combine keyword + semantic results using min-max normalized scoring.
769
+ * Mirrors the logic in searchMemoryHybrid() from memory/search.ts,
770
+ * but works on pre-computed result arrays so it works for any backend.
771
+ */
772
+ function combineHybridResults(keywordResults, semanticResults, maxResults, keywordWeight = .5, semanticWeight = .5) {
773
+ if (keywordResults.length === 0 && semanticResults.length === 0) return [];
774
+ const keyFor = (r) => `${r.projectId}:${r.path}:${r.startLine}:${r.endLine}`;
775
+ function minMaxNormalize(items) {
776
+ if (items.length === 0) return /* @__PURE__ */ new Map();
777
+ const min = Math.min(...items.map((r) => r.score));
778
+ const range = Math.max(...items.map((r) => r.score)) - min;
779
+ const m = /* @__PURE__ */ new Map();
780
+ for (const r of items) m.set(keyFor(r), range === 0 ? 1 : (r.score - min) / range);
781
+ return m;
782
+ }
783
+ const kwNorm = minMaxNormalize(keywordResults);
784
+ const semNorm = minMaxNormalize(semanticResults);
785
+ const allKeys = new Set([...keywordResults.map(keyFor), ...semanticResults.map(keyFor)]);
786
+ const metaMap = /* @__PURE__ */ new Map();
787
+ for (const r of [...keywordResults, ...semanticResults]) metaMap.set(keyFor(r), r);
788
+ const combined = [];
789
+ for (const key of allKeys) {
790
+ const meta = metaMap.get(key);
791
+ const kwScore = kwNorm.get(key) ?? 0;
792
+ const semScore = semNorm.get(key) ?? 0;
793
+ const combinedScore = keywordWeight * kwScore + semanticWeight * semScore;
794
+ combined.push({
795
+ ...meta,
796
+ score: combinedScore,
797
+ combinedScore
798
+ });
799
+ }
800
+ return combined.sort((a, b) => b.score - a.score).slice(0, maxResults).map(({ combinedScore: _unused, ...r }) => r);
801
+ }
802
+
803
+ //#endregion
804
+ export { toolProjectHealth as a, toolProjectTodo as c, toolSessionRoute as d, toolTopicDetect as f, toolProjectDetect as i, toolRegistrySearch as l, toolMemorySearch as n, toolProjectInfo as o, toolNotificationConfig as r, toolProjectList as s, toolMemoryGet as t, toolSessionList as u };
805
+ //# sourceMappingURL=tools-CLK4080-.mjs.map