pi-vault-mind 0.7.5 → 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 (42) 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 +213 -2
  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 +5 -2
  40. package/scripts/e2e-ws.mjs +364 -0
  41. package/scripts/fetch-modal-token.sh +4 -1
  42. package/scripts/setup-test-config.sh +66 -0
package/README.md CHANGED
@@ -87,7 +87,7 @@ obsidian plugin:install id=actions-uri enable
87
87
  obsidian plugin:install id=shellcommands enable
88
88
  ```
89
89
 
90
- Full walkthrough: see [`docs/OBSIDIAN_SETUP.md`](docs/OBSIDIAN_SETUP.md).
90
+ Full walkthrough: see [`docs/integrations/OBSIDIAN_SETUP.md`](docs/integrations/OBSIDIAN_SETUP.md).
91
91
 
92
92
  ### Recommended: Obsidian Community Plugins
93
93
 
@@ -182,7 +182,7 @@ The Broadcaster agent can generate podcasts, study guides, and slide decks from
182
182
  - **Embedding Provider** — choose one:
183
183
  - `@xenova/transformers` — built-in, no external deps (uses all-MiniLM-L6-v2, offline-capable)
184
184
  - `ollama` — requires Ollama running locally with `embeddinggemma` (higher quality)
185
- - `modal` — optional, **bring-your-own deploy**: offload embedding + bulk re-index to a cloud GPU service and sync vectors down for offline search (see [docs/MODAL_EMBEDDING.md](docs/MODAL_EMBEDDING.md)). Default behavior is unchanged until you opt in.
185
+ - `modal` — optional, **bring-your-own deploy**: offload embedding + bulk re-index to a cloud GPU service and sync vectors down for offline search (see [docs/integrations/MODAL_EMBEDDING.md](docs/integrations/MODAL_EMBEDDING.md)). Default behavior is unchanged until you opt in.
186
186
 
187
187
  > **On the Modal provider:** the local providers above work for everyone with
188
188
  > zero infrastructure — that's the default. `modal` is an **optional, self-hosted
@@ -371,21 +371,21 @@ Edit `pi-vault-mind.config.json` to match your domain:
371
371
 
372
372
  | Doc | Description |
373
373
  |---|---|
374
- | [docs/QUICKSTART.md](docs/QUICKSTART.md) | **Fastest path** — 60-second install, setup, first append |
375
- | [docs/NEW_VAULT_WALKTHROUGH.md](docs/NEW_VAULT_WALKTHROUGH.md) | **Fresh Mac → new vault → Modal → sync → verified**, one linear path (incl. multi-vault storage isolation) |
376
- | [docs/INSTALL.md](docs/INSTALL.md) | **Canonical install playbook** — all 5 layers (pi ext, skills, Obsidian, config, external CLIs) |
377
- | [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) | End-to-end workflow + daily "drop & forget" usage |
378
- | [docs/WALKTHROUGH_PROMPT.md](docs/WALKTHROUGH_PROMPT.md) | Paste-into-pi guided setup with checkpoints between phases |
374
+ | [docs/getting-started/QUICKSTART.md](docs/getting-started/QUICKSTART.md) | **Fastest path** — 60-second install, setup, first append |
375
+ | [docs/getting-started/NEW_VAULT_WALKTHROUGH.md](docs/getting-started/NEW_VAULT_WALKTHROUGH.md) | **Fresh Mac → new vault → Modal → sync → verified**, one linear path (incl. multi-vault storage isolation) |
376
+ | [docs/getting-started/INSTALL.md](docs/getting-started/INSTALL.md) | **Canonical install playbook** — all 5 layers (pi ext, skills, Obsidian, config, external CLIs) |
377
+ | [docs/getting-started/GETTING_STARTED.md](docs/getting-started/GETTING_STARTED.md) | End-to-end workflow + daily "drop & forget" usage |
378
+ | [docs/getting-started/WALKTHROUGH_PROMPT.md](docs/getting-started/WALKTHROUGH_PROMPT.md) | Paste-into-pi guided setup with checkpoints between phases |
379
379
 
380
380
  ### Architecture & design
381
381
 
382
382
  | Doc | Description |
383
383
  |---|---|
384
- | [docs/AGENTS.md](docs/AGENTS.md) | Agent Roster and Multi-Agent Architecture ("Fork & Review" model) |
385
- | [docs/EXTENSION_WIRING.md](docs/EXTENSION_WIRING.md) | Extension dependencies, runtime wiring, auto-install patterns |
386
- | [docs/DISPATCHER_SPEC.md](docs/DISPATCHER_SPEC.md) | Technical spec for the passive file-watcher and subagent routing — incl. the "Fork & Dispatch" rationale and thread resume |
387
- | [docs/AGENTS.md](docs/AGENTS.md) | Agent roster + the Miner's "Drop & Forget" ingestion pipeline |
388
- | [docs/OBSIDIAN_SETUP.md](docs/OBSIDIAN_SETUP.md) | Recommended Obsidian vault structure, plugins, and CLI |
384
+ | [docs/architecture/AGENTS.md](docs/architecture/AGENTS.md) | Agent Roster and Multi-Agent Architecture ("Fork & Review" model) |
385
+ | [docs/architecture/EXTENSION_WIRING.md](docs/architecture/EXTENSION_WIRING.md) | Extension dependencies, runtime wiring, auto-install patterns |
386
+ | [docs/architecture/DISPATCHER_SPEC.md](docs/architecture/DISPATCHER_SPEC.md) | Technical spec for the passive file-watcher and subagent routing — incl. the "Fork & Dispatch" rationale and thread resume |
387
+ | [docs/architecture/AGENTS.md](docs/architecture/AGENTS.md) | Agent roster + the Miner's "Drop & Forget" ingestion pipeline |
388
+ | [docs/integrations/OBSIDIAN_SETUP.md](docs/integrations/OBSIDIAN_SETUP.md) | Recommended Obsidian vault structure, plugins, and CLI |
389
389
 
390
390
  ### Modal embedding service (local integration done)
391
391
 
@@ -395,12 +395,12 @@ fallback that degrades to FTS, never crashes), a sync-down path that pulls
395
395
  server-side vectors into the local LanceDB with a monotonic `seq` watermark,
396
396
  remote bulk re-index (`/wiki reindex --all --reembed --remote`), and debounced +
397
397
  batched append embedding via the coalescer. Existing non-modal users see no
398
- behavior change. See [docs/MODAL_EMBEDDING.md](docs/MODAL_EMBEDDING.md) for the
398
+ behavior change. See [docs/integrations/MODAL_EMBEDDING.md](docs/integrations/MODAL_EMBEDDING.md) for the
399
399
  full design.
400
400
 
401
401
  | Doc | Description |
402
402
  |---|---|
403
- | [docs/MODAL_EMBEDDING.md](docs/MODAL_EMBEDDING.md) | Design of record: ADRs, HTTP contract, sync protocol, roadmap for the cloud embedding service |
403
+ | [docs/integrations/MODAL_EMBEDDING.md](docs/integrations/MODAL_EMBEDDING.md) | Design of record: ADRs, HTTP contract, sync protocol, roadmap for the cloud embedding service |
404
404
  | [docs/plans/modal-embedding-plan.md](docs/plans/modal-embedding-plan.md) | **Consolidated plan & next steps** — phases, owners, status, decision gate (in repo, not on the docs site) |
405
405
  | [docs/plans/embedding-open-questions.md](docs/plans/embedding-open-questions.md) | Embedding-strategy decision log (decided + open questions) |
406
406
  | [modal/](modal/) | The deployable Modal app: embedding service + bulk worker + sync + dataset generator |
@@ -421,22 +421,22 @@ full design.
421
421
 
422
422
  | Doc | Description |
423
423
  |---|---|
424
- | [docs/TESTING.md](docs/TESTING.md) | Test plan for agent / human / HITL personas, regression suite |
425
- | [docs/E2E_MANUAL_TEST.md](docs/E2E_MANUAL_TEST.md) | Manual end-to-end verification procedure (watcher → dispatch → vault) |
424
+ | [docs/testing/TESTING.md](docs/testing/TESTING.md) | Test plan for agent / human / HITL personas, regression suite |
425
+ | [docs/testing/E2E_MANUAL_TEST.md](docs/testing/E2E_MANUAL_TEST.md) | Manual end-to-end verification procedure (watcher → dispatch → vault) |
426
426
 
427
427
  ### Development
428
428
 
429
429
  | Doc | Description |
430
430
  |---|---|
431
- | [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) | Dev setup, testing, and commit conventions |
432
- | [docs/dev/PUBLISHING.md](docs/dev/PUBLISHING.md) | How to publish this extension to npm |
433
- | [docs/dev/FUTURE_WORK.md](docs/dev/FUTURE_WORK.md) | Roadmap — codegraph integration, pagination, TUI rendering, etc. |
431
+ | [docs/development/CONTRIBUTING.md](docs/development/CONTRIBUTING.md) | Dev setup, testing, and commit conventions |
432
+ | [docs/development/PUBLISHING.md](docs/development/PUBLISHING.md) | How to publish this extension to npm |
433
+ | [docs/development/FUTURE_WORK.md](docs/development/FUTURE_WORK.md) | Roadmap — codegraph integration, pagination, TUI rendering, etc. |
434
434
 
435
435
  ### Research
436
436
 
437
437
  | Doc | Description |
438
438
  |---|---|
439
- | [docs/COMPETITOR_COMPARISON.md](docs/COMPETITOR_COMPARISON.md) | Tier 1/2/3 comparison vs. other Obsidian-LLM tools (22 competitors) |
439
+ | [docs/research/COMPETITOR_COMPARISON.md](docs/research/COMPETITOR_COMPARISON.md) | Tier 1/2/3 comparison vs. other Obsidian-LLM tools (22 competitors) |
440
440
  | [docs/research/naming-decisions.md](docs/research/naming-decisions.md) | Historical record of the 2026-06-06 decision to name the project `pi-vault-mind` |
441
441
  | [docs/research/obsidian-links-reviewed.csv](docs/research/obsidian-links-reviewed.csv) | Curated subset of starred Obsidian repos with adoption verdicts |
442
442
 
@@ -449,7 +449,7 @@ full design.
449
449
 
450
450
  ## Contributing
451
451
 
452
- See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for dev setup, testing, and commit conventions.
452
+ See [docs/development/CONTRIBUTING.md](docs/development/CONTRIBUTING.md) for dev setup, testing, and commit conventions.
453
453
 
454
454
  ## License
455
455
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Agent dispatch queue.
3
+ *
4
+ * Persists every watcher dispatch as a job record so users can inspect what
5
+ * is pending, running, done, or failed, and retry failures. Jobs are stored
6
+ * append-only in a JSONL file inside the vault (`<vault>/.vault-mind/agent-queue.jsonl`).
7
+ *
8
+ * Status lifecycle:
9
+ * pending → running → done
10
+ * → failed → (retry) → pending → ...
11
+ * → cancelled (from pending only)
12
+ */
13
+ export type AgentJobStatus = "pending" | "running" | "done" | "failed" | "cancelled";
14
+ /**
15
+ * Event emitted by the queue whenever a job is created, updated, or
16
+ * reaches a terminal state. Subscribers receive a fresh, fully
17
+ * populated `AgentJob` (not a delta) so the WebSocket layer can
18
+ * ship a snapshot.
19
+ */
20
+ export type QueueEvent = {
21
+ type: "job-created";
22
+ job: AgentJob;
23
+ } | {
24
+ type: "job-updated";
25
+ job: AgentJob;
26
+ } | {
27
+ type: "job-completed";
28
+ job: AgentJob;
29
+ };
30
+ /** Subscribe to queue events. Returns an unsubscribe function. */
31
+ export declare const subscribeQueue: (cb: (e: QueueEvent) => void) => (() => void);
32
+ export interface AgentJob {
33
+ /** Unique job ID (same as the dispatchId used for writeback). */
34
+ id: string;
35
+ /** Vault root path this job belongs to. */
36
+ vaultPath: string;
37
+ /** File that contained the @agent marker(s). */
38
+ filePath: string;
39
+ /** Role extracted from the marker (e.g. miner, broadcaster). */
40
+ role: string;
41
+ /** Resolved agent name (e.g. vault-mind-miner). */
42
+ agentName: string;
43
+ /** Combined instruction sent to the subagent. */
44
+ instruction: string;
45
+ /** Number of markers bundled into this job. */
46
+ markerCount: number;
47
+ /** If part of a batch, the batch id (shared with other jobs in the same dispatch). */
48
+ batchId?: string;
49
+ /** Current status in the queue. */
50
+ status: AgentJobStatus;
51
+ /** Number of dispatch attempts (including retries). */
52
+ attempts: number;
53
+ /** ISO timestamp when the job was created. */
54
+ createdAt: string;
55
+ /** ISO timestamp when the job was last dispatched (running). */
56
+ dispatchedAt?: string;
57
+ /** ISO timestamp when the job reached a terminal state. */
58
+ completedAt?: string;
59
+ /** Last error message if status is failed. */
60
+ lastError?: string;
61
+ }
62
+ /** List jobs, optionally filtered by status. */
63
+ export declare const listJobs: (vaultPath: string, status?: AgentJobStatus) => AgentJob[];
64
+ /** Get a single job by id. */
65
+ export declare const getJob: (vaultPath: string, id: string) => AgentJob | undefined;
66
+ /** Create a new pending job. */
67
+ export declare const createJob: (vaultPath: string, job: Omit<AgentJob, "status" | "attempts" | "createdAt">) => AgentJob;
68
+ /** Update a job's status and optional fields. Returns the updated job. */
69
+ export declare const updateJob: (vaultPath: string, id: string, status: AgentJobStatus, updates?: Partial<AgentJob>) => AgentJob | undefined;
70
+ /** Mark a running job as completed because its dispatch marker was replaced. */
71
+ export declare const markJobDone: (vaultPath: string, id: string) => AgentJob | undefined;
72
+ /** Mark a running job as failed with an error message. */
73
+ export declare const markJobFailed: (vaultPath: string, id: string, error: string) => AgentJob | undefined;
74
+ /** Mark a job as dispatched (running). Increments attempts and sets dispatchedAt. */
75
+ export declare const markJobRunning: (vaultPath: string, id: string) => AgentJob | undefined;
76
+ /** Mark a pending job as cancelled. */
77
+ export declare const cancelJob: (vaultPath: string, id: string) => AgentJob | undefined;
78
+ /** True if the file still contains the dispatch marker for this job. */
79
+ export declare const markerStillPresent: (filePath: string, dispatchId: string) => boolean;
80
+ /** Scan a vault's queue and mark any running job whose marker is gone as done. */
81
+ export declare const sweepCompletedJobs: (vaultPath: string) => AgentJob[];
82
+ /** Compact the queue JSONL by rewriting only the latest record per job. */
83
+ export declare const compactQueue: (vaultPath: string) => void;
84
+ /** Group pending jobs in the same vault+role. Excludes cancelled/done/failed. */
85
+ export declare const listPendingByRole: (vaultPath: string) => Record<string, AgentJob[]>;
86
+ /** Mark a list of jobs as running, sharing the same batchId. */
87
+ export declare const markBatchRunning: (vaultPath: string, ids: string[], batchId: string) => void;
88
+ /**
89
+ * Shared retry helper: validates the job is in a retryable state
90
+ * (failed or cancelled), marks it running, and returns the job so
91
+ * the caller can re-send the dispatch message. Returns null with a
92
+ * reason string when the job cannot be retried.
93
+ */
94
+ export declare const retryJob: (vaultPath: string, id: string) => {
95
+ job: AgentJob;
96
+ } | {
97
+ error: string;
98
+ };
99
+ /**
100
+ * Shared cancel helper: validates the job is pending, cancels it,
101
+ * and returns the updated job. Returns null with a reason when the
102
+ * job cannot be cancelled.
103
+ */
104
+ export declare const cancelJobHelper: (vaultPath: string, id: string) => {
105
+ job: AgentJob;
106
+ } | {
107
+ error: string;
108
+ };
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Agent dispatch queue.
3
+ *
4
+ * Persists every watcher dispatch as a job record so users can inspect what
5
+ * is pending, running, done, or failed, and retry failures. Jobs are stored
6
+ * append-only in a JSONL file inside the vault (`<vault>/.vault-mind/agent-queue.jsonl`).
7
+ *
8
+ * Status lifecycle:
9
+ * pending → running → done
10
+ * → failed → (retry) → pending → ...
11
+ * → cancelled (from pending only)
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ const queueSubscribers = new Set();
16
+ /** Subscribe to queue events. Returns an unsubscribe function. */
17
+ export const subscribeQueue = (cb) => {
18
+ queueSubscribers.add(cb);
19
+ return () => queueSubscribers.delete(cb);
20
+ };
21
+ const emitQueueEvent = (e) => {
22
+ for (const cb of queueSubscribers) {
23
+ try {
24
+ cb(e);
25
+ }
26
+ catch {
27
+ // never let a bad listener crash the writer
28
+ }
29
+ }
30
+ };
31
+ const eventTypeFor = (status, prev) => {
32
+ if (!prev)
33
+ return "job-created";
34
+ if (status === "done" || status === "failed" || status === "cancelled")
35
+ return "job-completed";
36
+ return "job-updated";
37
+ };
38
+ const queueFileName = ".vault-mind/agent-queue.jsonl";
39
+ const queuePath = (vaultPath) => path.join(vaultPath, queueFileName);
40
+ const ensureQueueDir = (vaultPath) => {
41
+ const dir = path.dirname(queuePath(vaultPath));
42
+ if (!fs.existsSync(dir)) {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ }
45
+ };
46
+ /** Read all job records and keep the latest entry per job id. */
47
+ const loadRecords = (vaultPath) => {
48
+ const p = queuePath(vaultPath);
49
+ if (!fs.existsSync(p))
50
+ return {};
51
+ const out = {};
52
+ for (const line of fs.readFileSync(p, "utf-8").split(/\r?\n/)) {
53
+ if (!line.trim())
54
+ continue;
55
+ try {
56
+ const rec = JSON.parse(line);
57
+ if (rec.id)
58
+ out[rec.id] = rec;
59
+ }
60
+ catch {
61
+ // skip corrupt line
62
+ }
63
+ }
64
+ return out;
65
+ };
66
+ const appendRecord = (vaultPath, job) => {
67
+ ensureQueueDir(vaultPath);
68
+ fs.appendFileSync(queuePath(vaultPath), `${JSON.stringify(job)}\n`, "utf-8");
69
+ };
70
+ /** List jobs, optionally filtered by status. */
71
+ export const listJobs = (vaultPath, status) => {
72
+ const records = Object.values(loadRecords(vaultPath));
73
+ if (!status)
74
+ return records;
75
+ return records.filter((j) => j.status === status);
76
+ };
77
+ /** Get a single job by id. */
78
+ export const getJob = (vaultPath, id) => loadRecords(vaultPath)[id];
79
+ /** Create a new pending job. */
80
+ export const createJob = (vaultPath, job) => {
81
+ const full = {
82
+ ...job,
83
+ status: "pending",
84
+ attempts: 0,
85
+ createdAt: new Date().toISOString(),
86
+ };
87
+ appendRecord(vaultPath, full);
88
+ emitQueueEvent({ type: "job-created", job: full });
89
+ return full;
90
+ };
91
+ /** Update a job's status and optional fields. Returns the updated job. */
92
+ export const updateJob = (vaultPath, id, status, updates = {}) => {
93
+ const current = getJob(vaultPath, id);
94
+ if (!current)
95
+ return undefined;
96
+ const next = {
97
+ ...current,
98
+ ...updates,
99
+ id,
100
+ status,
101
+ };
102
+ if (status === "done" || status === "failed" || status === "cancelled") {
103
+ next.completedAt = new Date().toISOString();
104
+ }
105
+ appendRecord(vaultPath, next);
106
+ emitQueueEvent({ type: eventTypeFor(status, current.status), job: next });
107
+ return next;
108
+ };
109
+ /** Mark a running job as completed because its dispatch marker was replaced. */
110
+ export const markJobDone = (vaultPath, id) => updateJob(vaultPath, id, "done");
111
+ /** Mark a running job as failed with an error message. */
112
+ export const markJobFailed = (vaultPath, id, error) => updateJob(vaultPath, id, "failed", { lastError: error });
113
+ /** Mark a job as dispatched (running). Increments attempts and sets dispatchedAt. */
114
+ export const markJobRunning = (vaultPath, id) => {
115
+ const current = getJob(vaultPath, id);
116
+ if (!current)
117
+ return undefined;
118
+ return updateJob(vaultPath, id, "running", {
119
+ attempts: current.attempts + 1,
120
+ dispatchedAt: new Date().toISOString(),
121
+ completedAt: undefined,
122
+ lastError: undefined,
123
+ });
124
+ };
125
+ /** Mark a pending job as cancelled. */
126
+ export const cancelJob = (vaultPath, id) => {
127
+ const current = getJob(vaultPath, id);
128
+ if (!current || current.status !== "pending")
129
+ return undefined;
130
+ return updateJob(vaultPath, id, "cancelled");
131
+ };
132
+ /** True if the file still contains the dispatch marker for this job. */
133
+ export const markerStillPresent = (filePath, dispatchId) => {
134
+ try {
135
+ const content = fs.readFileSync(filePath, "utf-8");
136
+ return content.includes(`<!-- pi-dispatch:${dispatchId} -->`);
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ };
142
+ /** Scan a vault's queue and mark any running job whose marker is gone as done. */
143
+ export const sweepCompletedJobs = (vaultPath) => {
144
+ const completed = [];
145
+ for (const job of listJobs(vaultPath, "running")) {
146
+ if (!markerStillPresent(job.filePath, job.id)) {
147
+ const updated = markJobDone(vaultPath, job.id);
148
+ if (updated)
149
+ completed.push(updated);
150
+ }
151
+ }
152
+ return completed;
153
+ };
154
+ /** Compact the queue JSONL by rewriting only the latest record per job. */
155
+ export const compactQueue = (vaultPath) => {
156
+ const p = queuePath(vaultPath);
157
+ const records = Object.values(loadRecords(vaultPath));
158
+ if (records.length === 0) {
159
+ if (fs.existsSync(p))
160
+ fs.rmSync(p, { force: true });
161
+ return;
162
+ }
163
+ fs.writeFileSync(p, `${records.map((r) => JSON.stringify(r)).join("\n")}\n`, "utf-8");
164
+ };
165
+ /** Group pending jobs in the same vault+role. Excludes cancelled/done/failed. */
166
+ export const listPendingByRole = (vaultPath) => {
167
+ const out = {};
168
+ for (const job of listJobs(vaultPath, "pending")) {
169
+ const key = `${vaultPath}\u0000${job.role}\u0000${job.agentName}`;
170
+ if (!out[key])
171
+ out[key] = [];
172
+ out[key].push(job);
173
+ }
174
+ return out;
175
+ };
176
+ /** Mark a list of jobs as running, sharing the same batchId. */
177
+ export const markBatchRunning = (vaultPath, ids, batchId) => {
178
+ for (const id of ids) {
179
+ const current = getJob(vaultPath, id);
180
+ if (!current)
181
+ continue;
182
+ updateJob(vaultPath, id, "running", {
183
+ attempts: current.attempts + 1,
184
+ dispatchedAt: new Date().toISOString(),
185
+ completedAt: undefined,
186
+ lastError: undefined,
187
+ batchId,
188
+ });
189
+ }
190
+ };
191
+ /**
192
+ * Shared retry helper: validates the job is in a retryable state
193
+ * (failed or cancelled), marks it running, and returns the job so
194
+ * the caller can re-send the dispatch message. Returns null with a
195
+ * reason string when the job cannot be retried.
196
+ */
197
+ export const retryJob = (vaultPath, id) => {
198
+ const job = getJob(vaultPath, id);
199
+ if (!job)
200
+ return { error: `Job ${id} not found.` };
201
+ if (job.status !== "failed" && job.status !== "cancelled") {
202
+ return { error: `Job ${id} is ${job.status}. Only failed or cancelled jobs can be retried.` };
203
+ }
204
+ const updated = markJobRunning(vaultPath, id);
205
+ if (!updated)
206
+ return { error: `Failed to mark job ${id} as running.` };
207
+ return { job: updated };
208
+ };
209
+ /**
210
+ * Shared cancel helper: validates the job is pending, cancels it,
211
+ * and returns the updated job. Returns null with a reason when the
212
+ * job cannot be cancelled.
213
+ */
214
+ export const cancelJobHelper = (vaultPath, id) => {
215
+ const job = getJob(vaultPath, id);
216
+ if (!job)
217
+ return { error: `Job ${id} not found.` };
218
+ if (job.status !== "pending") {
219
+ return { error: `Job ${id} is ${job.status}. Only pending jobs can be cancelled.` };
220
+ }
221
+ const cancelled = cancelJob(vaultPath, id);
222
+ if (!cancelled)
223
+ return { error: `Failed to cancel job ${id}.` };
224
+ return { job: cancelled };
225
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Bearer-token auth for the extension's HTTP/WS server.
3
+ *
4
+ * Token resolution order:
5
+ * 1. `process.env.PVM_API_TOKEN`
6
+ * 2. `<homedir>/.pi/agent/vault-mind.env` (key=value, # comments)
7
+ * 3. `wiki.embedding.modal.apiToken` in the loaded config
8
+ *
9
+ * Returns `null` if no token is configured (auth is off until set).
10
+ *
11
+ * Cross-platform: `os.homedir()` (never `~/`); `chmod 600` on the
12
+ * dotenv file is the caller's responsibility and is a no-op on
13
+ * win32 (the file system doesn't honor POSIX modes there).
14
+ */
15
+ import type { UniversalConfig } from "./types.js";
16
+ /** Resolve the bearer token. `cfg` is optional; pass the loaded config to fall back to it. */
17
+ export declare const resolveAuthToken: (cfg?: UniversalConfig) => string | null;
18
+ export interface IncomingLike {
19
+ headers: {
20
+ authorization?: string | string[] | undefined;
21
+ };
22
+ }
23
+ export interface AuthResult {
24
+ ok: boolean;
25
+ reason?: "missing" | "malformed" | "mismatch";
26
+ }
27
+ /** Authorize a request against the resolved token. Returns `ok: true` if auth is off (no token configured). */
28
+ export declare const authoriseRequest: (req: IncomingLike, cfg?: UniversalConfig) => AuthResult;
29
+ /** Test-only: forget the cached token so tests get a fresh resolve on each call. */
30
+ export declare const _resetAuthCache: () => void;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Bearer-token auth for the extension's HTTP/WS server.
3
+ *
4
+ * Token resolution order:
5
+ * 1. `process.env.PVM_API_TOKEN`
6
+ * 2. `<homedir>/.pi/agent/vault-mind.env` (key=value, # comments)
7
+ * 3. `wiki.embedding.modal.apiToken` in the loaded config
8
+ *
9
+ * Returns `null` if no token is configured (auth is off until set).
10
+ *
11
+ * Cross-platform: `os.homedir()` (never `~/`); `chmod 600` on the
12
+ * dotenv file is the caller's responsibility and is a no-op on
13
+ * win32 (the file system doesn't honor POSIX modes there).
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ const ENV_VAR = "PVM_API_TOKEN";
19
+ const DOTENV_REL = path.join(".pi", "agent", "vault-mind.env");
20
+ /** Cached token resolution. The cache is invalidated by `setToken` / `clearToken`. */
21
+ let cached = null;
22
+ const readDotenvToken = () => {
23
+ const home = os.homedir();
24
+ if (!home)
25
+ return null;
26
+ const p = path.join(home, DOTENV_REL);
27
+ try {
28
+ const text = fs.readFileSync(p, "utf-8");
29
+ for (const line of text.split(/\r?\n/)) {
30
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s#]+)/);
31
+ if (!m)
32
+ continue;
33
+ if (m[1] !== ENV_VAR)
34
+ continue;
35
+ return m[3] ?? m[4] ?? m[2] ?? null;
36
+ }
37
+ }
38
+ catch {
39
+ // file missing or unreadable
40
+ }
41
+ return null;
42
+ };
43
+ const readConfigToken = (cfg) => {
44
+ return cfg?.wiki?.embedding?.modal?.apiToken;
45
+ };
46
+ /** Resolve the bearer token. `cfg` is optional; pass the loaded config to fall back to it. */
47
+ export const resolveAuthToken = (cfg) => {
48
+ if (process.env[ENV_VAR])
49
+ return process.env[ENV_VAR] ?? null;
50
+ if (cached && cached.value !== null)
51
+ return cached.value;
52
+ const fromFile = readDotenvToken();
53
+ if (fromFile) {
54
+ cached = { value: fromFile };
55
+ return fromFile;
56
+ }
57
+ const fromCfg = readConfigToken(cfg);
58
+ if (fromCfg) {
59
+ cached = { value: fromCfg };
60
+ return fromCfg;
61
+ }
62
+ return null;
63
+ };
64
+ /** Constant-time compare to avoid timing attacks on the bearer token. */
65
+ const safeEqual = (a, b) => {
66
+ if (a.length !== b.length)
67
+ return false;
68
+ let mismatch = 0;
69
+ for (let i = 0; i < a.length; i++) {
70
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
71
+ }
72
+ return mismatch === 0;
73
+ };
74
+ /** Authorize a request against the resolved token. Returns `ok: true` if auth is off (no token configured). */
75
+ export const authoriseRequest = (req, cfg) => {
76
+ const expected = resolveAuthToken(cfg);
77
+ if (!expected)
78
+ return { ok: true };
79
+ const raw = req.headers.authorization;
80
+ const header = Array.isArray(raw) ? raw[0] : raw;
81
+ if (!header)
82
+ return { ok: false, reason: "missing" };
83
+ const m = header.match(/^Bearer\s+(.+)$/i);
84
+ if (!m)
85
+ return { ok: false, reason: "malformed" };
86
+ if (!safeEqual(m[1], expected))
87
+ return { ok: false, reason: "mismatch" };
88
+ return { ok: true };
89
+ };
90
+ /** Test-only: forget the cached token so tests get a fresh resolve on each call. */
91
+ export const _resetAuthCache = () => {
92
+ cached = null;
93
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Background auto-indexing for collection directories.
3
+ *
4
+ * Watches the directories that contain configured collection JSONL files.
5
+ * When a collection file is created, modified, or deleted, a debounced
6
+ * reindex is triggered for that collection.
7
+ *
8
+ * Controlled by `wiki.autoIndex` (default false). Started alongside the HTTP
9
+ * server and stopped on session shutdown.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import type { CollectionDef, UniversalConfig, WikiConfig } from "./types.js";
13
+ export declare const DEFAULT_DEBOUNCE_MS = 2000;
14
+ export interface AutoIndexState {
15
+ /** Whether the auto-index watcher is active. */
16
+ running: boolean;
17
+ /** Active fs.watch handles keyed by watched directory. */
18
+ watchers: Map<string, fs.FSWatcher>;
19
+ /** Pending per-collection debounce timers. */
20
+ debounceTimers: Map<string, ReturnType<typeof setTimeout>>;
21
+ }
22
+ export interface StartAutoIndexOptions {
23
+ /** Optional pre-created state object (useful for tests). */
24
+ state?: AutoIndexState;
25
+ /** Override the default 2s debounce. */
26
+ debounceMs?: number;
27
+ /** Override the default reindex implementation. */
28
+ reindex?: (dataDir: string, collection: string, def: CollectionDef, cfg: WikiConfig) => Promise<void>;
29
+ }
30
+ export declare function createAutoIndexState(): AutoIndexState;
31
+ /**
32
+ * Start watching configured collection directories.
33
+ * Returns a stop function that closes watchers and clears timers.
34
+ */
35
+ export declare function startAutoIndex(cfg: UniversalConfig, options?: StartAutoIndexOptions): {
36
+ stop: () => void;
37
+ state: AutoIndexState;
38
+ };
39
+ /**
40
+ * Stop all watchers and clear pending debounces.
41
+ */
42
+ export declare function stopAutoIndex(state: AutoIndexState): void;
43
+ /**
44
+ * Re-index a single collection by reading its JSONL source file and upserting
45
+ * each valid entry into LanceDB. Malformed lines are skipped.
46
+ */
47
+ export declare const reindexCollection: (dataDir: string, collection: string, def: CollectionDef, cfg: WikiConfig) => Promise<void>;