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,161 @@
1
+ import { strict as assert } from "node:assert";
2
+ import * as fs from "node:fs";
3
+ import * as http from "node:http";
4
+ import * as path from "node:path";
5
+ import { after, before, describe, it } from "node:test";
6
+ import { _resetAuthCache } from "../src/auth.js";
7
+ import { createServerState, startServer, stopServer } from "../src/server.js";
8
+ import { createWatcherState } from "../src/watcher.js";
9
+ const envVar = "PVM_API_TOKEN";
10
+ const tmpVault = () => {
11
+ const p = path.join(process.cwd(), `.tmp-rest-wiki-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
12
+ if (fs.existsSync(p))
13
+ fs.rmSync(p, { recursive: true });
14
+ fs.mkdirSync(p, { recursive: true });
15
+ return p;
16
+ };
17
+ const fetchJson = (port, method, path, body, token) => new Promise((resolve, reject) => {
18
+ const opts = {
19
+ hostname: "127.0.0.1",
20
+ port,
21
+ path,
22
+ method,
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
26
+ },
27
+ };
28
+ const req = http.request(opts, (res) => {
29
+ let raw = "";
30
+ res.on("data", (chunk) => {
31
+ raw += chunk.toString();
32
+ });
33
+ res.on("end", () => {
34
+ try {
35
+ resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) });
36
+ }
37
+ catch {
38
+ resolve({ status: res.statusCode ?? 0, data: raw });
39
+ }
40
+ });
41
+ });
42
+ req.on("error", reject);
43
+ if (body !== undefined)
44
+ req.write(JSON.stringify(body));
45
+ req.end();
46
+ });
47
+ describe("REST wiki routes", () => {
48
+ let vaultPath;
49
+ let port;
50
+ let serverState;
51
+ let watcherState;
52
+ let savedEnv;
53
+ let savedHome;
54
+ const fakePi = {
55
+ sendUserMessage: () => { },
56
+ events: { emit: () => { } },
57
+ };
58
+ before(async () => {
59
+ savedEnv = process.env[envVar];
60
+ savedHome = process.env.HOME;
61
+ process.env[envVar] = "test-token";
62
+ vaultPath = tmpVault();
63
+ process.env.HOME = vaultPath;
64
+ // Create a minimal vault config so loadConfig works
65
+ fs.writeFileSync(path.join(vaultPath, "pi-vault-mind.config.json"), JSON.stringify({
66
+ version: 2,
67
+ collections: {
68
+ main: {
69
+ path: "collections/main.jsonl",
70
+ schema: ["id", "domain", "source", "fact", "tag", "artifact"],
71
+ dedupField: "fact",
72
+ },
73
+ },
74
+ wiki: { dataDir: ".lancedb", embedding: { provider: "transformers" }, ftsEnabled: true },
75
+ }), "utf-8");
76
+ // Create the collection file
77
+ fs.mkdirSync(path.join(vaultPath, "collections"), { recursive: true });
78
+ fs.writeFileSync(path.join(vaultPath, "collections", "main.jsonl"), "", "utf-8");
79
+ // Create a global config pointing to this vault
80
+ fs.mkdirSync(path.join(vaultPath, ".pi", "agent"), { recursive: true });
81
+ fs.writeFileSync(path.join(vaultPath, ".pi", "agent", "vault-mind.config.json"), JSON.stringify({ wiki: { vaults: { default: { path: vaultPath } } } }), "utf-8");
82
+ serverState = createServerState(0);
83
+ watcherState = createWatcherState();
84
+ startServer(fakePi, serverState, watcherState);
85
+ // Wait for the server to be ready (port 0 means OS assigns a random port)
86
+ await new Promise((resolve) => {
87
+ const check = () => {
88
+ if (serverState.server?.listening)
89
+ resolve();
90
+ else
91
+ setTimeout(check, 10);
92
+ };
93
+ check();
94
+ });
95
+ port = serverState.port;
96
+ });
97
+ after(() => {
98
+ stopServer(serverState);
99
+ if (savedEnv === undefined)
100
+ delete process.env[envVar];
101
+ else
102
+ process.env[envVar] = savedEnv;
103
+ if (savedHome === undefined)
104
+ process.env.HOME = undefined;
105
+ else
106
+ process.env.HOME = savedHome;
107
+ if (fs.existsSync(vaultPath))
108
+ fs.rmSync(vaultPath, { recursive: true });
109
+ _resetAuthCache();
110
+ });
111
+ it("POST /wiki/search returns 401 without token", async () => {
112
+ const { status } = await fetchJson(port, "POST", "/wiki/search", { query: "test" });
113
+ assert.equal(status, 401);
114
+ });
115
+ it("POST /wiki/search returns 200 or 500 with valid token (index may not be built)", async () => {
116
+ const { status, data } = await fetchJson(port, "POST", "/wiki/search", { query: "test" }, "test-token");
117
+ // 200 if index exists, 500 if LanceDB table not initialized — both are valid responses
118
+ assert.ok(status === 200 || status === 500, `expected 200 or 500, got ${status}`);
119
+ if (status === 200) {
120
+ const d = data;
121
+ assert.ok(Array.isArray(d.hits));
122
+ }
123
+ });
124
+ it("POST /wiki/search returns 400 when query is missing", async () => {
125
+ const { status } = await fetchJson(port, "POST", "/wiki/search", {}, "test-token");
126
+ assert.equal(status, 400);
127
+ });
128
+ it("POST /wiki/search respects collection and limit params", async () => {
129
+ const { status } = await fetchJson(port, "POST", "/wiki/search", { collection: "main", query: "test", limit: 3 }, "test-token");
130
+ assert.ok(status === 200 || status === 500, `expected 200 or 500, got ${status}`);
131
+ });
132
+ it("POST /wiki/append returns 401 without token", async () => {
133
+ const { status } = await fetchJson(port, "POST", "/wiki/append", {
134
+ collection: "main",
135
+ entry: { id: "1", fact: "hello" },
136
+ });
137
+ assert.equal(status, 401);
138
+ });
139
+ it("POST /wiki/append returns 200 or 500 with valid token (index may not be built)", async () => {
140
+ const { status } = await fetchJson(port, "POST", "/wiki/append", {
141
+ collection: "main",
142
+ mode: "autopilot",
143
+ entry: { id: "test-1", domain: "test", source: "test", fact: "hello world", tag: "test" },
144
+ }, "test-token");
145
+ assert.ok(status === 200 || status === 500, `expected 200 or 500, got ${status}`);
146
+ });
147
+ it("POST /wiki/append returns 400 for unknown collection", async () => {
148
+ const { status } = await fetchJson(port, "POST", "/wiki/append", { collection: "nonexistent", entry: { id: "1", fact: "x" } }, "test-token");
149
+ assert.equal(status, 400);
150
+ });
151
+ it("POST /wiki/append returns 400 when entry is missing", async () => {
152
+ const { status } = await fetchJson(port, "POST", "/wiki/append", { collection: "main" }, "test-token");
153
+ assert.equal(status, 400);
154
+ });
155
+ it("POST /wiki/search and /wiki/append both require POST method", async () => {
156
+ const s1 = await fetchJson(port, "GET", "/wiki/search", undefined, "test-token");
157
+ const s2 = await fetchJson(port, "GET", "/wiki/append", undefined, "test-token");
158
+ assert.equal(s1.status, 405);
159
+ assert.equal(s2.status, 405);
160
+ });
161
+ });
@@ -0,0 +1,219 @@
1
+ import { strict as assert } from "node:assert";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, it } from "node:test";
5
+ import { WebSocket } from "ws";
6
+ import { createJob, markJobDone, markJobRunning } from "../src/agent-queue.js";
7
+ import { _resetAuthCache } from "../src/auth.js";
8
+ import { createServerState, startServer, stopServer } from "../src/server.js";
9
+ import { createWatcherState } from "../src/watcher.js";
10
+ const envVar = "PVM_API_TOKEN";
11
+ const tmpVault = () => {
12
+ const p = path.join(process.cwd(), `.tmp-ws-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
13
+ if (fs.existsSync(p))
14
+ fs.rmSync(p, { recursive: true });
15
+ fs.mkdirSync(p, { recursive: true });
16
+ return p;
17
+ };
18
+ const waitForOpen = (ws, timeoutMs = 5000) => new Promise((resolve, reject) => {
19
+ if (ws.readyState === ws.OPEN)
20
+ return resolve();
21
+ const timer = setTimeout(() => reject(new Error("ws open timeout")), timeoutMs);
22
+ ws.on("open", () => {
23
+ clearTimeout(timer);
24
+ resolve();
25
+ });
26
+ ws.on("error", () => {
27
+ clearTimeout(timer);
28
+ reject(new Error("ws open error"));
29
+ });
30
+ });
31
+ const waitForClose = (ws, timeoutMs = 5000) => new Promise((resolve, reject) => {
32
+ if (ws.readyState === ws.CLOSED)
33
+ return resolve(0);
34
+ const timer = setTimeout(() => reject(new Error("ws close timeout")), timeoutMs);
35
+ ws.on("close", (code) => {
36
+ clearTimeout(timer);
37
+ resolve(code);
38
+ });
39
+ ws.on("error", () => {
40
+ clearTimeout(timer);
41
+ reject(new Error("ws close error"));
42
+ });
43
+ });
44
+ const collectEvents = (ws, count, timeoutMs = 5000) => new Promise((resolve, reject) => {
45
+ const events = [];
46
+ const timer = setTimeout(() => {
47
+ if (events.length >= count)
48
+ resolve(events);
49
+ else
50
+ reject(new Error(`expected ${count} events, got ${events.length}: ${JSON.stringify(events)}`));
51
+ }, timeoutMs);
52
+ ws.on("message", (data) => {
53
+ try {
54
+ events.push(JSON.parse(data.toString()));
55
+ }
56
+ catch {
57
+ events.push(data.toString());
58
+ }
59
+ if (events.length >= count) {
60
+ clearTimeout(timer);
61
+ resolve(events);
62
+ }
63
+ });
64
+ });
65
+ const connect = (port, token) => {
66
+ return new WebSocket(`ws://127.0.0.1:${port}/agent/stream`, {
67
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
68
+ });
69
+ };
70
+ describe("WebSocket /agent/stream", () => {
71
+ let vaultPath;
72
+ let serverState;
73
+ let watcherState;
74
+ let port;
75
+ let savedEnv;
76
+ let savedHome;
77
+ beforeEach(async () => {
78
+ savedEnv = process.env[envVar];
79
+ savedHome = process.env.HOME;
80
+ vaultPath = tmpVault();
81
+ process.env.HOME = vaultPath;
82
+ process.env[envVar] = "ws-test-token";
83
+ _resetAuthCache();
84
+ fs.mkdirSync(path.join(vaultPath, ".pi", "agent"), { recursive: true });
85
+ fs.writeFileSync(path.join(vaultPath, ".pi", "agent", "vault-mind.config.json"), JSON.stringify({
86
+ wiki: {
87
+ vaults: { default: { path: vaultPath } },
88
+ embedding: { modal: { apiToken: "ws-test-token" } },
89
+ },
90
+ }), "utf-8");
91
+ watcherState = createWatcherState();
92
+ serverState = createServerState(0);
93
+ startServer({}, serverState, watcherState);
94
+ await new Promise((resolve, reject) => {
95
+ const srv = serverState.server;
96
+ if (!srv)
97
+ return reject(new Error("server not created"));
98
+ if (srv.listening) {
99
+ const addr = srv.address();
100
+ port = typeof addr === "object" && addr ? addr.port : 0;
101
+ return resolve();
102
+ }
103
+ srv.once("listening", () => {
104
+ const addr = srv.address();
105
+ port = typeof addr === "object" && addr ? addr.port : 0;
106
+ resolve();
107
+ });
108
+ srv.once("error", reject);
109
+ });
110
+ });
111
+ afterEach(async () => {
112
+ stopServer(serverState);
113
+ if (savedEnv === undefined)
114
+ process.env[envVar] = undefined;
115
+ else
116
+ process.env[envVar] = savedEnv;
117
+ if (savedHome === undefined)
118
+ process.env.HOME = undefined;
119
+ else
120
+ process.env.HOME = savedHome;
121
+ if (fs.existsSync(vaultPath))
122
+ fs.rmSync(vaultPath, { recursive: true });
123
+ _resetAuthCache();
124
+ // allow sockets to close cleanly between tests
125
+ await new Promise((r) => setTimeout(r, 50));
126
+ });
127
+ it("rejects connection with 4401 when no token is sent", async () => {
128
+ const ws = connect(port);
129
+ await waitForOpen(ws);
130
+ const code = await waitForClose(ws);
131
+ assert.equal(code, 4401);
132
+ });
133
+ it("receives queue/snapshot on connect", async () => {
134
+ createJob(vaultPath, {
135
+ id: "j1",
136
+ vaultPath,
137
+ filePath: path.join(vaultPath, "test.md"),
138
+ role: "miner",
139
+ agentName: "vault-mind-miner",
140
+ instruction: "test",
141
+ markerCount: 1,
142
+ });
143
+ const ws = connect(port, "ws-test-token");
144
+ const eventsPromise = collectEvents(ws, 1);
145
+ await waitForOpen(ws);
146
+ const events = await eventsPromise;
147
+ ws.close();
148
+ assert.equal(events.length, 1);
149
+ const snap = events[0];
150
+ assert.equal(snap.type, "queue/snapshot");
151
+ assert.equal(snap.jobs.length, 1);
152
+ assert.equal(snap.jobs[0].id, "j1");
153
+ });
154
+ it("receives job-created event after creating a job", async () => {
155
+ const ws = connect(port, "ws-test-token");
156
+ const eventsPromise = collectEvents(ws, 1);
157
+ await waitForOpen(ws);
158
+ await eventsPromise; // drain snapshot
159
+ createJob(vaultPath, {
160
+ id: "j2",
161
+ vaultPath,
162
+ filePath: path.join(vaultPath, "test.md"),
163
+ role: "miner",
164
+ agentName: "vault-mind-miner",
165
+ instruction: "test",
166
+ markerCount: 1,
167
+ });
168
+ const events = await collectEvents(ws, 1);
169
+ ws.close();
170
+ assert.equal(events.length, 1);
171
+ const ev = events[0];
172
+ assert.equal(ev.type, "job-created");
173
+ assert.equal(ev.job.id, "j2");
174
+ });
175
+ it("receives job-updated and job-completed events", async () => {
176
+ createJob(vaultPath, {
177
+ id: "j3",
178
+ vaultPath,
179
+ filePath: path.join(vaultPath, "test.md"),
180
+ role: "miner",
181
+ agentName: "vault-mind-miner",
182
+ instruction: "test",
183
+ markerCount: 1,
184
+ });
185
+ const ws = connect(port, "ws-test-token");
186
+ const eventsPromise = collectEvents(ws, 1);
187
+ await waitForOpen(ws);
188
+ await eventsPromise; // drain snapshot
189
+ markJobRunning(vaultPath, "j3");
190
+ markJobDone(vaultPath, "j3");
191
+ const events = await collectEvents(ws, 2);
192
+ ws.close();
193
+ const types = events.map((e) => e.type);
194
+ assert.deepEqual(types, ["job-updated", "job-completed"]);
195
+ });
196
+ it("unsubscribes on close and does not receive further events", async () => {
197
+ const ws = connect(port, "ws-test-token");
198
+ const eventsPromise = collectEvents(ws, 1);
199
+ await waitForOpen(ws);
200
+ await eventsPromise; // drain snapshot
201
+ ws.close();
202
+ await waitForClose(ws);
203
+ createJob(vaultPath, {
204
+ id: "j4",
205
+ vaultPath,
206
+ filePath: path.join(vaultPath, "test.md"),
207
+ role: "miner",
208
+ agentName: "vault-mind-miner",
209
+ instruction: "test",
210
+ markerCount: 1,
211
+ });
212
+ let leaked = false;
213
+ ws.on("message", () => {
214
+ leaked = true;
215
+ });
216
+ await new Promise((r) => setTimeout(r, 500));
217
+ assert.equal(leaked, false);
218
+ });
219
+ });
@@ -0,0 +1,233 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import * as path from "node:path";
6
+ import { afterEach, beforeEach, describe, it } from "node:test";
7
+ const setupConfig = (dir) => {
8
+ fs.writeFileSync(path.join(dir, "pi-vault-mind.config.json"), JSON.stringify({
9
+ version: 2,
10
+ collections: {
11
+ main: {
12
+ path: "collections/main.jsonl",
13
+ schema: ["id", "domain", "source", "fact", "tag", "artifact"],
14
+ dedupField: "fact",
15
+ },
16
+ },
17
+ injectors: [],
18
+ wiki: {
19
+ dataDir: ".lancedb",
20
+ embedding: { provider: "transformers" },
21
+ ftsEnabled: false,
22
+ graph: { enabled: false },
23
+ },
24
+ }), "utf-8");
25
+ fs.mkdirSync(path.join(dir, "collections"), { recursive: true });
26
+ };
27
+ describe("tombstone_entry", () => {
28
+ let testDir;
29
+ let collectionPath;
30
+ let savedHome;
31
+ beforeEach(() => {
32
+ testDir = path.join(tmpdir(), `pi-vault-mind-tombstone-test-${randomUUID()}`);
33
+ fs.mkdirSync(testDir, { recursive: true });
34
+ setupConfig(testDir);
35
+ collectionPath = path.join(testDir, "collections", "main.jsonl");
36
+ savedHome = process.env.HOME;
37
+ process.env.HOME = testDir;
38
+ process.env.USERPROFILE = testDir;
39
+ });
40
+ afterEach(() => {
41
+ if (savedHome !== undefined)
42
+ process.env.HOME = savedHome;
43
+ else
44
+ process.env.HOME = undefined;
45
+ if (fs.existsSync(testDir))
46
+ fs.rmSync(testDir, { recursive: true });
47
+ });
48
+ it("replaces an entry with a tombstone at the same line position", async () => {
49
+ const { tombstoneEntryById } = await import("../src/tombstone.js");
50
+ fs.writeFileSync(collectionPath, `${[
51
+ JSON.stringify({
52
+ id: "a",
53
+ domain: "auth",
54
+ source: "x",
55
+ fact: "first",
56
+ tag: "test",
57
+ artifact: "",
58
+ }),
59
+ JSON.stringify({
60
+ id: "b",
61
+ domain: "api",
62
+ source: "y",
63
+ fact: "second",
64
+ tag: "test",
65
+ artifact: "",
66
+ }),
67
+ JSON.stringify({
68
+ id: "c",
69
+ domain: "ui",
70
+ source: "z",
71
+ fact: "third",
72
+ tag: "test",
73
+ artifact: "",
74
+ }),
75
+ "",
76
+ ].join("\n")}\n`, "utf-8");
77
+ const result = tombstoneEntryById(collectionPath, "b");
78
+ assert.equal(result.success, true);
79
+ assert.equal(result.lineIndex, 1);
80
+ const lines = fs.readFileSync(collectionPath, "utf-8").trimEnd().split("\n");
81
+ assert.equal(lines.length, 3);
82
+ assert.deepEqual(JSON.parse(lines[0]), {
83
+ id: "a",
84
+ domain: "auth",
85
+ source: "x",
86
+ fact: "first",
87
+ tag: "test",
88
+ artifact: "",
89
+ });
90
+ const tombstone = JSON.parse(lines[1]);
91
+ assert.equal(tombstone.id, "b");
92
+ assert.equal(tombstone.domain, "tombstone");
93
+ assert.equal(tombstone.source, "redaction");
94
+ assert.equal(tombstone.fact, "");
95
+ assert.equal(tombstone.tag, "tombstone");
96
+ assert.equal(tombstone.artifact, "");
97
+ assert.equal(tombstone.original_domain, "api");
98
+ assert.ok(typeof tombstone.tombstoned_at === "string");
99
+ assert.deepEqual(JSON.parse(lines[2]), {
100
+ id: "c",
101
+ domain: "ui",
102
+ source: "z",
103
+ fact: "third",
104
+ tag: "test",
105
+ artifact: "",
106
+ });
107
+ });
108
+ it("returns an error when the entry is not found", async () => {
109
+ const { tombstoneEntryById } = await import("../src/tombstone.js");
110
+ fs.writeFileSync(collectionPath, `${JSON.stringify({
111
+ id: "a",
112
+ domain: "auth",
113
+ source: "x",
114
+ fact: "first",
115
+ tag: "test",
116
+ artifact: "",
117
+ })}\n`, "utf-8");
118
+ const result = tombstoneEntryById(collectionPath, "missing");
119
+ assert.equal(result.success, false);
120
+ assert.ok(result.message.includes('"missing" not found'));
121
+ const lines = fs.readFileSync(collectionPath, "utf-8").trimEnd().split("\n");
122
+ assert.equal(lines.length, 1);
123
+ assert.equal(JSON.parse(lines[0]).id, "a");
124
+ });
125
+ it("returns an error when the collection file is missing", async () => {
126
+ const { tombstoneEntryById } = await import("../src/tombstone.js");
127
+ const missingPath = path.join(testDir, "collections", "missing.jsonl");
128
+ const result = tombstoneEntryById(missingPath, "a");
129
+ assert.equal(result.success, false);
130
+ assert.ok(result.message.includes("not found"));
131
+ });
132
+ it("preserves malformed and blank lines", async () => {
133
+ const { tombstoneEntryById } = await import("../src/tombstone.js");
134
+ fs.writeFileSync(collectionPath, `${[
135
+ JSON.stringify({
136
+ id: "a",
137
+ domain: "auth",
138
+ source: "x",
139
+ fact: "first",
140
+ tag: "test",
141
+ artifact: "",
142
+ }),
143
+ "not valid json",
144
+ "",
145
+ JSON.stringify({
146
+ id: "b",
147
+ domain: "api",
148
+ source: "y",
149
+ fact: "second",
150
+ tag: "test",
151
+ artifact: "",
152
+ }),
153
+ ].join("\n")}\n`, "utf-8");
154
+ const result = tombstoneEntryById(collectionPath, "b");
155
+ assert.equal(result.success, true);
156
+ assert.equal(result.lineIndex, 3);
157
+ const lines = fs.readFileSync(collectionPath, "utf-8").split("\n");
158
+ assert.equal(lines[0], JSON.stringify({
159
+ id: "a",
160
+ domain: "auth",
161
+ source: "x",
162
+ fact: "first",
163
+ tag: "test",
164
+ artifact: "",
165
+ }));
166
+ assert.equal(lines[1], "not valid json");
167
+ assert.equal(lines[2], "");
168
+ const tombstone = JSON.parse(lines[3]);
169
+ assert.equal(tombstone.id, "b");
170
+ assert.equal(tombstone.domain, "tombstone");
171
+ assert.equal(tombstone.original_domain, "api");
172
+ });
173
+ it("handles an entry without a domain field", async () => {
174
+ const { tombstoneEntryById } = await import("../src/tombstone.js");
175
+ fs.writeFileSync(collectionPath, `${JSON.stringify({ id: "a", source: "x", fact: "first", tag: "test", artifact: "" })}\n`, "utf-8");
176
+ const result = tombstoneEntryById(collectionPath, "a");
177
+ assert.equal(result.success, true);
178
+ const lines = fs.readFileSync(collectionPath, "utf-8").trimEnd().split("\n");
179
+ const tombstone = JSON.parse(lines[0]);
180
+ assert.equal(tombstone.original_domain, "");
181
+ });
182
+ it("registerTombstoneTool registers the pi tool", async () => {
183
+ const { registerTombstoneTool } = await import("../src/tombstone.js");
184
+ const registered = [];
185
+ const fakePi = {
186
+ registerTool: (tool) => registered.push({ name: tool.name, parameters: tool.parameters }),
187
+ };
188
+ registerTombstoneTool(fakePi);
189
+ assert.equal(registered.length, 1);
190
+ assert.equal(registered[0].name, "tombstone_entry");
191
+ assert.ok(registered[0].parameters);
192
+ });
193
+ it("tool execute tombstones an entry via collection config", async () => {
194
+ const { registerTombstoneTool } = await import("../src/tombstone.js");
195
+ let capturedTool;
196
+ const fakePi = {
197
+ registerTool: (tool) => {
198
+ capturedTool = tool;
199
+ },
200
+ };
201
+ registerTombstoneTool(fakePi);
202
+ fs.writeFileSync(collectionPath, `${JSON.stringify({
203
+ id: "target",
204
+ domain: "api",
205
+ source: "y",
206
+ fact: "second",
207
+ tag: "test",
208
+ artifact: "",
209
+ })}\n`, "utf-8");
210
+ const result = await capturedTool.execute("tool-id", { collection: "main", entryId: "target" }, undefined, undefined, { cwd: testDir });
211
+ assert.ok(result.content[0].text.includes('Tombstoned entry "target"'));
212
+ assert.equal(result.details.success, true);
213
+ assert.equal(result.details.lineIndex, 0);
214
+ assert.ok(result.details.tombstonedAt);
215
+ const lines = fs.readFileSync(collectionPath, "utf-8").trimEnd().split("\n");
216
+ const tombstone = JSON.parse(lines[0]);
217
+ assert.equal(tombstone.id, "target");
218
+ assert.equal(tombstone.domain, "tombstone");
219
+ });
220
+ it("tool execute returns an error for an unknown collection", async () => {
221
+ const { registerTombstoneTool } = await import("../src/tombstone.js");
222
+ let capturedTool;
223
+ const fakePi = {
224
+ registerTool: (tool) => {
225
+ capturedTool = tool;
226
+ },
227
+ };
228
+ registerTombstoneTool(fakePi);
229
+ const result = await capturedTool.execute("tool-id", { collection: "unknown", entryId: "target" }, undefined, undefined, { cwd: testDir });
230
+ assert.ok(result.content[0].text.includes('Unknown collection "unknown"'));
231
+ assert.deepEqual(result.details, {});
232
+ });
233
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vault-mind",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Passive Obsidian vault extension for pi. Watches @agent markers, dispatches forked subagents (Miner, Broadcaster, Heavy-Lifter), stores in LanceDB with vector + FTS + graph. Multi-agent 'Drop & Forget' workflow for the pi agent ecosystem.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "skills",
11
+ "scripts",
11
12
  "CHANGELOG.md",
12
13
  "README.md",
13
14
  "LICENSE"
@@ -60,7 +61,8 @@
60
61
  "dependencies": {
61
62
  "@lancedb/lancedb": "latest",
62
63
  "@xenova/transformers": "latest",
63
- "apache-arrow": "^21.1.0"
64
+ "apache-arrow": "^21.1.0",
65
+ "ws": "^8.21.0"
64
66
  },
65
67
  "peerDependencies": {
66
68
  "@earendil-works/pi-coding-agent": ">=0.75.0 <1.0.0"
@@ -70,6 +72,7 @@
70
72
  "@earendil-works/pi-coding-agent": "^0.79.0",
71
73
  "@earendil-works/pi-tui": "^0.79.0",
72
74
  "@types/node": "^20.0.0",
75
+ "@types/ws": "^8.18.1",
73
76
  "husky": "^9.1.6",
74
77
  "typebox": "^1.1.0",
75
78
  "typescript": "^5.0.0"
@@ -79,6 +82,7 @@
79
82
  "build": "tsc && tsc -p tsconfig.test.json",
80
83
  "dev": "tsc --watch",
81
84
  "test": "node --test dist/test/*.test.js",
85
+ "e2e:ws": "node scripts/e2e-ws.mjs",
82
86
  "e2e:modal": "node scripts/modal-e2e-smoke.mjs"
83
87
  }
84
88
  }