@tekmidian/pai 0.18.0 → 0.18.2
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-tE6ymgYb.mjs +86 -0
- package/dist/auto-route-tE6ymgYb.mjs.map +1 -0
- package/dist/checkpoint-block-CkvwYA5y.mjs +1062 -0
- package/dist/checkpoint-block-CkvwYA5y.mjs.map +1 -0
- package/dist/cli/index.mjs +6 -5
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +6 -5
- package/dist/cli/program.mjs.map +1 -1
- package/dist/clusters-CRXU2T6Z.mjs +201 -0
- package/dist/clusters-CRXU2T6Z.mjs.map +1 -0
- package/dist/config-CcdkNSWa.mjs +204 -0
- package/dist/config-CcdkNSWa.mjs.map +1 -0
- package/dist/daemon/index.mjs +9 -9
- package/dist/daemon-DPHtKQB2.mjs +1576 -0
- package/dist/daemon-DPHtKQB2.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +2 -2
- package/dist/detector-BGw8SNWe.mjs +74 -0
- package/dist/detector-BGw8SNWe.mjs.map +1 -0
- package/dist/factory-1UI19STL.mjs +72 -0
- package/dist/factory-1UI19STL.mjs.map +1 -0
- package/dist/indexer-backend-B93q9ICi.mjs +299 -0
- package/dist/indexer-backend-B93q9ICi.mjs.map +1 -0
- package/dist/ipc-client-aVKVERjJ.mjs +152 -0
- package/dist/ipc-client-aVKVERjJ.mjs.map +1 -0
- package/dist/kg-entity-r8duqhi9.mjs +176 -0
- package/dist/kg-entity-r8duqhi9.mjs.map +1 -0
- package/dist/latent-ideas-CHrgdkSW.mjs +191 -0
- package/dist/latent-ideas-CHrgdkSW.mjs.map +1 -0
- package/dist/main-resolver-BozXCqpl.mjs +1340 -0
- package/dist/main-resolver-BozXCqpl.mjs.map +1 -0
- package/dist/neighborhood-jPQBkbB-.mjs +135 -0
- package/dist/neighborhood-jPQBkbB-.mjs.map +1 -0
- package/dist/note-context-D2S2sR38.mjs +126 -0
- package/dist/note-context-D2S2sR38.mjs.map +1 -0
- package/dist/pick-BRdJ4gpT.mjs +12666 -0
- package/dist/pick-BRdJ4gpT.mjs.map +1 -0
- package/dist/pick-CNL0TMhQ.mjs +12731 -0
- package/dist/pick-CNL0TMhQ.mjs.map +1 -0
- package/dist/postgres-D96XWfwI.mjs +891 -0
- package/dist/postgres-D96XWfwI.mjs.map +1 -0
- package/dist/query-feedback-CdBJ2icj.mjs +76 -0
- package/dist/query-feedback-CdBJ2icj.mjs.map +1 -0
- package/dist/runtime-paths-B0P1TvUr.mjs +50 -0
- package/dist/runtime-paths-B0P1TvUr.mjs.map +1 -0
- package/dist/sqlite-CR9aMImG.mjs +271 -0
- package/dist/sqlite-CR9aMImG.mjs.map +1 -0
- package/dist/state-DTvy-jRB.mjs +102 -0
- package/dist/state-DTvy-jRB.mjs.map +1 -0
- package/dist/themes-HzMKPTas.mjs +148 -0
- package/dist/themes-HzMKPTas.mjs.map +1 -0
- package/dist/tools-DuorP_SS.mjs +1939 -0
- package/dist/tools-DuorP_SS.mjs.map +1 -0
- package/dist/trace-Bf7OprC5.mjs +137 -0
- package/dist/trace-Bf7OprC5.mjs.map +1 -0
- package/dist/vault-indexer-qAfX09RY.mjs +536 -0
- package/dist/vault-indexer-qAfX09RY.mjs.map +1 -0
- package/dist/work-queue-worker-Bov9eWMC.mjs +1856 -0
- package/dist/work-queue-worker-Bov9eWMC.mjs.map +1 -0
- package/dist/zettelkasten-DcZk-XSm.mjs +1063 -0
- package/dist/zettelkasten-DcZk-XSm.mjs.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { a as parseSessionTitleChunk, c as yieldToEventLoop, f as sha256File, i as isPathTooBroadForContentScan, l as chunkMarkdown, n as chunkId, o as walkContentFiles, r as detectTier, s as walkMdFiles, t as INDEX_YIELD_EVERY } from "./helpers-crDEr6S2.mjs";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
//#region src/memory/indexer/async.ts
|
|
6
|
+
/**
|
|
7
|
+
* Backend-aware async indexer for PAI federation memory.
|
|
8
|
+
*
|
|
9
|
+
* Provides the same functionality as sync.ts but writes through the
|
|
10
|
+
* StorageBackend interface instead of directly to better-sqlite3.
|
|
11
|
+
* Used when the daemon is configured with the Postgres backend.
|
|
12
|
+
*
|
|
13
|
+
* The SQLite path still uses sync.ts directly (which is faster for SQLite
|
|
14
|
+
* due to synchronous transactions).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Index a single file through the StorageBackend interface.
|
|
18
|
+
* Returns true if the file was re-indexed (changed or new), false if skipped.
|
|
19
|
+
*/
|
|
20
|
+
async function indexFileWithBackend(backend, projectId, rootPath, relativePath, source, tier) {
|
|
21
|
+
const absPath = join(rootPath, relativePath);
|
|
22
|
+
let content;
|
|
23
|
+
let stat;
|
|
24
|
+
try {
|
|
25
|
+
content = readFileSync(absPath, "utf8");
|
|
26
|
+
stat = statSync(absPath);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const hash = sha256File(content);
|
|
31
|
+
const mtime = Math.floor(stat.mtimeMs);
|
|
32
|
+
const size = stat.size;
|
|
33
|
+
if (await backend.getFileHash(projectId, relativePath) === hash) return false;
|
|
34
|
+
await backend.deleteChunksForFile(projectId, relativePath);
|
|
35
|
+
const rawChunks = chunkMarkdown(content);
|
|
36
|
+
const updatedAt = Date.now();
|
|
37
|
+
const chunks = rawChunks.map((c, i) => ({
|
|
38
|
+
id: chunkId(projectId, relativePath, i, c.startLine, c.endLine),
|
|
39
|
+
projectId,
|
|
40
|
+
source,
|
|
41
|
+
tier,
|
|
42
|
+
path: relativePath,
|
|
43
|
+
startLine: c.startLine,
|
|
44
|
+
endLine: c.endLine,
|
|
45
|
+
hash: c.hash,
|
|
46
|
+
text: c.text,
|
|
47
|
+
updatedAt,
|
|
48
|
+
embedding: null
|
|
49
|
+
}));
|
|
50
|
+
await backend.insertChunks(chunks);
|
|
51
|
+
await backend.upsertFile({
|
|
52
|
+
projectId,
|
|
53
|
+
path: relativePath,
|
|
54
|
+
source,
|
|
55
|
+
tier,
|
|
56
|
+
hash,
|
|
57
|
+
mtime,
|
|
58
|
+
size
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
async function indexProjectWithBackend(backend, projectId, rootPath, claudeNotesDir) {
|
|
63
|
+
const result = {
|
|
64
|
+
filesProcessed: 0,
|
|
65
|
+
chunksCreated: 0,
|
|
66
|
+
filesSkipped: 0
|
|
67
|
+
};
|
|
68
|
+
const filesToIndex = [];
|
|
69
|
+
const rootMemoryMd = join(rootPath, "MEMORY.md");
|
|
70
|
+
if (existsSync(rootMemoryMd)) filesToIndex.push({
|
|
71
|
+
absPath: rootMemoryMd,
|
|
72
|
+
rootBase: rootPath,
|
|
73
|
+
source: "memory",
|
|
74
|
+
tier: "evergreen"
|
|
75
|
+
});
|
|
76
|
+
const memoryDir = join(rootPath, "memory");
|
|
77
|
+
for (const absPath of walkMdFiles(memoryDir)) {
|
|
78
|
+
const tier = detectTier(relative(rootPath, absPath));
|
|
79
|
+
filesToIndex.push({
|
|
80
|
+
absPath,
|
|
81
|
+
rootBase: rootPath,
|
|
82
|
+
source: "memory",
|
|
83
|
+
tier
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const notesDir = join(rootPath, "Notes");
|
|
87
|
+
for (const absPath of walkMdFiles(notesDir)) filesToIndex.push({
|
|
88
|
+
absPath,
|
|
89
|
+
rootBase: rootPath,
|
|
90
|
+
source: "notes",
|
|
91
|
+
tier: "session"
|
|
92
|
+
});
|
|
93
|
+
{
|
|
94
|
+
const updatedAt = Date.now();
|
|
95
|
+
for (const absPath of walkMdFiles(notesDir)) {
|
|
96
|
+
const text = parseSessionTitleChunk(basename(absPath));
|
|
97
|
+
if (!text) continue;
|
|
98
|
+
const syntheticPath = `${relative(rootPath, absPath)}::title`;
|
|
99
|
+
const titleChunk = {
|
|
100
|
+
id: chunkId(projectId, syntheticPath, 0, 0, 0),
|
|
101
|
+
projectId,
|
|
102
|
+
source: "notes",
|
|
103
|
+
tier: "session",
|
|
104
|
+
path: syntheticPath,
|
|
105
|
+
startLine: 0,
|
|
106
|
+
endLine: 0,
|
|
107
|
+
hash: sha256File(text),
|
|
108
|
+
text,
|
|
109
|
+
updatedAt,
|
|
110
|
+
embedding: null
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
await backend.insertChunks([titleChunk]);
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!isPathTooBroadForContentScan(rootPath)) for (const absPath of walkContentFiles(rootPath)) filesToIndex.push({
|
|
118
|
+
absPath,
|
|
119
|
+
rootBase: rootPath,
|
|
120
|
+
source: "content",
|
|
121
|
+
tier: "topic"
|
|
122
|
+
});
|
|
123
|
+
if (claudeNotesDir && claudeNotesDir !== notesDir) {
|
|
124
|
+
for (const absPath of walkMdFiles(claudeNotesDir)) filesToIndex.push({
|
|
125
|
+
absPath,
|
|
126
|
+
rootBase: claudeNotesDir,
|
|
127
|
+
source: "notes",
|
|
128
|
+
tier: "session"
|
|
129
|
+
});
|
|
130
|
+
{
|
|
131
|
+
const updatedAt = Date.now();
|
|
132
|
+
for (const absPath of walkMdFiles(claudeNotesDir)) {
|
|
133
|
+
const text = parseSessionTitleChunk(basename(absPath));
|
|
134
|
+
if (!text) continue;
|
|
135
|
+
const syntheticPath = `${relative(claudeNotesDir, absPath)}::title`;
|
|
136
|
+
const titleChunk = {
|
|
137
|
+
id: chunkId(projectId, syntheticPath, 0, 0, 0),
|
|
138
|
+
projectId,
|
|
139
|
+
source: "notes",
|
|
140
|
+
tier: "session",
|
|
141
|
+
path: syntheticPath,
|
|
142
|
+
startLine: 0,
|
|
143
|
+
endLine: 0,
|
|
144
|
+
hash: sha256File(text),
|
|
145
|
+
text,
|
|
146
|
+
updatedAt,
|
|
147
|
+
embedding: null
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
await backend.insertChunks([titleChunk]);
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (claudeNotesDir.endsWith("/Notes")) {
|
|
155
|
+
const claudeProjectDir = claudeNotesDir.slice(0, -6);
|
|
156
|
+
const claudeMemoryMd = join(claudeProjectDir, "MEMORY.md");
|
|
157
|
+
if (existsSync(claudeMemoryMd)) filesToIndex.push({
|
|
158
|
+
absPath: claudeMemoryMd,
|
|
159
|
+
rootBase: claudeProjectDir,
|
|
160
|
+
source: "memory",
|
|
161
|
+
tier: "evergreen"
|
|
162
|
+
});
|
|
163
|
+
const claudeMemoryDir = join(claudeProjectDir, "memory");
|
|
164
|
+
for (const absPath of walkMdFiles(claudeMemoryDir)) {
|
|
165
|
+
const tier = detectTier(relative(claudeProjectDir, absPath));
|
|
166
|
+
filesToIndex.push({
|
|
167
|
+
absPath,
|
|
168
|
+
rootBase: claudeProjectDir,
|
|
169
|
+
source: "memory",
|
|
170
|
+
tier
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await yieldToEventLoop();
|
|
176
|
+
let filesSinceYield = 0;
|
|
177
|
+
for (const { absPath, rootBase, source, tier } of filesToIndex) {
|
|
178
|
+
if (filesSinceYield >= INDEX_YIELD_EVERY) {
|
|
179
|
+
await yieldToEventLoop();
|
|
180
|
+
filesSinceYield = 0;
|
|
181
|
+
}
|
|
182
|
+
filesSinceYield++;
|
|
183
|
+
const relPath = relative(rootBase, absPath);
|
|
184
|
+
try {
|
|
185
|
+
if (await indexFileWithBackend(backend, projectId, rootBase, relPath, source, tier)) {
|
|
186
|
+
const ids = await backend.getChunkIds(projectId, relPath);
|
|
187
|
+
result.filesProcessed++;
|
|
188
|
+
result.chunksCreated += ids.length;
|
|
189
|
+
} else result.filesSkipped++;
|
|
190
|
+
} catch {
|
|
191
|
+
result.filesSkipped++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const livePaths = /* @__PURE__ */ new Set();
|
|
195
|
+
for (const { absPath, rootBase } of filesToIndex) livePaths.add(relative(rootBase, absPath));
|
|
196
|
+
const dbChunkPaths = await backend.getDistinctChunkPaths(projectId);
|
|
197
|
+
const stalePaths = [];
|
|
198
|
+
for (const p of dbChunkPaths) {
|
|
199
|
+
const basePath = p.endsWith("::title") ? p.slice(0, -7) : p;
|
|
200
|
+
if (!livePaths.has(basePath)) stalePaths.push(p);
|
|
201
|
+
}
|
|
202
|
+
if (stalePaths.length > 0) await backend.deletePaths(projectId, stalePaths);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
const EMBED_BATCH_SIZE = 50;
|
|
206
|
+
/** Default ceiling on one pass. See EmbedPassOptions. */
|
|
207
|
+
const DEFAULT_MAX_CHUNKS_PER_PASS = 5e3;
|
|
208
|
+
/** Default wall-clock ceiling on one pass, in milliseconds. */
|
|
209
|
+
const DEFAULT_MAX_MILLIS_PER_PASS = 12e4;
|
|
210
|
+
/**
|
|
211
|
+
* Generate and store embeddings for unembedded chunks via the StorageBackend.
|
|
212
|
+
*
|
|
213
|
+
* The pass is deliberately bounded (see EmbedPassOptions) and resumable: it
|
|
214
|
+
* takes a slice of the backlog, embeds it, and returns so the scheduler can run
|
|
215
|
+
* an index pass before coming back. Draining the whole backlog in one call
|
|
216
|
+
* starves indexing for as long as the call runs.
|
|
217
|
+
*
|
|
218
|
+
* The optional `shouldStop` callback is checked between every batch. When it
|
|
219
|
+
* returns true the embed loop exits early so the caller (e.g. the daemon
|
|
220
|
+
* shutdown handler) can close the pool without racing against active queries.
|
|
221
|
+
*
|
|
222
|
+
* Returns the number of newly embedded chunks.
|
|
223
|
+
*/
|
|
224
|
+
async function embedChunksWithBackend(backend, shouldStop, projectNames, options) {
|
|
225
|
+
const { generateEmbeddings, serializeEmbedding } = await import("./embeddings-Bn86ssxR.mjs").then((n) => n.i);
|
|
226
|
+
const maxChunks = options?.maxChunks ?? DEFAULT_MAX_CHUNKS_PER_PASS;
|
|
227
|
+
const maxMillis = options?.maxMillis ?? DEFAULT_MAX_MILLIS_PER_PASS;
|
|
228
|
+
const deadline = Date.now() + maxMillis;
|
|
229
|
+
const rows = await backend.getUnembeddedChunkIds(void 0, maxChunks);
|
|
230
|
+
if (rows.length === 0) return 0;
|
|
231
|
+
const total = rows.length;
|
|
232
|
+
let embedded = 0;
|
|
233
|
+
const projectChunkCounts = /* @__PURE__ */ new Map();
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
const entry = projectChunkCounts.get(row.project_id);
|
|
236
|
+
if (entry) entry.count++;
|
|
237
|
+
else projectChunkCounts.set(row.project_id, {
|
|
238
|
+
count: 1,
|
|
239
|
+
samplePath: row.path
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const pName = (pid) => projectNames?.get(pid) ?? `project ${pid}`;
|
|
243
|
+
const projectSummary = Array.from(projectChunkCounts.entries()).map(([pid, { count, samplePath }]) => ` ${pName(pid)}: ${count} chunks (e.g. ${samplePath})`).join("\n");
|
|
244
|
+
process.stderr.write(`[pai-daemon] Embed pass: ${total} unembedded chunks across ${projectChunkCounts.size} project(s)\n${projectSummary}\n`);
|
|
245
|
+
let currentProjectId = -1;
|
|
246
|
+
let projectEmbedded = 0;
|
|
247
|
+
for (let i = 0; i < rows.length; i += EMBED_BATCH_SIZE) {
|
|
248
|
+
if (shouldStop?.()) {
|
|
249
|
+
process.stderr.write(`[pai-daemon] Embed pass cancelled after ${embedded}/${total} chunks (shutdown requested)\n`);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (Date.now() >= deadline) {
|
|
253
|
+
process.stderr.write(`[pai-daemon] Embed pass yielding after ${embedded}/${total} chunks (${maxMillis}ms budget spent); resuming next pass\n`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
const batch = rows.slice(i, i + EMBED_BATCH_SIZE);
|
|
257
|
+
await yieldToEventLoop();
|
|
258
|
+
const vecs = await generateEmbeddings(batch.map((r) => r.text));
|
|
259
|
+
await Promise.all(batch.map((row, j) => backend.updateEmbedding(row.id, serializeEmbedding(vecs[j]))));
|
|
260
|
+
for (const { project_id, path } of batch) {
|
|
261
|
+
if (project_id !== currentProjectId) {
|
|
262
|
+
if (currentProjectId !== -1) process.stderr.write(`[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\n`);
|
|
263
|
+
const info = projectChunkCounts.get(project_id);
|
|
264
|
+
process.stderr.write(`[pai-daemon] Embedding ${pName(project_id)} (${info?.count ?? "?"} chunks, starting at ${path})\n`);
|
|
265
|
+
currentProjectId = project_id;
|
|
266
|
+
projectEmbedded = 0;
|
|
267
|
+
}
|
|
268
|
+
projectEmbedded++;
|
|
269
|
+
}
|
|
270
|
+
embedded += batch.length;
|
|
271
|
+
const lastChunk = batch[batch.length - 1];
|
|
272
|
+
process.stderr.write(`[pai-daemon] Embedded ${embedded}/${total} chunks (${pName(lastChunk.project_id)}: ${lastChunk.path})\n`);
|
|
273
|
+
}
|
|
274
|
+
if (currentProjectId !== -1) process.stderr.write(`[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\n`);
|
|
275
|
+
return embedded;
|
|
276
|
+
}
|
|
277
|
+
async function indexAllWithBackend(backend, registryDb) {
|
|
278
|
+
const projects = registryDb.prepare("SELECT id, root_path, claude_notes_dir FROM projects WHERE status = 'active'").all();
|
|
279
|
+
const totals = {
|
|
280
|
+
filesProcessed: 0,
|
|
281
|
+
chunksCreated: 0,
|
|
282
|
+
filesSkipped: 0
|
|
283
|
+
};
|
|
284
|
+
for (const project of projects) {
|
|
285
|
+
await yieldToEventLoop();
|
|
286
|
+
const r = await indexProjectWithBackend(backend, project.id, project.root_path, project.claude_notes_dir);
|
|
287
|
+
totals.filesProcessed += r.filesProcessed;
|
|
288
|
+
totals.chunksCreated += r.chunksCreated;
|
|
289
|
+
totals.filesSkipped += r.filesSkipped;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
projects: projects.length,
|
|
293
|
+
result: totals
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
//#endregion
|
|
298
|
+
export { embedChunksWithBackend, indexAllWithBackend };
|
|
299
|
+
//# sourceMappingURL=indexer-backend-B93q9ICi.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"indexer-backend-B93q9ICi.mjs","names":[],"sources":["../src/memory/indexer/async.ts"],"sourcesContent":["/**\n * Backend-aware async indexer for PAI federation memory.\n *\n * Provides the same functionality as sync.ts but writes through the\n * StorageBackend interface instead of directly to better-sqlite3.\n * Used when the daemon is configured with the Postgres backend.\n *\n * The SQLite path still uses sync.ts directly (which is faster for SQLite\n * due to synchronous transactions).\n */\n\nimport { readFileSync, statSync, existsSync } from \"node:fs\";\nimport { join, relative, basename } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend, ChunkRow } from \"../../storage/interface.js\";\nimport { chunkMarkdown } from \"../chunker.js\";\nimport {\n sha256File,\n chunkId,\n detectTier,\n walkMdFiles,\n walkContentFiles,\n isPathTooBroadForContentScan,\n parseSessionTitleChunk,\n yieldToEventLoop,\n INDEX_YIELD_EVERY,\n} from \"./helpers.js\";\nimport type { IndexResult } from \"./types.js\";\n\nexport type { IndexResult };\n\n// ---------------------------------------------------------------------------\n// Single-file indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\n/**\n * Index a single file through the StorageBackend interface.\n * Returns true if the file was re-indexed (changed or new), false if skipped.\n */\nexport async function indexFileWithBackend(\n backend: StorageBackend,\n projectId: number,\n rootPath: string,\n relativePath: string,\n source: string,\n tier: string,\n): Promise<boolean> {\n const absPath = join(rootPath, relativePath);\n\n let content: string;\n let stat: ReturnType<typeof statSync>;\n try {\n content = readFileSync(absPath, \"utf8\");\n stat = statSync(absPath);\n } catch {\n return false;\n }\n\n const hash = sha256File(content);\n const mtime = Math.floor(stat.mtimeMs);\n const size = stat.size;\n\n // Change detection\n const existingHash = await backend.getFileHash(projectId, relativePath);\n if (existingHash === hash) return false;\n\n // Delete old chunks\n await backend.deleteChunksForFile(projectId, relativePath);\n\n // Chunk the content\n const rawChunks = chunkMarkdown(content);\n const updatedAt = Date.now();\n\n const chunks: ChunkRow[] = rawChunks.map((c, i) => ({\n id: chunkId(projectId, relativePath, i, c.startLine, c.endLine),\n projectId,\n source,\n tier,\n path: relativePath,\n startLine: c.startLine,\n endLine: c.endLine,\n hash: c.hash,\n text: c.text,\n updatedAt,\n embedding: null,\n }));\n\n // Insert chunks + update file record\n await backend.insertChunks(chunks);\n await backend.upsertFile({ projectId, path: relativePath, source, tier, hash, mtime, size });\n\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Project-level indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\nexport async function indexProjectWithBackend(\n backend: StorageBackend,\n projectId: number,\n rootPath: string,\n claudeNotesDir?: string | null,\n): Promise<IndexResult> {\n const result: IndexResult = { filesProcessed: 0, chunksCreated: 0, filesSkipped: 0 };\n\n const filesToIndex: Array<{ absPath: string; rootBase: string; source: string; tier: string }> = [];\n\n const rootMemoryMd = join(rootPath, \"MEMORY.md\");\n if (existsSync(rootMemoryMd)) {\n filesToIndex.push({ absPath: rootMemoryMd, rootBase: rootPath, source: \"memory\", tier: \"evergreen\" });\n }\n\n const memoryDir = join(rootPath, \"memory\");\n for (const absPath of walkMdFiles(memoryDir)) {\n const relPath = relative(rootPath, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"memory\", tier });\n }\n\n const notesDir = join(rootPath, \"Notes\");\n for (const absPath of walkMdFiles(notesDir)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic session-title chunks for Notes files\n {\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(notesDir)) {\n const fileName = basename(absPath);\n const text = parseSessionTitleChunk(fileName);\n if (!text) continue;\n const relPath = relative(rootPath, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n const titleChunk: ChunkRow = {\n id, projectId, source: \"notes\", tier: \"session\",\n path: syntheticPath, startLine: 0, endLine: 0,\n hash, text, updatedAt, embedding: null,\n };\n try {\n await backend.insertChunks([titleChunk]);\n } catch {\n // Skip title chunks that cause backend errors\n }\n }\n }\n\n if (!isPathTooBroadForContentScan(rootPath)) {\n for (const absPath of walkContentFiles(rootPath)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"content\", tier: \"topic\" });\n }\n }\n\n if (claudeNotesDir && claudeNotesDir !== notesDir) {\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n filesToIndex.push({ absPath, rootBase: claudeNotesDir, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic title chunks for claude notes dir\n {\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n const fileName = basename(absPath);\n const text = parseSessionTitleChunk(fileName);\n if (!text) continue;\n const relPath = relative(claudeNotesDir, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n const titleChunk: ChunkRow = {\n id, projectId, source: \"notes\", tier: \"session\",\n path: syntheticPath, startLine: 0, endLine: 0,\n hash, text, updatedAt, embedding: null,\n };\n try {\n await backend.insertChunks([titleChunk]);\n } catch {\n // Skip title chunks that cause backend errors\n }\n }\n }\n\n if (claudeNotesDir.endsWith(\"/Notes\")) {\n const claudeProjectDir = claudeNotesDir.slice(0, -\"/Notes\".length);\n const claudeMemoryMd = join(claudeProjectDir, \"MEMORY.md\");\n if (existsSync(claudeMemoryMd)) {\n filesToIndex.push({ absPath: claudeMemoryMd, rootBase: claudeProjectDir, source: \"memory\", tier: \"evergreen\" });\n }\n const claudeMemoryDir = join(claudeProjectDir, \"memory\");\n for (const absPath of walkMdFiles(claudeMemoryDir)) {\n const relPath = relative(claudeProjectDir, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: claudeProjectDir, source: \"memory\", tier });\n }\n }\n }\n\n await yieldToEventLoop();\n\n let filesSinceYield = 0;\n\n for (const { absPath, rootBase, source, tier } of filesToIndex) {\n if (filesSinceYield >= INDEX_YIELD_EVERY) {\n await yieldToEventLoop();\n filesSinceYield = 0;\n }\n filesSinceYield++;\n\n const relPath = relative(rootBase, absPath);\n try {\n const changed = await indexFileWithBackend(backend, projectId, rootBase, relPath, source, tier);\n\n if (changed) {\n const ids = await backend.getChunkIds(projectId, relPath);\n result.filesProcessed++;\n result.chunksCreated += ids.length;\n } else {\n result.filesSkipped++;\n }\n } catch {\n // Skip files that cause backend errors (e.g. null bytes in Postgres)\n result.filesSkipped++;\n }\n }\n\n // Prune stale paths\n const livePaths = new Set<string>();\n for (const { absPath, rootBase } of filesToIndex) {\n livePaths.add(relative(rootBase, absPath));\n }\n\n const dbChunkPaths = await backend.getDistinctChunkPaths(projectId);\n\n const stalePaths: string[] = [];\n for (const p of dbChunkPaths) {\n const basePath = p.endsWith(\"::title\") ? p.slice(0, -\"::title\".length) : p;\n if (!livePaths.has(basePath)) {\n stalePaths.push(p);\n }\n }\n\n if (stalePaths.length > 0) {\n await backend.deletePaths(projectId, stalePaths);\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Embedding generation via StorageBackend\n// ---------------------------------------------------------------------------\n\nconst EMBED_BATCH_SIZE = 50;\nconst EMBED_YIELD_EVERY = 1;\n\n/** Default ceiling on one pass. See EmbedPassOptions. */\nconst DEFAULT_MAX_CHUNKS_PER_PASS = 5_000;\n/** Default wall-clock ceiling on one pass, in milliseconds. */\nconst DEFAULT_MAX_MILLIS_PER_PASS = 120_000;\n\nexport interface EmbedPassOptions {\n /**\n * Stop the pass after this many chunks. Unbounded passes are the reason a\n * six-figure backlog starves the indexer: the daemon serialises indexing and\n * embedding against each other, so a pass that runs for hours means nothing\n * new gets indexed for hours. Bounding the pass costs nothing — every\n * embedding is written as it is produced, so the next pass resumes exactly\n * where this one stopped.\n */\n maxChunks?: number;\n /** Stop the pass after this much wall-clock time, whichever bound hits first. */\n maxMillis?: number;\n}\n\n/**\n * Generate and store embeddings for unembedded chunks via the StorageBackend.\n *\n * The pass is deliberately bounded (see EmbedPassOptions) and resumable: it\n * takes a slice of the backlog, embeds it, and returns so the scheduler can run\n * an index pass before coming back. Draining the whole backlog in one call\n * starves indexing for as long as the call runs.\n *\n * The optional `shouldStop` callback is checked between every batch. When it\n * returns true the embed loop exits early so the caller (e.g. the daemon\n * shutdown handler) can close the pool without racing against active queries.\n *\n * Returns the number of newly embedded chunks.\n */\nexport async function embedChunksWithBackend(\n backend: StorageBackend,\n shouldStop?: () => boolean,\n projectNames?: Map<number, string>,\n options?: EmbedPassOptions,\n): Promise<number> {\n const { generateEmbeddings, serializeEmbedding } = await import(\"../embeddings.js\");\n\n const maxChunks = options?.maxChunks ?? DEFAULT_MAX_CHUNKS_PER_PASS;\n const maxMillis = options?.maxMillis ?? DEFAULT_MAX_MILLIS_PER_PASS;\n const deadline = Date.now() + maxMillis;\n\n const rows = await backend.getUnembeddedChunkIds(undefined, maxChunks);\n if (rows.length === 0) return 0;\n\n const total = rows.length;\n let embedded = 0;\n\n // Build a summary of what needs embedding: count chunks per project_id\n const projectChunkCounts = new Map<number, { count: number; samplePath: string }>();\n for (const row of rows) {\n const entry = projectChunkCounts.get(row.project_id);\n if (entry) {\n entry.count++;\n } else {\n projectChunkCounts.set(row.project_id, { count: 1, samplePath: row.path });\n }\n }\n const pName = (pid: number) => projectNames?.get(pid) ?? `project ${pid}`;\n const projectSummary = Array.from(projectChunkCounts.entries())\n .map(([pid, { count, samplePath }]) => ` ${pName(pid)}: ${count} chunks (e.g. ${samplePath})`)\n .join(\"\\n\");\n process.stderr.write(\n `[pai-daemon] Embed pass: ${total} unembedded chunks across ${projectChunkCounts.size} project(s)\\n${projectSummary}\\n`\n );\n\n // Track current project for transition logging\n let currentProjectId = -1;\n let projectEmbedded = 0;\n\n for (let i = 0; i < rows.length; i += EMBED_BATCH_SIZE) {\n // Check cancellation between every batch before touching the pool again\n if (shouldStop?.()) {\n process.stderr.write(\n `[pai-daemon] Embed pass cancelled after ${embedded}/${total} chunks (shutdown requested)\\n`\n );\n break;\n }\n\n // Yield the daemon back to the indexer rather than run past the deadline.\n // The remaining rows are simply picked up by the next scheduled pass.\n if (Date.now() >= deadline) {\n process.stderr.write(\n `[pai-daemon] Embed pass yielding after ${embedded}/${total} chunks (${maxMillis}ms budget spent); resuming next pass\\n`\n );\n break;\n }\n\n const batch = rows.slice(i, i + EMBED_BATCH_SIZE);\n\n // Keep IPC responsive: the forward pass below is synchronous inside the\n // model, so yield before entering it rather than between chunks.\n await yieldToEventLoop();\n\n const vecs = await generateEmbeddings(batch.map((r) => r.text));\n // Issue the writes together rather than one round-trip at a time. Measured\n // on this machine the model embeds 40-67 chunks/s in isolation while the\n // daemon managed ~5/s: the difference was one sequential UPDATE per chunk,\n // so the pass spent most of its budget waiting on the network, not working.\n // Concurrency is bounded by the batch size, which the pool handles; the\n // SQLite backend is synchronous and simply ignores the difference.\n await Promise.all(\n batch.map((row, j) => backend.updateEmbedding(row.id, serializeEmbedding(vecs[j])))\n );\n\n // Attribute the batch to projects only after it is durably stored, and walk\n // it in order so a batch straddling a project boundary credits each side\n // correctly. Rows arrive ordered by project, so a boundary is a real\n // transition, not the per-chunk flapping this logging used to produce.\n for (const { project_id, path } of batch) {\n if (project_id !== currentProjectId) {\n if (currentProjectId !== -1) {\n process.stderr.write(\n `[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\\n`\n );\n }\n const info = projectChunkCounts.get(project_id);\n process.stderr.write(\n `[pai-daemon] Embedding ${pName(project_id)} (${info?.count ?? \"?\"} chunks, starting at ${path})\\n`\n );\n currentProjectId = project_id;\n projectEmbedded = 0;\n }\n projectEmbedded++;\n }\n\n embedded += batch.length;\n\n // Log progress with current file path for context\n const lastChunk = batch[batch.length - 1];\n process.stderr.write(\n `[pai-daemon] Embedded ${embedded}/${total} chunks (${pName(lastChunk.project_id)}: ${lastChunk.path})\\n`\n );\n }\n\n // Log final project completion\n if (currentProjectId !== -1) {\n process.stderr.write(\n `[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\\n`\n );\n }\n\n return embedded;\n}\n\n// ---------------------------------------------------------------------------\n// Global indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\nexport async function indexAllWithBackend(\n backend: StorageBackend,\n registryDb: Database,\n): Promise<{ projects: number; result: IndexResult }> {\n const projects = registryDb\n .prepare(\"SELECT id, root_path, claude_notes_dir FROM projects WHERE status = 'active'\")\n .all() as Array<{ id: number; root_path: string; claude_notes_dir: string | null }>;\n\n const totals: IndexResult = { filesProcessed: 0, chunksCreated: 0, filesSkipped: 0 };\n\n for (const project of projects) {\n await yieldToEventLoop();\n const r = await indexProjectWithBackend(backend, project.id, project.root_path, project.claude_notes_dir);\n totals.filesProcessed += r.filesProcessed;\n totals.chunksCreated += r.chunksCreated;\n totals.filesSkipped += r.filesSkipped;\n }\n\n return { projects: projects.length, result: totals };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { i as paiSocketPath } from "./runtime-paths-B0P1TvUr.mjs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
|
|
5
|
+
//#region src/daemon/ipc-client.ts
|
|
6
|
+
/**
|
|
7
|
+
* ipc-client.ts — IPC client for the PAI Daemon MCP shim
|
|
8
|
+
*
|
|
9
|
+
* PaiClient connects to the Unix Domain Socket served by daemon.ts
|
|
10
|
+
* and forwards tool calls to the daemon. Uses a fresh socket connection per
|
|
11
|
+
* call (connect → write JSON + newline → read response line → parse → destroy).
|
|
12
|
+
* This keeps the client stateless and avoids connection management complexity.
|
|
13
|
+
*
|
|
14
|
+
* Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).
|
|
15
|
+
*/
|
|
16
|
+
/** Default socket path */
|
|
17
|
+
const IPC_SOCKET_PATH = paiSocketPath();
|
|
18
|
+
/** Timeout for IPC calls (60 seconds) */
|
|
19
|
+
const IPC_TIMEOUT_MS = 6e4;
|
|
20
|
+
/**
|
|
21
|
+
* Thin IPC proxy that forwards tool calls to pai-daemon over a Unix
|
|
22
|
+
* Domain Socket. Each call opens a fresh connection, sends one NDJSON request,
|
|
23
|
+
* reads the response, and closes. Stateless and simple.
|
|
24
|
+
*/
|
|
25
|
+
var PaiClient = class {
|
|
26
|
+
socketPath;
|
|
27
|
+
constructor(socketPath) {
|
|
28
|
+
this.socketPath = socketPath ?? IPC_SOCKET_PATH;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Call a PAI tool by name with the given params.
|
|
32
|
+
* Returns the tool result or throws on error.
|
|
33
|
+
*/
|
|
34
|
+
async call(method, params) {
|
|
35
|
+
return this.send(method, params);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Check daemon status.
|
|
39
|
+
*/
|
|
40
|
+
async status() {
|
|
41
|
+
return await this.send("status", {});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Trigger an immediate index run.
|
|
45
|
+
*/
|
|
46
|
+
async triggerIndex() {
|
|
47
|
+
await this.send("index_now", {});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get the current notification config from the daemon.
|
|
51
|
+
*/
|
|
52
|
+
async getNotificationConfig() {
|
|
53
|
+
return await this.send("notification_get_config", {});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Patch the notification config on the daemon (and persist to disk).
|
|
57
|
+
*/
|
|
58
|
+
async setNotificationConfig(patch) {
|
|
59
|
+
return await this.send("notification_set_config", patch);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Send a notification via the daemon (routes to configured channels).
|
|
63
|
+
*/
|
|
64
|
+
async sendNotification(payload) {
|
|
65
|
+
return await this.send("notification_send", payload);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check whether the provided context text has drifted to a different project
|
|
69
|
+
* than the session's current routing.
|
|
70
|
+
*/
|
|
71
|
+
async topicCheck(params) {
|
|
72
|
+
return await this.send("topic_check", params);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Automatically detect which project a session belongs to.
|
|
76
|
+
* Tries path match, PAI.md marker walk, then topic detection (if context given).
|
|
77
|
+
*/
|
|
78
|
+
async sessionAutoRoute(params) {
|
|
79
|
+
const toolResult = await this.send("session_auto_route", params);
|
|
80
|
+
if (toolResult.isError) return null;
|
|
81
|
+
const text = toolResult.content?.[0]?.text ?? "";
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Send a single IPC request and wait for the response.
|
|
90
|
+
* Opens a new socket connection per call — simple and reliable.
|
|
91
|
+
*/
|
|
92
|
+
send(method, params) {
|
|
93
|
+
const socketPath = this.socketPath;
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
let socket = null;
|
|
96
|
+
let done = false;
|
|
97
|
+
let buffer = "";
|
|
98
|
+
let timer = null;
|
|
99
|
+
function finish(error, value) {
|
|
100
|
+
if (done) return;
|
|
101
|
+
done = true;
|
|
102
|
+
if (timer !== null) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
socket?.destroy();
|
|
108
|
+
} catch {}
|
|
109
|
+
if (error) reject(error);
|
|
110
|
+
else resolve(value);
|
|
111
|
+
}
|
|
112
|
+
socket = connect(socketPath, () => {
|
|
113
|
+
const request = {
|
|
114
|
+
id: randomUUID(),
|
|
115
|
+
method,
|
|
116
|
+
params
|
|
117
|
+
};
|
|
118
|
+
socket.write(JSON.stringify(request) + "\n");
|
|
119
|
+
});
|
|
120
|
+
socket.on("data", (chunk) => {
|
|
121
|
+
buffer += chunk.toString();
|
|
122
|
+
const nl = buffer.indexOf("\n");
|
|
123
|
+
if (nl === -1) return;
|
|
124
|
+
const line = buffer.slice(0, nl);
|
|
125
|
+
buffer = buffer.slice(nl + 1);
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
response = JSON.parse(line);
|
|
129
|
+
} catch {
|
|
130
|
+
finish(/* @__PURE__ */ new Error(`IPC parse error: ${line}`));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) finish(new Error(response.error ?? "IPC call failed"));
|
|
134
|
+
else finish(null, response.result);
|
|
135
|
+
});
|
|
136
|
+
socket.on("error", (e) => {
|
|
137
|
+
if (e.code === "ENOENT" || e.code === "ECONNREFUSED") finish(/* @__PURE__ */ new Error("PAI daemon not running. Start it with: pai daemon serve"));
|
|
138
|
+
else finish(e);
|
|
139
|
+
});
|
|
140
|
+
socket.on("end", () => {
|
|
141
|
+
if (!done) finish(/* @__PURE__ */ new Error("IPC connection closed before response"));
|
|
142
|
+
});
|
|
143
|
+
timer = setTimeout(() => {
|
|
144
|
+
finish(/* @__PURE__ */ new Error("IPC call timed out after 60s"));
|
|
145
|
+
}, IPC_TIMEOUT_MS);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
export { PaiClient as t };
|
|
152
|
+
//# sourceMappingURL=ipc-client-aVKVERjJ.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ipc-client-aVKVERjJ.mjs","names":[],"sources":["../src/daemon/ipc-client.ts"],"sourcesContent":["/**\n * ipc-client.ts — IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect → write JSON + newline → read response line → parse → destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = paiSocketPath();\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n */\n async call(method: string, params: Record<string, unknown>): Promise<unknown> {\n return this.send(method, params);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call — simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(\"IPC call timed out after 60s\"));\n }, IPC_TIMEOUT_MS);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA4BA,MAAa,kBAAkB,eAAe;;AAG9C,MAAM,iBAAiB;;;;;;AAwBvB,IAAa,YAAb,MAAuB;CACrB,AAAiB;CAEjB,YAAY,YAAqB;AAC/B,OAAK,aAAa,cAAc;;;;;;CAOlC,MAAM,KAAK,QAAgB,QAAmD;AAC5E,SAAO,KAAK,KAAK,QAAQ,OAAO;;;;;CAMlC,MAAM,SAA2C;AAE/C,SADe,MAAM,KAAK,KAAK,UAAU,EAAE,CAAC;;;;;CAO9C,MAAM,eAA8B;AAClC,QAAM,KAAK,KAAK,aAAa,EAAE,CAAC;;;;;CAUlC,MAAM,wBAGH;AAED,SADe,MAAM,KAAK,KAAK,2BAA2B,EAAE,CAAC;;;;;CAO/D,MAAM,sBAAsB,OAIgB;AAE1C,SADe,MAAM,KAAK,KAAK,2BAA2B,MAAiC;;;;;CAO7F,MAAM,iBAAiB,SAIC;AAEtB,SADe,MAAM,KAAK,KAAK,qBAAqB,QAAmC;;;;;;CAYzF,MAAM,WAAW,QAAqD;AAEpE,SADe,MAAM,KAAK,KAAK,eAAe,OAA6C;;;;;;CAY7F,MAAM,iBAAiB,QAGa;EAIlC,MAAM,aADS,MAAM,KAAK,KAAK,sBAAsB,OAAkC;AAEvF,MAAI,WAAW,QAAS,QAAO;EAC/B,MAAM,OAAO,WAAW,UAAU,IAAI,QAAQ;AAE9C,MAAI;AACF,UAAO,KAAK,MAAM,KAAK;UACjB;AACN,UAAO;;;;;;;CAYX,AAAQ,KACN,QACA,QACkB;EAClB,MAAM,aAAa,KAAK;AAExB,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,IAAI,SAAwB;GAC5B,IAAI,OAAO;GACX,IAAI,SAAS;GACb,IAAI,QAA8C;GAElD,SAAS,OAAO,OAAqB,OAAuB;AAC1D,QAAI,KAAM;AACV,WAAO;AACP,QAAI,UAAU,MAAM;AAClB,kBAAa,MAAM;AACnB,aAAQ;;AAEV,QAAI;AACF,aAAQ,SAAS;YACX;AAGR,QAAI,MACF,QAAO,MAAM;QAEb,SAAQ,MAAM;;AAIlB,YAAS,QAAQ,kBAAkB;IACjC,MAAM,UAAsB;KAC1B,IAAI,YAAY;KAChB;KACA;KACD;AACD,WAAQ,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;KAC7C;AAEF,UAAO,GAAG,SAAS,UAAkB;AACnC,cAAU,MAAM,UAAU;IAC1B,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,QAAI,OAAO,GAAI;IAEf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;AAChC,aAAS,OAAO,MAAM,KAAK,EAAE;IAE7B,IAAI;AACJ,QAAI;AACF,gBAAW,KAAK,MAAM,KAAK;YACrB;AACN,4BAAO,IAAI,MAAM,oBAAoB,OAAO,CAAC;AAC7C;;AAGF,QAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,kBAAkB,CAAC;QAEtD,QAAO,MAAM,SAAS,OAAO;KAE/B;AAEF,UAAO,GAAG,UAAU,MAA6B;AAC/C,QAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBACE,IAAI,MACF,0DACD,CACF;QAED,QAAO,EAAE;KAEX;AAEF,UAAO,GAAG,aAAa;AACrB,QAAI,CAAC,KACH,wBAAO,IAAI,MAAM,wCAAwC,CAAC;KAE5D;AAEF,WAAQ,iBAAiB;AACvB,2BAAO,IAAI,MAAM,+BAA+B,CAAC;MAChD,eAAe;IAClB"}
|