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.
- package/README.md +21 -21
- package/dist/src/agent-queue.d.ts +108 -0
- package/dist/src/agent-queue.js +225 -0
- package/dist/src/auth.d.ts +30 -0
- package/dist/src/auth.js +93 -0
- package/dist/src/auto-index.d.ts +47 -0
- package/dist/src/auto-index.js +122 -0
- package/dist/src/commands.js +265 -4
- package/dist/src/discover-schema.d.ts +27 -0
- package/dist/src/discover-schema.js +255 -0
- package/dist/src/index.js +2 -0
- package/dist/src/intake.d.ts +4 -0
- package/dist/src/intake.js +248 -0
- package/dist/src/server.d.ts +31 -0
- package/dist/src/server.js +561 -4
- package/dist/src/settings-ui.js +11 -11
- package/dist/src/tombstone.d.ts +13 -0
- package/dist/src/tombstone.js +103 -0
- package/dist/src/tools.d.ts +8 -0
- package/dist/src/tools.js +58 -4
- package/dist/src/types.d.ts +47 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils.d.ts +4 -2
- package/dist/src/utils.js +9 -4
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +170 -11
- package/dist/test/agent-queue.test.js +204 -0
- package/dist/test/auth.test.js +136 -0
- package/dist/test/auto-indexer.test.js +2 -0
- package/dist/test/dedup.test.js +62 -0
- package/dist/test/discover-schema.test.js +201 -0
- package/dist/test/modal-config.test.js +17 -2
- package/dist/test/queue-events.test.js +146 -0
- package/dist/test/rest-queue.test.js +234 -0
- package/dist/test/rest-setup.test.js +213 -0
- package/dist/test/rest-wiki.test.js +161 -0
- package/dist/test/server-ws.test.js +219 -0
- package/dist/test/tombstone.test.js +233 -0
- package/package.json +6 -2
- package/scripts/e2e-ws.mjs +364 -0
- package/scripts/fetch-modal-token.sh +66 -0
- package/scripts/modal-e2e-smoke.mjs +171 -0
- package/scripts/setup-test-config.sh +66 -0
- 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
|
+
};
|
package/dist/src/tools.d.ts
CHANGED
|
@@ -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
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
{
|
|
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
|
};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
package/dist/src/utils.d.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
export declare const
|
|
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
|
-
|
|
7
|
-
export const
|
|
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
|
-
|
|
34
|
-
|
|
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;
|
package/dist/src/watcher.d.ts
CHANGED
|
@@ -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.
|
package/dist/src/watcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|