@context-vault/core 2.17.1 → 3.0.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 (101) hide show
  1. package/dist/capture.d.ts +21 -0
  2. package/dist/capture.d.ts.map +1 -0
  3. package/dist/capture.js +269 -0
  4. package/dist/capture.js.map +1 -0
  5. package/dist/categories.d.ts +6 -0
  6. package/dist/categories.d.ts.map +1 -0
  7. package/dist/categories.js +50 -0
  8. package/dist/categories.js.map +1 -0
  9. package/dist/config.d.ts +4 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +190 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/constants.d.ts +33 -0
  14. package/dist/constants.d.ts.map +1 -0
  15. package/dist/constants.js +23 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/db.d.ts +13 -0
  18. package/dist/db.d.ts.map +1 -0
  19. package/dist/db.js +191 -0
  20. package/dist/db.js.map +1 -0
  21. package/dist/embed.d.ts +5 -0
  22. package/dist/embed.d.ts.map +1 -0
  23. package/dist/embed.js +78 -0
  24. package/dist/embed.js.map +1 -0
  25. package/dist/files.d.ts +13 -0
  26. package/dist/files.d.ts.map +1 -0
  27. package/dist/files.js +66 -0
  28. package/dist/files.js.map +1 -0
  29. package/dist/formatters.d.ts +8 -0
  30. package/dist/formatters.d.ts.map +1 -0
  31. package/dist/formatters.js +18 -0
  32. package/dist/formatters.js.map +1 -0
  33. package/dist/frontmatter.d.ts +12 -0
  34. package/dist/frontmatter.d.ts.map +1 -0
  35. package/dist/frontmatter.js +101 -0
  36. package/dist/frontmatter.js.map +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +297 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/ingest-url.d.ts +20 -0
  42. package/dist/ingest-url.d.ts.map +1 -0
  43. package/dist/ingest-url.js +113 -0
  44. package/dist/ingest-url.js.map +1 -0
  45. package/dist/main.d.ts +14 -0
  46. package/dist/main.d.ts.map +1 -0
  47. package/dist/main.js +25 -0
  48. package/dist/main.js.map +1 -0
  49. package/dist/search.d.ts +18 -0
  50. package/dist/search.d.ts.map +1 -0
  51. package/dist/search.js +238 -0
  52. package/dist/search.js.map +1 -0
  53. package/dist/types.d.ts +176 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +66 -16
  58. package/src/capture.ts +308 -0
  59. package/src/categories.ts +54 -0
  60. package/src/{core/config.js → config.ts} +34 -33
  61. package/src/{constants.js → constants.ts} +6 -3
  62. package/src/db.ts +229 -0
  63. package/src/{index/embed.js → embed.ts} +10 -35
  64. package/src/{core/files.js → files.ts} +15 -20
  65. package/src/{capture/formatters.js → formatters.ts} +13 -11
  66. package/src/{core/frontmatter.js → frontmatter.ts} +26 -33
  67. package/src/index.ts +351 -0
  68. package/src/ingest-url.ts +99 -0
  69. package/src/main.ts +111 -0
  70. package/src/{retrieve/index.js → search.ts} +62 -150
  71. package/src/types.ts +166 -0
  72. package/src/capture/file-ops.js +0 -99
  73. package/src/capture/import-pipeline.js +0 -46
  74. package/src/capture/importers.js +0 -387
  75. package/src/capture/index.js +0 -250
  76. package/src/capture/ingest-url.js +0 -252
  77. package/src/consolidation/index.js +0 -112
  78. package/src/core/categories.js +0 -73
  79. package/src/core/error-log.js +0 -54
  80. package/src/core/linking.js +0 -161
  81. package/src/core/migrate-dirs.js +0 -196
  82. package/src/core/status.js +0 -350
  83. package/src/core/telemetry.js +0 -90
  84. package/src/core/temporal.js +0 -146
  85. package/src/index/db.js +0 -586
  86. package/src/index/index.js +0 -583
  87. package/src/index.js +0 -71
  88. package/src/server/helpers.js +0 -44
  89. package/src/server/tools/clear-context.js +0 -47
  90. package/src/server/tools/context-status.js +0 -182
  91. package/src/server/tools/create-snapshot.js +0 -200
  92. package/src/server/tools/delete-context.js +0 -60
  93. package/src/server/tools/get-context.js +0 -765
  94. package/src/server/tools/ingest-project.js +0 -244
  95. package/src/server/tools/ingest-url.js +0 -88
  96. package/src/server/tools/list-buckets.js +0 -116
  97. package/src/server/tools/list-context.js +0 -163
  98. package/src/server/tools/save-context.js +0 -632
  99. package/src/server/tools/session-start.js +0 -285
  100. package/src/server/tools.js +0 -172
  101. package/src/sync/sync.js +0 -235
