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,204 @@
|
|
|
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 { cancelJob, createJob, getJob, listJobs, listPendingByRole, markBatchRunning, markJobFailed, markJobRunning, markerStillPresent, sweepCompletedJobs, } from "../src/agent-queue.js";
|
|
6
|
+
const tmpDir = () => path.join(process.cwd(), ".tmp-agent-queue-test");
|
|
7
|
+
const makeTestFile = (vaultPath, content) => {
|
|
8
|
+
const dir = path.join(vaultPath, "Agent");
|
|
9
|
+
if (!fs.existsSync(dir))
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
const p = path.join(dir, "test.md");
|
|
12
|
+
fs.writeFileSync(p, content, "utf-8");
|
|
13
|
+
return p;
|
|
14
|
+
};
|
|
15
|
+
describe("agent-queue", () => {
|
|
16
|
+
let vaultPath;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vaultPath = tmpDir();
|
|
19
|
+
if (fs.existsSync(vaultPath))
|
|
20
|
+
fs.rmSync(vaultPath, { recursive: true });
|
|
21
|
+
fs.mkdirSync(vaultPath, { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (fs.existsSync(vaultPath))
|
|
25
|
+
fs.rmSync(vaultPath, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
it("creates a pending job and lists it", () => {
|
|
28
|
+
const job = createJob(vaultPath, {
|
|
29
|
+
id: "miner-123",
|
|
30
|
+
vaultPath,
|
|
31
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 -->"),
|
|
32
|
+
role: "miner",
|
|
33
|
+
agentName: "vault-mind-miner",
|
|
34
|
+
instruction: "test task",
|
|
35
|
+
markerCount: 1,
|
|
36
|
+
});
|
|
37
|
+
assert.equal(job.status, "pending");
|
|
38
|
+
assert.equal(job.attempts, 0);
|
|
39
|
+
assert.deepEqual(listJobs(vaultPath), [job]);
|
|
40
|
+
assert.deepEqual(listJobs(vaultPath, "pending"), [job]);
|
|
41
|
+
assert.deepEqual(listJobs(vaultPath, "running"), []);
|
|
42
|
+
});
|
|
43
|
+
it("marks a job running and increments attempts", () => {
|
|
44
|
+
createJob(vaultPath, {
|
|
45
|
+
id: "miner-123",
|
|
46
|
+
vaultPath,
|
|
47
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 -->"),
|
|
48
|
+
role: "miner",
|
|
49
|
+
agentName: "vault-mind-miner",
|
|
50
|
+
instruction: "test task",
|
|
51
|
+
markerCount: 1,
|
|
52
|
+
});
|
|
53
|
+
const running = markJobRunning(vaultPath, "miner-123") ?? assert.fail("expected job");
|
|
54
|
+
assert.equal(running.status, "running");
|
|
55
|
+
assert.equal(running.attempts, 1);
|
|
56
|
+
assert.ok(running.dispatchedAt);
|
|
57
|
+
});
|
|
58
|
+
it("marks a running job done when marker is gone", () => {
|
|
59
|
+
const filePath = makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 --> summary");
|
|
60
|
+
createJob(vaultPath, {
|
|
61
|
+
id: "miner-123",
|
|
62
|
+
vaultPath,
|
|
63
|
+
filePath,
|
|
64
|
+
role: "miner",
|
|
65
|
+
agentName: "vault-mind-miner",
|
|
66
|
+
instruction: "test task",
|
|
67
|
+
markerCount: 1,
|
|
68
|
+
});
|
|
69
|
+
markJobRunning(vaultPath, "miner-123");
|
|
70
|
+
fs.writeFileSync(filePath, "> summary", "utf-8");
|
|
71
|
+
const completed = sweepCompletedJobs(vaultPath);
|
|
72
|
+
assert.equal(completed.length, 1);
|
|
73
|
+
assert.equal(completed[0].status, "done");
|
|
74
|
+
assert.equal(getJob(vaultPath, "miner-123")?.status, "done");
|
|
75
|
+
});
|
|
76
|
+
it("marks a job failed with an error", () => {
|
|
77
|
+
createJob(vaultPath, {
|
|
78
|
+
id: "miner-123",
|
|
79
|
+
vaultPath,
|
|
80
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 -->"),
|
|
81
|
+
role: "miner",
|
|
82
|
+
agentName: "vault-mind-miner",
|
|
83
|
+
instruction: "test task",
|
|
84
|
+
markerCount: 1,
|
|
85
|
+
});
|
|
86
|
+
markJobRunning(vaultPath, "miner-123");
|
|
87
|
+
const failed = markJobFailed(vaultPath, "miner-123", "timeout") ?? assert.fail("expected job");
|
|
88
|
+
assert.equal(failed.status, "failed");
|
|
89
|
+
assert.equal(failed.lastError, "timeout");
|
|
90
|
+
assert.ok(failed.completedAt);
|
|
91
|
+
});
|
|
92
|
+
it("retries a failed job by marking it running again", () => {
|
|
93
|
+
createJob(vaultPath, {
|
|
94
|
+
id: "miner-123",
|
|
95
|
+
vaultPath,
|
|
96
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 -->"),
|
|
97
|
+
role: "miner",
|
|
98
|
+
agentName: "vault-mind-miner",
|
|
99
|
+
instruction: "test task",
|
|
100
|
+
markerCount: 1,
|
|
101
|
+
});
|
|
102
|
+
markJobRunning(vaultPath, "miner-123");
|
|
103
|
+
markJobFailed(vaultPath, "miner-123", "timeout");
|
|
104
|
+
const retried = markJobRunning(vaultPath, "miner-123") ?? assert.fail("expected job");
|
|
105
|
+
assert.equal(retried.status, "running");
|
|
106
|
+
assert.equal(retried.attempts, 2);
|
|
107
|
+
assert.equal(retried.lastError, undefined);
|
|
108
|
+
});
|
|
109
|
+
it("cancels a pending job", () => {
|
|
110
|
+
createJob(vaultPath, {
|
|
111
|
+
id: "miner-123",
|
|
112
|
+
vaultPath,
|
|
113
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 -->"),
|
|
114
|
+
role: "miner",
|
|
115
|
+
agentName: "vault-mind-miner",
|
|
116
|
+
instruction: "test task",
|
|
117
|
+
markerCount: 1,
|
|
118
|
+
});
|
|
119
|
+
const cancelled = cancelJob(vaultPath, "miner-123") ?? assert.fail("expected job");
|
|
120
|
+
assert.equal(cancelled.status, "cancelled");
|
|
121
|
+
assert.equal(cancelJob(vaultPath, "miner-123"), undefined); // cannot cancel twice
|
|
122
|
+
});
|
|
123
|
+
it("markerStillPresent detects dispatch comment", () => {
|
|
124
|
+
const filePath = makeTestFile(vaultPath, "<!-- pi-dispatch:miner-123 --> summary");
|
|
125
|
+
assert.equal(markerStillPresent(filePath, "miner-123"), true);
|
|
126
|
+
fs.writeFileSync(filePath, "> summary", "utf-8");
|
|
127
|
+
assert.equal(markerStillPresent(filePath, "miner-123"), false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe("agent-queue batching helpers", () => {
|
|
131
|
+
let vaultPath;
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
vaultPath = path.join(process.cwd(), ".tmp-agent-queue-batch-test");
|
|
134
|
+
if (fs.existsSync(vaultPath))
|
|
135
|
+
fs.rmSync(vaultPath, { recursive: true });
|
|
136
|
+
fs.mkdirSync(vaultPath, { recursive: true });
|
|
137
|
+
});
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
if (fs.existsSync(vaultPath))
|
|
140
|
+
fs.rmSync(vaultPath, { recursive: true });
|
|
141
|
+
});
|
|
142
|
+
it("listPendingByRole groups pending jobs by vault+role+agent", () => {
|
|
143
|
+
createJob(vaultPath, {
|
|
144
|
+
id: "m1",
|
|
145
|
+
vaultPath,
|
|
146
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:m1 -->"),
|
|
147
|
+
role: "miner",
|
|
148
|
+
agentName: "vault-mind-miner",
|
|
149
|
+
instruction: "a",
|
|
150
|
+
markerCount: 1,
|
|
151
|
+
});
|
|
152
|
+
createJob(vaultPath, {
|
|
153
|
+
id: "m2",
|
|
154
|
+
vaultPath,
|
|
155
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:m2 -->"),
|
|
156
|
+
role: "miner",
|
|
157
|
+
agentName: "vault-mind-miner",
|
|
158
|
+
instruction: "b",
|
|
159
|
+
markerCount: 1,
|
|
160
|
+
});
|
|
161
|
+
createJob(vaultPath, {
|
|
162
|
+
id: "b1",
|
|
163
|
+
vaultPath,
|
|
164
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:b1 -->"),
|
|
165
|
+
role: "broadcaster",
|
|
166
|
+
agentName: "vault-mind-broadcaster",
|
|
167
|
+
instruction: "c",
|
|
168
|
+
markerCount: 1,
|
|
169
|
+
});
|
|
170
|
+
const groups = listPendingByRole(vaultPath);
|
|
171
|
+
assert.equal(Object.keys(groups).length, 2);
|
|
172
|
+
const minerJobs = groups[`${vaultPath}\u0000miner\u0000vault-mind-miner`];
|
|
173
|
+
assert.equal(minerJobs.length, 2);
|
|
174
|
+
const bcasterJobs = groups[`${vaultPath}\u0000broadcaster\u0000vault-mind-broadcaster`];
|
|
175
|
+
assert.equal(bcasterJobs.length, 1);
|
|
176
|
+
});
|
|
177
|
+
it("markBatchRunning sets status running and shared batchId", () => {
|
|
178
|
+
createJob(vaultPath, {
|
|
179
|
+
id: "m1",
|
|
180
|
+
vaultPath,
|
|
181
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:m1 -->"),
|
|
182
|
+
role: "miner",
|
|
183
|
+
agentName: "vault-mind-miner",
|
|
184
|
+
instruction: "a",
|
|
185
|
+
markerCount: 1,
|
|
186
|
+
});
|
|
187
|
+
createJob(vaultPath, {
|
|
188
|
+
id: "m2",
|
|
189
|
+
vaultPath,
|
|
190
|
+
filePath: makeTestFile(vaultPath, "<!-- pi-dispatch:m2 -->"),
|
|
191
|
+
role: "miner",
|
|
192
|
+
agentName: "vault-mind-miner",
|
|
193
|
+
instruction: "b",
|
|
194
|
+
markerCount: 1,
|
|
195
|
+
});
|
|
196
|
+
markBatchRunning(vaultPath, ["m1", "m2"], "batch-abc");
|
|
197
|
+
assert.equal(getJob(vaultPath, "m1")?.status, "running");
|
|
198
|
+
assert.equal(getJob(vaultPath, "m2")?.status, "running");
|
|
199
|
+
assert.equal(getJob(vaultPath, "m1")?.batchId, "batch-abc");
|
|
200
|
+
assert.equal(getJob(vaultPath, "m2")?.batchId, "batch-abc");
|
|
201
|
+
assert.equal(getJob(vaultPath, "m1")?.attempts, 1);
|
|
202
|
+
assert.equal(getJob(vaultPath, "m2")?.attempts, 1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
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 { _resetAuthCache, authoriseRequest, resolveAuthToken } from "../src/auth.js";
|
|
6
|
+
const envVar = "PVM_API_TOKEN";
|
|
7
|
+
const noop = () => { };
|
|
8
|
+
const cleanupEnv = () => {
|
|
9
|
+
delete process.env[envVar];
|
|
10
|
+
_resetAuthCache();
|
|
11
|
+
};
|
|
12
|
+
const tmpVault = () => {
|
|
13
|
+
const p = path.join(process.cwd(), `.tmp-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
|
+
if (fs.existsSync(p))
|
|
15
|
+
fs.rmSync(p, { recursive: true });
|
|
16
|
+
fs.mkdirSync(p, { recursive: true });
|
|
17
|
+
return p;
|
|
18
|
+
};
|
|
19
|
+
describe("auth (server-side bearer token)", () => {
|
|
20
|
+
let savedEnv;
|
|
21
|
+
let homeSnapshot;
|
|
22
|
+
let fakeHome = null;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
savedEnv = process.env[envVar];
|
|
25
|
+
homeSnapshot = process.env.HOME;
|
|
26
|
+
cleanupEnv();
|
|
27
|
+
fakeHome = tmpVault();
|
|
28
|
+
process.env.HOME = fakeHome;
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (savedEnv === undefined)
|
|
32
|
+
delete process.env[envVar];
|
|
33
|
+
else
|
|
34
|
+
process.env[envVar] = savedEnv;
|
|
35
|
+
if (homeSnapshot === undefined)
|
|
36
|
+
process.env.HOME = undefined;
|
|
37
|
+
else
|
|
38
|
+
process.env.HOME = homeSnapshot;
|
|
39
|
+
if (fakeHome)
|
|
40
|
+
fs.rmSync(fakeHome, { recursive: true });
|
|
41
|
+
_resetAuthCache();
|
|
42
|
+
});
|
|
43
|
+
it("env var wins over dotenv and config", () => {
|
|
44
|
+
process.env[envVar] = "env-token";
|
|
45
|
+
fs.mkdirSync(path.join(fakeHome, ".pi", "agent"), { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(fakeHome, ".pi", "agent", "vault-mind.env"), 'PVM_API_TOKEN="dotenv-token"\n', "utf-8");
|
|
47
|
+
const cfg = {
|
|
48
|
+
version: 2,
|
|
49
|
+
collections: {},
|
|
50
|
+
injectors: [],
|
|
51
|
+
wiki: {
|
|
52
|
+
dataDir: ".lancedb",
|
|
53
|
+
embedding: { provider: "modal", modal: { apiToken: "cfg-token" } },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
assert.equal(resolveAuthToken(cfg), "env-token");
|
|
57
|
+
});
|
|
58
|
+
it("dotenv wins over config when env is unset", () => {
|
|
59
|
+
fs.mkdirSync(path.join(fakeHome, ".pi", "agent"), { recursive: true });
|
|
60
|
+
fs.writeFileSync(path.join(fakeHome, ".pi", "agent", "vault-mind.env"), 'PVM_API_TOKEN="dotenv-token"\n', "utf-8");
|
|
61
|
+
const cfg = {
|
|
62
|
+
version: 2,
|
|
63
|
+
collections: {},
|
|
64
|
+
injectors: [],
|
|
65
|
+
wiki: {
|
|
66
|
+
dataDir: ".lancedb",
|
|
67
|
+
embedding: { provider: "modal", modal: { apiToken: "cfg-token" } },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
assert.equal(resolveAuthToken(cfg), "dotenv-token");
|
|
71
|
+
});
|
|
72
|
+
it("config wins when env and dotenv are unset", () => {
|
|
73
|
+
const cfg = {
|
|
74
|
+
version: 2,
|
|
75
|
+
collections: {},
|
|
76
|
+
injectors: [],
|
|
77
|
+
wiki: {
|
|
78
|
+
dataDir: ".lancedb",
|
|
79
|
+
embedding: { provider: "modal", modal: { apiToken: "cfg-token" } },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
assert.equal(resolveAuthToken(cfg), "cfg-token");
|
|
83
|
+
});
|
|
84
|
+
it("returns null when nothing is configured (auth off)", () => {
|
|
85
|
+
assert.equal(resolveAuthToken(), null);
|
|
86
|
+
});
|
|
87
|
+
it("dotenv parsing handles comments, blank lines, and quotes", () => {
|
|
88
|
+
fs.mkdirSync(path.join(fakeHome, ".pi", "agent"), { recursive: true });
|
|
89
|
+
fs.writeFileSync(path.join(fakeHome, ".pi", "agent", "vault-mind.env"), '# a comment\n\nOTHER_VAR=foo\nPVM_API_TOKEN="quoted token"\nPVM_API_TOKEN=unquoted\n', "utf-8");
|
|
90
|
+
assert.equal(resolveAuthToken(), "quoted token");
|
|
91
|
+
});
|
|
92
|
+
it("authoriseRequest returns ok when no token is configured", () => {
|
|
93
|
+
const result = authoriseRequest({ headers: {} });
|
|
94
|
+
assert.equal(result.ok, true);
|
|
95
|
+
});
|
|
96
|
+
it("authoriseRequest rejects requests with no Authorization header", () => {
|
|
97
|
+
process.env[envVar] = "secret";
|
|
98
|
+
const result = authoriseRequest({ headers: {} });
|
|
99
|
+
assert.equal(result.ok, false);
|
|
100
|
+
assert.equal(result.reason, "missing");
|
|
101
|
+
});
|
|
102
|
+
it("authoriseRequest rejects malformed Authorization header", () => {
|
|
103
|
+
process.env[envVar] = "secret";
|
|
104
|
+
const result = authoriseRequest({ headers: { authorization: "Token secret" } });
|
|
105
|
+
assert.equal(result.ok, false);
|
|
106
|
+
assert.equal(result.reason, "malformed");
|
|
107
|
+
});
|
|
108
|
+
it("authoriseRequest rejects mismatched token", () => {
|
|
109
|
+
process.env[envVar] = "secret";
|
|
110
|
+
const result = authoriseRequest({ headers: { authorization: "Bearer wrong" } });
|
|
111
|
+
assert.equal(result.ok, false);
|
|
112
|
+
assert.equal(result.reason, "mismatch");
|
|
113
|
+
});
|
|
114
|
+
it("authoriseRequest accepts the correct bearer token", () => {
|
|
115
|
+
process.env[envVar] = "secret";
|
|
116
|
+
const result = authoriseRequest({ headers: { authorization: "Bearer secret" } });
|
|
117
|
+
assert.equal(result.ok, true);
|
|
118
|
+
});
|
|
119
|
+
it("uses os.homedir() and does not consult process.cwd()", () => {
|
|
120
|
+
// Set a token in the fake home; switch cwd somewhere else; expect the token.
|
|
121
|
+
fs.mkdirSync(path.join(fakeHome, ".pi", "agent"), { recursive: true });
|
|
122
|
+
fs.writeFileSync(path.join(fakeHome, ".pi", "agent", "vault-mind.env"), 'PVM_API_TOKEN="home-token"\n', "utf-8");
|
|
123
|
+
const cwd = process.cwd();
|
|
124
|
+
const other = tmpVault();
|
|
125
|
+
try {
|
|
126
|
+
process.chdir(other);
|
|
127
|
+
assert.equal(resolveAuthToken(), "home-token");
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
process.chdir(cwd);
|
|
131
|
+
fs.rmSync(other, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// silence unused-var
|
|
135
|
+
noop();
|
|
136
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isDuplicateResult, jaccardSimilarity } from "../src/tools.js";
|
|
4
|
+
describe("jaccardSimilarity", () => {
|
|
5
|
+
it("returns 1 for identical strings", () => {
|
|
6
|
+
assert.equal(jaccardSimilarity("hello world", "hello world"), 1);
|
|
7
|
+
});
|
|
8
|
+
it("returns 0 when one input is empty", () => {
|
|
9
|
+
assert.equal(jaccardSimilarity("hello", ""), 0);
|
|
10
|
+
assert.equal(jaccardSimilarity("", "hello"), 0);
|
|
11
|
+
});
|
|
12
|
+
it("is case-insensitive", () => {
|
|
13
|
+
assert.equal(jaccardSimilarity("Hello World", "hello world"), 1);
|
|
14
|
+
});
|
|
15
|
+
it("returns a high score for near-duplicates", () => {
|
|
16
|
+
const score = jaccardSimilarity("hello world", "hello worlds");
|
|
17
|
+
assert.ok(score >= 0.85, `expected >= 0.85, got ${score}`);
|
|
18
|
+
});
|
|
19
|
+
it("returns a low score for unrelated strings", () => {
|
|
20
|
+
const score = jaccardSimilarity("hello world", "goodbye moon");
|
|
21
|
+
assert.ok(score < 0.5, `expected < 0.5, got ${score}`);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("isDuplicateResult", () => {
|
|
25
|
+
it("detects exact duplicates", () => {
|
|
26
|
+
const result = isDuplicateResult([{ fact: "alpha" }], "fact", "alpha", "exact", 0.85);
|
|
27
|
+
assert.equal(result.duplicate, true);
|
|
28
|
+
assert.equal(result.matchedValue, "alpha");
|
|
29
|
+
});
|
|
30
|
+
it("misses different values in exact mode", () => {
|
|
31
|
+
const result = isDuplicateResult([{ fact: "alpha" }], "fact", "beta", "exact", 0.85);
|
|
32
|
+
assert.equal(result.duplicate, false);
|
|
33
|
+
});
|
|
34
|
+
it("does not duplicate when incoming value is empty", () => {
|
|
35
|
+
const result = isDuplicateResult([{ fact: "alpha" }], "fact", "", "exact", 0.85);
|
|
36
|
+
assert.equal(result.duplicate, false);
|
|
37
|
+
});
|
|
38
|
+
it("skips non-string candidate fields", () => {
|
|
39
|
+
const result = isDuplicateResult([{ fact: 123 }], "fact", "123", "exact", 0.85);
|
|
40
|
+
assert.equal(result.duplicate, false);
|
|
41
|
+
});
|
|
42
|
+
it("detects fuzzy near-duplicates above threshold", () => {
|
|
43
|
+
const result = isDuplicateResult([{ fact: "hello world" }], "fact", "hello worlds", "fuzzy", 0.85);
|
|
44
|
+
assert.equal(result.duplicate, true);
|
|
45
|
+
assert.equal(result.matchedValue, "hello world");
|
|
46
|
+
assert.ok(result.similarity !== undefined && result.similarity >= 0.85);
|
|
47
|
+
});
|
|
48
|
+
it("rejects fuzzy candidates below threshold", () => {
|
|
49
|
+
const result = isDuplicateResult([{ fact: "hello world" }], "fact", "goodbye moon", "fuzzy", 0.85);
|
|
50
|
+
assert.equal(result.duplicate, false);
|
|
51
|
+
});
|
|
52
|
+
it("checks all results and returns first fuzzy match", () => {
|
|
53
|
+
const results = [{ fact: "foo bar" }, { fact: "hello world" }, { fact: "baz qux" }];
|
|
54
|
+
const result = isDuplicateResult(results, "fact", "hello worlds", "fuzzy", 0.85);
|
|
55
|
+
assert.equal(result.duplicate, true);
|
|
56
|
+
assert.equal(result.matchedValue, "hello world");
|
|
57
|
+
});
|
|
58
|
+
it("honors custom threshold", () => {
|
|
59
|
+
const result = isDuplicateResult([{ fact: "hello world" }], "fact", "hello there", "fuzzy", 0.99);
|
|
60
|
+
assert.equal(result.duplicate, false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
import { discoverSchemaFromFile, handleDiscoverSchema, inferType, registerDiscoverSchemaTool, runDiscoverSchema, writeCollectionToConfig, } from "../src/discover-schema.js";
|
|
8
|
+
describe("discover-schema", () => {
|
|
9
|
+
let testDir;
|
|
10
|
+
let mockCtx;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
testDir = path.join(tmpdir(), `discover-schema-test-${randomUUID()}`);
|
|
13
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
14
|
+
process.env.HOME = testDir;
|
|
15
|
+
process.env.USERPROFILE = testDir;
|
|
16
|
+
mockCtx = {
|
|
17
|
+
cwd: testDir,
|
|
18
|
+
hasUI: true,
|
|
19
|
+
ui: {
|
|
20
|
+
messages: [],
|
|
21
|
+
notify(text, type) {
|
|
22
|
+
this.messages.push({ text, type });
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
// cleanup best-effort; HOME is already redirected
|
|
29
|
+
try {
|
|
30
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it("inferType maps JSON values to scalar types", () => {
|
|
37
|
+
assert.equal(inferType("hello"), "string");
|
|
38
|
+
assert.equal(inferType(42), "number");
|
|
39
|
+
assert.equal(inferType(true), "boolean");
|
|
40
|
+
assert.equal(inferType([1, 2]), "array");
|
|
41
|
+
assert.equal(inferType({ a: 1 }), "object");
|
|
42
|
+
assert.equal(inferType(null), null);
|
|
43
|
+
assert.equal(inferType(undefined), null);
|
|
44
|
+
assert.equal(inferType(Number.NaN), null);
|
|
45
|
+
assert.equal(inferType(Number.POSITIVE_INFINITY), null);
|
|
46
|
+
});
|
|
47
|
+
it("discovers schema from a simple jsonl file", () => {
|
|
48
|
+
const filePath = path.join(testDir, "events.jsonl");
|
|
49
|
+
fs.writeFileSync(filePath, [
|
|
50
|
+
JSON.stringify({ id: "1", domain: "auth", fact: "login works", tag: "test", count: 3 }),
|
|
51
|
+
JSON.stringify({ id: "2", domain: "auth", fact: "logout works", tag: "test", count: 7 }),
|
|
52
|
+
].join("\n"));
|
|
53
|
+
const result = discoverSchemaFromFile(filePath, 100);
|
|
54
|
+
assert.deepStrictEqual(result.schema.sort(), ["count", "domain", "fact", "id", "tag"]);
|
|
55
|
+
assert.equal(result.types.id, "string");
|
|
56
|
+
assert.equal(result.types.domain, "string");
|
|
57
|
+
assert.equal(result.types.fact, "string");
|
|
58
|
+
assert.equal(result.types.tag, "string");
|
|
59
|
+
assert.equal(result.types.count, "number");
|
|
60
|
+
assert.equal(result.rowCount, 2);
|
|
61
|
+
assert.equal(result.sampled, 2);
|
|
62
|
+
});
|
|
63
|
+
it("discovers arrays and objects", () => {
|
|
64
|
+
const filePath = path.join(testDir, "nested.jsonl");
|
|
65
|
+
fs.writeFileSync(filePath, [
|
|
66
|
+
JSON.stringify({ id: "1", tags: ["a", "b"], meta: { source: "web" } }),
|
|
67
|
+
JSON.stringify({ id: "2", tags: ["c"], meta: { source: "api" } }),
|
|
68
|
+
].join("\n"));
|
|
69
|
+
const result = discoverSchemaFromFile(filePath, 100);
|
|
70
|
+
assert.ok(result.schema.includes("id"));
|
|
71
|
+
assert.ok(result.schema.includes("tags"));
|
|
72
|
+
assert.ok(result.schema.includes("meta"));
|
|
73
|
+
assert.equal(result.types.tags, "array");
|
|
74
|
+
assert.equal(result.types.meta, "object");
|
|
75
|
+
});
|
|
76
|
+
it("reports mixed when a field has multiple JSON types", () => {
|
|
77
|
+
const filePath = path.join(testDir, "mixed.jsonl");
|
|
78
|
+
fs.writeFileSync(filePath, [JSON.stringify({ id: "1", value: "text" }), JSON.stringify({ id: "2", value: 123 })].join("\n"));
|
|
79
|
+
const result = discoverSchemaFromFile(filePath, 100);
|
|
80
|
+
assert.equal(result.types.value, "mixed");
|
|
81
|
+
});
|
|
82
|
+
it("ignores malformed lines while sampling", () => {
|
|
83
|
+
const filePath = path.join(testDir, "noisy.jsonl");
|
|
84
|
+
fs.writeFileSync(filePath, [
|
|
85
|
+
JSON.stringify({ id: "1", fact: "ok" }),
|
|
86
|
+
"not json",
|
|
87
|
+
JSON.stringify({ id: "2", fact: "also ok" }),
|
|
88
|
+
"",
|
|
89
|
+
].join("\n"));
|
|
90
|
+
const result = discoverSchemaFromFile(filePath, 100);
|
|
91
|
+
assert.equal(result.rowCount, 3); // non-empty lines: 2 valid JSON + 1 malformed
|
|
92
|
+
assert.deepStrictEqual(result.schema.sort(), ["fact", "id"]);
|
|
93
|
+
});
|
|
94
|
+
it("samples only the first N rows", () => {
|
|
95
|
+
const filePath = path.join(testDir, "large.jsonl");
|
|
96
|
+
const lines = [];
|
|
97
|
+
for (let i = 0; i < 200; i++) {
|
|
98
|
+
lines.push(JSON.stringify({ id: String(i), fact: `fact ${i}` }));
|
|
99
|
+
}
|
|
100
|
+
fs.writeFileSync(filePath, lines.join("\n"));
|
|
101
|
+
const result = discoverSchemaFromFile(filePath, 10);
|
|
102
|
+
assert.equal(result.sampled, 10);
|
|
103
|
+
assert.equal(result.rowCount, 200);
|
|
104
|
+
});
|
|
105
|
+
it("writes a discovered collection to the project config", () => {
|
|
106
|
+
const collectionsDir = path.join(testDir, "collections");
|
|
107
|
+
fs.mkdirSync(collectionsDir, { recursive: true });
|
|
108
|
+
const filePath = path.join(collectionsDir, "events.jsonl");
|
|
109
|
+
fs.writeFileSync(filePath, [
|
|
110
|
+
JSON.stringify({ id: "1", domain: "auth", fact: "login" }),
|
|
111
|
+
JSON.stringify({ id: "2", domain: "auth", fact: "logout" }),
|
|
112
|
+
].join("\n"));
|
|
113
|
+
const result = runDiscoverSchema(testDir, filePath);
|
|
114
|
+
assert.equal(result.collectionName, "events");
|
|
115
|
+
assert.equal(result.path, path.join("collections", "events.jsonl"));
|
|
116
|
+
assert.deepStrictEqual(result.schema.sort(), ["domain", "fact", "id"]);
|
|
117
|
+
const cfgPath = path.join(testDir, "pi-vault-mind.config.json");
|
|
118
|
+
assert.ok(fs.existsSync(cfgPath), "config file should be created");
|
|
119
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
120
|
+
assert.ok(cfg.collections.events, "events collection should exist");
|
|
121
|
+
assert.equal(cfg.collections.events.path, path.join("collections", "events.jsonl"));
|
|
122
|
+
assert.deepStrictEqual(cfg.collections.events.schema.sort(), ["domain", "fact", "id"]);
|
|
123
|
+
});
|
|
124
|
+
it("uses an explicit collection name when provided", () => {
|
|
125
|
+
const filePath = path.join(testDir, "events.jsonl");
|
|
126
|
+
fs.writeFileSync(filePath, JSON.stringify({ id: "1", fact: "x" }));
|
|
127
|
+
const result = runDiscoverSchema(testDir, filePath, "my_events");
|
|
128
|
+
assert.equal(result.collectionName, "my_events");
|
|
129
|
+
const cfgPath = path.join(testDir, "pi-vault-mind.config.json");
|
|
130
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
131
|
+
assert.ok(cfg.collections.my_events);
|
|
132
|
+
assert.equal(cfg.collections.my_events.path, "events.jsonl");
|
|
133
|
+
});
|
|
134
|
+
it("merges with existing project config without overwriting unrelated keys", () => {
|
|
135
|
+
const cfgPath = path.join(testDir, "pi-vault-mind.config.json");
|
|
136
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
137
|
+
version: 2,
|
|
138
|
+
wiki: { dataDir: ".lancedb" },
|
|
139
|
+
collections: {
|
|
140
|
+
main: { path: "collections/main.jsonl", schema: ["id", "fact"] },
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
const filePath = path.join(testDir, "new.jsonl");
|
|
144
|
+
fs.writeFileSync(filePath, JSON.stringify({ id: "1", extra: "data" }));
|
|
145
|
+
runDiscoverSchema(testDir, filePath, "new");
|
|
146
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
147
|
+
assert.equal(cfg.version, 2);
|
|
148
|
+
assert.equal(cfg.wiki.dataDir, ".lancedb");
|
|
149
|
+
assert.ok(cfg.collections.main);
|
|
150
|
+
assert.ok(cfg.collections.new);
|
|
151
|
+
});
|
|
152
|
+
it("writeCollectionToConfig preserves dedup options", () => {
|
|
153
|
+
const cfgPath = path.join(testDir, "pi-vault-mind.config.json");
|
|
154
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ version: 2 }));
|
|
155
|
+
writeCollectionToConfig(testDir, "main", {
|
|
156
|
+
path: "collections/main.jsonl",
|
|
157
|
+
schema: ["id", "fact"],
|
|
158
|
+
dedupField: "fact",
|
|
159
|
+
dedupMode: "fuzzy",
|
|
160
|
+
dedupThreshold: 0.9,
|
|
161
|
+
});
|
|
162
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
163
|
+
assert.equal(cfg.collections.main.dedupField, "fact");
|
|
164
|
+
assert.equal(cfg.collections.main.dedupMode, "fuzzy");
|
|
165
|
+
assert.equal(cfg.collections.main.dedupThreshold, 0.9);
|
|
166
|
+
});
|
|
167
|
+
it("registerDiscoverSchemaTool registers a tool with the expected name", () => {
|
|
168
|
+
const registered = [];
|
|
169
|
+
const mockPi = {
|
|
170
|
+
registerTool: (tool) => {
|
|
171
|
+
registered.push(tool);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
registerDiscoverSchemaTool(mockPi);
|
|
175
|
+
assert.equal(registered.length, 1);
|
|
176
|
+
assert.equal(registered[0].name, "discover_schema");
|
|
177
|
+
assert.equal(registered[0].label, "Discover JSONL Schema");
|
|
178
|
+
});
|
|
179
|
+
it("handleDiscoverSchema registers a collection via slash command", () => {
|
|
180
|
+
const filePath = path.join(testDir, "cmd.jsonl");
|
|
181
|
+
fs.writeFileSync(filePath, JSON.stringify({ id: "1", value: 42 }));
|
|
182
|
+
void handleDiscoverSchema(filePath, mockCtx);
|
|
183
|
+
assert.equal(mockCtx.ui.messages.length, 1);
|
|
184
|
+
assert.equal(mockCtx.ui.messages[0].type, "info");
|
|
185
|
+
assert.ok(mockCtx.ui.messages[0].text.includes("cmd"));
|
|
186
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(testDir, "pi-vault-mind.config.json"), "utf-8"));
|
|
187
|
+
assert.ok(cfg.collections.cmd);
|
|
188
|
+
});
|
|
189
|
+
it("handleDiscoverSchema reports usage errors", () => {
|
|
190
|
+
void handleDiscoverSchema("", mockCtx);
|
|
191
|
+
assert.equal(mockCtx.ui.messages.length, 1);
|
|
192
|
+
assert.equal(mockCtx.ui.messages[0].type, "error");
|
|
193
|
+
assert.ok(mockCtx.ui.messages[0].text.includes("Usage"));
|
|
194
|
+
});
|
|
195
|
+
it("handleDiscoverSchema reports file errors", () => {
|
|
196
|
+
void handleDiscoverSchema("missing.jsonl", mockCtx);
|
|
197
|
+
assert.equal(mockCtx.ui.messages.length, 1);
|
|
198
|
+
assert.equal(mockCtx.ui.messages[0].type, "error");
|
|
199
|
+
assert.ok(mockCtx.ui.messages[0].text.includes("File not found"));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -8,14 +8,29 @@ const wiki = (over = {}) => ({
|
|
|
8
8
|
});
|
|
9
9
|
describe("modal-config", () => {
|
|
10
10
|
let origToken;
|
|
11
|
-
|
|
11
|
+
let dotenvExisted = false;
|
|
12
|
+
let dotenvBak = "";
|
|
13
|
+
beforeEach(async () => {
|
|
12
14
|
origToken = process.env[MODAL_TOKEN_ENV];
|
|
15
|
+
// Also guard against the dotenv file (~/.pi/agent/vault-mind.env)
|
|
16
|
+
// which resolveModalToken reads as a fallback.
|
|
17
|
+
const fs = await import("node:fs");
|
|
18
|
+
if (fs.existsSync(MODAL_TOKEN_ENV_PATH)) {
|
|
19
|
+
dotenvExisted = true;
|
|
20
|
+
dotenvBak = `${MODAL_TOKEN_ENV_PATH}.bak`;
|
|
21
|
+
fs.renameSync(MODAL_TOKEN_ENV_PATH, dotenvBak);
|
|
22
|
+
}
|
|
13
23
|
});
|
|
14
|
-
afterEach(() => {
|
|
24
|
+
afterEach(async () => {
|
|
15
25
|
if (origToken === undefined)
|
|
16
26
|
delete process.env[MODAL_TOKEN_ENV];
|
|
17
27
|
else
|
|
18
28
|
process.env[MODAL_TOKEN_ENV] = origToken;
|
|
29
|
+
if (dotenvExisted) {
|
|
30
|
+
const fs = await import("node:fs");
|
|
31
|
+
fs.renameSync(dotenvBak, MODAL_TOKEN_ENV_PATH);
|
|
32
|
+
dotenvExisted = false;
|
|
33
|
+
}
|
|
19
34
|
});
|
|
20
35
|
it("resolveModalToken: env wins over config apiToken", () => {
|
|
21
36
|
process.env[MODAL_TOKEN_ENV] = "env-token";
|