@context-vault/core 2.15.0 → 2.17.1
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/package.json +2 -3
- package/src/capture/file-ops.js +2 -0
- package/src/capture/index.js +14 -0
- package/src/constants.js +7 -2
- package/src/core/categories.js +1 -0
- package/src/core/config.js +9 -2
- package/src/core/files.js +6 -29
- package/src/core/frontmatter.js +1 -0
- package/src/core/linking.js +161 -0
- package/src/core/migrate-dirs.js +196 -0
- package/src/core/status.js +28 -2
- package/src/core/temporal.js +146 -0
- package/src/index/db.js +178 -8
- package/src/index/index.js +113 -48
- package/src/index.js +5 -0
- package/src/retrieve/index.js +16 -136
- package/src/server/tools/context-status.js +7 -0
- package/src/server/tools/create-snapshot.js +37 -68
- package/src/server/tools/get-context.js +120 -19
- package/src/server/tools/save-context.js +29 -6
- package/src/server/tools.js +0 -2
- package/src/server/tools/submit-feedback.js +0 -55
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporal shortcut resolver.
|
|
3
|
+
*
|
|
4
|
+
* Converts natural-language time expressions to ISO date strings.
|
|
5
|
+
* Fully pure — accepts an explicit `now` Date for deterministic testing.
|
|
6
|
+
*
|
|
7
|
+
* Supported shortcuts (case-insensitive, spaces or underscores):
|
|
8
|
+
* today → start of today (00:00:00 local midnight in UTC)
|
|
9
|
+
* yesterday → { since: start of yesterday, until: end of yesterday }
|
|
10
|
+
* this_week → start of current ISO week (Monday 00:00)
|
|
11
|
+
* last_N_days → N days ago (e.g. last_3_days, last 7 days)
|
|
12
|
+
* last_N_weeks → N weeks ago
|
|
13
|
+
* last_N_months → N calendar months ago (approximate: N × 30 days)
|
|
14
|
+
*
|
|
15
|
+
* Anything that doesn't match a shortcut is returned unchanged so that ISO
|
|
16
|
+
* date strings pass straight through without modification.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SHORTCUT_RE = /^last[_ ](\d+)[_ ](day|days|week|weeks|month|months)$/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start of today in UTC (i.e. the midnight boundary at which the local date begins,
|
|
23
|
+
* expressed as an ISO string). We operate in UTC throughout so tests are portable.
|
|
24
|
+
*
|
|
25
|
+
* @param {Date} now
|
|
26
|
+
* @returns {Date}
|
|
27
|
+
*/
|
|
28
|
+
function startOfToday(now) {
|
|
29
|
+
const d = new Date(now);
|
|
30
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a single temporal expression to one or two ISO date strings.
|
|
36
|
+
*
|
|
37
|
+
* @param {"since"|"until"} role - Which parameter we are resolving.
|
|
38
|
+
* @param {string} value - Raw parameter value from the caller.
|
|
39
|
+
* @param {Date} [now] - Override for "now" (defaults to `new Date()`).
|
|
40
|
+
* @returns {string} - Resolved ISO date string (or original value if unrecognised).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveTemporalShortcut(role, value, now = new Date()) {
|
|
43
|
+
if (!value || typeof value !== "string") return value;
|
|
44
|
+
|
|
45
|
+
const trimmed = value.trim().toLowerCase().replace(/\s+/g, "_");
|
|
46
|
+
|
|
47
|
+
if (trimmed === "today") {
|
|
48
|
+
const start = startOfToday(now);
|
|
49
|
+
if (role === "until") {
|
|
50
|
+
// "until today" means up to end-of-today
|
|
51
|
+
const end = new Date(start);
|
|
52
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
53
|
+
return end.toISOString();
|
|
54
|
+
}
|
|
55
|
+
return start.toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (trimmed === "yesterday") {
|
|
59
|
+
const todayStart = startOfToday(now);
|
|
60
|
+
const yesterdayStart = new Date(todayStart);
|
|
61
|
+
yesterdayStart.setUTCDate(yesterdayStart.getUTCDate() - 1);
|
|
62
|
+
if (role === "since") return yesterdayStart.toISOString();
|
|
63
|
+
// role === "until": end of yesterday = start of today
|
|
64
|
+
return todayStart.toISOString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (trimmed === "this_week") {
|
|
68
|
+
// Monday 00:00 UTC of the current week
|
|
69
|
+
const todayStart = startOfToday(now);
|
|
70
|
+
const dayOfWeek = todayStart.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
|
71
|
+
const daysFromMonday = (dayOfWeek + 6) % 7; // 0 on Monday
|
|
72
|
+
const monday = new Date(todayStart);
|
|
73
|
+
monday.setUTCDate(monday.getUTCDate() - daysFromMonday);
|
|
74
|
+
if (role === "since") return monday.toISOString();
|
|
75
|
+
// "until this_week" means up to now (end of today)
|
|
76
|
+
const endOfToday = new Date(todayStart);
|
|
77
|
+
endOfToday.setUTCDate(endOfToday.getUTCDate() + 1);
|
|
78
|
+
return endOfToday.toISOString();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (trimmed === "this_month") {
|
|
82
|
+
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
83
|
+
if (role === "since") return d.toISOString();
|
|
84
|
+
const endOfMonth = new Date(
|
|
85
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1),
|
|
86
|
+
);
|
|
87
|
+
return endOfMonth.toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const m = SHORTCUT_RE.exec(trimmed);
|
|
91
|
+
if (m) {
|
|
92
|
+
const n = parseInt(m[1], 10);
|
|
93
|
+
const unit = m[2].replace(/s$/, ""); // normalise plural → singular
|
|
94
|
+
let ms;
|
|
95
|
+
if (unit === "day") {
|
|
96
|
+
ms = n * 86400000;
|
|
97
|
+
} else if (unit === "week") {
|
|
98
|
+
ms = n * 7 * 86400000;
|
|
99
|
+
} else {
|
|
100
|
+
// month → approximate as 30 days
|
|
101
|
+
ms = n * 30 * 86400000;
|
|
102
|
+
}
|
|
103
|
+
const target = new Date(now.getTime() - ms);
|
|
104
|
+
target.setUTCHours(0, 0, 0, 0);
|
|
105
|
+
return target.toISOString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Unrecognised — pass through unchanged (ISO dates, empty strings, etc.)
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve both `since` and `until` parameters, handling the special case where
|
|
114
|
+
* "yesterday" sets both bounds automatically when only `since` is specified.
|
|
115
|
+
*
|
|
116
|
+
* Returns `{ since, until }` with resolved ISO strings (or originals).
|
|
117
|
+
*
|
|
118
|
+
* @param {{ since?: string, until?: string }} params
|
|
119
|
+
* @param {Date} [now]
|
|
120
|
+
* @returns {{ since: string|undefined, until: string|undefined }}
|
|
121
|
+
*/
|
|
122
|
+
export function resolveTemporalParams(params, now = new Date()) {
|
|
123
|
+
let { since, until } = params;
|
|
124
|
+
|
|
125
|
+
// Special case: "yesterday" on `since` without an explicit `until`
|
|
126
|
+
// auto-fills `until` to the end of yesterday.
|
|
127
|
+
if (
|
|
128
|
+
since?.trim().toLowerCase() === "yesterday" &&
|
|
129
|
+
(until === undefined || until === null)
|
|
130
|
+
) {
|
|
131
|
+
since = resolveTemporalShortcut("since", since, now);
|
|
132
|
+
until = resolveTemporalShortcut("until", "yesterday", now);
|
|
133
|
+
return { since, until };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
since:
|
|
138
|
+
since !== undefined
|
|
139
|
+
? resolveTemporalShortcut("since", since, now)
|
|
140
|
+
: since,
|
|
141
|
+
until:
|
|
142
|
+
until !== undefined
|
|
143
|
+
? resolveTemporalShortcut("until", until, now)
|
|
144
|
+
: until,
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/index/db.js
CHANGED
|
@@ -42,7 +42,69 @@ function runTransaction(db, fn) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// Local-mode schema: no multi-tenancy or encryption columns.
|
|
46
|
+
// Identity uniqueness is scoped to (kind, identity_key) — no user_id.
|
|
47
|
+
export const LOCAL_SCHEMA_DDL = `
|
|
48
|
+
CREATE TABLE IF NOT EXISTS vault (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
kind TEXT NOT NULL,
|
|
51
|
+
category TEXT NOT NULL DEFAULT 'knowledge',
|
|
52
|
+
title TEXT,
|
|
53
|
+
body TEXT NOT NULL,
|
|
54
|
+
meta TEXT,
|
|
55
|
+
tags TEXT,
|
|
56
|
+
source TEXT,
|
|
57
|
+
file_path TEXT UNIQUE,
|
|
58
|
+
identity_key TEXT,
|
|
59
|
+
expires_at TEXT,
|
|
60
|
+
superseded_by TEXT,
|
|
61
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
62
|
+
updated_at TEXT,
|
|
63
|
+
hit_count INTEGER DEFAULT 0,
|
|
64
|
+
last_accessed_at TEXT,
|
|
65
|
+
source_files TEXT,
|
|
66
|
+
tier TEXT DEFAULT 'working' CHECK(tier IN ('ephemeral', 'working', 'durable')),
|
|
67
|
+
related_to TEXT
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_vault_category ON vault(category);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_vault_category_created ON vault(category, created_at DESC);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_vault_updated ON vault(updated_at DESC);
|
|
74
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(kind, identity_key) WHERE identity_key IS NOT NULL AND category = 'entity';
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL;
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_vault_tier ON vault(tier);
|
|
77
|
+
|
|
78
|
+
-- Single FTS5 table
|
|
79
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vault_fts USING fts5(
|
|
80
|
+
title, body, tags, kind,
|
|
81
|
+
content='vault', content_rowid='rowid'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
-- FTS sync triggers
|
|
85
|
+
CREATE TRIGGER IF NOT EXISTS vault_ai AFTER INSERT ON vault BEGIN
|
|
86
|
+
INSERT INTO vault_fts(rowid, title, body, tags, kind)
|
|
87
|
+
VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
|
|
88
|
+
END;
|
|
89
|
+
CREATE TRIGGER IF NOT EXISTS vault_ad AFTER DELETE ON vault BEGIN
|
|
90
|
+
INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
|
|
91
|
+
VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
|
|
92
|
+
END;
|
|
93
|
+
CREATE TRIGGER IF NOT EXISTS vault_au AFTER UPDATE ON vault BEGIN
|
|
94
|
+
INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
|
|
95
|
+
VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
|
|
96
|
+
INSERT INTO vault_fts(rowid, title, body, tags, kind)
|
|
97
|
+
VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
|
|
98
|
+
END;
|
|
99
|
+
|
|
100
|
+
-- Single vec table (384-dim float32 for all-MiniLM-L6-v2)
|
|
101
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vault_vec USING vec0(embedding float[384]);
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
// Hosted-mode schema: adds multi-tenancy (user_id, team_id) and at-rest
|
|
105
|
+
// encryption columns (body_encrypted, title_encrypted, meta_encrypted, iv).
|
|
106
|
+
// Identity uniqueness is scoped to (user_id, kind, identity_key).
|
|
107
|
+
export const HOSTED_SCHEMA_DDL = `
|
|
46
108
|
CREATE TABLE IF NOT EXISTS vault (
|
|
47
109
|
id TEXT PRIMARY KEY,
|
|
48
110
|
kind TEXT NOT NULL,
|
|
@@ -67,7 +129,8 @@ export const SCHEMA_DDL = `
|
|
|
67
129
|
hit_count INTEGER DEFAULT 0,
|
|
68
130
|
last_accessed_at TEXT,
|
|
69
131
|
source_files TEXT,
|
|
70
|
-
tier TEXT DEFAULT 'working' CHECK(tier IN ('ephemeral', 'working', 'durable'))
|
|
132
|
+
tier TEXT DEFAULT 'working' CHECK(tier IN ('ephemeral', 'working', 'durable')),
|
|
133
|
+
related_to TEXT
|
|
71
134
|
);
|
|
72
135
|
|
|
73
136
|
CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
|
|
@@ -106,7 +169,13 @@ export const SCHEMA_DDL = `
|
|
|
106
169
|
CREATE VIRTUAL TABLE IF NOT EXISTS vault_vec USING vec0(embedding float[384]);
|
|
107
170
|
`;
|
|
108
171
|
|
|
109
|
-
|
|
172
|
+
// Backward-compatible alias — kept for external consumers that reference SCHEMA_DDL.
|
|
173
|
+
export const SCHEMA_DDL = HOSTED_SCHEMA_DDL;
|
|
174
|
+
|
|
175
|
+
// Current target schema version. Bump this on every migration.
|
|
176
|
+
const CURRENT_VERSION = 14;
|
|
177
|
+
|
|
178
|
+
export async function initDatabase(dbPath, { mode = "local" } = {}) {
|
|
110
179
|
const sqliteVec = await loadSqliteVec();
|
|
111
180
|
|
|
112
181
|
function createDb(path) {
|
|
@@ -121,6 +190,8 @@ export async function initDatabase(dbPath) {
|
|
|
121
190
|
return db;
|
|
122
191
|
}
|
|
123
192
|
|
|
193
|
+
const schemaDdl = mode === "hosted" ? HOSTED_SCHEMA_DDL : LOCAL_SCHEMA_DDL;
|
|
194
|
+
|
|
124
195
|
const db = createDb(dbPath);
|
|
125
196
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
126
197
|
|
|
@@ -155,14 +226,14 @@ export async function initDatabase(dbPath) {
|
|
|
155
226
|
} catch {}
|
|
156
227
|
|
|
157
228
|
const freshDb = createDb(dbPath);
|
|
158
|
-
freshDb.exec(
|
|
159
|
-
freshDb.exec(
|
|
229
|
+
freshDb.exec(schemaDdl);
|
|
230
|
+
freshDb.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
|
|
160
231
|
return freshDb;
|
|
161
232
|
}
|
|
162
233
|
|
|
163
234
|
if (version < 5) {
|
|
164
|
-
db.exec(
|
|
165
|
-
db.exec(
|
|
235
|
+
db.exec(schemaDdl);
|
|
236
|
+
db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
|
|
166
237
|
} else if (version === 5) {
|
|
167
238
|
// v5 -> v6 migration: add multi-tenancy + encryption columns
|
|
168
239
|
// Wrapped in transaction with duplicate-column guards for idempotent retry
|
|
@@ -344,12 +415,108 @@ export async function initDatabase(dbPath) {
|
|
|
344
415
|
});
|
|
345
416
|
}
|
|
346
417
|
|
|
418
|
+
if (version >= 5 && version <= 12) {
|
|
419
|
+
// v12 -> v13 migration: add related_to column for graph linking
|
|
420
|
+
runTransaction(db, () => {
|
|
421
|
+
try {
|
|
422
|
+
db.exec(`ALTER TABLE vault ADD COLUMN related_to TEXT`);
|
|
423
|
+
} catch (e) {
|
|
424
|
+
if (!e.message.includes("duplicate column")) throw e;
|
|
425
|
+
}
|
|
426
|
+
db.exec("PRAGMA user_version = 13");
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (version >= 5 && version <= 13) {
|
|
431
|
+
// v13 -> v14 migration: separate local and hosted schemas.
|
|
432
|
+
// Local mode: drop the 6 hosted-only columns (user_id, team_id,
|
|
433
|
+
// body_encrypted, title_encrypted, meta_encrypted, iv) and rebuild
|
|
434
|
+
// the identity index without user_id.
|
|
435
|
+
// Hosted mode: no structural change — just bump version.
|
|
436
|
+
runTransaction(db, () => {
|
|
437
|
+
if (mode === "local") {
|
|
438
|
+
// Must drop indexes that reference the columns before dropping columns.
|
|
439
|
+
db.exec(`DROP INDEX IF EXISTS idx_vault_user`);
|
|
440
|
+
db.exec(`DROP INDEX IF EXISTS idx_vault_team`);
|
|
441
|
+
db.exec(`DROP INDEX IF EXISTS idx_vault_identity`);
|
|
442
|
+
const dropColumnSafe = (col) => {
|
|
443
|
+
try {
|
|
444
|
+
db.exec(`ALTER TABLE vault DROP COLUMN ${col}`);
|
|
445
|
+
} catch (e) {
|
|
446
|
+
// Column may not exist on older schemas that never had it added.
|
|
447
|
+
if (!e.message.includes("no such column")) throw e;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
dropColumnSafe("user_id");
|
|
451
|
+
dropColumnSafe("team_id");
|
|
452
|
+
dropColumnSafe("body_encrypted");
|
|
453
|
+
dropColumnSafe("title_encrypted");
|
|
454
|
+
dropColumnSafe("meta_encrypted");
|
|
455
|
+
dropColumnSafe("iv");
|
|
456
|
+
// Recreate identity uniqueness index scoped to (kind, identity_key),
|
|
457
|
+
// restricted to entity-category entries only (knowledge/event entries
|
|
458
|
+
// with identity_key are informational and may duplicate).
|
|
459
|
+
db.exec(
|
|
460
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(kind, identity_key) WHERE identity_key IS NOT NULL AND category = 'entity'`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
347
467
|
return db;
|
|
348
468
|
}
|
|
349
469
|
|
|
350
|
-
export function prepareStatements(db) {
|
|
470
|
+
export function prepareStatements(db, mode = "local") {
|
|
351
471
|
try {
|
|
472
|
+
if (mode === "local") {
|
|
473
|
+
// Local mode: no user_id, team_id, or encryption columns.
|
|
474
|
+
// insertEntry has 15 params (no user_id).
|
|
475
|
+
// getByIdentityKey and upsertByIdentityKey have no user_id WHERE clause.
|
|
476
|
+
return {
|
|
477
|
+
_mode: "local",
|
|
478
|
+
insertEntry: db.prepare(
|
|
479
|
+
`INSERT INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, source_files, tier) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
480
|
+
),
|
|
481
|
+
updateEntry: db.prepare(
|
|
482
|
+
`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ?, updated_at = datetime('now') WHERE file_path = ?`,
|
|
483
|
+
),
|
|
484
|
+
deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
|
|
485
|
+
getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
|
|
486
|
+
getRowidByPath: db.prepare(
|
|
487
|
+
`SELECT rowid FROM vault WHERE file_path = ?`,
|
|
488
|
+
),
|
|
489
|
+
getEntryById: db.prepare(`SELECT * FROM vault WHERE id = ?`),
|
|
490
|
+
getByIdentityKey: db.prepare(
|
|
491
|
+
`SELECT * FROM vault WHERE kind = ? AND identity_key = ?`,
|
|
492
|
+
),
|
|
493
|
+
upsertByIdentityKey: db.prepare(
|
|
494
|
+
`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ?, source_files = ?, updated_at = datetime('now') WHERE kind = ? AND identity_key = ?`,
|
|
495
|
+
),
|
|
496
|
+
updateSourceFiles: db.prepare(
|
|
497
|
+
`UPDATE vault SET source_files = ? WHERE id = ?`,
|
|
498
|
+
),
|
|
499
|
+
updateRelatedTo: db.prepare(
|
|
500
|
+
`UPDATE vault SET related_to = ? WHERE id = ?`,
|
|
501
|
+
),
|
|
502
|
+
insertVecStmt: db.prepare(
|
|
503
|
+
`INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`,
|
|
504
|
+
),
|
|
505
|
+
deleteVecStmt: db.prepare(`DELETE FROM vault_vec WHERE rowid = ?`),
|
|
506
|
+
updateSupersededBy: db.prepare(
|
|
507
|
+
`UPDATE vault SET superseded_by = ? WHERE id = ?`,
|
|
508
|
+
),
|
|
509
|
+
clearSupersededByRef: db.prepare(
|
|
510
|
+
`UPDATE vault SET superseded_by = NULL WHERE superseded_by = ?`,
|
|
511
|
+
),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Hosted mode: full schema with user_id scoping and encryption support.
|
|
516
|
+
// insertEntry has 16 params (includes user_id).
|
|
517
|
+
// getByIdentityKey and upsertByIdentityKey scope by user_id IS ?.
|
|
352
518
|
return {
|
|
519
|
+
_mode: "hosted",
|
|
353
520
|
insertEntry: db.prepare(
|
|
354
521
|
`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, source_files, tier) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
355
522
|
),
|
|
@@ -372,6 +539,9 @@ export function prepareStatements(db) {
|
|
|
372
539
|
updateSourceFiles: db.prepare(
|
|
373
540
|
`UPDATE vault SET source_files = ? WHERE id = ?`,
|
|
374
541
|
),
|
|
542
|
+
updateRelatedTo: db.prepare(
|
|
543
|
+
`UPDATE vault SET related_to = ? WHERE id = ?`,
|
|
544
|
+
),
|
|
375
545
|
insertVecStmt: db.prepare(
|
|
376
546
|
`INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`,
|
|
377
547
|
),
|
package/src/index/index.js
CHANGED
|
@@ -66,37 +66,53 @@ export async function indexEntry(
|
|
|
66
66
|
const cat = category || categoryFor(kind);
|
|
67
67
|
const effectiveTier = tier || defaultTierFor(kind);
|
|
68
68
|
const userIdVal = userId || null;
|
|
69
|
+
const isLocal = ctx.stmts._mode === "local";
|
|
69
70
|
|
|
70
71
|
let wasUpdate = false;
|
|
71
72
|
|
|
72
|
-
// Entity upsert: check by (kind, identity_key, user_id) first
|
|
73
|
+
// Entity upsert: check by (kind, identity_key[, user_id]) first.
|
|
74
|
+
// Local mode omits user_id — all entries are user-agnostic.
|
|
73
75
|
if (cat === "entity" && identity_key) {
|
|
74
|
-
const existing =
|
|
75
|
-
kind,
|
|
76
|
-
identity_key,
|
|
77
|
-
userIdVal,
|
|
78
|
-
);
|
|
76
|
+
const existing = isLocal
|
|
77
|
+
? ctx.stmts.getByIdentityKey.get(kind, identity_key)
|
|
78
|
+
: ctx.stmts.getByIdentityKey.get(kind, identity_key, userIdVal);
|
|
79
79
|
if (existing) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
80
|
+
if (isLocal) {
|
|
81
|
+
ctx.stmts.upsertByIdentityKey.run(
|
|
82
|
+
title || null,
|
|
83
|
+
body,
|
|
84
|
+
metaJson,
|
|
85
|
+
tagsJson,
|
|
86
|
+
source || "claude-code",
|
|
87
|
+
cat,
|
|
88
|
+
filePath,
|
|
89
|
+
expires_at || null,
|
|
90
|
+
sourceFilesJson,
|
|
91
|
+
kind,
|
|
92
|
+
identity_key,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
ctx.stmts.upsertByIdentityKey.run(
|
|
96
|
+
title || null,
|
|
97
|
+
body,
|
|
98
|
+
metaJson,
|
|
99
|
+
tagsJson,
|
|
100
|
+
source || "claude-code",
|
|
101
|
+
cat,
|
|
102
|
+
filePath,
|
|
103
|
+
expires_at || null,
|
|
104
|
+
sourceFilesJson,
|
|
105
|
+
kind,
|
|
106
|
+
identity_key,
|
|
107
|
+
userIdVal,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
94
110
|
wasUpdate = true;
|
|
95
111
|
}
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
if (!wasUpdate) {
|
|
99
|
-
// Prepare encryption if ctx.encrypt is available
|
|
115
|
+
// Prepare encryption if ctx.encrypt is available (hosted mode only)
|
|
100
116
|
let encrypted = null;
|
|
101
117
|
if (ctx.encrypt) {
|
|
102
118
|
encrypted = await ctx.encrypt({ title, body, meta });
|
|
@@ -104,7 +120,8 @@ export async function indexEntry(
|
|
|
104
120
|
|
|
105
121
|
try {
|
|
106
122
|
if (encrypted) {
|
|
107
|
-
//
|
|
123
|
+
// Hosted-mode encrypted insert: store preview in body for FTS,
|
|
124
|
+
// full content in encrypted columns.
|
|
108
125
|
const bodyPreview = body.slice(0, 200);
|
|
109
126
|
ctx.stmts.insertEntryEncrypted.run(
|
|
110
127
|
id,
|
|
@@ -128,7 +145,27 @@ export async function indexEntry(
|
|
|
128
145
|
sourceFilesJson,
|
|
129
146
|
effectiveTier,
|
|
130
147
|
);
|
|
148
|
+
} else if (isLocal) {
|
|
149
|
+
// Local mode: no user_id column — 15 params.
|
|
150
|
+
ctx.stmts.insertEntry.run(
|
|
151
|
+
id,
|
|
152
|
+
kind,
|
|
153
|
+
cat,
|
|
154
|
+
title || null,
|
|
155
|
+
body,
|
|
156
|
+
metaJson,
|
|
157
|
+
tagsJson,
|
|
158
|
+
source || "claude-code",
|
|
159
|
+
filePath,
|
|
160
|
+
identity_key || null,
|
|
161
|
+
expires_at || null,
|
|
162
|
+
createdAt,
|
|
163
|
+
createdAt,
|
|
164
|
+
sourceFilesJson,
|
|
165
|
+
effectiveTier,
|
|
166
|
+
);
|
|
131
167
|
} else {
|
|
168
|
+
// Hosted mode without encryption: 16 params (includes user_id).
|
|
132
169
|
ctx.stmts.insertEntry.run(
|
|
133
170
|
id,
|
|
134
171
|
userIdVal,
|
|
@@ -196,18 +233,20 @@ export async function indexEntry(
|
|
|
196
233
|
);
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
236
|
+
// Skip embedding generation for event entries — they are excluded from
|
|
237
|
+
// default semantic search and don't need vector representations
|
|
238
|
+
if (cat !== "event") {
|
|
239
|
+
const embeddingText = [title, body].filter(Boolean).join(" ");
|
|
240
|
+
const embedding = await ctx.embed(embeddingText);
|
|
202
241
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
242
|
+
if (embedding) {
|
|
243
|
+
try {
|
|
244
|
+
ctx.deleteVec(rowid);
|
|
245
|
+
} catch {
|
|
246
|
+
/* no-op if not found */
|
|
247
|
+
}
|
|
248
|
+
ctx.insertVec(rowid, embedding);
|
|
209
249
|
}
|
|
210
|
-
ctx.insertVec(rowid, embedding);
|
|
211
250
|
}
|
|
212
251
|
}
|
|
213
252
|
|
|
@@ -260,10 +299,14 @@ export async function reindex(ctx, opts = {}) {
|
|
|
260
299
|
|
|
261
300
|
if (!existsSync(ctx.config.vaultDir)) return stats;
|
|
262
301
|
|
|
263
|
-
// Use INSERT OR IGNORE for reindex — handles files with duplicate frontmatter IDs
|
|
264
|
-
//
|
|
302
|
+
// Use INSERT OR IGNORE for reindex — handles files with duplicate frontmatter IDs.
|
|
303
|
+
// Local mode: no user_id column (15 params).
|
|
304
|
+
// Hosted mode: user_id is NULL for file-sourced entries (14 params, NULL literal).
|
|
305
|
+
const isLocalReindex = ctx.stmts._mode === "local";
|
|
265
306
|
const upsertEntry = ctx.db.prepare(
|
|
266
|
-
|
|
307
|
+
isLocalReindex
|
|
308
|
+
? `INSERT OR IGNORE INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
309
|
+
: `INSERT OR IGNORE INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
267
310
|
);
|
|
268
311
|
|
|
269
312
|
// Auto-discover kind directories, supporting both:
|
|
@@ -315,7 +358,7 @@ export async function reindex(ctx, opts = {}) {
|
|
|
315
358
|
// P3: Fetch all mutable fields for change detection
|
|
316
359
|
const dbRows = ctx.db
|
|
317
360
|
.prepare(
|
|
318
|
-
"SELECT id, file_path, body, title, tags, meta FROM vault WHERE kind = ?",
|
|
361
|
+
"SELECT id, file_path, body, title, tags, meta, related_to FROM vault WHERE kind = ?",
|
|
319
362
|
)
|
|
320
363
|
.all(kind);
|
|
321
364
|
const dbByPath = new Map(dbRows.map((r) => [r.file_path, r]));
|
|
@@ -341,6 +384,12 @@ export async function reindex(ctx, opts = {}) {
|
|
|
341
384
|
// Extract identity_key and expires_at from frontmatter
|
|
342
385
|
const identity_key = fmMeta.identity_key || null;
|
|
343
386
|
const expires_at = fmMeta.expires_at || null;
|
|
387
|
+
const related_to = Array.isArray(fmMeta.related_to)
|
|
388
|
+
? fmMeta.related_to
|
|
389
|
+
: null;
|
|
390
|
+
const relatedToJson = related_to?.length
|
|
391
|
+
? JSON.stringify(related_to)
|
|
392
|
+
: null;
|
|
344
393
|
|
|
345
394
|
// Derive folder from disk location (source of truth)
|
|
346
395
|
const meta = { ...(parsed.meta || {}) };
|
|
@@ -370,15 +419,20 @@ export async function reindex(ctx, opts = {}) {
|
|
|
370
419
|
fmMeta.updated || created,
|
|
371
420
|
);
|
|
372
421
|
if (result.changes > 0) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
422
|
+
if (relatedToJson && ctx.stmts.updateRelatedTo) {
|
|
423
|
+
ctx.stmts.updateRelatedTo.run(relatedToJson, id);
|
|
424
|
+
}
|
|
425
|
+
if (category !== "event") {
|
|
426
|
+
const rowidResult = ctx.stmts.getRowid.get(id);
|
|
427
|
+
if (rowidResult?.rowid) {
|
|
428
|
+
const embeddingText = [parsed.title, parsed.body]
|
|
429
|
+
.filter(Boolean)
|
|
430
|
+
.join(" ");
|
|
431
|
+
pendingEmbeds.push({
|
|
432
|
+
rowid: rowidResult.rowid,
|
|
433
|
+
text: embeddingText,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
382
436
|
}
|
|
383
437
|
stats.added++;
|
|
384
438
|
} else {
|
|
@@ -392,8 +446,16 @@ export async function reindex(ctx, opts = {}) {
|
|
|
392
446
|
const bodyChanged = existing.body !== parsed.body;
|
|
393
447
|
const tagsChanged = tagsJson !== (existing.tags || null);
|
|
394
448
|
const metaChanged = metaJson !== (existing.meta || null);
|
|
395
|
-
|
|
396
|
-
|
|
449
|
+
const relatedToChanged =
|
|
450
|
+
relatedToJson !== (existing.related_to || null);
|
|
451
|
+
|
|
452
|
+
if (
|
|
453
|
+
bodyChanged ||
|
|
454
|
+
titleChanged ||
|
|
455
|
+
tagsChanged ||
|
|
456
|
+
metaChanged ||
|
|
457
|
+
relatedToChanged
|
|
458
|
+
) {
|
|
397
459
|
ctx.stmts.updateEntry.run(
|
|
398
460
|
parsed.title || null,
|
|
399
461
|
parsed.body,
|
|
@@ -405,9 +467,12 @@ export async function reindex(ctx, opts = {}) {
|
|
|
405
467
|
expires_at,
|
|
406
468
|
filePath,
|
|
407
469
|
);
|
|
470
|
+
if (relatedToChanged && ctx.stmts.updateRelatedTo) {
|
|
471
|
+
ctx.stmts.updateRelatedTo.run(relatedToJson, existing.id);
|
|
472
|
+
}
|
|
408
473
|
|
|
409
474
|
// Queue re-embed if title or body changed (vector ops deferred to Phase 2)
|
|
410
|
-
if (bodyChanged || titleChanged) {
|
|
475
|
+
if ((bodyChanged || titleChanged) && category !== "event") {
|
|
411
476
|
const rowid = ctx.stmts.getRowid.get(existing.id)?.rowid;
|
|
412
477
|
if (rowid) {
|
|
413
478
|
const embeddingText = [parsed.title, parsed.body]
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,11 @@ export {
|
|
|
29
29
|
parseEntryFromMarkdown,
|
|
30
30
|
} from "./core/frontmatter.js";
|
|
31
31
|
export { gatherVaultStatus } from "./core/status.js";
|
|
32
|
+
export {
|
|
33
|
+
PLURAL_TO_SINGULAR,
|
|
34
|
+
planMigration,
|
|
35
|
+
executeMigration,
|
|
36
|
+
} from "./core/migrate-dirs.js";
|
|
32
37
|
|
|
33
38
|
// Capture layer
|
|
34
39
|
export {
|