pi-vault-mind 0.7.4 → 0.8.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 (44) hide show
  1. package/README.md +21 -21
  2. package/dist/src/agent-queue.d.ts +108 -0
  3. package/dist/src/agent-queue.js +225 -0
  4. package/dist/src/auth.d.ts +30 -0
  5. package/dist/src/auth.js +93 -0
  6. package/dist/src/auto-index.d.ts +47 -0
  7. package/dist/src/auto-index.js +122 -0
  8. package/dist/src/commands.js +265 -4
  9. package/dist/src/discover-schema.d.ts +27 -0
  10. package/dist/src/discover-schema.js +255 -0
  11. package/dist/src/index.js +2 -0
  12. package/dist/src/intake.d.ts +4 -0
  13. package/dist/src/intake.js +248 -0
  14. package/dist/src/server.d.ts +31 -0
  15. package/dist/src/server.js +561 -4
  16. package/dist/src/settings-ui.js +11 -11
  17. package/dist/src/tombstone.d.ts +13 -0
  18. package/dist/src/tombstone.js +103 -0
  19. package/dist/src/tools.d.ts +8 -0
  20. package/dist/src/tools.js +58 -4
  21. package/dist/src/types.d.ts +47 -0
  22. package/dist/src/types.js +1 -0
  23. package/dist/src/utils.d.ts +4 -2
  24. package/dist/src/utils.js +9 -4
  25. package/dist/src/watcher.d.ts +7 -1
  26. package/dist/src/watcher.js +170 -11
  27. package/dist/test/agent-queue.test.js +204 -0
  28. package/dist/test/auth.test.js +136 -0
  29. package/dist/test/auto-indexer.test.js +2 -0
  30. package/dist/test/dedup.test.js +62 -0
  31. package/dist/test/discover-schema.test.js +201 -0
  32. package/dist/test/modal-config.test.js +17 -2
  33. package/dist/test/queue-events.test.js +146 -0
  34. package/dist/test/rest-queue.test.js +234 -0
  35. package/dist/test/rest-setup.test.js +213 -0
  36. package/dist/test/rest-wiki.test.js +161 -0
  37. package/dist/test/server-ws.test.js +219 -0
  38. package/dist/test/tombstone.test.js +233 -0
  39. package/package.json +6 -2
  40. package/scripts/e2e-ws.mjs +364 -0
  41. package/scripts/fetch-modal-token.sh +66 -0
  42. package/scripts/modal-e2e-smoke.mjs +171 -0
  43. package/scripts/setup-test-config.sh +66 -0
  44. package/dist/test/index.test.d.ts +0 -1
