pi-vault-mind 0.14.2 → 0.16.3
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/CHANGELOG.md +293 -0
- package/README.md +5 -5
- package/agents/broadcaster.agent.md +2 -2
- package/agents/heavy-lifter.agent.md +1 -1
- package/agents/main.agent.md +29 -0
- package/agents/manager.agent.md +1 -1
- package/agents/miner.agent.md +1 -1
- package/dist/packages/obsidian/src/client.js +331 -0
- package/dist/packages/obsidian/src/types.js +1 -0
- package/dist/src/activity.d.ts +49 -0
- package/dist/src/activity.js +175 -0
- package/dist/src/agent-bus/Agent.d.ts +9 -1
- package/dist/src/agent-bus/Agent.js +18 -2
- package/dist/src/agent-bus/index.d.ts +5 -5
- package/dist/src/agent-bus/index.js +1 -1
- package/dist/src/agent-queue.js +12 -3
- package/dist/src/agents/BroadcasterAgent.d.ts +1 -1
- package/dist/src/agents/BroadcasterAgent.js +2 -2
- package/dist/src/agents/HeavyLifterAgent.d.ts +1 -1
- package/dist/src/agents/HeavyLifterAgent.js +2 -2
- package/dist/src/agents/ManagerAgent.js +1 -1
- package/dist/src/agents/MinerAgent.d.ts +1 -1
- package/dist/src/agents/MinerAgent.js +2 -2
- package/dist/src/agents/index.d.ts +2 -2
- package/dist/src/agents/index.js +2 -2
- package/dist/src/auth.d.ts +17 -15
- package/dist/src/auth.js +26 -37
- package/dist/src/autosync.js +2 -2
- package/dist/src/commands.d.ts +2 -19
- package/dist/src/commands.js +301 -231
- package/dist/src/config-keys.d.ts +16 -0
- package/dist/src/config-keys.js +45 -0
- package/dist/src/discover-schema.js +4 -4
- package/dist/src/edits.d.ts +21 -0
- package/dist/src/edits.js +75 -0
- package/dist/src/embedding-probe.d.ts +34 -0
- package/dist/src/embedding-probe.js +142 -0
- package/dist/src/embedding-providers.d.ts +133 -0
- package/dist/src/embedding-providers.js +173 -0
- package/dist/src/embedding-secrets.d.ts +38 -0
- package/dist/src/embedding-secrets.js +291 -0
- package/dist/src/engine.d.ts +6 -0
- package/dist/src/engine.js +78 -6
- package/dist/src/events.js +7 -10
- package/dist/src/extension-packages.d.ts +9 -0
- package/dist/src/extension-packages.js +14 -0
- package/dist/src/git.d.ts +19 -0
- package/dist/src/git.js +78 -0
- package/dist/src/graph.d.ts +2 -1
- package/dist/src/graph.js +47 -13
- package/dist/src/identities-config.d.ts +6 -0
- package/dist/src/identities-config.js +39 -0
- package/dist/src/identity-injector.js +1 -1
- package/dist/src/index.js +9 -26
- package/dist/src/lance.d.ts +9 -0
- package/dist/src/lance.js +143 -203
- package/dist/src/modal-client.d.ts +16 -0
- package/dist/src/modal-client.js +16 -0
- package/dist/src/modal-config.d.ts +37 -14
- package/dist/src/modal-config.js +59 -32
- package/dist/src/model-router.d.ts +27 -0
- package/dist/src/model-router.js +67 -0
- package/dist/src/models.d.ts +42 -0
- package/dist/src/models.js +78 -0
- package/dist/src/personalize.js +17 -3
- package/dist/src/scaffold.d.ts +48 -0
- package/dist/src/scaffold.js +246 -0
- package/dist/src/server.d.ts +47 -4
- package/dist/src/server.js +988 -221
- package/dist/src/session-search.d.ts +63 -0
- package/dist/src/session-search.js +379 -0
- package/dist/src/settings-ui.d.ts +2 -2
- package/dist/src/settings-ui.js +76 -135
- package/dist/src/sync.d.ts +3 -3
- package/dist/src/sync.js +19 -15
- package/dist/src/tool-catalog.d.ts +18 -0
- package/dist/src/tool-catalog.js +232 -0
- package/dist/src/tools/marksman.js +1 -1
- package/dist/src/tools.js +111 -65
- package/dist/src/types.d.ts +127 -71
- package/dist/src/utils.d.ts +57 -6
- package/dist/src/utils.js +114 -28
- package/dist/src/vault-tools.d.ts +34 -0
- package/dist/src/vault-tools.js +99 -0
- package/dist/src/vault-writer.js +4 -1
- package/dist/src/vm-handlers.js +140 -43
- package/dist/src/watcher.js +9 -9
- package/dist/test/activity-tool.test.js +54 -0
- package/dist/test/agent-bus/Agent.test.js +174 -0
- package/dist/test/agent-bus/AgentLoader.test.js +180 -0
- package/dist/test/agent-bus/AgentRegistry.test.js +86 -0
- package/dist/test/agent-bus/LLMProvider.test.js +162 -0
- package/dist/test/agent-bus/TaskQueue.test.js +256 -0
- package/dist/test/agent-dispatch-context.test.js +73 -0
- package/dist/test/agent-queue.test.js +1 -1
- package/dist/test/agents/Agents.test.js +282 -0
- package/dist/test/auth.test.js +57 -56
- package/dist/test/bridge-identity.test.js +34 -3
- package/dist/test/classify-search-mode.test.js +51 -0
- package/dist/test/commands.test.js +410 -0
- package/dist/test/config-merge.test.js +98 -0
- package/dist/test/context-capture.test.js +56 -0
- package/dist/test/discover-schema.test.js +8 -5
- package/dist/test/dispatch.test.js +45 -91
- package/dist/test/edits.test.js +144 -0
- package/dist/test/embedding-probe.test.js +424 -0
- package/dist/test/embedding-providers.test.js +157 -0
- package/dist/test/embedding-secrets.test.js +210 -0
- package/dist/test/engine-identity.test.js +1 -1
- package/dist/test/events.test.js +675 -0
- package/dist/test/git-bridge.test.js +160 -0
- package/dist/test/graph.test.js +464 -0
- package/dist/test/helpers/server.js +81 -0
- package/dist/test/identity-injector.test.js +197 -0
- package/dist/test/index.test.js +5 -7
- package/dist/test/lance-modal.test.js +67 -2
- package/dist/test/lance.test.js +429 -0
- package/dist/test/modal-client.test.js +1 -1
- package/dist/test/modal-config.test.js +92 -79
- package/dist/test/model-readiness.test.js +122 -0
- package/dist/test/model-router.test.js +99 -0
- package/dist/test/obsidian-client.test.js +42 -0
- package/dist/test/personalize.test.js +1 -1
- package/dist/test/rest-models.test.js +153 -0
- package/dist/test/rest-queue.test.js +135 -87
- package/dist/test/rest-setup.test.js +625 -115
- package/dist/test/rest-vm.test.js +355 -94
- package/dist/test/server-cors.test.js +48 -0
- package/dist/test/server-ws.test.js +61 -34
- package/dist/test/session-crud.test.js +323 -0
- package/dist/test/session-search.test.js +139 -0
- package/dist/test/settings-ui.test.js +113 -4
- package/dist/test/sync.test.js +12 -12
- package/dist/test/tombstone.test.js +8 -10
- package/dist/test/tool-catalog.test.js +86 -0
- package/dist/test/utils.test.js +588 -0
- package/dist/test/vault-tools.test.js +103 -12
- package/dist/test/vault-writer.test.js +371 -0
- package/dist/test/vm-handlers.test.js +620 -0
- package/extension-packages.json +9 -0
- package/package.json +107 -90
- package/pi-vault-mind.config.example.json +24 -2
- package/scripts/check-deps.sh +35 -32
- package/scripts/cli-e2e.mjs +215 -0
- package/scripts/configuration-e2e.mjs +783 -0
- package/scripts/e2e-commands.mjs +391 -0
- package/scripts/e2e-ws.mjs +3 -2
- package/scripts/fetch-modal-token.sh +4 -3
- package/scripts/generate-config-keys-json.mjs +45 -0
- package/scripts/generate-extension-packages-json.mjs +55 -0
- package/scripts/pi-session-query +0 -0
- package/scripts/publish-obsidian-plugin.sh +0 -0
- package/scripts/reset-test-vault.sh +2 -2
- package/scripts/setup-test-config.sh +0 -0
- package/scripts/setup-vault-pi.sh +48 -9
- package/scripts/test-slash-commands.mjs +2 -2
- package/scripts/vault-pi.sh +12 -3
- package/skills/github-release-notes-file/SKILL.md +42 -0
- package/skills/obsidian-guided-test-session/SKILL.md +110 -0
- package/skills/obsidian-interactive-test/SKILL.md +88 -0
- package/skills/obsidian-plugin-publish/SKILL.md +58 -0
- package/skills/pi-vault-mind-release/SKILL.md +141 -0
- package/skills/pre-test-release/SKILL.md +116 -0
- package/dist/test/auto-indexer.test.js +0 -2
|
@@ -5,16 +5,8 @@ import { afterEach, beforeEach, describe, it } from "node:test";
|
|
|
5
5
|
import { WebSocket } from "ws";
|
|
6
6
|
import { createJob, markJobDone, markJobRunning } from "../src/agent-queue.js";
|
|
7
7
|
import { _resetAuthCache } from "../src/auth.js";
|
|
8
|
-
import {
|
|
9
|
-
import { createWatcherState } from "../src/watcher.js";
|
|
8
|
+
import { fakePi, makeVault, startTestServer } from "./helpers/server.js";
|
|
10
9
|
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
10
|
const waitForOpen = (ws, timeoutMs = 5000) => new Promise((resolve, reject) => {
|
|
19
11
|
if (ws.readyState === ws.OPEN)
|
|
20
12
|
return resolve();
|
|
@@ -67,49 +59,43 @@ const connect = (port, token) => {
|
|
|
67
59
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
68
60
|
});
|
|
69
61
|
};
|
|
62
|
+
const isRecord = (value) => Boolean(value) && typeof value === "object";
|
|
63
|
+
const isActivityTone = (value) => value === "neutral" || value === "ok" || value === "warn" || value === "error";
|
|
64
|
+
function assertIsoString(value) {
|
|
65
|
+
if (typeof value !== "string")
|
|
66
|
+
assert.fail(`expected string timestamp, got ${String(value)}`);
|
|
67
|
+
assert.ok(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value), `expected ISO timestamp, got ${String(value)}`);
|
|
68
|
+
assert.ok(!Number.isNaN(Date.parse(value)), `expected parseable ISO timestamp, got ${value}`);
|
|
69
|
+
}
|
|
70
70
|
describe("WebSocket /agent/stream", () => {
|
|
71
71
|
let vaultPath;
|
|
72
|
-
let
|
|
73
|
-
let watcherState;
|
|
72
|
+
let srv;
|
|
74
73
|
let port;
|
|
75
74
|
let savedEnv;
|
|
76
75
|
let savedHome;
|
|
76
|
+
let savedCwd;
|
|
77
77
|
beforeEach(async () => {
|
|
78
78
|
savedEnv = process.env[envVar];
|
|
79
79
|
savedHome = process.env.HOME;
|
|
80
|
-
|
|
80
|
+
savedCwd = process.cwd();
|
|
81
|
+
vaultPath = makeVault("ws");
|
|
81
82
|
process.env.HOME = vaultPath;
|
|
82
83
|
process.env[envVar] = "ws-test-token";
|
|
83
84
|
_resetAuthCache();
|
|
84
|
-
|
|
85
|
-
fs.
|
|
85
|
+
process.chdir(vaultPath);
|
|
86
|
+
fs.mkdirSync(path.join(vaultPath, ".vault-mind"), { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(vaultPath, ".vault-mind", "vault-mind.config.json"), JSON.stringify({
|
|
86
88
|
vaultMind: {
|
|
87
89
|
vaults: { default: { path: vaultPath } },
|
|
88
90
|
embedding: { modal: { apiToken: "ws-test-token" } },
|
|
89
91
|
},
|
|
90
92
|
}), "utf-8");
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
});
|
|
93
|
+
srv = await startTestServer(fakePi);
|
|
94
|
+
port = srv.port;
|
|
110
95
|
});
|
|
111
96
|
afterEach(async () => {
|
|
112
|
-
|
|
97
|
+
srv.stop();
|
|
98
|
+
process.chdir(savedCwd);
|
|
113
99
|
if (savedEnv === undefined)
|
|
114
100
|
process.env[envVar] = undefined;
|
|
115
101
|
else
|
|
@@ -193,6 +179,47 @@ describe("WebSocket /agent/stream", () => {
|
|
|
193
179
|
const types = events.map((e) => e.type);
|
|
194
180
|
assert.deepEqual(types, ["job-updated", "job-notification", "job-completed"]);
|
|
195
181
|
});
|
|
182
|
+
it("markJobRunning emits both queue update and activity event frames", async () => {
|
|
183
|
+
createJob(vaultPath, {
|
|
184
|
+
id: "j-activity",
|
|
185
|
+
vaultPath,
|
|
186
|
+
filePath: path.join(vaultPath, "activity.md"),
|
|
187
|
+
role: "miner",
|
|
188
|
+
agentName: "vault-mind-miner",
|
|
189
|
+
instruction: "test",
|
|
190
|
+
markerCount: 1,
|
|
191
|
+
});
|
|
192
|
+
const ws = connect(port, "ws-test-token");
|
|
193
|
+
const snapshotPromise = collectEvents(ws, 1);
|
|
194
|
+
await waitForOpen(ws);
|
|
195
|
+
await snapshotPromise;
|
|
196
|
+
try {
|
|
197
|
+
const eventsPromise = collectEvents(ws, 2, 1000);
|
|
198
|
+
markJobRunning(vaultPath, "j-activity");
|
|
199
|
+
const events = await eventsPromise;
|
|
200
|
+
const queueEvent = events.find((event) => isRecord(event) && event.type === "job-updated");
|
|
201
|
+
assert.ok(queueEvent, `expected job-updated frame, got ${JSON.stringify(events)}`);
|
|
202
|
+
assert.ok(isRecord(queueEvent.job));
|
|
203
|
+
assert.equal(queueEvent.job.id, "j-activity");
|
|
204
|
+
const activityFrame = events.find((event) => isRecord(event) && event.type === "activity-event");
|
|
205
|
+
assert.ok(activityFrame, `expected activity-event frame, got ${JSON.stringify(events)}`);
|
|
206
|
+
assert.ok(isRecord(activityFrame.event));
|
|
207
|
+
const activity = activityFrame.event;
|
|
208
|
+
assert.equal(activity.jobId, "j-activity");
|
|
209
|
+
assert.equal(activity.kind, "status");
|
|
210
|
+
assertIsoString(activity.ts);
|
|
211
|
+
if (typeof activity.label !== "string" || activity.label.trim().length === 0) {
|
|
212
|
+
assert.fail("expected non-empty activity label");
|
|
213
|
+
}
|
|
214
|
+
if (activity.tone !== undefined) {
|
|
215
|
+
assert.ok(isActivityTone(activity.tone));
|
|
216
|
+
assert.notEqual(activity.tone, "error");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
ws.close();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
196
223
|
it("unsubscribes on close and does not receive further events", async () => {
|
|
197
224
|
const ws = connect(port, "ws-test-token");
|
|
198
225
|
const eventsPromise = collectEvents(ws, 1);
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
6
|
+
import { archiveSession, deleteSession, exportSession, getSession, listSessions, renameSession, SESSION_ID_RE, } from "../src/session-search.js";
|
|
7
|
+
import { fakePi, fetchJson, makeVault, startTestServer, TEST_TOKEN, writeVaultConfig, } from "./helpers/server.js";
|
|
8
|
+
const envVar = "PVM_API_TOKEN";
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === "object" && value !== null;
|
|
11
|
+
}
|
|
12
|
+
describe("Session CRUD (BC-7)", () => {
|
|
13
|
+
let tmpDir;
|
|
14
|
+
let sessionsDir;
|
|
15
|
+
let savedEnv;
|
|
16
|
+
let vaultPath;
|
|
17
|
+
let port;
|
|
18
|
+
let srv;
|
|
19
|
+
let savedCwd;
|
|
20
|
+
let savedSessionsDir;
|
|
21
|
+
before(() => {
|
|
22
|
+
savedEnv = process.env[envVar];
|
|
23
|
+
savedCwd = process.cwd();
|
|
24
|
+
savedSessionsDir = process.env.PI_SESSIONS_DIR;
|
|
25
|
+
process.env[envVar] = TEST_TOKEN;
|
|
26
|
+
});
|
|
27
|
+
after(() => {
|
|
28
|
+
srv?.stop();
|
|
29
|
+
if (savedEnv === undefined)
|
|
30
|
+
delete process.env[envVar];
|
|
31
|
+
else
|
|
32
|
+
process.env[envVar] = savedEnv;
|
|
33
|
+
process.chdir(savedCwd);
|
|
34
|
+
if (savedSessionsDir === undefined)
|
|
35
|
+
delete process.env.PI_SESSIONS_DIR;
|
|
36
|
+
else
|
|
37
|
+
process.env.PI_SESSIONS_DIR = savedSessionsDir;
|
|
38
|
+
});
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
// Fresh temp sessions dir for each test
|
|
41
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pvm-session-crud-"));
|
|
42
|
+
sessionsDir = path.join(tmpDir, "sessions");
|
|
43
|
+
const slugDir = path.join(sessionsDir, "--test--");
|
|
44
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
45
|
+
// Seed a single session
|
|
46
|
+
const sessionFile = path.join(slugDir, "2026-07-13T14-25-00_abc.jsonl");
|
|
47
|
+
const lines = [
|
|
48
|
+
JSON.stringify({ type: "session", id: "sess-001", cwd: "/test", timestamp: "T" }),
|
|
49
|
+
JSON.stringify({ type: "session_name", name: "My Session" }),
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
type: "message",
|
|
52
|
+
id: "m1",
|
|
53
|
+
timestamp: "T",
|
|
54
|
+
message: { role: "user", content: "Hello world" },
|
|
55
|
+
}),
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
type: "message",
|
|
58
|
+
id: "m2",
|
|
59
|
+
timestamp: "T",
|
|
60
|
+
message: { role: "assistant", content: "Hi there" },
|
|
61
|
+
}),
|
|
62
|
+
];
|
|
63
|
+
fs.writeFileSync(sessionFile, lines.join("\n") + "\n", "utf-8");
|
|
64
|
+
// Point the session-search module at our temp dir
|
|
65
|
+
process.env.PI_SESSIONS_DIR = sessionsDir;
|
|
66
|
+
});
|
|
67
|
+
// REST tests need a vault; chdir into it before each REST test
|
|
68
|
+
async function setupVaultForRest() {
|
|
69
|
+
vaultPath = makeVault("session-crud");
|
|
70
|
+
writeVaultConfig(vaultPath, {
|
|
71
|
+
vaultMind: { vaults: { default: { path: vaultPath } } },
|
|
72
|
+
});
|
|
73
|
+
process.chdir(vaultPath);
|
|
74
|
+
}
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
srv?.stop();
|
|
77
|
+
srv = undefined;
|
|
78
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
79
|
+
if (fs.existsSync(vaultPath))
|
|
80
|
+
fs.rmSync(vaultPath, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
async function bootServer() {
|
|
83
|
+
srv = await startTestServer(fakePi);
|
|
84
|
+
port = srv.port;
|
|
85
|
+
}
|
|
86
|
+
// ── Unit tests for pure functions ────────────────────────────────
|
|
87
|
+
describe("SESSION_ID_RE", () => {
|
|
88
|
+
it("accepts valid session file stems", () => {
|
|
89
|
+
assert.ok(SESSION_ID_RE.test("2026-07-13T14-25-00_abc123"));
|
|
90
|
+
assert.ok(SESSION_ID_RE.test("simple-id"));
|
|
91
|
+
assert.ok(SESSION_ID_RE.test("v1.0.0"));
|
|
92
|
+
});
|
|
93
|
+
it("rejects path traversal and special chars", () => {
|
|
94
|
+
assert.ok(!SESSION_ID_RE.test("../etc/passwd"));
|
|
95
|
+
assert.ok(!SESSION_ID_RE.test("a/b"));
|
|
96
|
+
assert.ok(!SESSION_ID_RE.test("-leading-dash"));
|
|
97
|
+
assert.ok(!SESSION_ID_RE.test("has space"));
|
|
98
|
+
assert.ok(!SESSION_ID_RE.test(""));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("listSessions", () => {
|
|
102
|
+
it("returns summaries for valid sessions", () => {
|
|
103
|
+
const sessions = listSessions(sessionsDir);
|
|
104
|
+
assert.equal(sessions.length, 1);
|
|
105
|
+
const s = sessions[0];
|
|
106
|
+
assert.equal(s.id, "2026-07-13T14-25-00_abc");
|
|
107
|
+
assert.equal(s.name, "My Session");
|
|
108
|
+
assert.equal(s.messageCount, 2);
|
|
109
|
+
assert.equal(s.archived, false);
|
|
110
|
+
assert.ok(s.preview.includes("Hello"));
|
|
111
|
+
});
|
|
112
|
+
it("returns [] for missing dir", () => {
|
|
113
|
+
assert.deepEqual(listSessions("/nonexistent"), []);
|
|
114
|
+
});
|
|
115
|
+
it("returns [] for empty dir", () => {
|
|
116
|
+
assert.deepEqual(listSessions(""), []);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("getSession", () => {
|
|
120
|
+
it("returns detail with entries for valid id", () => {
|
|
121
|
+
const detail = getSession(sessionsDir, "2026-07-13T14-25-00_abc");
|
|
122
|
+
assert.equal(detail.id, "2026-07-13T14-25-00_abc");
|
|
123
|
+
assert.equal(detail.entries.length, 2);
|
|
124
|
+
assert.equal(detail.entries[0].role, "user");
|
|
125
|
+
assert.equal(detail.entries[0].content, "Hello world");
|
|
126
|
+
assert.equal(detail.entries[1].role, "assistant");
|
|
127
|
+
});
|
|
128
|
+
it("throws for invalid id", () => {
|
|
129
|
+
assert.throws(() => getSession(sessionsDir, "../etc"), /Invalid session id/);
|
|
130
|
+
});
|
|
131
|
+
it("throws for missing session", () => {
|
|
132
|
+
assert.throws(() => getSession(sessionsDir, "nonexistent"), /Session not found/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe("renameSession", () => {
|
|
136
|
+
it("appends a session_name entry", () => {
|
|
137
|
+
const r = renameSession(sessionsDir, "2026-07-13T14-25-00_abc", "New Name");
|
|
138
|
+
assert.equal(r.ok, true);
|
|
139
|
+
assert.equal(r.name, "New Name");
|
|
140
|
+
// Read back — name should be "New Name"
|
|
141
|
+
const detail = getSession(sessionsDir, "2026-07-13T14-25-00_abc");
|
|
142
|
+
assert.equal(detail.name, "New Name");
|
|
143
|
+
});
|
|
144
|
+
it("rejects empty name", () => {
|
|
145
|
+
assert.throws(() => renameSession(sessionsDir, "2026-07-13T14-25-00_abc", ""), /empty/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe("archiveSession", () => {
|
|
149
|
+
it("appends archive flag and round-trips", () => {
|
|
150
|
+
const r = archiveSession(sessionsDir, "2026-07-13T14-25-00_abc");
|
|
151
|
+
assert.equal(r.ok, true);
|
|
152
|
+
// Read back — archived should be true
|
|
153
|
+
const detail = getSession(sessionsDir, "2026-07-13T14-25-00_abc");
|
|
154
|
+
assert.equal(detail.archived, true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe("deleteSession", () => {
|
|
158
|
+
it("removes the session file", () => {
|
|
159
|
+
deleteSession(sessionsDir, "2026-07-13T14-25-00_abc");
|
|
160
|
+
assert.deepEqual(listSessions(sessionsDir), []);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe("exportSession", () => {
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
vaultPath = makeVault("session-crud-export");
|
|
166
|
+
});
|
|
167
|
+
it("copies the session to .vault-mind/exports", () => {
|
|
168
|
+
const r = exportSession(sessionsDir, "2026-07-13T14-25-00_abc", vaultPath);
|
|
169
|
+
assert.ok(r.path.endsWith(".jsonl"));
|
|
170
|
+
assert.ok(fs.existsSync(r.path));
|
|
171
|
+
const content = fs.readFileSync(r.path, "utf-8");
|
|
172
|
+
assert.ok(content.includes("Hello world"));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
// ── REST integration tests ───────────────────────────────────────
|
|
176
|
+
describe("REST /sessions", () => {
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
await setupVaultForRest();
|
|
179
|
+
});
|
|
180
|
+
it("GET /sessions returns the list", async () => {
|
|
181
|
+
await bootServer();
|
|
182
|
+
const { status, body } = await fetchJson(port, "GET", "/sessions", { token: TEST_TOKEN });
|
|
183
|
+
assert.equal(status, 200);
|
|
184
|
+
assert.ok(Array.isArray(body), "expected session list array");
|
|
185
|
+
assert.equal(body.length, 1);
|
|
186
|
+
const first = body[0];
|
|
187
|
+
assert.ok(isRecord(first));
|
|
188
|
+
assert.ok(typeof first.id === "string");
|
|
189
|
+
assert.equal(first.id, "2026-07-13T14-25-00_abc");
|
|
190
|
+
});
|
|
191
|
+
it("GET /sessions requires auth", async () => {
|
|
192
|
+
await bootServer();
|
|
193
|
+
const { status } = await fetchJson(port, "GET", "/sessions");
|
|
194
|
+
assert.equal(status, 401);
|
|
195
|
+
});
|
|
196
|
+
it("GET /sessions/:id returns detail", async () => {
|
|
197
|
+
await bootServer();
|
|
198
|
+
const { status, body } = await fetchJson(port, "GET", "/sessions/2026-07-13T14-25-00_abc", {
|
|
199
|
+
token: TEST_TOKEN,
|
|
200
|
+
});
|
|
201
|
+
assert.equal(status, 200);
|
|
202
|
+
assert.ok(isRecord(body));
|
|
203
|
+
assert.ok(typeof body.id === "string");
|
|
204
|
+
assert.equal(body.id, "2026-07-13T14-25-00_abc");
|
|
205
|
+
assert.ok(Array.isArray(body.entries));
|
|
206
|
+
assert.equal(body.entries.length, 2);
|
|
207
|
+
const first = body.entries[0];
|
|
208
|
+
assert.ok(isRecord(first));
|
|
209
|
+
assert.ok(typeof first.role === "string");
|
|
210
|
+
assert.equal(first.role, "user");
|
|
211
|
+
});
|
|
212
|
+
it("GET /sessions/:id returns 404 for missing", async () => {
|
|
213
|
+
await bootServer();
|
|
214
|
+
const { status } = await fetchJson(port, "GET", "/sessions/nonexistent", {
|
|
215
|
+
token: TEST_TOKEN,
|
|
216
|
+
});
|
|
217
|
+
assert.equal(status, 404);
|
|
218
|
+
});
|
|
219
|
+
it("GET /sessions/:id returns 404 for invalid id (path traversal)", async () => {
|
|
220
|
+
await bootServer();
|
|
221
|
+
const { status } = await fetchJson(port, "GET", "/sessions/..%2Fetc", { token: TEST_TOKEN });
|
|
222
|
+
// %2F decodes to /, which doesn't match the session path regex → 404
|
|
223
|
+
assert.equal(status, 404);
|
|
224
|
+
});
|
|
225
|
+
it("PATCH /sessions/:id renames the session", async () => {
|
|
226
|
+
await bootServer();
|
|
227
|
+
const { status, body } = await fetchJson(port, "PATCH", "/sessions/2026-07-13T14-25-00_abc", {
|
|
228
|
+
body: { name: "Renamed" },
|
|
229
|
+
token: TEST_TOKEN,
|
|
230
|
+
});
|
|
231
|
+
assert.equal(status, 200);
|
|
232
|
+
assert.ok(isRecord(body));
|
|
233
|
+
assert.ok(typeof body.name === "string");
|
|
234
|
+
assert.equal(body.name, "Renamed");
|
|
235
|
+
// Verify via GET
|
|
236
|
+
const { body: detail } = await fetchJson(port, "GET", "/sessions/2026-07-13T14-25-00_abc", {
|
|
237
|
+
token: TEST_TOKEN,
|
|
238
|
+
});
|
|
239
|
+
assert.ok(isRecord(detail));
|
|
240
|
+
assert.ok(typeof detail.name === "string");
|
|
241
|
+
assert.equal(detail.name, "Renamed");
|
|
242
|
+
});
|
|
243
|
+
it("PATCH /sessions/:id rejects empty name with 400", async () => {
|
|
244
|
+
await bootServer();
|
|
245
|
+
const { status } = await fetchJson(port, "PATCH", "/sessions/2026-07-13T14-25-00_abc", {
|
|
246
|
+
body: { name: "" },
|
|
247
|
+
token: TEST_TOKEN,
|
|
248
|
+
});
|
|
249
|
+
assert.equal(status, 400);
|
|
250
|
+
});
|
|
251
|
+
it("POST /sessions/:id/export exports to vault", async () => {
|
|
252
|
+
await bootServer();
|
|
253
|
+
const { status, body } = await fetchJson(port, "POST", "/sessions/2026-07-13T14-25-00_abc/export", { token: TEST_TOKEN });
|
|
254
|
+
assert.equal(status, 200);
|
|
255
|
+
assert.ok(isRecord(body));
|
|
256
|
+
assert.ok(typeof body.path === "string");
|
|
257
|
+
assert.ok(fs.existsSync(body.path));
|
|
258
|
+
});
|
|
259
|
+
it("POST /sessions/:id/archive archives the session", async () => {
|
|
260
|
+
await bootServer();
|
|
261
|
+
const { status } = await fetchJson(port, "POST", "/sessions/2026-07-13T14-25-00_abc/archive", { token: TEST_TOKEN });
|
|
262
|
+
assert.equal(status, 200);
|
|
263
|
+
// Verify via GET — archived should be true
|
|
264
|
+
const { body } = await fetchJson(port, "GET", "/sessions/2026-07-13T14-25-00_abc", {
|
|
265
|
+
token: TEST_TOKEN,
|
|
266
|
+
});
|
|
267
|
+
assert.ok(isRecord(body));
|
|
268
|
+
assert.equal(body.archived, true);
|
|
269
|
+
});
|
|
270
|
+
it("DELETE /sessions/:id removes the session", async () => {
|
|
271
|
+
await bootServer();
|
|
272
|
+
const { status } = await fetchJson(port, "DELETE", "/sessions/2026-07-13T14-25-00_abc", {
|
|
273
|
+
token: TEST_TOKEN,
|
|
274
|
+
});
|
|
275
|
+
assert.equal(status, 200);
|
|
276
|
+
// Verify gone
|
|
277
|
+
assert.deepEqual(listSessions(sessionsDir), []);
|
|
278
|
+
});
|
|
279
|
+
it("DELETE /sessions/:id returns 409 for active session", async () => {
|
|
280
|
+
await bootServer();
|
|
281
|
+
// Set the server's active session ID to the test session
|
|
282
|
+
srv.serverState.activeSessionId = "2026-07-13T14-25-00_abc";
|
|
283
|
+
const { status, body } = await fetchJson(port, "DELETE", "/sessions/2026-07-13T14-25-00_abc", { token: TEST_TOKEN });
|
|
284
|
+
assert.equal(status, 409);
|
|
285
|
+
assert.ok(isRecord(body));
|
|
286
|
+
assert.ok(typeof body.error === "string");
|
|
287
|
+
assert.ok(body.error.includes("active"));
|
|
288
|
+
});
|
|
289
|
+
it("PATCH /sessions/:id returns 409 for active session", async () => {
|
|
290
|
+
await bootServer();
|
|
291
|
+
srv.serverState.activeSessionId = "2026-07-13T14-25-00_abc";
|
|
292
|
+
const { status } = await fetchJson(port, "PATCH", "/sessions/2026-07-13T14-25-00_abc", {
|
|
293
|
+
body: { name: "X" },
|
|
294
|
+
token: TEST_TOKEN,
|
|
295
|
+
});
|
|
296
|
+
assert.equal(status, 409);
|
|
297
|
+
});
|
|
298
|
+
it("POST /sessions/:id/archive returns 409 for active session", async () => {
|
|
299
|
+
await bootServer();
|
|
300
|
+
srv.serverState.activeSessionId = "2026-07-13T14-25-00_abc";
|
|
301
|
+
const { status } = await fetchJson(port, "POST", "/sessions/2026-07-13T14-25-00_abc/archive", { token: TEST_TOKEN });
|
|
302
|
+
assert.equal(status, 409);
|
|
303
|
+
});
|
|
304
|
+
it("DELETE /sessions/:id returns 409 via X-Active-Session header (plugin path)", async () => {
|
|
305
|
+
await bootServer();
|
|
306
|
+
// Plugin sets the active session via header on each request
|
|
307
|
+
const { status, body } = await fetchJson(port, "DELETE", "/sessions/2026-07-13T14-25-00_abc", { token: TEST_TOKEN, headers: { "X-Active-Session": "2026-07-13T14-25-00_abc" } });
|
|
308
|
+
assert.equal(status, 409);
|
|
309
|
+
assert.ok(isRecord(body));
|
|
310
|
+
assert.ok(typeof body.error === "string");
|
|
311
|
+
assert.ok(body.error.includes("active"));
|
|
312
|
+
});
|
|
313
|
+
it("POST /sessions/:id/export does NOT require write gate (read-only)", async () => {
|
|
314
|
+
await bootServer();
|
|
315
|
+
srv.serverState.activeSessionId = "2026-07-13T14-25-00_abc";
|
|
316
|
+
const { status } = await fetchJson(port, "POST", "/sessions/2026-07-13T14-25-00_abc/export", {
|
|
317
|
+
token: TEST_TOKEN,
|
|
318
|
+
});
|
|
319
|
+
// Export is read-only — should succeed even for active session
|
|
320
|
+
assert.equal(status, 200);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
import { searchSessions } from "../src/session-search.js";
|
|
7
|
+
describe("searchSessions", () => {
|
|
8
|
+
let tmpDir;
|
|
9
|
+
let sessionsDir;
|
|
10
|
+
before(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pvm-session-test-"));
|
|
12
|
+
sessionsDir = path.join(tmpDir, "sessions");
|
|
13
|
+
// Create a cwd-slug dir
|
|
14
|
+
const slugDir = path.join(sessionsDir, "--workspace--test");
|
|
15
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
16
|
+
// Session file: header + session_name + messages
|
|
17
|
+
const sessionFile = path.join(slugDir, "2026-07-13T14-25-00_abc123.jsonl");
|
|
18
|
+
const lines = [
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
type: "session",
|
|
21
|
+
id: "sess-001",
|
|
22
|
+
cwd: "/workspace/test",
|
|
23
|
+
timestamp: "2026-07-13T14:25:00Z",
|
|
24
|
+
}),
|
|
25
|
+
JSON.stringify({ type: "session_name", name: "Test Session" }),
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
type: "message",
|
|
28
|
+
id: "msg-001",
|
|
29
|
+
parentId: undefined,
|
|
30
|
+
timestamp: "2026-07-13T14:25:01Z",
|
|
31
|
+
message: {
|
|
32
|
+
role: "user",
|
|
33
|
+
content: "Tell me about the widget system",
|
|
34
|
+
timestamp: "2026-07-13T14:25:01Z",
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
type: "message",
|
|
39
|
+
id: "msg-002",
|
|
40
|
+
parentId: "msg-001",
|
|
41
|
+
timestamp: "2026-07-13T14:25:02Z",
|
|
42
|
+
message: {
|
|
43
|
+
role: "assistant",
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text",
|
|
47
|
+
text: "To be completely clear, this is a much longer preamble that goes on for quite a while so that we can verify the widget truncation logic actually produces leading and trailing ellipsis characters.",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
type: "message",
|
|
54
|
+
id: "msg-003",
|
|
55
|
+
parentId: "msg-002",
|
|
56
|
+
timestamp: "2026-07-13T14:25:03Z",
|
|
57
|
+
message: { role: "user", content: "No match here" },
|
|
58
|
+
}),
|
|
59
|
+
];
|
|
60
|
+
fs.writeFileSync(sessionFile, lines.join("\n") + "\n", "utf-8");
|
|
61
|
+
});
|
|
62
|
+
after(() => {
|
|
63
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
it("returns session hits matching the query", () => {
|
|
66
|
+
const hits = searchSessions(sessionsDir, "widget", 10);
|
|
67
|
+
assert.ok(hits.length >= 2, `expected at least 2 hits, got ${hits.length}`);
|
|
68
|
+
for (const h of hits) {
|
|
69
|
+
assert.equal(h._source, "session");
|
|
70
|
+
assert.equal(h.sessionId, "sess-001");
|
|
71
|
+
assert.equal(h.sessionTitle, "Test Session");
|
|
72
|
+
assert.ok(h.score > 0);
|
|
73
|
+
assert.ok(h.snippet.length > 0);
|
|
74
|
+
assert.ok(h.path.endsWith(".jsonl"));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it("returns correct role for matched entries", () => {
|
|
78
|
+
const hits = searchSessions(sessionsDir, "widget", 10);
|
|
79
|
+
const roles = hits.map((h) => h.role);
|
|
80
|
+
assert.ok(roles.includes("user"), "should have user hit");
|
|
81
|
+
assert.ok(roles.includes("assistant"), "should have assistant hit");
|
|
82
|
+
});
|
|
83
|
+
it("uses entryId from the entry id field", () => {
|
|
84
|
+
const hits = searchSessions(sessionsDir, "widget", 10);
|
|
85
|
+
const ids = hits.map((h) => h.entryId);
|
|
86
|
+
assert.ok(ids.includes("msg-001"), "should have msg-001");
|
|
87
|
+
assert.ok(ids.includes("msg-002"), "should have msg-002");
|
|
88
|
+
});
|
|
89
|
+
it("ellipsizes snippet when truncated", () => {
|
|
90
|
+
const hits = searchSessions(sessionsDir, "widget", 10);
|
|
91
|
+
const longHit = hits.find((h) => h.role === "assistant");
|
|
92
|
+
assert.ok(longHit, "should have an assistant hit");
|
|
93
|
+
assert.ok(longHit.snippet.startsWith("…"), "assistant snippet should have leading ellipsis");
|
|
94
|
+
assert.ok(longHit.snippet.endsWith("…"), "assistant snippet should have trailing ellipsis");
|
|
95
|
+
});
|
|
96
|
+
it("returns empty array for non-matching query", () => {
|
|
97
|
+
const hits = searchSessions(sessionsDir, "nonexistent", 10);
|
|
98
|
+
assert.equal(hits.length, 0);
|
|
99
|
+
});
|
|
100
|
+
it("returns empty array for empty query", () => {
|
|
101
|
+
const hits = searchSessions(sessionsDir, "", 10);
|
|
102
|
+
assert.equal(hits.length, 0);
|
|
103
|
+
});
|
|
104
|
+
it("returns empty array for missing dir", () => {
|
|
105
|
+
const hits = searchSessions("/nonexistent/path", "widget", 10);
|
|
106
|
+
assert.equal(hits.length, 0);
|
|
107
|
+
});
|
|
108
|
+
it("ranks by score descending", () => {
|
|
109
|
+
// Create a file with 2 occurrences vs 1 occurrence
|
|
110
|
+
const slugDir = path.join(sessionsDir, "--workspace--test2");
|
|
111
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
112
|
+
const file = path.join(slugDir, "2026-07-13T15-00-00_def456.jsonl");
|
|
113
|
+
const lines = [
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
type: "session",
|
|
116
|
+
id: "sess-002",
|
|
117
|
+
cwd: "/test",
|
|
118
|
+
timestamp: "2026-07-13T15:00:00Z",
|
|
119
|
+
}),
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
type: "message",
|
|
122
|
+
id: "m1",
|
|
123
|
+
timestamp: "T",
|
|
124
|
+
message: { role: "user", content: "widget widget widget" },
|
|
125
|
+
}),
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
type: "message",
|
|
128
|
+
id: "m2",
|
|
129
|
+
timestamp: "T",
|
|
130
|
+
message: { role: "assistant", content: "widget" },
|
|
131
|
+
}),
|
|
132
|
+
];
|
|
133
|
+
fs.writeFileSync(file, lines.join("\n") + "\n", "utf-8");
|
|
134
|
+
const hits = searchSessions(sessionsDir, "widget", 10);
|
|
135
|
+
assert.ok(hits.length >= 3, `expected at least 3 hits, got ${hits.length}`);
|
|
136
|
+
// Score 3 should rank above score 1
|
|
137
|
+
assert.ok(hits[0].score >= hits[1].score, "hits should be sorted by score desc");
|
|
138
|
+
});
|
|
139
|
+
});
|