pi-vault-mind 0.16.9 → 0.16.11
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 +25 -5
- package/dist/packages/obsidian/src/client.js +33 -1
- package/dist/src/collection-injector-manager.d.ts +32 -0
- package/dist/src/collection-injector-manager.js +394 -0
- package/dist/src/commands.js +35 -48
- package/dist/src/config-keys.d.ts +2 -2
- package/dist/src/config-keys.js +0 -2
- package/dist/src/embedding-probe.d.ts +0 -1
- package/dist/src/embedding-probe.js +3 -4
- package/dist/src/engine.js +2 -0
- package/dist/src/modal-config.d.ts +4 -8
- package/dist/src/modal-config.js +13 -25
- package/dist/src/server.js +87 -21
- package/dist/src/settings-ui.d.ts +4 -5
- package/dist/src/settings-ui.js +33 -61
- package/dist/src/tool-catalog.d.ts +2 -2
- package/dist/src/tool-catalog.js +39 -2
- package/dist/src/tools.js +126 -0
- package/dist/src/types.d.ts +2 -10
- package/dist/src/vm-handlers.js +69 -0
- package/dist/test/collection-injector-manager.test.js +122 -0
- package/dist/test/collection-injector-rest.test.js +237 -0
- package/dist/test/collection-management-scripts.test.js +121 -0
- package/dist/test/modal-config.test.js +15 -22
- package/dist/test/rest-setup.test.js +11 -0
- package/dist/test/settings-ui.test.js +5 -5
- package/dist/test/tool-catalog.test.js +41 -4
- package/dist/test/vm-handlers.test.js +69 -0
- package/package.json +1 -1
- package/scripts/create-collection.mjs +72 -0
- package/scripts/create-injector.mjs +75 -0
- package/scripts/install-modal.sh +66 -0
- package/scripts/reset-test-vault.sh +46 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.16.
|
|
3
|
+
## 0.16.11 / 0.6.15 — 2026-07-20
|
|
4
4
|
|
|
5
5
|
### Fixed
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
7
|
+
- **Panel recovery after bridge startup.** A later panel reconciliation now rehydrates the same Queue, Activity, session, collection, pending-edit, and Git metadata that initial controller startup loads, so a bridge that becomes available after the panel mounted no longer leaves those surfaces permanently stale.
|
|
8
|
+
- **Deterministic ReturnVape reset.** `reset-test-vault.sh` verifies the PID and port registered in `.vault-mind/server.json` serve the target vault before terminating and waiting for that runtime. A stale registry cannot signal an unrelated PID. The reset no longer copies local plugin artifacts; BRAT is the only test-vault update path.
|
|
9
9
|
|
|
10
|
-
###
|
|
10
|
+
### Verification
|
|
11
|
+
|
|
12
|
+
- Added focused regressions for panel metadata recovery after an initial bridge outage, verified runtime termination, and stale-registry safety.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## 0.16.10 / 0.6.14 — 2026-07-20
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **First-run retry with notes.** After cancellation or a terminal personalization failure, the Personalize card retains an optional draft. A blank draft retries with `/vm personalize`; a nonblank trimmed draft is sent as explicit `@agent-personalize:` input and clears only after durable personalization completes.
|
|
20
|
+
- **Typed collection and injector management.** Vault Mind Settings now exposes authenticated collection and injector CRUD controls with validation for vault-relative paths, collection schemas, regex patterns, and referenced targets.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **First-run chat lifecycle.** The onboarding surface remains stable through configuration and personalization transitions. The standard message feed is deferred until a durable personalized state and is instantiated at most once, preventing duplicate first-run cards from reactive remounts.
|
|
25
|
+
- **Long diff readability.** Diff cards collapse long proposal sides by default while retaining access to the full edit.
|
|
26
|
+
- **Settings persistence and layout.** The setup wizard forwards Auto-start to `POST /vm/setup`; Settings categories write the canonical nested configuration shape, and the plugin Settings pane uses the available Obsidian width.
|
|
27
|
+
- **Tool-card escaped text.** JSON-decoded multiline tool output renders actual line breaks and indentation instead of literal escape sequences.
|
|
28
|
+
|
|
29
|
+
### Verification
|
|
11
30
|
|
|
12
|
-
- Added
|
|
31
|
+
- Added focused coverage for retry-note dispatch, cancellation/failure retention, durable-success clearing, one-card first-run lifecycle transitions, long diff collapse, Settings persistence, and collection/injector contracts.
|
|
32
|
+
- The collection/injector CRUD and first-run retry flows remain pending published-artifact ReturnVape walkthroughs.
|
|
13
33
|
|
|
14
34
|
|
|
15
35
|
## 0.6.12 — 2026-07-19
|
|
@@ -6,6 +6,14 @@ const encodeWebSocketAuthProtocol = (token) => {
|
|
|
6
6
|
const encoded = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7
7
|
return `pvm-auth.${encoded}`;
|
|
8
8
|
};
|
|
9
|
+
export class HttpStatusError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(status, message) {
|
|
12
|
+
super(`${status} ${message}`);
|
|
13
|
+
this.name = "HttpStatusError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
9
17
|
/**
|
|
10
18
|
* HTTP + WebSocket client for the pi-vault-mind extension.
|
|
11
19
|
*
|
|
@@ -142,7 +150,7 @@ export class VaultMindClient {
|
|
|
142
150
|
// keep raw text as the server message
|
|
143
151
|
}
|
|
144
152
|
// Never echo the request body or secret material in the thrown error.
|
|
145
|
-
throw new
|
|
153
|
+
throw new HttpStatusError(res.status, serverMessage);
|
|
146
154
|
}
|
|
147
155
|
return text ? JSON.parse(text) : undefined;
|
|
148
156
|
}
|
|
@@ -285,6 +293,30 @@ export class VaultMindClient {
|
|
|
285
293
|
async vmStats() {
|
|
286
294
|
return (await this.httpJson("GET", "/vm/stats"));
|
|
287
295
|
}
|
|
296
|
+
async listManagedCollections() {
|
|
297
|
+
return (await this.httpJson("GET", "/vm/collections"));
|
|
298
|
+
}
|
|
299
|
+
async upsertManagedCollection(name, input) {
|
|
300
|
+
return (await this.httpJson("PUT", `/vm/collections/${encodeURIComponent(name)}`, input));
|
|
301
|
+
}
|
|
302
|
+
async patchManagedCollection(name, input) {
|
|
303
|
+
return (await this.httpJson("PATCH", `/vm/collections/${encodeURIComponent(name)}`, input));
|
|
304
|
+
}
|
|
305
|
+
async deleteManagedCollection(name) {
|
|
306
|
+
await this.httpJson("DELETE", `/vm/collections/${encodeURIComponent(name)}`);
|
|
307
|
+
}
|
|
308
|
+
async listManagedInjectors() {
|
|
309
|
+
return (await this.httpJson("GET", "/vm/injectors"));
|
|
310
|
+
}
|
|
311
|
+
async upsertManagedInjector(name, input) {
|
|
312
|
+
return (await this.httpJson("PUT", `/vm/injectors/${encodeURIComponent(name)}`, input));
|
|
313
|
+
}
|
|
314
|
+
async patchManagedInjector(name, input) {
|
|
315
|
+
return (await this.httpJson("PATCH", `/vm/injectors/${encodeURIComponent(name)}`, input));
|
|
316
|
+
}
|
|
317
|
+
async deleteManagedInjector(name) {
|
|
318
|
+
await this.httpJson("DELETE", `/vm/injectors/${encodeURIComponent(name)}`);
|
|
319
|
+
}
|
|
288
320
|
/** GET /vault-mind/config — full config + hasToken + remote block */
|
|
289
321
|
async getConfig() {
|
|
290
322
|
return (await this.httpJson("GET", "/vault-mind/config"));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface CollectionConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
schema: string[];
|
|
5
|
+
dedupField?: string;
|
|
6
|
+
dedupMode?: "exact" | "fuzzy";
|
|
7
|
+
dedupThreshold?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CollectionRecord extends CollectionConfig {
|
|
10
|
+
count: number;
|
|
11
|
+
malformed: number;
|
|
12
|
+
}
|
|
13
|
+
export interface InjectorConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
regex: string;
|
|
16
|
+
collection: string;
|
|
17
|
+
captureGroup?: number;
|
|
18
|
+
filterField?: string;
|
|
19
|
+
artifactPath?: string;
|
|
20
|
+
template?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare class CollectionInjectorManagerError extends Error {
|
|
23
|
+
readonly status: 400 | 404 | 409;
|
|
24
|
+
constructor(message: string, status: 400 | 404 | 409);
|
|
25
|
+
}
|
|
26
|
+
export declare function listCollections(vaultPath: string): CollectionRecord[];
|
|
27
|
+
export declare function upsertCollection(vaultPath: string, request: CollectionConfig): CollectionRecord;
|
|
28
|
+
export declare function patchCollection(vaultPath: string, name: string, patch: Record<string, unknown>): CollectionRecord;
|
|
29
|
+
export declare function deleteCollection(vaultPath: string, name: string): void;
|
|
30
|
+
export declare function listInjectors(vaultPath: string): InjectorConfig[];
|
|
31
|
+
export declare function upsertInjector(vaultPath: string, request: InjectorConfig): InjectorConfig;
|
|
32
|
+
export declare function deleteInjector(vaultPath: string, name: string): void;
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getConfigPath } from "./utils.js";
|
|
5
|
+
export class CollectionInjectorManagerError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "CollectionInjectorManagerError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function requiredText(value, label) {
|
|
17
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
18
|
+
throw new CollectionInjectorManagerError(`${label} is required.`, 400);
|
|
19
|
+
}
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
function readConfig(vaultPath) {
|
|
23
|
+
const configPath = getConfigPath(vaultPath);
|
|
24
|
+
if (!fs.existsSync(configPath)) {
|
|
25
|
+
throw new CollectionInjectorManagerError("No vault configuration found. Run setup first.", 404);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
29
|
+
if (!isRecord(parsed))
|
|
30
|
+
throw new Error("Configuration must be an object.");
|
|
31
|
+
return { configPath, config: parsed };
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof CollectionInjectorManagerError)
|
|
35
|
+
throw error;
|
|
36
|
+
throw new CollectionInjectorManagerError(`Could not read vault configuration: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function writeConfig(configPath, config) {
|
|
40
|
+
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
43
|
+
fs.renameSync(temporaryPath, configPath);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(temporaryPath);
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function resolveVaultCollectionPath(vaultPath, collectionPath) {
|
|
54
|
+
if (path.isAbsolute(collectionPath)) {
|
|
55
|
+
throw new CollectionInjectorManagerError("Collection paths must be relative to the vault.", 400);
|
|
56
|
+
}
|
|
57
|
+
if (path.extname(collectionPath) !== ".jsonl") {
|
|
58
|
+
throw new CollectionInjectorManagerError("Collection paths must target a .jsonl file.", 400);
|
|
59
|
+
}
|
|
60
|
+
const absolutePath = path.resolve(vaultPath, collectionPath);
|
|
61
|
+
const relativePath = path.relative(vaultPath, absolutePath);
|
|
62
|
+
if (!relativePath ||
|
|
63
|
+
relativePath === ".." ||
|
|
64
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
65
|
+
path.isAbsolute(relativePath)) {
|
|
66
|
+
throw new CollectionInjectorManagerError("Collection path must stay inside the vault.", 400);
|
|
67
|
+
}
|
|
68
|
+
const realVaultPath = fs.realpathSync.native(vaultPath);
|
|
69
|
+
let parentPath = vaultPath;
|
|
70
|
+
for (const segment of relativePath.split(path.sep).slice(0, -1)) {
|
|
71
|
+
parentPath = path.join(parentPath, segment);
|
|
72
|
+
if (!fs.existsSync(parentPath))
|
|
73
|
+
fs.mkdirSync(parentPath);
|
|
74
|
+
const realParentPath = fs.realpathSync.native(parentPath);
|
|
75
|
+
const parentRelativePath = path.relative(realVaultPath, realParentPath);
|
|
76
|
+
if (parentRelativePath === ".." ||
|
|
77
|
+
parentRelativePath.startsWith(`..${path.sep}`) ||
|
|
78
|
+
path.isAbsolute(parentRelativePath)) {
|
|
79
|
+
throw new CollectionInjectorManagerError("Collection path cannot traverse a linked directory outside the vault.", 400);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (fs.existsSync(absolutePath) && fs.lstatSync(absolutePath).isSymbolicLink()) {
|
|
83
|
+
throw new CollectionInjectorManagerError("Collection path cannot be a symbolic link.", 400);
|
|
84
|
+
}
|
|
85
|
+
return absolutePath;
|
|
86
|
+
}
|
|
87
|
+
function resolveSchemaAlias(name, value, collections, visited = new Set()) {
|
|
88
|
+
if (!isRecord(value)) {
|
|
89
|
+
throw new CollectionInjectorManagerError(`Collection "${name}" has an invalid definition.`, 400);
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(value.schema)) {
|
|
92
|
+
return value.schema.map((field) => requiredText(field, `Collection "${name}" schema field`));
|
|
93
|
+
}
|
|
94
|
+
if (typeof value.schema !== "string" || !value.schema.trim()) {
|
|
95
|
+
throw new CollectionInjectorManagerError(`Collection "${name}" requires a schema.`, 400);
|
|
96
|
+
}
|
|
97
|
+
const alias = value.schema.trim();
|
|
98
|
+
if (visited.has(alias)) {
|
|
99
|
+
throw new CollectionInjectorManagerError(`Collection "${name}" has a circular schema alias.`, 400);
|
|
100
|
+
}
|
|
101
|
+
const target = collections[alias];
|
|
102
|
+
if (target === undefined) {
|
|
103
|
+
throw new CollectionInjectorManagerError(`Collection "${name}" references missing schema alias "${alias}".`, 400);
|
|
104
|
+
}
|
|
105
|
+
visited.add(alias);
|
|
106
|
+
return resolveSchemaAlias(alias, target, collections, visited);
|
|
107
|
+
}
|
|
108
|
+
function collectionRecord(name, value, vaultPath, collections) {
|
|
109
|
+
if (!isRecord(value)) {
|
|
110
|
+
throw new CollectionInjectorManagerError(`Collection "${name}" has an invalid definition.`, 400);
|
|
111
|
+
}
|
|
112
|
+
const collectionPath = requiredText(value.path, `Collection "${name}" path`);
|
|
113
|
+
const schema = collections
|
|
114
|
+
? resolveSchemaAlias(name, value, collections, new Set([name]))
|
|
115
|
+
: Array.isArray(value.schema)
|
|
116
|
+
? value.schema.map((field) => requiredText(field, `Collection "${name}" schema field`))
|
|
117
|
+
: [];
|
|
118
|
+
if (schema.length === 0) {
|
|
119
|
+
throw new CollectionInjectorManagerError(`Collection "${name}" requires at least one schema field.`, 400);
|
|
120
|
+
}
|
|
121
|
+
const absolutePath = path.isAbsolute(collectionPath)
|
|
122
|
+
? collectionPath
|
|
123
|
+
: path.join(vaultPath, collectionPath);
|
|
124
|
+
let count = 0;
|
|
125
|
+
let malformed = 0;
|
|
126
|
+
if (fs.existsSync(absolutePath)) {
|
|
127
|
+
for (const line of fs.readFileSync(absolutePath, "utf-8").split("\n")) {
|
|
128
|
+
if (!line.trim())
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
JSON.parse(line);
|
|
132
|
+
count += 1;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
malformed += 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const dedupMode = value.dedupMode;
|
|
140
|
+
const dedupThreshold = value.dedupThreshold;
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
path: collectionPath,
|
|
144
|
+
schema,
|
|
145
|
+
...(typeof value.dedupField === "string" && value.dedupField.trim()
|
|
146
|
+
? { dedupField: value.dedupField.trim() }
|
|
147
|
+
: {}),
|
|
148
|
+
...(dedupMode === "exact" || dedupMode === "fuzzy" ? { dedupMode } : {}),
|
|
149
|
+
...(typeof dedupThreshold === "number" && Number.isFinite(dedupThreshold)
|
|
150
|
+
? { dedupThreshold }
|
|
151
|
+
: {}),
|
|
152
|
+
count,
|
|
153
|
+
malformed,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function assertVaultArtifactPath(vaultPath, artifactPath) {
|
|
157
|
+
if (path.isAbsolute(artifactPath)) {
|
|
158
|
+
throw new CollectionInjectorManagerError("Injector artifact paths must be relative to the vault.", 400);
|
|
159
|
+
}
|
|
160
|
+
const absolutePath = path.resolve(vaultPath, artifactPath);
|
|
161
|
+
const relativePath = path.relative(vaultPath, absolutePath);
|
|
162
|
+
if (!relativePath ||
|
|
163
|
+
relativePath === ".." ||
|
|
164
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
165
|
+
path.isAbsolute(relativePath)) {
|
|
166
|
+
throw new CollectionInjectorManagerError("Injector artifact path must stay inside the vault.", 400);
|
|
167
|
+
}
|
|
168
|
+
let existingParentPath = path.dirname(absolutePath);
|
|
169
|
+
while (!fs.existsSync(existingParentPath)) {
|
|
170
|
+
const nextParentPath = path.dirname(existingParentPath);
|
|
171
|
+
if (nextParentPath === existingParentPath)
|
|
172
|
+
break;
|
|
173
|
+
existingParentPath = nextParentPath;
|
|
174
|
+
}
|
|
175
|
+
const realVaultPath = fs.realpathSync.native(vaultPath);
|
|
176
|
+
const realParentPath = fs.realpathSync.native(existingParentPath);
|
|
177
|
+
const parentRelativePath = path.relative(realVaultPath, realParentPath);
|
|
178
|
+
if (parentRelativePath === ".." ||
|
|
179
|
+
parentRelativePath.startsWith(`..${path.sep}`) ||
|
|
180
|
+
path.isAbsolute(parentRelativePath)) {
|
|
181
|
+
throw new CollectionInjectorManagerError("Injector artifact path cannot traverse a linked directory outside the vault.", 400);
|
|
182
|
+
}
|
|
183
|
+
if (fs.existsSync(absolutePath) && fs.lstatSync(absolutePath).isSymbolicLink()) {
|
|
184
|
+
throw new CollectionInjectorManagerError("Injector artifact path cannot be a symbolic link.", 400);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function injectorRecord(value) {
|
|
188
|
+
if (!isRecord(value))
|
|
189
|
+
throw new CollectionInjectorManagerError("Injector has an invalid definition.", 400);
|
|
190
|
+
return {
|
|
191
|
+
name: requiredText(value.name, "Injector name"),
|
|
192
|
+
regex: requiredText(value.regex, "Injector regex"),
|
|
193
|
+
collection: requiredText(value.collection, "Injector collection"),
|
|
194
|
+
...(typeof value.captureGroup === "number" ? { captureGroup: value.captureGroup } : {}),
|
|
195
|
+
...(typeof value.filterField === "string" && value.filterField.trim()
|
|
196
|
+
? { filterField: value.filterField.trim() }
|
|
197
|
+
: {}),
|
|
198
|
+
...(typeof value.artifactPath === "string" && value.artifactPath.trim()
|
|
199
|
+
? { artifactPath: value.artifactPath.trim() }
|
|
200
|
+
: {}),
|
|
201
|
+
...(typeof value.template === "string" && value.template.trim()
|
|
202
|
+
? { template: value.template }
|
|
203
|
+
: {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function collectionReferences(config, name) {
|
|
207
|
+
const references = [];
|
|
208
|
+
for (const injector of config.injectors ?? []) {
|
|
209
|
+
if (isRecord(injector) && injector.collection === name) {
|
|
210
|
+
references.push(`injector "${String(injector.name ?? "unnamed")}"`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const [collectionName, definition] of Object.entries(config.collections ?? {})) {
|
|
214
|
+
if (collectionName !== name && isRecord(definition) && definition.schema === name) {
|
|
215
|
+
references.push(`collection "${collectionName}" schema alias`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const vaultMind = config.vaultMind;
|
|
219
|
+
const embedding = isRecord(vaultMind?.embedding) ? vaultMind.embedding : undefined;
|
|
220
|
+
const sync = isRecord(embedding?.sync) ? embedding.sync : undefined;
|
|
221
|
+
if (Array.isArray(sync?.collections) && sync.collections.includes(name)) {
|
|
222
|
+
references.push("embedding sync collections");
|
|
223
|
+
}
|
|
224
|
+
if (isRecord(embedding?.collectionModels) && Object.hasOwn(embedding.collectionModels, name)) {
|
|
225
|
+
references.push("embedding collection models");
|
|
226
|
+
}
|
|
227
|
+
const identities = isRecord(vaultMind?.identities) ? vaultMind.identities : undefined;
|
|
228
|
+
const roles = isRecord(identities?.roles) ? identities.roles : {};
|
|
229
|
+
for (const [role, profile] of Object.entries(roles)) {
|
|
230
|
+
if (!isRecord(profile))
|
|
231
|
+
continue;
|
|
232
|
+
for (const key of ["readCollections", "writeCollections"]) {
|
|
233
|
+
if (Array.isArray(profile[key]) && profile[key].includes(name)) {
|
|
234
|
+
references.push(`identity role "${role}" ${key}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return references;
|
|
239
|
+
}
|
|
240
|
+
export function listCollections(vaultPath) {
|
|
241
|
+
const { config } = readConfig(vaultPath);
|
|
242
|
+
const collections = isRecord(config.collections) ? config.collections : {};
|
|
243
|
+
return Object.entries(collections).map(([name, value]) => collectionRecord(name, value, vaultPath, collections));
|
|
244
|
+
}
|
|
245
|
+
export function upsertCollection(vaultPath, request) {
|
|
246
|
+
if (!isRecord(request) || !Array.isArray(request.schema)) {
|
|
247
|
+
throw new CollectionInjectorManagerError("Collection requires a schema array.", 400);
|
|
248
|
+
}
|
|
249
|
+
const name = requiredText(request.name, "Collection name");
|
|
250
|
+
const collectionPath = requiredText(request.path, "Collection path");
|
|
251
|
+
const schema = request.schema.map((field) => requiredText(field, "Collection schema field"));
|
|
252
|
+
if (schema.length === 0) {
|
|
253
|
+
throw new CollectionInjectorManagerError("Collection requires at least one schema field.", 400);
|
|
254
|
+
}
|
|
255
|
+
if (new Set(schema).size !== schema.length) {
|
|
256
|
+
throw new CollectionInjectorManagerError("Collection schema fields must be unique.", 400);
|
|
257
|
+
}
|
|
258
|
+
if (request.dedupMode && request.dedupMode !== "exact" && request.dedupMode !== "fuzzy") {
|
|
259
|
+
throw new CollectionInjectorManagerError("Dedup mode must be exact or fuzzy.", 400);
|
|
260
|
+
}
|
|
261
|
+
if (request.dedupThreshold !== undefined &&
|
|
262
|
+
(!Number.isFinite(request.dedupThreshold) ||
|
|
263
|
+
request.dedupThreshold < 0 ||
|
|
264
|
+
request.dedupThreshold > 1)) {
|
|
265
|
+
throw new CollectionInjectorManagerError("Dedup threshold must be between 0 and 1.", 400);
|
|
266
|
+
}
|
|
267
|
+
const { configPath, config } = readConfig(vaultPath);
|
|
268
|
+
const collections = isRecord(config.collections) ? config.collections : {};
|
|
269
|
+
const dedupField = typeof request.dedupField === "string" ? request.dedupField.trim() : undefined;
|
|
270
|
+
const definition = {
|
|
271
|
+
path: collectionPath,
|
|
272
|
+
schema,
|
|
273
|
+
...(dedupField ? { dedupField } : {}),
|
|
274
|
+
...(request.dedupMode ? { dedupMode: request.dedupMode } : {}),
|
|
275
|
+
...(request.dedupThreshold !== undefined ? { dedupThreshold: request.dedupThreshold } : {}),
|
|
276
|
+
};
|
|
277
|
+
const absolutePath = resolveVaultCollectionPath(vaultPath, collectionPath);
|
|
278
|
+
config.collections = { ...collections, [name]: definition };
|
|
279
|
+
if (!fs.existsSync(absolutePath))
|
|
280
|
+
fs.writeFileSync(absolutePath, "", "utf-8");
|
|
281
|
+
writeConfig(configPath, config);
|
|
282
|
+
return collectionRecord(name, definition, vaultPath);
|
|
283
|
+
}
|
|
284
|
+
export function patchCollection(vaultPath, name, patch) {
|
|
285
|
+
const collectionName = requiredText(name, "Collection name");
|
|
286
|
+
const { configPath, config } = readConfig(vaultPath);
|
|
287
|
+
const collections = isRecord(config.collections) ? config.collections : {};
|
|
288
|
+
const current = collections[collectionName];
|
|
289
|
+
if (!isRecord(current)) {
|
|
290
|
+
throw new CollectionInjectorManagerError(`Collection "${collectionName}" was not found.`, 404);
|
|
291
|
+
}
|
|
292
|
+
const { name: _ignoredName, ...definitionPatch } = patch;
|
|
293
|
+
const definition = { ...current, ...definitionPatch };
|
|
294
|
+
if (Array.isArray(definition.schema)) {
|
|
295
|
+
return upsertCollection(vaultPath, {
|
|
296
|
+
name: collectionName,
|
|
297
|
+
path: requiredText(definition.path, "Collection path"),
|
|
298
|
+
schema: definition.schema.map((field) => requiredText(field, "Collection schema field")),
|
|
299
|
+
...(typeof definition.dedupField === "string" ? { dedupField: definition.dedupField } : {}),
|
|
300
|
+
...(definition.dedupMode === "exact" || definition.dedupMode === "fuzzy"
|
|
301
|
+
? { dedupMode: definition.dedupMode }
|
|
302
|
+
: {}),
|
|
303
|
+
...(typeof definition.dedupThreshold === "number"
|
|
304
|
+
? { dedupThreshold: definition.dedupThreshold }
|
|
305
|
+
: {}),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (typeof definition.schema !== "string" || !definition.schema.trim()) {
|
|
309
|
+
throw new CollectionInjectorManagerError("Collection requires a schema array or alias.", 400);
|
|
310
|
+
}
|
|
311
|
+
const collectionPath = requiredText(definition.path, "Collection path");
|
|
312
|
+
if (definition.dedupMode !== undefined &&
|
|
313
|
+
definition.dedupMode !== "exact" &&
|
|
314
|
+
definition.dedupMode !== "fuzzy") {
|
|
315
|
+
throw new CollectionInjectorManagerError("Dedup mode must be exact or fuzzy.", 400);
|
|
316
|
+
}
|
|
317
|
+
if (definition.dedupThreshold !== undefined &&
|
|
318
|
+
(typeof definition.dedupThreshold !== "number" ||
|
|
319
|
+
!Number.isFinite(definition.dedupThreshold) ||
|
|
320
|
+
definition.dedupThreshold < 0 ||
|
|
321
|
+
definition.dedupThreshold > 1)) {
|
|
322
|
+
throw new CollectionInjectorManagerError("Dedup threshold must be between 0 and 1.", 400);
|
|
323
|
+
}
|
|
324
|
+
const updatedCollections = { ...collections, [collectionName]: definition };
|
|
325
|
+
const schema = resolveSchemaAlias(collectionName, definition, updatedCollections, new Set([collectionName]));
|
|
326
|
+
if (schema.length === 0) {
|
|
327
|
+
throw new CollectionInjectorManagerError("Collection requires at least one schema field.", 400);
|
|
328
|
+
}
|
|
329
|
+
const absolutePath = resolveVaultCollectionPath(vaultPath, collectionPath);
|
|
330
|
+
if (!fs.existsSync(absolutePath))
|
|
331
|
+
fs.writeFileSync(absolutePath, "", "utf-8");
|
|
332
|
+
config.collections = updatedCollections;
|
|
333
|
+
writeConfig(configPath, config);
|
|
334
|
+
return collectionRecord(collectionName, definition, vaultPath, updatedCollections);
|
|
335
|
+
}
|
|
336
|
+
export function deleteCollection(vaultPath, name) {
|
|
337
|
+
const collectionName = requiredText(name, "Collection name");
|
|
338
|
+
const { configPath, config } = readConfig(vaultPath);
|
|
339
|
+
const collections = isRecord(config.collections) ? config.collections : {};
|
|
340
|
+
if (!Object.hasOwn(collections, collectionName)) {
|
|
341
|
+
throw new CollectionInjectorManagerError(`Collection "${collectionName}" was not found.`, 404);
|
|
342
|
+
}
|
|
343
|
+
const references = collectionReferences(config, collectionName);
|
|
344
|
+
if (references.length > 0) {
|
|
345
|
+
throw new CollectionInjectorManagerError(`Collection "${collectionName}" is referenced by ${references.join(", ")}. Remove those references before deleting it.`, 409);
|
|
346
|
+
}
|
|
347
|
+
delete collections[collectionName];
|
|
348
|
+
config.collections = collections;
|
|
349
|
+
writeConfig(configPath, config);
|
|
350
|
+
}
|
|
351
|
+
export function listInjectors(vaultPath) {
|
|
352
|
+
const { config } = readConfig(vaultPath);
|
|
353
|
+
return (config.injectors ?? []).map(injectorRecord);
|
|
354
|
+
}
|
|
355
|
+
export function upsertInjector(vaultPath, request) {
|
|
356
|
+
const injector = injectorRecord(request);
|
|
357
|
+
try {
|
|
358
|
+
new RegExp(injector.regex);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
throw new CollectionInjectorManagerError(`Invalid injector regex: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
362
|
+
}
|
|
363
|
+
if (injector.captureGroup !== undefined &&
|
|
364
|
+
(!Number.isInteger(injector.captureGroup) || injector.captureGroup < 0)) {
|
|
365
|
+
throw new CollectionInjectorManagerError("Capture group must be a non-negative integer.", 400);
|
|
366
|
+
}
|
|
367
|
+
if (injector.artifactPath)
|
|
368
|
+
assertVaultArtifactPath(vaultPath, injector.artifactPath);
|
|
369
|
+
const { configPath, config } = readConfig(vaultPath);
|
|
370
|
+
const collections = isRecord(config.collections) ? config.collections : {};
|
|
371
|
+
if (!Object.hasOwn(collections, injector.collection)) {
|
|
372
|
+
throw new CollectionInjectorManagerError(`Target collection "${injector.collection}" was not found.`, 400);
|
|
373
|
+
}
|
|
374
|
+
const injectors = (config.injectors ?? []).map(injectorRecord);
|
|
375
|
+
const existingIndex = injectors.findIndex((candidate) => candidate.name === injector.name);
|
|
376
|
+
if (existingIndex >= 0)
|
|
377
|
+
injectors[existingIndex] = injector;
|
|
378
|
+
else
|
|
379
|
+
injectors.push(injector);
|
|
380
|
+
config.injectors = injectors;
|
|
381
|
+
writeConfig(configPath, config);
|
|
382
|
+
return injector;
|
|
383
|
+
}
|
|
384
|
+
export function deleteInjector(vaultPath, name) {
|
|
385
|
+
const injectorName = requiredText(name, "Injector name");
|
|
386
|
+
const { configPath, config } = readConfig(vaultPath);
|
|
387
|
+
const injectors = (config.injectors ?? []).map(injectorRecord);
|
|
388
|
+
const remaining = injectors.filter((injector) => injector.name !== injectorName);
|
|
389
|
+
if (remaining.length === injectors.length) {
|
|
390
|
+
throw new CollectionInjectorManagerError(`Injector "${injectorName}" was not found.`, 404);
|
|
391
|
+
}
|
|
392
|
+
config.injectors = remaining;
|
|
393
|
+
writeConfig(configPath, config);
|
|
394
|
+
}
|