@@ -0,0 +1,13 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export interface TombstoneResult {
3
+ success: boolean;
4
+ lineIndex?: number;
5
+ tombstonedAt?: string;
6
+ message: string;
7
+ }
8
+ /**
9
+ * Replace a JSONL entry with a tombstone record at the same line position.
10
+ * Preserves blank lines and malformed lines so line numbers stay stable.
11
+ */
12
+ export declare const tombstoneEntryById: (collectionPath: string, entryId: string) => TombstoneResult;
13
+ export declare const registerTombstoneTool: (pi: ExtensionAPI) => void;
@@ -0,0 +1,103 @@
1
+ import * as fs from "node:fs";
2
+ import { Type } from "typebox";
3
+ import { collectionNames, loadConfig } from "./utils.js";
4
+ /**
5
+ * Replace a JSONL entry with a tombstone record at the same line position.
6
+ * Preserves blank lines and malformed lines so line numbers stay stable.
7
+ */
8
+ export const tombstoneEntryById = (collectionPath, entryId) => {
9
+ if (!fs.existsSync(collectionPath)) {
10
+ return {
11
+ success: false,
12
+ message: `Collection file not found at ${collectionPath}.`,
13
+ };
14
+ }
15
+ const raw = fs.readFileSync(collectionPath, "utf-8");
16
+ const hasTrailingNewline = raw.endsWith("\n");
17
+ const lines = raw.split("\n");
18
+ const entries = hasTrailingNewline ? lines.slice(0, -1) : lines;
19
+ let foundIndex = -1;
20
+ let originalDomain = "";
21
+ for (let i = 0; i < entries.length; i++) {
22
+ const line = entries[i].trim();
23
+ if (!line)
24
+ continue;
25
+ try {
26
+ const entry = JSON.parse(line);
27
+ if (entry && entry.id === entryId) {
28
+ foundIndex = i;
29
+ originalDomain = typeof entry.domain === "string" ? entry.domain : "";
30
+ break;
31
+ }
32
+ }
33
+ catch {
34
+ /* skip malformed */
35
+ }
36
+ }
37
+ if (foundIndex === -1) {
38
+ return {
39
+ success: false,
40
+ message: `Entry "${entryId}" not found in ${collectionPath}.`,
41
+ };
42
+ }
43
+ const tombstonedAt = new Date().toISOString();
44
+ const tombstone = {
45
+ id: entryId,
46
+ domain: "tombstone",
47
+ source: "redaction",
48
+ fact: "",
49
+ tag: "tombstone",
50
+ artifact: "",
51
+ tombstoned_at: tombstonedAt,
52
+ original_domain: originalDomain,
53
+ };
54
+ entries[foundIndex] = JSON.stringify(tombstone);
55
+ const output = `${entries.join("\n")}\n`;
56
+ fs.writeFileSync(collectionPath, output, "utf-8");
57
+ return {
58
+ success: true,
59
+ lineIndex: foundIndex,
60
+ tombstonedAt,
61
+ message: `Tombstoned entry "${entryId}" at line ${foundIndex + 1} in ${collectionPath}.`,
62
+ };
63
+ };
64
+ export const registerTombstoneTool = (pi) => {
65
+ pi.registerTool({
66
+ name: "tombstone_entry",
67
+ label: "Tombstone Wiki Entry",
68
+ description: "Mark a wiki entry as deleted by replacing it with a tombstone record in the JSONL log.",
69
+ promptSnippet: 'tombstone_entry(collection="main", entryId="...")',
70
+ promptGuidelines: [
71
+ "Use to redact or remove an entry without breaking the append-only JSONL format.",
72
+ "The original entry is replaced by a tombstone record at the same line position.",
73
+ ],
74
+ parameters: Type.Object({
75
+ collection: Type.String({ description: "Collection name containing the entry" }),
76
+ entryId: Type.String({ description: "ID of the entry to tombstone" }),
77
+ }),
78
+ async execute(_id, params, _signal, _onUpdate, ctx) {
79
+ const cfg = loadConfig(ctx.cwd);
80
+ const def = cfg.collections[params.collection];
81
+ if (!def) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: `Unknown collection "${params.collection}". Available: ${collectionNames(cfg).join(", ")}`,
87
+ },
88
+ ],
89
+ details: {},
90
+ };
91
+ }
92
+ const result = tombstoneEntryById(def.path, params.entryId);
93
+ return {
94
+ content: [{ type: "text", text: result.message }],
95
+ details: {
96
+ success: result.success,
97
+ lineIndex: result.lineIndex,
98
+ tombstonedAt: result.tombstonedAt,
99
+ },
100
+ };
101
+ },
102
+ });
103
+ };
@@ -1,2 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ /** Jaccard similarity over character bigrams. Returns 0–1. */
3
+ export declare const jaccardSimilarity: (a: string, b: string) => number;
4
+ /** Decide whether any search result is a duplicate of the incoming value. */
5
+ export declare const isDuplicateResult: (results: unknown[], dedupField: string, incomingValue: string, mode: "exact" | "fuzzy", threshold: number) => {
6
+ duplicate: boolean;
7
+ matchedValue?: string;
8
+ similarity?: number;
9
+ };
2
10
  export declare const registerTools: (pi: ExtensionAPI) => void;
package/dist/src/tools.js CHANGED
@@ -1,10 +1,56 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Type } from "typebox";
4
+ import { registerDiscoverSchemaTool } from "./discover-schema.js";
4
5
  import { graphUpsert, queryGraph } from "./graph.js";