@@ -1,285 +0,0 @@
1
- import { z } from "zod";
2
- import { execSync } from "node:child_process";
3
- import { ok, err, ensureVaultExists } from "../helpers.js";
4
-
5
- const DEFAULT_MAX_TOKENS = 4000;
6
- const RECENT_DAYS = 7;
7
- const MAX_BODY_PER_ENTRY = 400;
8
- const PRIORITY_KINDS = ["decision", "insight", "pattern"];
9
- const SESSION_SUMMARY_KIND = "session";
10
-
11
- export const name = "session_start";
12
-
13
- export const description =
14
- "Auto-assemble a context brief for the current project on session start. Pulls recent entries, last session summary, and active decisions/blockers into a token-budgeted capsule formatted for agent consumption.";
15
-
16
- export const inputSchema = {
17
- project: z
18
- .string()
19
- .optional()
20
- .describe(
21
- "Project name or tag to scope the brief. Auto-detected from cwd/git remote if not provided.",
22
- ),
23
- max_tokens: z
24
- .number()
25
- .optional()
26
- .describe(
27
- "Token budget for the capsule (rough estimate: 1 token ~ 4 chars). Default: 4000.",
28
- ),
29
- buckets: z
30
- .array(z.string())
31
- .optional()
32
- .describe(
33
- "Bucket names to scope the session brief. Each name expands to a 'bucket:<name>' tag filter. When provided, the brief only includes entries from these buckets.",
34
- ),
35
- };
36
-
37
- function detectProject() {
38
- try {
39
- const remote = execSync("git remote get-url origin 2>/dev/null", {
40
- encoding: "utf-8",
41
- timeout: 3000,
42
- stdio: ["pipe", "pipe", "pipe"],
43
- }).trim();
44
- if (remote) {
45
- const match = remote.match(/\/([^/]+?)(?:\.git)?$/);
46
- if (match) return match[1];
47
- }
48
- } catch {}
49
-
50
- try {
51
- const cwd = process.cwd();
52
- const parts = cwd.split(/[/\\]/);
53
- return parts[parts.length - 1];
54
- } catch {}
55
-
56
- return null;
57
- }
58
-
59
- function truncateBody(body, maxLen = MAX_BODY_PER_ENTRY) {
60
- if (!body) return "(no body)";
61
- if (body.length <= maxLen) return body;
62
- return body.slice(0, maxLen) + "...";
63
- }
64
-
65
- function estimateTokens(text) {
66
- return Math.ceil((text || "").length / 4);
67
- }
68
-
69
- function formatEntry(entry) {
70
- const tags = entry.tags ? JSON.parse(entry.tags) : [];
71
- const tagStr = tags.length ? tags.join(", ") : "none";
72
- const date = entry.updated_at || entry.created_at || "unknown";
73
- return [
74
- `- **${entry.title || "(untitled)"}** [${entry.kind}]`,
75
- ` tags: ${tagStr} | ${date} | id: \`${entry.id}\``,
76
- ` ${truncateBody(entry.body).replace(/\n+/g, " ").trim()}`,
77
- ].join("\n");
78
- }
79
-
80
- export async function handler({ project, max_tokens, buckets }, ctx, { ensureIndexed }) {
81
- const { config } = ctx;
82
- const userId = ctx.userId !== undefined ? ctx.userId : undefined;
83
-
84
- const vaultErr = ensureVaultExists(config);
85
- if (vaultErr) return vaultErr;
86
-
87
- await ensureIndexed();
88
-
89
- const effectiveProject = project?.trim() || detectProject();
90
- const tokenBudget = max_tokens || DEFAULT_MAX_TOKENS;
91
-
92
- const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
93
- const effectiveTags = bucketTags.length
94
- ? bucketTags
95
- : effectiveProject
96
- ? [effectiveProject]
97
- : [];
98
-
99
- const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
100
-
101
- const sections = [];
102
- let tokensUsed = 0;
103
-
104
- sections.push(
105
- `# Session Brief${effectiveProject ? ` — ${effectiveProject}` : ""}`,
106
- );
107
- const bucketsLabel = buckets?.length ? ` | buckets: ${buckets.join(", ")}` : "";
108
- sections.push(
109
- `_Generated ${new Date().toISOString().slice(0, 10)} | budget: ${tokenBudget} tokens${bucketsLabel}_\n`,
110
- );
111
- tokensUsed += estimateTokens(sections.join("\n"));
112
-
113
- const lastSession = queryLastSession(ctx, userId, effectiveTags);
114
- if (lastSession) {
115
- const sessionBlock = [
116
- "## Last Session Summary",
117
- truncateBody(lastSession.body, 600),
118
- "",
119
- ].join("\n");
120
- const sessionTokens = estimateTokens(sessionBlock);
121
- if (tokensUsed + sessionTokens <= tokenBudget) {
122
- sections.push(sessionBlock);
123
- tokensUsed += sessionTokens;
124
- }
125
- }
126
-
127
- const decisions = queryByKinds(
128
- ctx,
129
- PRIORITY_KINDS,
130
- sinceDate,
131
- userId,
132
- effectiveTags,
133
- );
134
- if (decisions.length > 0) {
135
- const header = "## Active Decisions, Insights & Patterns\n";
136
- const headerTokens = estimateTokens(header);
137
- if (tokensUsed + headerTokens <= tokenBudget) {
138
- const entryLines = [];
139
- tokensUsed += headerTokens;
140
- for (const entry of decisions) {
141
- const line = formatEntry(entry);
142
- const lineTokens = estimateTokens(line);
143
- if (tokensUsed + lineTokens > tokenBudget) break;
144
- entryLines.push(line);
145
- tokensUsed += lineTokens;
146
- }
147
- if (entryLines.length > 0) {
148
- sections.push(header + entryLines.join("\n") + "\n");
149
- }
150
- }
151
- }
152
-
153
- const recent = queryRecent(ctx, sinceDate, userId, effectiveTags);
154
- const seenIds = new Set(decisions.map((d) => d.id));
155
- if (lastSession) seenIds.add(lastSession.id);
156
- const deduped = recent.filter((r) => !seenIds.has(r.id));
157
-
158
- if (deduped.length > 0) {
159
- const header = `## Recent Entries (last ${RECENT_DAYS} days)\n`;
160
- const headerTokens = estimateTokens(header);
161
- if (tokensUsed + headerTokens <= tokenBudget) {
162
- const entryLines = [];
163
- tokensUsed += headerTokens;
164
- for (const entry of deduped) {
165
- const line = formatEntry(entry);
166
- const lineTokens = estimateTokens(line);
167
- if (tokensUsed + lineTokens > tokenBudget) break;
168
- entryLines.push(line);
169
- tokensUsed += lineTokens;
170
- }
171
- if (entryLines.length > 0) {
172
- sections.push(header + entryLines.join("\n") + "\n");
173
- }
174
- }
175
- }
176
-
177
- const totalEntries =
178
- (lastSession ? 1 : 0) +
179
- decisions.length +
180
- deduped.filter((d) => {
181
- const line = formatEntry(d);
182
- return true;
183
- }).length;
184
-
185
- sections.push("---");
186
- sections.push(
187
- `_${tokensUsed} / ${tokenBudget} tokens used | project: ${effectiveProject || "unscoped"}_`,
188
- );
189
-
190
- const result = ok(sections.join("\n"));
191
- result._meta = {
192
- project: effectiveProject || null,
193
- buckets: buckets || null,
194
- tokens_used: tokensUsed,
195
- tokens_budget: tokenBudget,
196
- sections: {
197
- last_session: lastSession ? 1 : 0,
198
- decisions: decisions.length,
199
- recent: deduped.length,
200
- },
201
- };
202
- return result;
203
- }
204
-
205
- function queryLastSession(ctx, userId, effectiveTags) {
206
- const clauses = [`kind = '${SESSION_SUMMARY_KIND}'`];
207
- const params = [];
208
-
209
- if (userId !== undefined) {
210
- clauses.push("user_id = ?");
211
- params.push(userId);
212
- }
213
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
214
- clauses.push("superseded_by IS NULL");
215
-
216
- const where = `WHERE ${clauses.join(" AND ")}`;
217
- const rows = ctx.db
218
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 5`)
219
- .all(...params);
220
-
221
- if (effectiveTags.length) {
222
- const match = rows.find((r) => {
223
- const tags = r.tags ? JSON.parse(r.tags) : [];
224
- return effectiveTags.some((t) => tags.includes(t));
225
- });
226
- if (match) return match;
227
- }
228
- return rows[0] || null;
229
- }
230
-
231
- function queryByKinds(ctx, kinds, since, userId, effectiveTags) {
232
- const kindPlaceholders = kinds.map(() => "?").join(",");
233
- const clauses = [`kind IN (${kindPlaceholders})`];
234
- const params = [...kinds];
235
-
236
- clauses.push("created_at >= ?");
237
- params.push(since);
238
-
239
- if (userId !== undefined) {
240
- clauses.push("user_id = ?");
241
- params.push(userId);
242
- }
243
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
244
- clauses.push("superseded_by IS NULL");
245
-
246
- const where = `WHERE ${clauses.join(" AND ")}`;
247
- const rows = ctx.db
248
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
249
- .all(...params);
250
-
251
- if (effectiveTags.length) {
252
- const tagged = rows.filter((r) => {
253
- const tags = r.tags ? JSON.parse(r.tags) : [];
254
- return effectiveTags.some((t) => tags.includes(t));
255
- });
256
- if (tagged.length > 0) return tagged;
257
- }
258
- return rows;
259
- }
260
-
261
- function queryRecent(ctx, since, userId, effectiveTags) {
262
- const clauses = ["created_at >= ?"];
263
- const params = [since];
264
-
265
- if (userId !== undefined) {
266
- clauses.push("user_id = ?");
267
- params.push(userId);
268
- }
269
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
270
- clauses.push("superseded_by IS NULL");
271
-
272
- const where = `WHERE ${clauses.join(" AND ")}`;
273
- const rows = ctx.db
274
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
275
- .all(...params);
276
-
277
- if (effectiveTags.length) {
278
- const tagged = rows.filter((r) => {
279
- const tags = r.tags ? JSON.parse(r.tags) : [];
280
- return effectiveTags.some((t) => tags.includes(t));
281
- });
282
- if (tagged.length > 0) return tagged;
283
- }
284
- return rows;
285
- }
@@ -1,172 +0,0 @@
1
- import { reindex } from "../index/index.js";
2
- import { captureAndIndex } from "../capture/index.js";
3
- import { err } from "./helpers.js";
4
- import { sendTelemetryEvent } from "../core/telemetry.js";
5
- import pkg from "../../package.json" with { type: "json" };
6
-
7
- import * as getContext from "./tools/get-context.js";
8
- import * as saveContext from "./tools/save-context.js";
9
- import * as listContext from "./tools/list-context.js";
10
- import * as deleteContext from "./tools/delete-context.js";
11
- import * as ingestUrl from "./tools/ingest-url.js";
12
- import * as contextStatus from "./tools/context-status.js";
13
- import * as clearContext from "./tools/clear-context.js";
14
- import * as createSnapshot from "./tools/create-snapshot.js";
15
- import * as sessionStart from "./tools/session-start.js";
16
- import * as listBuckets from "./tools/list-buckets.js";
17
- import * as ingestProject from "./tools/ingest-project.js";
18
-
19
- const toolModules = [
20
- getContext,
21
- saveContext,
22
- listContext,
23
- deleteContext,
24
- ingestUrl,
25
- ingestProject,
26
- contextStatus,
27
- clearContext,
28
- createSnapshot,
29
- sessionStart,
30
- listBuckets,
31
- ];
32
-
33
- const TOOL_TIMEOUT_MS = 60_000;
34
-
35
- export function registerTools(server, ctx) {
36
- const userId = ctx.userId !== undefined ? ctx.userId : undefined;
37
-
38
- function tracked(handler, toolName) {
39
- return async (...args) => {
40
- if (ctx.activeOps) ctx.activeOps.count++;
41
- let timer;
42
- let handlerPromise;
43
- try {
44
- handlerPromise = Promise.resolve(handler(...args));
45
- const result = await Promise.race([
46
- handlerPromise,
47
- new Promise((_, reject) => {
48
- timer = setTimeout(
49
- () => reject(new Error("TOOL_TIMEOUT")),
50
- TOOL_TIMEOUT_MS,
51
- );
52
- }),
53
- ]);
54
- if (ctx.toolStats) ctx.toolStats.ok++;
55
- return result;
56
- } catch (e) {
57
- if (e.message === "TOOL_TIMEOUT") {
58
- // Suppress any late rejection from the still-running handler to
59
- // prevent unhandled promise rejection warnings in the host process.
60
- handlerPromise?.catch(() => {});
61
- if (ctx.toolStats) {
62
- ctx.toolStats.errors++;
63
- ctx.toolStats.lastError = {
64
- tool: toolName,
65
- code: "TIMEOUT",
66
- timestamp: Date.now(),
67
- };
68
- }
69
- sendTelemetryEvent(ctx.config, {
70
- event: "tool_error",
71
- code: "TIMEOUT",
72
- tool: toolName,
73
- cv_version: pkg.version,
74
- });
75
- return err(
76
- "Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
77
- "TIMEOUT",
78
- );
79
- }
80
- if (ctx.toolStats) {
81
- ctx.toolStats.errors++;
82
- ctx.toolStats.lastError = {
83
- tool: toolName,
84
- code: "UNKNOWN",
85
- timestamp: Date.now(),
86
- };
87
- }
88
- sendTelemetryEvent(ctx.config, {
89
- event: "tool_error",
90
- code: "UNKNOWN",
91
- tool: toolName,
92
- cv_version: pkg.version,
93
- });
94
- try {
95
- await captureAndIndex(ctx, {
96
- kind: "feedback",
97
- title: `Unhandled error in ${toolName ?? "tool"} call`,
98
- body: `${e.message}\n\n${e.stack ?? ""}`,
99
- tags: ["bug", "auto-captured"],
100
- source: "auto-capture",
101
- meta: {
102
- tool: toolName,
103
- error_type: e.constructor?.name,
104
- cv_version: pkg.version,
105
- auto: true,
106
- },
107
- });
108
- } catch {} // never block on feedback capture
109
- throw e;
110
- } finally {
111
- clearTimeout(timer);
112
- if (ctx.activeOps) ctx.activeOps.count--;
113
- }
114
- };
115
- }
116
-
117
- // In hosted mode, skip reindex — DB is always in sync via writeEntry→indexEntry
118
- let reindexDone = userId !== undefined ? true : false;
119
- let reindexPromise = null;
120
- let reindexAttempts = 0;
121
- let reindexFailed = false;
122
- const MAX_REINDEX_ATTEMPTS = 2;
123
-
124
- async function ensureIndexed() {
125
- if (reindexDone) return;
126
- if (reindexPromise) return reindexPromise;
127
- // Assign promise synchronously to prevent concurrent calls from both entering reindex()
128
- const promise = reindex(ctx, { fullSync: true })
129
- .then((stats) => {
130
- reindexDone = true;
131
- const total = stats.added + stats.updated + stats.removed;
132
- if (total > 0) {
133
- console.error(
134
- `[context-vault] Auto-reindex: +${stats.added} ~${stats.updated} -${stats.removed} (${stats.unchanged} unchanged)`,
135
- );
136
- }
137
- })
138
- .catch((e) => {
139
- reindexAttempts++;
140
- console.error(
141
- `[context-vault] Auto-reindex failed (attempt ${reindexAttempts}/${MAX_REINDEX_ATTEMPTS}): ${e.message}`,
142
- );
143
- if (reindexAttempts >= MAX_REINDEX_ATTEMPTS) {
144
- console.error(
145
- `[context-vault] Giving up on auto-reindex. Run \`context-vault reindex\` manually to diagnose.`,
146
- );
147
- reindexDone = true;
148
- reindexFailed = true;
149
- } else {
150
- reindexPromise = null; // Allow retry on next tool call
151
- }
152
- });
153
- reindexPromise = promise;
154
- return reindexPromise;
155
- }
156
-
157
- const shared = {
158
- ensureIndexed,
159
- get reindexFailed() {
160
- return reindexFailed;
161
- },
162
- };
163
-
164
- for (const mod of toolModules) {
165
- server.tool(
166
- mod.name,
167
- mod.description,
168
- mod.inputSchema,
169
- tracked((args) => mod.handler(args, ctx, shared), mod.name),
170
- );
171
- }
172
- }
package/src/sync/sync.js DELETED
@@ -1,235 +0,0 @@
1
- /**
2
- * sync.js — Bidirectional sync protocol
3
- *
4
- * v1 design:
5
- * - Additive-only — no delete propagation (avoids data loss)
6
- * - Last-write-wins by created_at for conflicts (both have same ID)
7
- * - Push uses POST /api/vault/import/bulk
8
- * - Pull uses GET /api/vault/export + local captureAndIndex()
9
- */
10
-
11
- import { captureAndIndex } from "../capture/index.js";
12
-
13
- /**
14
- * Build a manifest of local vault entries (id → { id, created_at, kind, title }).
15
- *
16
- * @param {import('../server/types.js').BaseCtx} ctx
17
- * @returns {Map<string, { id: string, created_at: string, kind: string, title: string|null }>}
18
- */
19
- export function buildLocalManifest(ctx) {
20
- const rows = ctx.db
21
- .prepare(
22
- "SELECT id, created_at, kind, title FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now'))",
23
- )
24
- .all();
25
-
26
- const manifest = new Map();
27
- for (const row of rows) {
28
- manifest.set(row.id, {
29
- id: row.id,
30
- created_at: row.created_at,
31
- kind: row.kind,
32
- title: row.title || null,
33
- });
34
- }
35
- return manifest;
36
- }
37
-
38
- /**
39
- * Fetch the remote vault manifest from the hosted API.
40
- *
41
- * @param {string} hostedUrl - Base URL of hosted service
42
- * @param {string} apiKey - Bearer token
43
- * @returns {Promise<Map<string, { id: string, created_at: string, kind: string, title: string|null }>>}
44
- */
45
- export async function fetchRemoteManifest(hostedUrl, apiKey) {
46
- const response = await fetch(`${hostedUrl}/api/vault/manifest`, {
47
- headers: { Authorization: `Bearer ${apiKey}` },
48
- });
49
-
50
- if (!response.ok) {
51
- throw new Error(`Failed to fetch remote manifest: HTTP ${response.status}`);
52
- }
53
-
54
- const data = await response.json();
55
- const manifest = new Map();
56
-
57
- for (const entry of data.entries || []) {
58
- manifest.set(entry.id, {
59
- id: entry.id,
60
- created_at: entry.created_at,
61
- kind: entry.kind,
62
- title: entry.title || null,
63
- });
64
- }
65
-
66
- return manifest;
67
- }
68
-
69
- /**
70
- * @typedef {object} SyncPlan
71
- * @property {string[]} toPush - Entry IDs that exist locally but not remotely
72
- * @property {string[]} toPull - Entry IDs that exist remotely but not locally
73
- * @property {string[]} upToDate - Entry IDs that exist in both
74
- */
75
-
76
- /**
77
- * Compute what needs to be pushed/pulled by comparing manifests.
78
- * Additive-only: entries in both are considered up-to-date.
79
- *
80
- * @param {Map<string, object>} local
81
- * @param {Map<string, object>} remote
82
- * @returns {SyncPlan}
83
- */
84
- export function computeSyncPlan(local, remote) {
85
- const toPush = [];
86
- const toPull = [];
87
- const upToDate = [];
88
-
89
- // Find local-only entries
90
- for (const id of local.keys()) {
91
- if (remote.has(id)) {
92
- upToDate.push(id);
93
- } else {
94
- toPush.push(id);
95
- }
96
- }
97
-
98
- // Find remote-only entries
99
- for (const id of remote.keys()) {
100
- if (!local.has(id)) {
101
- toPull.push(id);
102
- }
103
- }
104
-
105
- return { toPush, toPull, upToDate };
106
- }
107
-
108
- /**
109
- * Execute a sync plan: push local entries to remote, pull remote entries to local.
110
- *
111
- * @param {import('../server/types.js').BaseCtx & Partial<import('../server/types.js').HostedCtxExtensions>} ctx
112
- * @param {{ hostedUrl: string, apiKey: string, plan: SyncPlan, onProgress?: (phase: string, current: number, total: number) => void }} opts
113
- * @returns {Promise<{ pushed: number, pulled: number, failed: number, errors: string[] }>}
114
- */
115
- export async function executeSync(
116
- ctx,
117
- { hostedUrl, apiKey, plan, onProgress },
118
- ) {
119
- let pushed = 0;
120
- let pulled = 0;
121
- let failed = 0;
122
- const errors = [];
123
-
124
- // ── Push: upload local-only entries to remote ──
125
- if (plan.toPush.length > 0) {
126
- const BATCH_SIZE = 50;
127
- const entries = [];
128
-
129
- // Collect full entry data for push
130
- for (const id of plan.toPush) {
131
- const row = ctx.stmts.getEntryById.get(id);
132
- if (!row) continue;
133
-
134
- entries.push({
135
- kind: row.kind,
136
- title: row.title || null,
137
- body: row.body,
138
- tags: row.tags ? JSON.parse(row.tags) : [],
139
- meta: row.meta ? JSON.parse(row.meta) : undefined,
140
- source: row.source || "sync-push",
141
- identity_key: row.identity_key || undefined,
142
- expires_at: row.expires_at || undefined,
143
- });
144
- }
145
-
146
- // Push in batches
147
- for (let i = 0; i < entries.length; i += BATCH_SIZE) {
148
- const batch = entries.slice(i, i + BATCH_SIZE);
149
- if (onProgress) onProgress("push", i + batch.length, entries.length);
150
-
151
- try {
152
- const response = await fetch(`${hostedUrl}/api/vault/import/bulk`, {
153
- method: "POST",
154
- headers: {
155
- "Content-Type": "application/json",
156
- Authorization: `Bearer ${apiKey}`,
157
- },
158
- body: JSON.stringify({ entries: batch }),
159
- });
160
-
161
- if (!response.ok) {
162
- const errData = await response.json().catch(() => ({}));
163
- failed += batch.length;
164
- errors.push(
165
- `Push batch failed: HTTP ${response.status} — ${errData.error || "unknown"}`,
166
- );
167
- continue;
168
- }
169
-
170
- const result = await response.json();
171
- pushed += result.imported || 0;
172
- failed += result.failed || 0;
173
- if (result.errors?.length) {
174
- errors.push(...result.errors);
175
- }
176
- } catch (err) {
177
- failed += batch.length;
178
- errors.push(`Push batch failed: ${err.message}`);
179
- }
180
- }
181
- }
182
-
183
- // ── Pull: download remote-only entries to local ──
184
- if (plan.toPull.length > 0) {
185
- if (onProgress) onProgress("pull", 0, plan.toPull.length);
186
-
187
- try {
188
- const response = await fetch(`${hostedUrl}/api/vault/export`, {
189
- headers: { Authorization: `Bearer ${apiKey}` },
190
- });
191
-
192
- if (!response.ok) {
193
- throw new Error(`Export failed: HTTP ${response.status}`);
194
- }
195
-
196
- const data = await response.json();
197
- const remoteEntries = data.entries || [];
198
-
199
- // Filter to only pull entries we need
200
- const pullIds = new Set(plan.toPull);
201
- const entriesToPull = remoteEntries.filter((e) => pullIds.has(e.id));
202
-
203
- for (let i = 0; i < entriesToPull.length; i++) {
204
- const entry = entriesToPull[i];
205
- if (onProgress) onProgress("pull", i + 1, entriesToPull.length);
206
-
207
- try {
208
- await captureAndIndex(ctx, {
209
- kind: entry.kind,
210
- title: entry.title,
211
- body: entry.body,
212
- meta:
213
- entry.meta && typeof entry.meta === "object"
214
- ? entry.meta
215
- : undefined,
216
- tags: Array.isArray(entry.tags) ? entry.tags : undefined,
217
- source: entry.source || "sync-pull",
218
- identity_key: entry.identity_key,
219
- expires_at: entry.expires_at,
220
- userId: ctx.userId || null,
221
- });
222
- pulled++;
223
- } catch (err) {
224
- failed++;
225
- errors.push(`Pull "${entry.title || entry.id}": ${err.message}`);
226
- }
227
- }
228
- } catch (err) {
229
- failed += plan.toPull.length;
230
- errors.push(`Pull failed: ${err.message}`);
231
- }
232
- }
233
-
234
- return { pushed, pulled, failed, errors };
235
- }