engrm 0.1.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.
- package/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { MemDatabase } from "./sqlite.js";
|
|
6
|
+
|
|
7
|
+
let db: MemDatabase;
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(join(tmpdir(), "engrm-vec-test-"));
|
|
12
|
+
db = new MemDatabase(join(tmpDir, "test.db"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
db.close();
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// All tests conditional on sqlite-vec being available
|
|
21
|
+
describe("sqlite-vec integration", () => {
|
|
22
|
+
test("vecAvailable is true when extension loads", () => {
|
|
23
|
+
expect(db.vecAvailable).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("vec_observations table exists", () => {
|
|
27
|
+
if (!db.vecAvailable) return;
|
|
28
|
+
const tables = db.db
|
|
29
|
+
.query<{ name: string }, []>(
|
|
30
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='vec_observations'"
|
|
31
|
+
)
|
|
32
|
+
.all();
|
|
33
|
+
expect(tables.length).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("vecInsert and searchVec round-trip", () => {
|
|
37
|
+
if (!db.vecAvailable) return;
|
|
38
|
+
|
|
39
|
+
// Create a project + observation
|
|
40
|
+
const project = db.upsertProject({
|
|
41
|
+
canonical_id: "test/project",
|
|
42
|
+
name: "test",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const obs = db.insertObservation({
|
|
46
|
+
project_id: project.id,
|
|
47
|
+
type: "bugfix",
|
|
48
|
+
title: "Fix auth bug",
|
|
49
|
+
quality: 0.8,
|
|
50
|
+
user_id: "david",
|
|
51
|
+
device_id: "laptop",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Insert embedding
|
|
55
|
+
const vec = new Float32Array(384);
|
|
56
|
+
vec[0] = 1.0;
|
|
57
|
+
vec[1] = 0.5;
|
|
58
|
+
db.vecInsert(obs.id, vec);
|
|
59
|
+
|
|
60
|
+
// Search with same vector — should find it
|
|
61
|
+
const results = db.searchVec(vec, project.id, ["active"], 5);
|
|
62
|
+
expect(results.length).toBe(1);
|
|
63
|
+
expect(results[0]!.observation_id).toBe(obs.id);
|
|
64
|
+
expect(results[0]!.distance).toBe(0); // exact match
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("searchVec filters by project", () => {
|
|
68
|
+
if (!db.vecAvailable) return;
|
|
69
|
+
|
|
70
|
+
const proj1 = db.upsertProject({ canonical_id: "p1", name: "p1" });
|
|
71
|
+
const proj2 = db.upsertProject({ canonical_id: "p2", name: "p2" });
|
|
72
|
+
|
|
73
|
+
const obs1 = db.insertObservation({
|
|
74
|
+
project_id: proj1.id,
|
|
75
|
+
type: "bugfix",
|
|
76
|
+
title: "P1 fix",
|
|
77
|
+
quality: 0.8,
|
|
78
|
+
user_id: "david",
|
|
79
|
+
device_id: "laptop",
|
|
80
|
+
});
|
|
81
|
+
const obs2 = db.insertObservation({
|
|
82
|
+
project_id: proj2.id,
|
|
83
|
+
type: "bugfix",
|
|
84
|
+
title: "P2 fix",
|
|
85
|
+
quality: 0.8,
|
|
86
|
+
user_id: "david",
|
|
87
|
+
device_id: "laptop",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const vec = new Float32Array(384);
|
|
91
|
+
vec[0] = 1.0;
|
|
92
|
+
db.vecInsert(obs1.id, vec);
|
|
93
|
+
db.vecInsert(obs2.id, vec);
|
|
94
|
+
|
|
95
|
+
// Search scoped to project 1
|
|
96
|
+
const results = db.searchVec(vec, proj1.id, ["active"], 5);
|
|
97
|
+
expect(results.length).toBe(1);
|
|
98
|
+
expect(results[0]!.observation_id).toBe(obs1.id);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("searchVec excludes superseded observations", () => {
|
|
102
|
+
if (!db.vecAvailable) return;
|
|
103
|
+
|
|
104
|
+
const project = db.upsertProject({ canonical_id: "test/proj", name: "test" });
|
|
105
|
+
|
|
106
|
+
const obs1 = db.insertObservation({
|
|
107
|
+
project_id: project.id,
|
|
108
|
+
type: "decision",
|
|
109
|
+
title: "Old decision",
|
|
110
|
+
quality: 0.7,
|
|
111
|
+
user_id: "david",
|
|
112
|
+
device_id: "laptop",
|
|
113
|
+
});
|
|
114
|
+
const obs2 = db.insertObservation({
|
|
115
|
+
project_id: project.id,
|
|
116
|
+
type: "decision",
|
|
117
|
+
title: "New decision",
|
|
118
|
+
quality: 0.9,
|
|
119
|
+
user_id: "david",
|
|
120
|
+
device_id: "laptop",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const vec = new Float32Array(384);
|
|
124
|
+
vec[0] = 1.0;
|
|
125
|
+
db.vecInsert(obs1.id, vec);
|
|
126
|
+
db.vecInsert(obs2.id, vec);
|
|
127
|
+
|
|
128
|
+
// Supersede obs1 with obs2
|
|
129
|
+
db.supersedeObservation(obs1.id, obs2.id);
|
|
130
|
+
|
|
131
|
+
// Search should only return obs2
|
|
132
|
+
const results = db.searchVec(vec, null, ["active"], 5);
|
|
133
|
+
expect(results.length).toBe(1);
|
|
134
|
+
expect(results[0]!.observation_id).toBe(obs2.id);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("vecDelete removes embedding", () => {
|
|
138
|
+
if (!db.vecAvailable) return;
|
|
139
|
+
|
|
140
|
+
const project = db.upsertProject({ canonical_id: "t/p", name: "t" });
|
|
141
|
+
const obs = db.insertObservation({
|
|
142
|
+
project_id: project.id,
|
|
143
|
+
type: "bugfix",
|
|
144
|
+
title: "Test",
|
|
145
|
+
quality: 0.8,
|
|
146
|
+
user_id: "u",
|
|
147
|
+
device_id: "d",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const vec = new Float32Array(384);
|
|
151
|
+
vec[0] = 1.0;
|
|
152
|
+
db.vecInsert(obs.id, vec);
|
|
153
|
+
expect(db.searchVec(vec, null, ["active"], 5).length).toBe(1);
|
|
154
|
+
|
|
155
|
+
db.vecDelete(obs.id);
|
|
156
|
+
expect(db.searchVec(vec, null, ["active"], 5).length).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("getUnembeddedObservations returns unembedded only", () => {
|
|
160
|
+
if (!db.vecAvailable) return;
|
|
161
|
+
|
|
162
|
+
const project = db.upsertProject({ canonical_id: "t/p", name: "t" });
|
|
163
|
+
|
|
164
|
+
const obs1 = db.insertObservation({
|
|
165
|
+
project_id: project.id,
|
|
166
|
+
type: "bugfix",
|
|
167
|
+
title: "Embedded",
|
|
168
|
+
quality: 0.8,
|
|
169
|
+
user_id: "u",
|
|
170
|
+
device_id: "d",
|
|
171
|
+
});
|
|
172
|
+
db.insertObservation({
|
|
173
|
+
project_id: project.id,
|
|
174
|
+
type: "bugfix",
|
|
175
|
+
title: "Not embedded",
|
|
176
|
+
quality: 0.8,
|
|
177
|
+
user_id: "u",
|
|
178
|
+
device_id: "d",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Embed only obs1
|
|
182
|
+
const vec = new Float32Array(384);
|
|
183
|
+
db.vecInsert(obs1.id, vec);
|
|
184
|
+
|
|
185
|
+
const unembedded = db.getUnembeddedObservations(10);
|
|
186
|
+
expect(unembedded.length).toBe(1);
|
|
187
|
+
expect(unembedded[0]!.title).toBe("Not embedded");
|
|
188
|
+
|
|
189
|
+
expect(db.getUnembeddedCount()).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("searchVec returns empty when vecAvailable but no embeddings", () => {
|
|
193
|
+
if (!db.vecAvailable) return;
|
|
194
|
+
const vec = new Float32Array(384);
|
|
195
|
+
const results = db.searchVec(vec, null, ["active"], 5);
|
|
196
|
+
expect(results.length).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test, afterEach } from "bun:test";
|
|
2
|
+
import { getApiKey, getBaseUrl, buildSourceId, parseSourceId } from "./auth.js";
|
|
3
|
+
import type { Config } from "../config.js";
|
|
4
|
+
|
|
5
|
+
function makeConfig(overrides: Partial<Config> = {}): Config {
|
|
6
|
+
return {
|
|
7
|
+
candengo_url: overrides.candengo_url ?? "https://candengo.com",
|
|
8
|
+
candengo_api_key: overrides.candengo_api_key ?? "cvk_test123",
|
|
9
|
+
site_id: overrides.site_id ?? "test-site",
|
|
10
|
+
namespace: overrides.namespace ?? "dev-memory",
|
|
11
|
+
user_id: overrides.user_id ?? "david",
|
|
12
|
+
device_id: overrides.device_id ?? "laptop-abc",
|
|
13
|
+
user_email: "",
|
|
14
|
+
teams: [],
|
|
15
|
+
sync: { enabled: true, interval_seconds: 30, batch_size: 50 },
|
|
16
|
+
search: { default_limit: 10, local_boost: 1.2, scope: "all" },
|
|
17
|
+
scrubbing: { enabled: true, custom_patterns: [], default_sensitivity: "shared" },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
delete process.env.ENGRM_TOKEN;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("getApiKey", () => {
|
|
26
|
+
test("returns env var when set", () => {
|
|
27
|
+
process.env.ENGRM_TOKEN = "cvk_from_env";
|
|
28
|
+
expect(getApiKey(makeConfig())).toBe("cvk_from_env");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("falls back to config when env var not set", () => {
|
|
32
|
+
expect(getApiKey(makeConfig())).toBe("cvk_test123");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns null when both empty", () => {
|
|
36
|
+
expect(getApiKey(makeConfig({ candengo_api_key: "" }))).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ignores env var without cvk_ prefix", () => {
|
|
40
|
+
process.env.ENGRM_TOKEN = "not_a_valid_key";
|
|
41
|
+
expect(getApiKey(makeConfig())).toBe("cvk_test123");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("getBaseUrl", () => {
|
|
46
|
+
test("returns config URL", () => {
|
|
47
|
+
expect(getBaseUrl(makeConfig())).toBe("https://candengo.com");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns null for empty URL", () => {
|
|
51
|
+
expect(getBaseUrl(makeConfig({ candengo_url: "" }))).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("buildSourceId", () => {
|
|
56
|
+
test("produces correct format", () => {
|
|
57
|
+
const config = makeConfig({ user_id: "david", device_id: "laptop-abc" });
|
|
58
|
+
expect(buildSourceId(config, 42)).toBe("david-laptop-abc-obs-42");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("parseSourceId", () => {
|
|
63
|
+
test("parses valid source ID", () => {
|
|
64
|
+
const result = parseSourceId("david-laptop-abc-obs-42");
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
userId: "david",
|
|
67
|
+
deviceId: "laptop-abc",
|
|
68
|
+
localId: 42,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns null for invalid format", () => {
|
|
73
|
+
expect(parseSourceId("invalid")).toBeNull();
|
|
74
|
+
expect(parseSourceId("")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/sync/auth.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync authentication utilities.
|
|
3
|
+
*
|
|
4
|
+
* Resolves API credentials from environment variables or config.
|
|
5
|
+
* Environment variable takes precedence for CI/CD use.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Config } from "../config.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get API key for Candengo Vector.
|
|
12
|
+
* Priority: ENGRM_TOKEN env var → config.candengo_api_key
|
|
13
|
+
*/
|
|
14
|
+
export function getApiKey(config: Config): string | null {
|
|
15
|
+
const envKey = process.env.ENGRM_TOKEN;
|
|
16
|
+
if (envKey && envKey.startsWith("cvk_")) return envKey;
|
|
17
|
+
if (config.candengo_api_key && config.candengo_api_key.length > 0) {
|
|
18
|
+
return config.candengo_api_key;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get base URL for Candengo Vector API.
|
|
25
|
+
*/
|
|
26
|
+
export function getBaseUrl(config: Config): string | null {
|
|
27
|
+
if (config.candengo_url && config.candengo_url.length > 0) {
|
|
28
|
+
return config.candengo_url;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a globally unique source ID for a record.
|
|
35
|
+
* Format: {user_id}-{device_id}-{type}-{local_id}
|
|
36
|
+
* Default type is "obs" for backwards compatibility.
|
|
37
|
+
*/
|
|
38
|
+
export function buildSourceId(config: Config, localId: number, type: string = "obs"): string {
|
|
39
|
+
return `${config.user_id}-${config.device_id}-${type}-${localId}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a source ID back into its components.
|
|
44
|
+
* Returns null if the format doesn't match.
|
|
45
|
+
*/
|
|
46
|
+
export function parseSourceId(
|
|
47
|
+
sourceId: string
|
|
48
|
+
): { userId: string; deviceId: string; localId: number } | null {
|
|
49
|
+
// Format: {user_id}-{device_id}-obs-{local_id}
|
|
50
|
+
// Split on "-obs-" which is our guaranteed delimiter
|
|
51
|
+
const obsIndex = sourceId.lastIndexOf("-obs-");
|
|
52
|
+
if (obsIndex === -1) return null;
|
|
53
|
+
|
|
54
|
+
const prefix = sourceId.slice(0, obsIndex);
|
|
55
|
+
const localIdStr = sourceId.slice(obsIndex + 5); // skip "-obs-"
|
|
56
|
+
const localId = parseInt(localIdStr, 10);
|
|
57
|
+
if (isNaN(localId)) return null;
|
|
58
|
+
|
|
59
|
+
// Split prefix on first "-" to get userId and deviceId
|
|
60
|
+
const firstDash = prefix.indexOf("-");
|
|
61
|
+
if (firstDash === -1) return null;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
userId: prefix.slice(0, firstDash),
|
|
65
|
+
deviceId: prefix.slice(firstDash + 1),
|
|
66
|
+
localId,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candengo Vector REST client.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Vector API for ingest, search, and delete operations.
|
|
5
|
+
* Uses Bun's built-in fetch — no external HTTP dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Config } from "../config.js";
|
|
9
|
+
import { getApiKey, getBaseUrl } from "./auth.js";
|
|
10
|
+
|
|
11
|
+
// --- Types ---
|
|
12
|
+
|
|
13
|
+
export interface VectorDocument {
|
|
14
|
+
site_id: string;
|
|
15
|
+
namespace: string;
|
|
16
|
+
source_type: string;
|
|
17
|
+
source_id: string;
|
|
18
|
+
content: string;
|
|
19
|
+
metadata: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VectorSearchResult {
|
|
23
|
+
source_id: string;
|
|
24
|
+
content: string;
|
|
25
|
+
score: number;
|
|
26
|
+
metadata: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VectorSearchResponse {
|
|
30
|
+
results: VectorSearchResult[];
|
|
31
|
+
total?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VectorChangeFeedResponse {
|
|
35
|
+
changes: VectorSearchResult[];
|
|
36
|
+
cursor: string;
|
|
37
|
+
has_more: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Client ---
|
|
41
|
+
|
|
42
|
+
export class VectorClient {
|
|
43
|
+
private readonly baseUrl: string;
|
|
44
|
+
private readonly apiKey: string;
|
|
45
|
+
readonly siteId: string;
|
|
46
|
+
readonly namespace: string;
|
|
47
|
+
|
|
48
|
+
constructor(config: Config) {
|
|
49
|
+
const baseUrl = getBaseUrl(config);
|
|
50
|
+
const apiKey = getApiKey(config);
|
|
51
|
+
|
|
52
|
+
if (!baseUrl || !apiKey) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"VectorClient requires candengo_url and candengo_api_key"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.baseUrl = baseUrl.replace(/\/$/, ""); // strip trailing slash
|
|
59
|
+
this.apiKey = apiKey;
|
|
60
|
+
this.siteId = config.site_id;
|
|
61
|
+
this.namespace = config.namespace;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if the client has valid configuration.
|
|
66
|
+
*/
|
|
67
|
+
static isConfigured(config: Config): boolean {
|
|
68
|
+
return getApiKey(config) !== null && getBaseUrl(config) !== null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ingest a single document.
|
|
73
|
+
*/
|
|
74
|
+
async ingest(doc: VectorDocument): Promise<void> {
|
|
75
|
+
await this.request("POST", "/v1/ingest", doc);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Batch ingest multiple documents.
|
|
80
|
+
*/
|
|
81
|
+
async batchIngest(docs: VectorDocument[]): Promise<void> {
|
|
82
|
+
if (docs.length === 0) return;
|
|
83
|
+
await this.request("POST", "/v1/ingest/batch", { documents: docs });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Semantic search with optional metadata filters.
|
|
88
|
+
*/
|
|
89
|
+
async search(
|
|
90
|
+
query: string,
|
|
91
|
+
metadataFilter?: Record<string, string>,
|
|
92
|
+
limit: number = 10
|
|
93
|
+
): Promise<VectorSearchResponse> {
|
|
94
|
+
const body: Record<string, unknown> = { query: query, limit: limit };
|
|
95
|
+
if (metadataFilter) {
|
|
96
|
+
body.metadata_filter = metadataFilter;
|
|
97
|
+
}
|
|
98
|
+
return this.request("POST", "/v1/search", body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Delete documents by source IDs.
|
|
103
|
+
*/
|
|
104
|
+
async deleteBySourceIds(sourceIds: string[]): Promise<void> {
|
|
105
|
+
if (sourceIds.length === 0) return;
|
|
106
|
+
await this.request("POST", "/v1/documents/delete", {
|
|
107
|
+
source_ids: sourceIds,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pull changes from the sync change feed.
|
|
113
|
+
*/
|
|
114
|
+
async pullChanges(
|
|
115
|
+
cursor?: string,
|
|
116
|
+
limit: number = 50
|
|
117
|
+
): Promise<VectorChangeFeedResponse> {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
if (cursor) params.set("cursor", cursor);
|
|
120
|
+
params.set("namespace", this.namespace);
|
|
121
|
+
params.set("limit", String(limit));
|
|
122
|
+
return this.request("GET", `/v1/sync/changes?${params.toString()}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Health check.
|
|
127
|
+
*/
|
|
128
|
+
async health(): Promise<boolean> {
|
|
129
|
+
try {
|
|
130
|
+
await this.request("GET", "/health");
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Internal ---
|
|
138
|
+
|
|
139
|
+
private async request<T>(
|
|
140
|
+
method: string,
|
|
141
|
+
path: string,
|
|
142
|
+
body?: unknown
|
|
143
|
+
): Promise<T> {
|
|
144
|
+
const url = `${this.baseUrl}${path}`;
|
|
145
|
+
const headers: Record<string, string> = {
|
|
146
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const init: RequestInit = { method, headers };
|
|
151
|
+
if (body && method !== "GET") {
|
|
152
|
+
init.body = JSON.stringify(body);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const response = await fetch(url, init);
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
const text = await response.text().catch(() => "");
|
|
159
|
+
throw new VectorApiError(response.status, text, path);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Some endpoints (DELETE, ingest) may return 204 with no body
|
|
163
|
+
if (
|
|
164
|
+
response.status === 204 ||
|
|
165
|
+
response.headers.get("content-length") === "0"
|
|
166
|
+
) {
|
|
167
|
+
return undefined as T;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return response.json() as Promise<T>;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export class VectorApiError extends Error {
|
|
175
|
+
constructor(
|
|
176
|
+
readonly status: number,
|
|
177
|
+
readonly body: string,
|
|
178
|
+
readonly path: string
|
|
179
|
+
) {
|
|
180
|
+
super(`Vector API error ${status} on ${path}: ${body}`);
|
|
181
|
+
this.name = "VectorApiError";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { MemDatabase } from "../storage/sqlite.js";
|
|
6
|
+
import type { Config } from "../config.js";
|
|
7
|
+
import { SyncEngine } from "./engine.js";
|
|
8
|
+
|
|
9
|
+
let db: MemDatabase;
|
|
10
|
+
let tmpDir: string;
|
|
11
|
+
|
|
12
|
+
function makeConfig(overrides: Partial<Config> = {}): Config {
|
|
13
|
+
return {
|
|
14
|
+
candengo_url: overrides.candengo_url ?? "",
|
|
15
|
+
candengo_api_key: overrides.candengo_api_key ?? "",
|
|
16
|
+
site_id: "test-site",
|
|
17
|
+
namespace: "dev-memory",
|
|
18
|
+
user_id: "david",
|
|
19
|
+
device_id: "laptop-abc",
|
|
20
|
+
user_email: "",
|
|
21
|
+
teams: [],
|
|
22
|
+
sync: { enabled: true, interval_seconds: 30, batch_size: 50 },
|
|
23
|
+
search: { default_limit: 10, local_boost: 1.2, scope: "all" },
|
|
24
|
+
scrubbing: { enabled: true, custom_patterns: [], default_sensitivity: "shared" },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmpDir = mkdtempSync(join(tmpdir(), "candengo-engine-test-"));
|
|
30
|
+
db = new MemDatabase(join(tmpDir, "test.db"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
db.close();
|
|
35
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("SyncEngine", () => {
|
|
39
|
+
test("is not configured when no API key", () => {
|
|
40
|
+
const engine = new SyncEngine(db, makeConfig());
|
|
41
|
+
expect(engine.isConfigured()).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("is configured when API key and URL provided", () => {
|
|
45
|
+
const engine = new SyncEngine(
|
|
46
|
+
db,
|
|
47
|
+
makeConfig({
|
|
48
|
+
candengo_url: "https://candengo.com",
|
|
49
|
+
candengo_api_key: "cvk_test123",
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
expect(engine.isConfigured()).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("start is no-op when not configured", () => {
|
|
56
|
+
const engine = new SyncEngine(db, makeConfig());
|
|
57
|
+
engine.start();
|
|
58
|
+
expect(engine.isRunning()).toBe(false);
|
|
59
|
+
engine.stop(); // should not throw
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("start and stop work cleanly when configured", () => {
|
|
63
|
+
const engine = new SyncEngine(
|
|
64
|
+
db,
|
|
65
|
+
makeConfig({
|
|
66
|
+
candengo_url: "https://candengo.com",
|
|
67
|
+
candengo_api_key: "cvk_test123",
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
engine.start();
|
|
71
|
+
expect(engine.isRunning()).toBe(true);
|
|
72
|
+
engine.stop();
|
|
73
|
+
expect(engine.isRunning()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("stop is idempotent", () => {
|
|
77
|
+
const engine = new SyncEngine(db, makeConfig());
|
|
78
|
+
engine.stop();
|
|
79
|
+
engine.stop();
|
|
80
|
+
// Should not throw
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("start with sync disabled is no-op", () => {
|
|
84
|
+
const config = makeConfig({
|
|
85
|
+
candengo_url: "https://candengo.com",
|
|
86
|
+
candengo_api_key: "cvk_test123",
|
|
87
|
+
});
|
|
88
|
+
config.sync.enabled = false;
|
|
89
|
+
const engine = new SyncEngine(db, config);
|
|
90
|
+
engine.start();
|
|
91
|
+
expect(engine.isRunning()).toBe(false);
|
|
92
|
+
engine.stop();
|
|
93
|
+
});
|
|
94
|
+
});
|