5
6
  import { getStatus, searchFts, searchHybrid, upsertEntry, } from "./lance.js";
7
+ import { registerTombstoneTool } from "./tombstone.js";
6
8
  import { collectionNames, ensureDir, findConfig, loadConfig } from "./utils.js";
7
9
  import { VaultWriter } from "./vault-writer.js";
10
+ /** Jaccard similarity over character bigrams. Returns 0–1. */
11
+ export const jaccardSimilarity = (a, b) => {
12
+ if (a === b)
13
+ return 1;
14
+ const bigrams = (value) => {
15
+ const set = new Set();
16
+ const lower = value.toLowerCase();
17
+ for (let i = 0; i < lower.length - 1; i++) {
18
+ set.add(lower.slice(i, i + 2));
19
+ }
20
+ return set;
21
+ };
22
+ const setA = bigrams(a);
23
+ const setB = bigrams(b);
24
+ if (setA.size === 0 || setB.size === 0)
25
+ return 0;
26
+ let intersection = 0;
27
+ for (const g of setA) {
28
+ if (setB.has(g))
29
+ intersection++;
30
+ }
31
+ return intersection / (setA.size + setB.size - intersection);
32
+ };
33
+ /** Decide whether any search result is a duplicate of the incoming value. */
34
+ export const isDuplicateResult = (results, dedupField, incomingValue, mode, threshold) => {
35
+ if (!incomingValue)
36
+ return { duplicate: false };
37
+ for (const result of results) {
38
+ const candidate = result?.[dedupField];
39
+ if (typeof candidate !== "string")
40
+ continue;
41
+ if (mode === "exact") {
42
+ if (candidate === incomingValue) {
43
+ return { duplicate: true, matchedValue: candidate };
44
+ }
45
+ continue;
46
+ }
47
+ const similarity = jaccardSimilarity(candidate, incomingValue);
48
+ if (similarity >= threshold) {
49
+ return { duplicate: true, matchedValue: candidate, similarity };
50
+ }
51
+ }
52
+ return { duplicate: false };
53
+ };
8
54
  export const registerTools = (pi) => {
9
55
  /* ── promote_wiki ── */
10
56
  pi.registerTool({
@@ -358,12 +404,18 @@ export const registerTools = (pi) => {
358
404
  // autopilot
359
405
  if (def.dedupField && fs.existsSync(def.path)) {
360
406
  try {
361
- const results = await searchHybrid(cfg.wiki.dataDir, params.collection, params.entry[def.dedupField] || "", 1, cfg.wiki);
362
- if (results.length > 0 &&
363
- results[0][def.dedupField] === params.entry[def.dedupField]) {
407
+ const mode = def.dedupMode ?? cfg.wiki.dedupMode ?? "exact";
408
+ const threshold = def.dedupThreshold ?? cfg.wiki.dedupThreshold ?? 0.85;
409
+ const searchLimit = mode === "fuzzy" ? 10 : 1;
410
+ const results = await searchHybrid(cfg.wiki.dataDir, params.collection, params.entry[def.dedupField] || "", searchLimit, cfg.wiki);
411
+ const dup = isDuplicateResult(results, def.dedupField, params.entry[def.dedupField] || "", mode, threshold);
412
+ if (dup.duplicate) {
364
413
  return {
365
414
  content: [
366
- { type: "text", text: `Duplicate detected via "${def.dedupField}". Skipped.` },
415
+ {
416
+ type: "text",
417
+ text: `Duplicate detected via "${def.dedupField}" (mode=${mode}${dup.similarity !== undefined ? `, similarity=${dup.similarity.toFixed(2)}` : ""}). Skipped.`,
418
+ },
367
419
  ],
368
420
  details: {},
369
421
  };
@@ -769,4 +821,6 @@ export const registerTools = (pi) => {
769
821
  }
770
822
  },
771
823
  });
824
+ registerDiscoverSchemaTool(pi);
825
+ registerTombstoneTool(pi);
772
826
  };
@@ -2,6 +2,10 @@ export interface CollectionDef {
2
2
  path: string;
3
3
  schema: string[];
4
4
  dedupField?: string;
5
+ /** Dedup strategy: exact string match (default) or fuzzy Jaccard similarity. */
6
+ dedupMode?: "exact" | "fuzzy";
7
+ /** Jaccard similarity threshold for fuzzy dedup. Default 0.85. */
8
+ dedupThreshold?: number;
5
9
  }
6
10
  export interface InjectorDef {
7
11
  name: string;
@@ -140,6 +144,12 @@ export interface WikiConfig {
140
144
  vaults?: Record<string, VaultConfig>;
141
145
  ftsEnabled?: boolean;
142
146
  httpPort?: number;
147
+ /** Whether to watch collection directories and auto-reindex on changes (default false). */
148
+ autoIndex?: boolean;
149
+ /** Default dedup strategy for collections without their own setting. */
150
+ dedupMode?: "exact" | "fuzzy";
151
+ /** Default Jaccard similarity threshold for fuzzy dedup. Default 0.85. */
152
+ dedupThreshold?: number;
143
153
  }
144
154
  export interface PiContextDef {
145
155
  enabled?: boolean;
@@ -207,3 +217,40 @@ export interface WikiIngestInput {
207
217
  source: string;
208
218
  sourceType?: "url" | "file" | "text";
209
219
  }
220
+ export interface TombstoneWikiInput {
221
+ collection: string;
222
+ entryId: string;
223
+ }
224
+ export interface IntakeQuestionOption {
225
+ label: string;
226
+ description?: string;
227
+ }
228
+ export interface IntakeQuestion {
229
+ question: string;
230
+ header: string;
231
+ options?: IntakeQuestionOption[];
232
+ multi_select: boolean;
233
+ }
234
+ export interface AskIntakeInput {
235
+ questions: IntakeQuestion[];
236
+ persist?: "none" | "ledger" | "vault" | "both";
237
+ collection?: string;
238
+ }
239
+ export interface DiscoverSchemaInput {
240
+ file: string;
241
+ name?: string;
242
+ sample?: number;
243
+ }
244
+ export interface AskIntakeResult {
245
+ answers: Record<string, string | string[]>;
246
+ cancelled: boolean;
247
+ partial: boolean;
248
+ ledgerEntry?: {
249
+ id: string;
250
+ path: string;
251
+ tag: string;
252
+ };
253
+ vaultEntry?: {
254
+ path: string;
255
+ };
256
+ }
package/dist/src/types.js CHANGED
@@ -38,6 +38,7 @@ export const DEFAULT_CONFIG = {
38
38
  },
39
39
  ftsEnabled: true,
40
40
  httpPort: 11435,
41
+ autoIndex: false,
41
42
  },
42
43
  extensionCompatibility: {
43
44
  "pi-context": {
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { PiContextDef, UniversalConfig } from "./types.js";
3
3
  export declare const CONFIG_FILES: string[];
4
- export declare const GLOBAL_CONFIG_DIR: string;
5
- export declare const GLOBAL_CONFIG_PATH: string;
4
+ /** Lazy — recomputes HOME each call so tests can override it. */
5
+ export declare const getGlobalConfigDir: () => string;
6
+ /** Lazy — recomputes HOME each call so tests can override it. */
7
+ export declare const getGlobalConfigPath: () => string;
6
8
  export declare const EXT_ROOT: string;
7
9
  /** Expand a leading `~` to the user's home directory. */
8
10
  export declare const expandHome: (p: string) => string;
package/dist/src/utils.js CHANGED
@@ -3,8 +3,10 @@ import * as path from "node:path";
3
3
  import { DEFAULT_CONFIG } from "./types.js";
4
4
  // Config filenames, in priority order (first match wins).
5
5
  export const CONFIG_FILES = ["pi-vault-mind.config.json", ".pi/vault-mind.config.json"];
6
- export const GLOBAL_CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent");
7
- export const GLOBAL_CONFIG_PATH = path.join(GLOBAL_CONFIG_DIR, "vault-mind.config.json");
6
+ /** Lazy — recomputes HOME each call so tests can override it. */
7
+ export const getGlobalConfigDir = () => path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent");
8
+ /** Lazy — recomputes HOME each call so tests can override it. */
9
+ export const getGlobalConfigPath = () => path.join(getGlobalConfigDir(), "vault-mind.config.json");
8
10
  export const EXT_ROOT = path.join(import.meta.dirname, "..");
9
11
  /** Expand a leading `~` to the user's home directory. */
10
12
  export const expandHome = (p) => {
@@ -30,8 +32,9 @@ export const ensureDir = (filePath) => {
30
32
  };
31
33
  export const findConfig = (cwd) => {
32
34
  const result = {};
33
- if (fs.existsSync(GLOBAL_CONFIG_PATH)) {
34
- result.global = GLOBAL_CONFIG_PATH;
35
+ const globalPath = getGlobalConfigPath();
36
+ if (fs.existsSync(globalPath)) {
37
+ result.global = globalPath;
35
38
  }
36
39
  for (const rel of CONFIG_FILES) {
37
40
  const fp = path.join(cwd, rel);
@@ -90,6 +93,8 @@ const mergeConfigLayer = (base, layer, cwd) => {
90
93
  path: resolvePath(cwd, def.path || (resolvedBase?.path ?? "")),
91
94
  schema: typeof def.schema === "string" ? (resolvedBase?.schema ?? []) : def.schema,
92
95
  dedupField: def.dedupField ?? resolvedBase?.dedupField,
96
+ dedupMode: def.dedupMode ?? resolvedBase?.dedupMode,
97
+ dedupThreshold: def.dedupThreshold ?? resolvedBase?.dedupThreshold,
93
98
  };
94
99
  }
95
100
  merged.collections = resolvedCollections;
@@ -39,6 +39,8 @@ interface AgentGroup {
39
39
  combinedInstruction: string;
40
40
  /** Unique dispatch ID for tracking and writeback */
41
41
  dispatchId: string;
42
+ /** Vault root this group belongs to (used for queue persistence). */
43
+ vaultPath?: string;
42
44
  }
43
45
  export interface WatcherState {
44
46
  /** Active file watchers keyed by vault name */
@@ -55,6 +57,10 @@ export interface WatcherState {
55
57
  activeDispatches: number;
56
58
  /** Queue of pending dispatch tasks */
57
59
  pendingQueue: AgentGroup[];
60
+ /** Per-vault+role batch flush timers, keyed by `${vault}\u0000${role}`. */
61
+ batchTimers: Map<string, ReturnType<typeof setTimeout>>;
62
+ /** Pending job IDs per vault+role key, awaiting a batch flush. */
63
+ batchPending: Map<string, string[]>;
58
64
  /** Active dispatch records (for status reporting) */
59
65
  activeDispatches_: Map<string, DispatchRecord>;
60
66
  }
@@ -75,7 +81,7 @@ interface DispatchRecord {
75
81
  * 2. Different role markers create separate groups.
76
82
  * 3. Named IDs (e.g., @agent-Miner:customId) force isolation regardless of role.
77
83
  */
78
- export declare function scanFile(filePath: string): AgentGroup[];
84
+ export declare function scanFile(filePath: string, vaultPath?: string): AgentGroup[];
79
85
  /**
80
86
  * Generate a unique dispatch ID for tracking and writeback targeting.
81
87
  * Exported for testing.
@@ -16,12 +16,17 @@
16
16
  */
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
+ import { createJob, getJob, markBatchRunning, markJobDone, markJobFailed, markJobRunning, markerStillPresent, sweepCompletedJobs, } from "./agent-queue.js";
19
20
  import { expandHome } from "./utils.js";
20
21
  // ── Constants ────────────────────────────────────────────────────────────────────
21
22
  const MARKER_REGEX = /@agent-(\w+)(?::(\S+))?\s*(.*)/;
22
23
  const WRITEBACK_MARKER_REGEX = /<!--\s*pi-dispatch:(\S+)\s*-->/;
23
24
  const DEBOUNCE_MS = 1000;
24
25
  const MAX_CONCURRENT = 3;
26
+ /** How long to wait after the last new marker before flushing a batch. */
27
+ const BATCH_FLUSH_MS = 2000;
28
+ /** Maximum number of pending jobs in a single role batch before forcing a flush. */
29
+ const BATCH_MAX_SIZE = 6;
25
30
  const CONTEXT_LINES = 15;
26
31
  const WATCHED_EXTENSIONS = new Set([
27
32
  ".txt",
@@ -104,7 +109,7 @@ function isWatchableFile(filePath) {
104
109
  * 2. Different role markers create separate groups.
105
110
  * 3. Named IDs (e.g., @agent-Miner:customId) force isolation regardless of role.
106
111
  */
107
- export function scanFile(filePath) {
112
+ export function scanFile(filePath, vaultPath) {
108
113
  let content;
109
114
  try {
110
115
  content = fs.readFileSync(filePath, "utf-8");
@@ -176,6 +181,7 @@ export function scanFile(filePath) {
176
181
  filePath,
177
182
  combinedInstruction,
178
183
  dispatchId: generateDispatchId(role),
184
+ vaultPath,
179
185
  });
180
186
  }
181
187
  return groups;
@@ -253,7 +259,7 @@ export function startWatcher(pi, vaults, state) {
253
259
  clearTimeout(existingTimer);
254
260
  const timer = setTimeout(() => {
255
261
  state.debounceTimers.delete(filePath);
256
- handleFileChange(pi, filePath, state);
262
+ handleFileChange(pi, vaultPath, filePath, state);
257
263
  }, DEBOUNCE_MS);
258
264
  state.debounceTimers.set(filePath, timer);
259
265
  });
@@ -280,22 +286,32 @@ export function stopWatcher(state) {
280
286
  state.processing.clear();
281
287
  console.log("[pi-vault-mind] Watcher stopped.");
282
288
  }
283
- function handleFileChange(pi, filePath, state) {
289
+ function handleFileChange(pi, vaultPath, filePath, state) {
284
290
  if (state.processing.has(filePath))
285
291
  return;
286
- const groups = scanFile(filePath);
292
+ // Sweep completed jobs before creating new ones
293
+ sweepCompletedJobs(vaultPath);
294
+ const groups = scanFile(filePath, vaultPath);
287
295
  if (groups.length === 0)
288
296
  return;
289
297
  state.processing.add(filePath);
290
298
  console.log(`[pi-vault-mind] Detected ${groups.length} task group(s) in ${filePath}`);
291
299
  // Replace markers with dispatch comments (embeds dispatch ID for writeback)
292
300
  markProcessing(filePath, groups, state.activeDispatches_);
293
- // Queue the groups for dispatch
301
+ // Create persistent job records and queue them for batched dispatch
294
302
  for (const group of groups) {
303
+ createJob(vaultPath, {
304
+ id: group.dispatchId,
305
+ vaultPath,
306
+ filePath,
307
+ role: group.role,
308
+ agentName: resolveAgentName(group.role),
309
+ instruction: group.combinedInstruction,
310
+ markerCount: group.markers.length,
311
+ });
295
312
  state.pendingQueue.push(group);
313
+ enqueueBatch(pi, vaultPath, group, state);
296
314
  }
297
- // Process the queue
298
- processQueue(pi, state);
299
315
  }
300
316
  export function processQueue(pi, state) {
301
317
  while (state.pendingQueue.length > 0 && state.activeDispatches < state.maxConcurrent) {
@@ -305,9 +321,7 @@ export function processQueue(pi, state) {
305
321
  dispatchGroup(pi, group, state);
306
322
  }
307
323
  }
308
- function dispatchGroup(pi, group, state) {
309
- state.activeDispatches++;
310
- const role = group.role;
324
+ const resolveAgentName = (role) => {
311
325
  const agentMap = {
312
326
  miner: "vault-mind-miner",
313
327
  manager: "vault-mind-manager",
@@ -317,11 +331,154 @@ function dispatchGroup(pi, group, state) {
317
331
  researcher: "vault-mind-miner",
318
332
  podcast: "vault-mind-broadcaster",
319
333
  };
320
- const agentName = agentMap[role] || `vault-mind-${role}`;
334
+ return agentMap[role] || `vault-mind-${role}`;
335
+ };
336
+ const batchKey = (vaultPath, role) => `${vaultPath}\u0000${role}`;
337
+ /**
338
+ * Add a group to the per-vault+role batch buffer. Flushes immediately when
339
+ * the buffer reaches BATCH_MAX_SIZE, or schedules a flush after BATCH_FLUSH_MS
340
+ * of inactivity.
341
+ */
342
+ function enqueueBatch(pi, vaultPath, group, state) {
343
+ const key = batchKey(vaultPath, group.role);
344
+ const buffer = state.batchPending.get(key) ?? [];
345
+ buffer.push(group.dispatchId);
346
+ state.batchPending.set(key, buffer);
347
+ if (buffer.length >= BATCH_MAX_SIZE) {
348
+ flushBatch(pi, key, state);
349
+ return;
350
+ }
351
+ const existing = state.batchTimers.get(key);
352
+ if (existing)
353
+ clearTimeout(existing);
354
+ const timer = setTimeout(() => flushBatch(pi, key, state), BATCH_FLUSH_MS);
355
+ state.batchTimers.set(key, timer);
356
+ }
357
+ /** Dispatch a combined task for all pending jobs in the same vault+role buffer. */
358
+ function flushBatch(pi, key, state) {
359
+ const timer = state.batchTimers.get(key);
360
+ if (timer) {
361
+ clearTimeout(timer);
362
+ state.batchTimers.delete(key);
363
+ }
364
+ const ids = state.batchPending.get(key);
365
+ if (!ids || ids.length === 0)
366
+ return;
367
+ state.batchPending.delete(key);
368
+ const firstId = ids[0];
369
+ if (!firstId)
370
+ return;
371
+ // All jobs in the buffer share the same vault+role; use the first to derive.
372
+ const first = state.pendingQueue.find((g) => g.dispatchId === firstId);
373
+ if (!first)
374
+ return;
375
+ const vaultPath = first.vaultPath;
376
+ const role = first.role;
377
+ const agentName = resolveAgentName(role);
378
+ const groups = ids
379
+ .map((id) => state.pendingQueue.find((g) => g.dispatchId === id))
380
+ .filter((g) => g !== undefined);
381
+ if (groups.length === 0)
382
+ return;
383
+ const batchId = `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
384
+ markBatchRunning(vaultPath, ids, batchId);
385
+ state.activeDispatches++;
386
+ // Build a combined task description that lists every member's file and marker.
387
+ const memberList = groups
388
+ .map((g, i) => `${i + 1}. **${g.filePath}** — dispatch id \`${g.dispatchId}\` (${g.markers.length} marker(s))`)
389
+ .join("\n");
390
+ const memberInstructions = groups
391
+ .map((g, i) => `### Task ${i + 1} — ${g.filePath}\n\n${g.combinedInstruction}`)
392
+ .join("\n\n---\n\n");
393
+ const writebackInstructions = [
394
+ "## Result Writeback",
395
+ "For EACH task above, replace the `<!-- pi-dispatch:DISPATCH_ID -->` marker in its file with a brief summary (1-3 lines, prefixed with `> ` to render as a blockquote).",
396
+ "",
397
+ "Example replacement:",
398
+ "> Extracted 3 entities (PPO, Reward Model, RLHF) and 2 relationships. Stored in LanceDB.",
399
+ ].join("\n");
400
+ const taskDescription = [
401
+ `Batched agent dispatch (${groups.length} task(s)) for @agent-${role}:`,
402
+ "",
403
+ memberList,
404
+ "",
405
+ "Please dispatch a SINGLE subagent to handle all of them. The subagent must replace every dispatch marker listed above.",
406
+ "",
407
+ "```",
408
+ "subagent({",
409
+ ` agent: "${agentName}",`,
410
+ ` task: ${JSON.stringify(`${memberInstructions}\n\n${writebackInstructions}`)},`,
411
+ ` context: "fork",`,
412
+ " async: true,",
413
+ "})",
414
+ "```",
415
+ "",
416
+ `Batch ID: ${batchId}`,
417
+ `Agent: ${agentName}`,
418
+ ].join("\n");
419
+ try {
420
+ pi.sendUserMessage(taskDescription, { deliverAs: "followUp" });
421
+ console.log(`[pi-vault-mind] Dispatched ${agentName} batch (${groups.length} task(s), id=${batchId})`);
422
+ // Schedule a timeout guard for the whole batch. If any member's marker is
423
+ // still present after BATCH_TIMEOUT_MS, mark that one failed; others that
424
+ // completed (marker gone) will be marked done by the sweep.
425
+ setTimeout(() => {
426
+ for (const id of ids) {
427
+ const job = getJob(vaultPath, id);
428
+ if (!job || job.status !== "running")
429
+ continue;
430
+ if (markerStillPresent(job.filePath, id)) {
431
+ markJobFailed(vaultPath, id, "Batch timeout: marker not replaced in time");
432
+ }
433
+ }
434
+ }, 10 * 60 * 1000);
435
+ }
436
+ catch (err) {
437
+ console.error(`[pi-vault-mind] Failed to dispatch ${agentName} batch:`, err.message);
438
+ for (const id of ids) {
439
+ markJobFailed(vaultPath, id, `Dispatch error: ${err.message}`);
440
+ }
441
+ }
442
+ finally {
443
+ state.activeDispatches--;
444
+ processQueue(pi, state);
445
+ }
446
+ }
447
+ /** True if the file still contains the dispatch marker for a given job id. */
448
+ const markerStillPresentFor = (filePath, dispatchId) => {
449
+ try {
450
+ const content = fs.readFileSync(filePath, "utf-8");
451
+ return content.includes(`<!-- pi-dispatch:${dispatchId} -->`);
452
+ }
453
+ catch {
454
+ return false;
455
+ }
456
+ };
457
+ function dispatchGroup(pi, group, state) {
458
+ state.activeDispatches++;
459
+ const role = group.role;
460
+ const agentName = resolveAgentName(role);
321
461
  // Update the dispatch record with the resolved agent name
322
462
  const record = state.activeDispatches_.get(group.dispatchId);
323
463
  if (record)
324
464
  record.agentName = agentName;
465
+ // Persist job as running and schedule a timeout check
466
+ const vaultPath = group.vaultPath;
467
+ markJobRunning(vaultPath, group.dispatchId);
468
+ const timeoutMs = 10 * 60 * 1000; // 10 minutes; subagent long tasks can take a while
469
+ setTimeout(() => {
470
+ const job = getJob(vaultPath, group.dispatchId);
471
+ if (!job || job.status !== "running")
472
+ return;
473
+ // If the marker is gone, the subagent completed without us noticing
474
+ if (!fs.existsSync(job.filePath) ||
475
+ !fs.readFileSync(job.filePath, "utf-8").includes(`<!-- pi-dispatch:${job.id} -->`)) {
476
+ markJobDone(vaultPath, job.id);
477
+ }
478
+ else {
479
+ markJobFailed(vaultPath, job.id, "Timeout: subagent did not replace the dispatch marker in time");
480
+ }
481
+ }, timeoutMs);
325
482
  // Build writeback instructions: tell the subagent exactly how to replace its marker
326
483
  const writebackInstructions = [
327
484
  "## Result Writeback",
@@ -407,6 +564,8 @@ export function createWatcherState() {
407
564
  maxConcurrent: MAX_CONCURRENT,
408
565
  activeDispatches: 0,
409
566
  pendingQueue: [],
567
+ batchTimers: new Map(),
568
+ batchPending: new Map(),
410
569
  activeDispatches_: new Map(),
411
570
  };
412
571
  }