@fortemi/core 2026.6.4 → 2026.6.5

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/dist/index.js CHANGED
@@ -135,406 +135,63 @@ function getDataDir(persistence, archiveName) {
135
135
  return void 0;
136
136
  }
137
137
  }
138
- async function createPGliteInstance(persistence, archiveName = "default") {
138
+ async function createPGliteInstance(persistence, archiveName = "default", options = {}) {
139
139
  const dataDir = getDataDir(persistence, archiveName);
140
- const options = {
140
+ const pgliteOptions = {
141
141
  database: "postgres",
142
142
  // PGlite 0.4.x breaking change: explicit required
143
143
  extensions: { vector }
144
144
  };
145
145
  if (dataDir) {
146
- options.dataDir = dataDir;
146
+ pgliteOptions.dataDir = dataDir;
147
147
  }
148
- const db = await PGlite.create(options);
148
+ if (options.loadDataDir) {
149
+ pgliteOptions.loadDataDir = options.loadDataDir;
150
+ }
151
+ const db = await PGlite.create(pgliteOptions);
149
152
  await db.exec("CREATE EXTENSION IF NOT EXISTS vector");
150
153
  return db;
151
154
  }
152
155
 
153
- // src/worker/worker-client.ts
154
- var PGliteWorkerClient = class {
155
- constructor(worker) {
156
- this.worker = worker;
157
- this.readyPromise = new Promise((resolve) => {
158
- this.resolveReady = resolve;
159
- });
160
- this.worker.addEventListener("message", (e) => {
161
- const msg = e.data;
162
- if (msg.type === "READY") {
163
- this.resolveReady();
164
- return;
165
- }
166
- if (!("id" in msg)) return;
167
- const pending = this.pending.get(msg.id);
168
- if (!pending) return;
169
- this.pending.delete(msg.id);
170
- if (msg.type === "ERROR") {
171
- pending.reject(new Error(msg.error));
172
- } else {
173
- pending.resolve(msg);
174
- }
175
- });
176
- }
177
- pending = /* @__PURE__ */ new Map();
178
- readyPromise;
179
- resolveReady;
180
- /** Resolves when the worker broadcasts READY after database initialisation. */
181
- async waitReady() {
182
- return this.readyPromise;
183
- }
184
- send(request) {
185
- const id = generateId();
186
- return new Promise((resolve, reject) => {
187
- this.pending.set(id, {
188
- resolve,
189
- reject
190
- });
191
- this.worker.postMessage({ ...request, id });
192
- });
193
- }
194
- async query(sql, params) {
195
- const resp = await this.send({ type: "QUERY", sql, params });
196
- return { rows: resp.rows, fields: resp.fields };
197
- }
198
- async exec(sql) {
199
- await this.send({ type: "EXEC", sql });
200
- }
201
- async transaction(fn) {
202
- const resp = await this.send({ type: "BEGIN" });
203
- const txId = resp.txId;
204
- const proxy = new TransactionProxy(this, txId);
205
- try {
206
- const result = await fn(proxy);
207
- await this.send({ type: "COMMIT", txId });
208
- return result;
209
- } catch (err) {
210
- await this.send({ type: "ROLLBACK", txId }).catch(() => {
211
- });
212
- throw err;
213
- }
214
- }
215
- /** Forward TX_QUERY for TransactionProxy — not part of the public surface. */
216
- async _txQuery(txId, sql, params) {
217
- const resp = await this.send({
218
- type: "TX_QUERY",
219
- txId,
220
- sql,
221
- params
222
- });
223
- return { rows: resp.rows };
224
- }
225
- /** Forward TX_EXEC for TransactionProxy — not part of the public surface. */
226
- async _txExec(txId, sql) {
227
- await this.send({ type: "TX_EXEC", txId, sql });
228
- }
229
- async ping() {
230
- await this.send({ type: "PING" });
231
- }
232
- async close() {
233
- await this.send({ type: "CLOSE" });
234
- this.worker.terminate();
235
- }
236
- };
237
- var TransactionProxy = class {
238
- constructor(client, txId) {
239
- this.client = client;
240
- this.txId = txId;
241
- }
242
- async query(sql, params) {
243
- return this.client._txQuery(this.txId, sql, params);
244
- }
245
- async exec(sql) {
246
- return this.client._txExec(this.txId, sql);
247
- }
248
- };
156
+ // src/migrations/0001_initial_schema.ts
157
+ var migration0001 = {
158
+ version: 1,
159
+ name: "0001_initial_schema",
160
+ sql: `
161
+ -- Archive (multi-database tracking)
162
+ CREATE TABLE IF NOT EXISTS archive (
163
+ id TEXT PRIMARY KEY,
164
+ name TEXT NOT NULL UNIQUE,
165
+ schema_version INTEGER NOT NULL DEFAULT 0,
166
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
167
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
168
+ );
249
169
 
250
- // src/storage-backend.ts
251
- var PGliteStorageBackend = class {
252
- constructor(id, db) {
253
- this.id = id;
254
- this.db = db;
255
- }
256
- mode = "readwrite";
257
- query(sql, params) {
258
- return this.db.query(sql, params);
259
- }
260
- exec(sql) {
261
- return this.db.exec(sql);
262
- }
263
- async transaction(fn) {
264
- return this.db.transaction((tx) => fn(tx));
265
- }
266
- close() {
267
- return this.db.close();
268
- }
269
- };
270
- var PGliteStorageBackendFactory = class {
271
- async open(input) {
272
- const db = await createPGliteInstance(input.persistence, input.archiveName);
273
- return new PGliteStorageBackend(`pglite:${input.persistence}:${input.archiveName}`, db);
274
- }
275
- };
276
- var defaultStorageBackendFactory = new PGliteStorageBackendFactory();
277
- var PGliteWorkerStorageBackend = class {
278
- constructor(id, client) {
279
- this.id = id;
280
- this.client = client;
281
- }
282
- mode = "readwrite";
283
- query(sql, params) {
284
- return this.client.query(sql, params);
285
- }
286
- exec(sql) {
287
- return this.client.exec(sql);
288
- }
289
- transaction(fn) {
290
- return this.client.transaction(fn);
291
- }
292
- close() {
293
- return this.client.close();
294
- }
295
- };
296
- var PGliteWorkerStorageBackendFactory = class {
297
- constructor(options) {
298
- this.options = options;
299
- }
300
- async open(input) {
301
- const worker = this.options.createWorker();
302
- const client = new PGliteWorkerClient(worker);
303
- worker.postMessage({
304
- type: "INIT",
305
- persistence: input.persistence,
306
- archiveName: input.archiveName
307
- });
308
- await client.waitReady();
309
- return new PGliteWorkerStorageBackend(`pglite-worker:${input.persistence}:${input.archiveName}`, client);
310
- }
311
- };
170
+ -- Note (core entity)
171
+ CREATE TABLE IF NOT EXISTS note (
172
+ id TEXT PRIMARY KEY,
173
+ archive_id TEXT REFERENCES archive(id),
174
+ title TEXT,
175
+ format TEXT NOT NULL DEFAULT 'markdown',
176
+ source TEXT NOT NULL DEFAULT 'user',
177
+ visibility TEXT NOT NULL DEFAULT 'private',
178
+ revision_mode TEXT NOT NULL DEFAULT 'standard',
179
+ is_starred BOOLEAN NOT NULL DEFAULT false,
180
+ is_pinned BOOLEAN NOT NULL DEFAULT false,
181
+ is_archived BOOLEAN NOT NULL DEFAULT false,
182
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
183
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
184
+ deleted_at TIMESTAMPTZ,
185
+ tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title, ''))) STORED
186
+ );
312
187
 
313
- // src/capability-manager.ts
314
- var VALID_TRANSITIONS = {
315
- enable: ["unloaded", "disabled", "error"],
316
- disable: ["ready"],
317
- markReady: ["loading"],
318
- markError: ["loading"]
319
- };
320
- function assertTransition(method, name, current) {
321
- const allowed = VALID_TRANSITIONS[method];
322
- if (!allowed.includes(current)) {
323
- throw new Error(
324
- `CapabilityManager.${method}('${name}'): invalid transition from state '${current}'. Allowed source states: ${allowed.join(", ")}.`
325
- );
326
- }
327
- }
328
- var CapabilityManager = class {
329
- constructor(events) {
330
- this.events = events;
331
- const names = ["semantic", "llm", "audio", "vision", "pdf"];
332
- for (const name of names) {
333
- this.capabilities.set(name, { name, state: "unloaded" });
334
- }
335
- }
336
- capabilities = /* @__PURE__ */ new Map();
337
- loaders = /* @__PURE__ */ new Map();
338
- progressMessages = /* @__PURE__ */ new Map();
339
- /**
340
- * Register an async loader for a capability.
341
- * Called by enable(); if no loader is registered the capability transitions
342
- * directly to ready (useful for capabilities that require no async init).
343
- */
344
- registerLoader(name, loader) {
345
- this.loaders.set(name, loader);
346
- }
347
- getState(name) {
348
- return this.capabilities.get(name)?.state ?? "unloaded";
349
- }
350
- isReady(name) {
351
- return this.getState(name) === "ready";
352
- }
353
- /**
354
- * Enable a capability.
355
- * Valid from: unloaded, disabled, error (retry).
356
- * Runs the registered loader if present; transitions to ready on success,
357
- * error on failure.
358
- */
359
- async enable(name) {
360
- const entry = this.capabilities.get(name);
361
- if (!entry) return;
362
- if (entry.state === "ready" || entry.state === "loading") return;
363
- assertTransition("enable", name, entry.state);
364
- entry.state = "loading";
365
- entry.error = void 0;
366
- this.events.emit("capability.loading", { name });
367
- const loader = this.loaders.get(name);
368
- if (!loader) {
369
- entry.state = "ready";
370
- this.events.emit("capability.ready", { name });
371
- return;
372
- }
373
- try {
374
- await loader();
375
- if (entry.state === "loading") {
376
- entry.state = "ready";
377
- this.events.emit("capability.ready", { name });
378
- }
379
- } catch (err) {
380
- if (entry.state === "loading") {
381
- const message = err instanceof Error ? err.message : String(err);
382
- entry.state = "error";
383
- entry.error = message;
384
- }
385
- }
386
- }
387
- /**
388
- * Disable a ready capability.
389
- * Valid from: ready only.
390
- */
391
- disable(name) {
392
- const entry = this.capabilities.get(name);
393
- if (!entry) return;
394
- assertTransition("disable", name, entry.state);
395
- entry.state = "disabled";
396
- this.events.emit("capability.disabled", { name });
397
- }
398
- /**
399
- * Mark a loading capability as ready (external use, e.g. bridge protocol).
400
- * Valid from: loading only.
401
- */
402
- markReady(name) {
403
- const entry = this.capabilities.get(name);
404
- if (!entry) return;
405
- assertTransition("markReady", name, entry.state);
406
- entry.state = "ready";
407
- entry.error = void 0;
408
- this.events.emit("capability.ready", { name });
409
- }
410
- /**
411
- * Mark a loading capability as errored (external use, e.g. bridge protocol).
412
- * Valid from: loading only.
413
- */
414
- markError(name, error) {
415
- const entry = this.capabilities.get(name);
416
- if (!entry) return;
417
- assertTransition("markError", name, entry.state);
418
- entry.state = "error";
419
- entry.error = error;
420
- }
421
- /**
422
- * Report loading progress (0-100).
423
- * Emits capability.loading with progress if the capability is currently loading.
424
- * No-op if the capability is not in loading state.
425
- */
426
- reportProgress(name, progress) {
427
- const entry = this.capabilities.get(name);
428
- if (!entry || entry.state !== "loading") return;
429
- this.events.emit("capability.loading", { name, progress });
430
- }
431
- /** Set a human-readable progress message for a loading capability */
432
- setProgress(name, message) {
433
- this.progressMessages.set(name, message);
434
- this.events.emit("capability.loading", { name, progress: -1 });
435
- }
436
- /** Get the current progress message for a capability */
437
- getProgress(name) {
438
- return this.progressMessages.get(name);
439
- }
440
- getError(name) {
441
- return this.capabilities.get(name)?.error;
442
- }
443
- listAll() {
444
- return Array.from(this.capabilities.values()).map(({ name, state }) => ({
445
- name,
446
- state
447
- }));
448
- }
449
- };
450
-
451
- // src/migration-runner.ts
452
- var MigrationRunner = class {
453
- constructor(db, events) {
454
- this.db = db;
455
- this.events = events;
456
- }
457
- async ensureSchemaTable() {
458
- await this.db.exec(`
459
- CREATE TABLE IF NOT EXISTS schema_version (
460
- version INTEGER NOT NULL,
461
- name TEXT NOT NULL,
462
- applied_at TIMESTAMPTZ DEFAULT now(),
463
- PRIMARY KEY (version)
464
- )
465
- `);
466
- }
467
- async getCurrentVersion() {
468
- const result = await this.db.query(
469
- "SELECT COALESCE(MAX(version), 0) AS version FROM schema_version"
470
- );
471
- return result.rows[0].version;
472
- }
473
- async apply(migrations) {
474
- await this.ensureSchemaTable();
475
- const currentVersion = await this.getCurrentVersion();
476
- const pending = migrations.filter((m) => m.version > currentVersion).sort((a, b) => a.version - b.version);
477
- let applied = 0;
478
- for (const migration of pending) {
479
- await this.db.transaction(async (tx) => {
480
- await tx.exec(migration.sql);
481
- await tx.query(
482
- "INSERT INTO schema_version (version, name) VALUES ($1, $2)",
483
- [migration.version, migration.name]
484
- );
485
- });
486
- applied++;
487
- this.events?.emit("migration.applied", { version: migration.version });
488
- }
489
- return applied;
490
- }
491
- async getAppliedMigrations() {
492
- const result = await this.db.query(
493
- "SELECT version, name FROM schema_version ORDER BY version"
494
- );
495
- return result.rows;
496
- }
497
- };
498
-
499
- // src/migrations/0001_initial_schema.ts
500
- var migration0001 = {
501
- version: 1,
502
- name: "0001_initial_schema",
503
- sql: `
504
- -- Archive (multi-database tracking)
505
- CREATE TABLE IF NOT EXISTS archive (
506
- id TEXT PRIMARY KEY,
507
- name TEXT NOT NULL UNIQUE,
508
- schema_version INTEGER NOT NULL DEFAULT 0,
509
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
510
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
511
- );
512
-
513
- -- Note (core entity)
514
- CREATE TABLE IF NOT EXISTS note (
515
- id TEXT PRIMARY KEY,
516
- archive_id TEXT REFERENCES archive(id),
517
- title TEXT,
518
- format TEXT NOT NULL DEFAULT 'markdown',
519
- source TEXT NOT NULL DEFAULT 'user',
520
- visibility TEXT NOT NULL DEFAULT 'private',
521
- revision_mode TEXT NOT NULL DEFAULT 'standard',
522
- is_starred BOOLEAN NOT NULL DEFAULT false,
523
- is_pinned BOOLEAN NOT NULL DEFAULT false,
524
- is_archived BOOLEAN NOT NULL DEFAULT false,
525
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
526
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
527
- deleted_at TIMESTAMPTZ,
528
- tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title, ''))) STORED
529
- );
530
-
531
- -- Note original content (immutable)
532
- CREATE TABLE IF NOT EXISTS note_original (
533
- id TEXT PRIMARY KEY,
534
- note_id TEXT NOT NULL REFERENCES note(id),
535
- content TEXT NOT NULL,
536
- content_hash TEXT NOT NULL,
537
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
188
+ -- Note original content (immutable)
189
+ CREATE TABLE IF NOT EXISTS note_original (
190
+ id TEXT PRIMARY KEY,
191
+ note_id TEXT NOT NULL REFERENCES note(id),
192
+ content TEXT NOT NULL,
193
+ content_hash TEXT NOT NULL,
194
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
538
195
  );
539
196
 
540
197
  -- Note revised current (mutable, latest revision)
@@ -952,1196 +609,931 @@ var allMigrations = [
952
609
  migration0009
953
610
  ];
954
611
 
955
- // src/archive-manager.ts
956
- var ArchiveManager = class {
957
- constructor(persistenceOrFactory, events, persistenceOverride) {
958
- this.events = events;
959
- if (typeof persistenceOrFactory === "string") {
960
- this.persistence = persistenceOrFactory;
961
- this.backendFactory = defaultStorageBackendFactory;
962
- } else {
963
- this.persistence = persistenceOverride ?? "memory";
964
- this.backendFactory = persistenceOrFactory;
965
- }
966
- this.archives.set("default", {
967
- name: "default",
968
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
969
- });
970
- }
971
- currentArchive = "default";
972
- db = null;
973
- archives = /* @__PURE__ */ new Map();
974
- persistence;
975
- backendFactory;
976
- getCurrentArchiveName() {
977
- return this.currentArchive;
978
- }
979
- getDb() {
980
- return this.db;
612
+ // src/data-archive.ts
613
+ var DB_SNAPSHOT_SCHEMA_VERSION = "fortemi.db-snapshot.v1";
614
+ var SUPPORTED_PGLITE_VERSION = "0.4.1";
615
+ var CURRENT_MIGRATION_HEAD = allMigrations.reduce(
616
+ (head, migration) => Math.max(head, migration.version),
617
+ 0
618
+ );
619
+ async function readMigrationHead(db) {
620
+ try {
621
+ const result = await db.query(
622
+ "SELECT COALESCE(MAX(version), 0) AS head FROM schema_version"
623
+ );
624
+ return Number(result.rows[0]?.head ?? 0);
625
+ } catch {
626
+ return 0;
981
627
  }
982
- async open(archiveName = "default") {
983
- if (this.db) {
984
- await this.db.close();
985
- }
986
- this.db = await this.backendFactory.open({
987
- archiveName,
988
- persistence: this.persistence
989
- });
990
- const runner = new MigrationRunner(this.db, this.events);
991
- await runner.apply(allMigrations);
992
- this.currentArchive = archiveName;
993
- if (!this.archives.has(archiveName)) {
994
- this.archives.set(archiveName, {
995
- name: archiveName,
996
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
997
- });
998
- }
999
- this.events?.emit("archive.switched", { name: archiveName });
1000
- return this.db;
628
+ }
629
+ async function readPgvectorVersion(db) {
630
+ try {
631
+ const result = await db.query(
632
+ "SELECT extversion FROM pg_extension WHERE extname = 'vector'"
633
+ );
634
+ return result.rows[0]?.extversion ?? null;
635
+ } catch {
636
+ return null;
1001
637
  }
1002
- async create(archiveName) {
1003
- if (this.archives.has(archiveName)) {
1004
- throw new Error(`Archive '${archiveName}' already exists`);
1005
- }
1006
- return this.open(archiveName);
638
+ }
639
+ async function dumpDbSnapshot(db, options = {}) {
640
+ const migration_head = await readMigrationHead(db);
641
+ const pgvector_version = await readPgvectorVersion(db);
642
+ const data = await db.dumpDataDir(options.compression ?? "gzip");
643
+ const meta = {
644
+ schema_version: DB_SNAPSHOT_SCHEMA_VERSION,
645
+ pglite_version: SUPPORTED_PGLITE_VERSION,
646
+ pgvector_version,
647
+ migration_head,
648
+ created_at: options.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
649
+ ...options.fortemiVersion ? { fortemi_version: options.fortemiVersion } : {}
650
+ };
651
+ return { data, meta };
652
+ }
653
+ function majorMinor(version) {
654
+ const parts = version.split(".");
655
+ return `${parts[0] ?? "0"}.${parts[1] ?? "0"}`;
656
+ }
657
+ function verifyDbSnapshotMeta(meta, expected = {}) {
658
+ const reasons = [];
659
+ const warnings = [];
660
+ const expectedHead = expected.migrationHead ?? CURRENT_MIGRATION_HEAD;
661
+ const expectedPglite = expected.pgliteVersion ?? SUPPORTED_PGLITE_VERSION;
662
+ if (meta.schema_version !== DB_SNAPSHOT_SCHEMA_VERSION) {
663
+ reasons.push(`unsupported snapshot schema_version: ${String(meta.schema_version)}`);
1007
664
  }
1008
- async switchTo(archiveName) {
1009
- return this.open(archiveName);
665
+ if (meta.migration_head !== expectedHead) {
666
+ reasons.push(`migration head mismatch: snapshot=${meta.migration_head}, supported=${expectedHead}`);
1010
667
  }
1011
- async delete(archiveName) {
1012
- if (archiveName === "default") {
1013
- throw new Error("Cannot delete the default archive");
1014
- }
1015
- if (this.currentArchive === archiveName && this.db) {
1016
- await this.db.close();
1017
- this.db = null;
1018
- }
1019
- this.archives.delete(archiveName);
668
+ if (majorMinor(meta.pglite_version) !== majorMinor(expectedPglite)) {
669
+ reasons.push(`PGlite version mismatch: snapshot=${meta.pglite_version}, supported=${expectedPglite}`);
1020
670
  }
1021
- listArchives() {
1022
- return Array.from(this.archives.values());
671
+ if (expected.pgvectorVersion !== void 0 && meta.pgvector_version !== expected.pgvectorVersion) {
672
+ warnings.push(`pgvector version differs: snapshot=${String(meta.pgvector_version)}, expected=${String(expected.pgvectorVersion)}`);
1023
673
  }
1024
- async close() {
1025
- if (this.db) {
1026
- await this.db.close();
1027
- this.db = null;
1028
- }
674
+ return { compatible: reasons.length === 0, reasons, warnings };
675
+ }
676
+ var DbSnapshotVersionError = class extends Error {
677
+ reasons;
678
+ meta;
679
+ constructor(reasons, meta) {
680
+ super(`Incompatible DB snapshot: ${reasons.join("; ")}`);
681
+ this.name = "DbSnapshotVersionError";
682
+ this.reasons = reasons;
683
+ this.meta = meta;
1029
684
  }
1030
685
  };
686
+ function isInlineSnapshot(source) {
687
+ return typeof source === "object" && "data" in source && "meta" in source;
688
+ }
689
+ async function resolveSnapshotSource(source, fetchImpl) {
690
+ if (isInlineSnapshot(source)) return source;
691
+ const dataUrl = typeof source === "string" ? source : source.dataUrl;
692
+ const metaUrl = typeof source === "string" ? `${source}.meta.json` : source.metaUrl ?? `${source.dataUrl}.meta.json`;
693
+ const metaResponse = await fetchImpl(metaUrl);
694
+ if (!metaResponse.ok) {
695
+ throw new Error(`Failed to fetch snapshot meta (${metaResponse.status}): ${metaUrl}`);
696
+ }
697
+ const meta = await metaResponse.json();
698
+ const dataResponse = await fetchImpl(dataUrl);
699
+ if (!dataResponse.ok) {
700
+ throw new Error(`Failed to fetch snapshot data (${dataResponse.status}): ${dataUrl}`);
701
+ }
702
+ const data = await dataResponse.blob();
703
+ return { data, meta };
704
+ }
705
+ async function restoreDbSnapshot(source, options = {}) {
706
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
707
+ const { data, meta } = await resolveSnapshotSource(source, fetchImpl);
708
+ const compat = verifyDbSnapshotMeta(meta, options.expectations);
709
+ if (!compat.compatible) {
710
+ throw new DbSnapshotVersionError(compat.reasons, meta);
711
+ }
712
+ const createOptions = { loadDataDir: data };
713
+ return createPGliteInstance(options.persistence ?? "memory", options.archiveName ?? "default", createOptions);
714
+ }
715
+ function computeHash(data) {
716
+ const digest = sha256(data);
717
+ return `sha256:${bytesToHex(digest)}`;
718
+ }
1031
719
 
1032
- // src/create-fortemi.ts
1033
- function createFortemi(config) {
1034
- const events = new TypedEventBus();
1035
- return {
1036
- events,
1037
- config,
1038
- destroy() {
1039
- events.removeAllListeners();
1040
- }
1041
- };
1042
- }
1043
- function computeHash(data) {
1044
- const digest = sha256(data);
1045
- return `sha256:${bytesToHex(digest)}`;
1046
- }
1047
-
1048
- // src/service-worker/register.ts
1049
- async function registerServiceWorker(swUrl = "/sw.js") {
1050
- if (!("serviceWorker" in navigator)) {
1051
- return { registered: false, error: "Service Workers not supported" };
720
+ // src/repositories/notes-repository.ts
721
+ var NotesRepository = class {
722
+ constructor(db, events) {
723
+ this.db = db;
724
+ this.events = events;
1052
725
  }
1053
- try {
1054
- const registration = await navigator.serviceWorker.register(swUrl, {
1055
- type: "module",
1056
- scope: "/"
726
+ /**
727
+ * Create a new note with its original content record, current revision,
728
+ * optional tags, and an auto-queued title_generation job when no title
729
+ * is provided.
730
+ *
731
+ * All writes happen in a single transaction.
732
+ */
733
+ async create(input) {
734
+ const noteId = generateId();
735
+ const originalId = generateId();
736
+ const contentHash = computeHash(new TextEncoder().encode(input.content));
737
+ await this.db.transaction(async (tx) => {
738
+ await tx.query(
739
+ `INSERT INTO note (id, archive_id, title, format, source, visibility)
740
+ VALUES ($1, $2, $3, $4, $5, $6)`,
741
+ [
742
+ noteId,
743
+ input.archive_id ?? null,
744
+ input.title ?? null,
745
+ input.format ?? "markdown",
746
+ input.source ?? "user",
747
+ input.visibility ?? "private"
748
+ ]
749
+ );
750
+ await tx.query(
751
+ `INSERT INTO note_original (id, note_id, content, content_hash)
752
+ VALUES ($1, $2, $3, $4)`,
753
+ [originalId, noteId, input.content, contentHash]
754
+ );
755
+ await tx.query(
756
+ `INSERT INTO note_revised_current (note_id, content)
757
+ VALUES ($1, $2)`,
758
+ [noteId, input.content]
759
+ );
760
+ if (input.tags?.length) {
761
+ for (const tag of input.tags) {
762
+ await tx.query(
763
+ `INSERT INTO note_tag (id, note_id, tag) VALUES ($1, $2, $3)`,
764
+ [generateId(), noteId, tag]
765
+ );
766
+ }
767
+ }
768
+ await tx.query(
769
+ `INSERT INTO job_queue (id, note_id, job_type, status, priority, required_capability)
770
+ VALUES ($1, $2, 'ai_revision', 'pending', 1, 'llm')`,
771
+ [generateId(), noteId]
772
+ );
773
+ if (!input.title) {
774
+ await tx.query(
775
+ `INSERT INTO job_queue (id, note_id, job_type, status, priority)
776
+ VALUES ($1, $2, 'title_generation', 'pending', 2)`,
777
+ [generateId(), noteId]
778
+ );
779
+ }
780
+ await tx.query(
781
+ `INSERT INTO job_queue (id, note_id, job_type, status, priority, required_capability)
782
+ VALUES ($1, $2, 'embedding', 'pending', 3, 'semantic')`,
783
+ [generateId(), noteId]
784
+ );
1057
785
  });
1058
- if (registration.installing) {
1059
- await new Promise((resolve) => {
1060
- registration.installing.addEventListener("statechange", function handler() {
1061
- if (this.state === "activated") {
1062
- this.removeEventListener("statechange", handler);
1063
- resolve();
1064
- }
1065
- });
1066
- });
786
+ this.events?.emit("note.created", { id: noteId });
787
+ return this.get(noteId);
788
+ }
789
+ /**
790
+ * Fetch a single note by its ID.
791
+ * Returns NoteFull which includes original content, current revision, and tags.
792
+ * Throws when the note does not exist.
793
+ */
794
+ async get(id) {
795
+ const noteResult = await this.db.query(
796
+ `SELECT n.id, n.archive_id, n.title, n.format, n.source, n.visibility,
797
+ n.revision_mode, n.is_starred, n.is_pinned, n.is_archived,
798
+ n.created_at, n.updated_at, n.deleted_at,
799
+ o.id AS original_id,
800
+ o.content AS original_content,
801
+ o.content_hash,
802
+ o.created_at AS original_created_at,
803
+ c.content AS current_content,
804
+ c.ai_metadata,
805
+ c.generation_count,
806
+ c.model,
807
+ c.is_user_edited,
808
+ c.updated_at AS current_updated_at
809
+ FROM note n
810
+ LEFT JOIN note_original o ON o.note_id = n.id
811
+ LEFT JOIN note_revised_current c ON c.note_id = n.id
812
+ WHERE n.id = $1`,
813
+ [id]
814
+ );
815
+ if (noteResult.rows.length === 0) {
816
+ throw new Error(`Note not found: ${id}`);
1067
817
  }
1068
- return { registered: true, registration };
1069
- } catch (err) {
818
+ const row = noteResult.rows[0];
819
+ const tagsResult = await this.db.query(
820
+ `SELECT tag FROM note_tag WHERE note_id = $1 ORDER BY tag`,
821
+ [id]
822
+ );
1070
823
  return {
1071
- registered: false,
1072
- error: err instanceof Error ? err.message : String(err)
824
+ id: row.id,
825
+ archive_id: row.archive_id,
826
+ title: row.title,
827
+ format: row.format,
828
+ source: row.source,
829
+ visibility: row.visibility,
830
+ revision_mode: row.revision_mode,
831
+ is_starred: row.is_starred,
832
+ is_pinned: row.is_pinned,
833
+ is_archived: row.is_archived,
834
+ created_at: row.created_at,
835
+ updated_at: row.updated_at,
836
+ deleted_at: row.deleted_at,
837
+ tags: tagsResult.rows.map((r) => r.tag),
838
+ original: {
839
+ id: row.original_id,
840
+ content: row.original_content,
841
+ content_hash: row.content_hash,
842
+ created_at: row.original_created_at
843
+ },
844
+ current: {
845
+ content: row.current_content,
846
+ ai_metadata: row.ai_metadata,
847
+ generation_count: row.generation_count,
848
+ model: row.model,
849
+ is_user_edited: row.is_user_edited,
850
+ updated_at: row.current_updated_at
851
+ }
1073
852
  };
1074
853
  }
1075
- }
1076
-
1077
- // src/service-worker/routes.ts
1078
- async function parseJsonBody(request) {
1079
- try {
1080
- return await request.json();
1081
- } catch {
1082
- return null;
1083
- }
1084
- }
1085
- function jsonResponse(data, status = 200) {
1086
- return new Response(JSON.stringify(data), {
1087
- status,
1088
- headers: { "Content-Type": "application/json" }
1089
- });
1090
- }
1091
- function errorResponse(message, status) {
1092
- return jsonResponse({ error: message }, status);
1093
- }
1094
- var DB_NOT_CONNECTED = "Database not connected. REST API available in standalone mode only.";
1095
- function createRoutes() {
1096
- return [
1097
- // GET /api/v1/notes — list notes
1098
- {
1099
- method: "GET",
1100
- pattern: /^\/api\/v1\/notes\/?$/,
1101
- handler: async () => {
1102
- return errorResponse(DB_NOT_CONNECTED, 503);
1103
- }
1104
- },
1105
- // POST /api/v1/notes — create note
1106
- {
1107
- method: "POST",
1108
- pattern: /^\/api\/v1\/notes\/?$/,
1109
- handler: async (request) => {
1110
- const body = await parseJsonBody(request);
1111
- if (body === null) return errorResponse("Invalid JSON body", 400);
1112
- return errorResponse(DB_NOT_CONNECTED, 503);
1113
- }
1114
- },
1115
- // POST /api/v1/notes/:id/restore — restore soft-deleted note (must come before /:id)
1116
- {
1117
- method: "POST",
1118
- pattern: /^\/api\/v1\/notes\/([^/]+)\/restore\/?$/,
1119
- handler: async () => {
1120
- return errorResponse(DB_NOT_CONNECTED, 503);
1121
- }
1122
- },
1123
- // POST /api/v1/notes/:id/star star or unstar a note
1124
- {
1125
- method: "POST",
1126
- pattern: /^\/api\/v1\/notes\/([^/]+)\/star\/?$/,
1127
- handler: async () => {
1128
- return errorResponse(DB_NOT_CONNECTED, 503);
1129
- }
1130
- },
1131
- // POST /api/v1/notes/:id/archive archive or unarchive a note
1132
- {
1133
- method: "POST",
1134
- pattern: /^\/api\/v1\/notes\/([^/]+)\/archive\/?$/,
1135
- handler: async () => {
1136
- return errorResponse(DB_NOT_CONNECTED, 503);
1137
- }
1138
- },
1139
- // GET /api/v1/notes/:id — get a single note
1140
- {
1141
- method: "GET",
1142
- pattern: /^\/api\/v1\/notes\/([^/]+)\/?$/,
1143
- handler: async () => {
1144
- return errorResponse(DB_NOT_CONNECTED, 503);
1145
- }
1146
- },
1147
- // PUT /api/v1/notes/:id — update a note
1148
- {
1149
- method: "PUT",
1150
- pattern: /^\/api\/v1\/notes\/([^/]+)\/?$/,
1151
- handler: async () => {
1152
- return errorResponse(DB_NOT_CONNECTED, 503);
1153
- }
1154
- },
1155
- // DELETE /api/v1/notes/:id — soft-delete a note
1156
- {
1157
- method: "DELETE",
1158
- pattern: /^\/api\/v1\/notes\/([^/]+)\/?$/,
1159
- handler: async () => {
1160
- return errorResponse(DB_NOT_CONNECTED, 503);
1161
- }
1162
- },
1163
- // GET /api/v1/search — full-text search
1164
- {
1165
- method: "GET",
1166
- pattern: /^\/api\/v1\/search\/?$/,
1167
- handler: async () => {
1168
- return errorResponse(DB_NOT_CONNECTED, 503);
854
+ /**
855
+ * List notes with optional filtering, sorting, and pagination.
856
+ * Excludes soft-deleted notes by default (pass include_deleted: true to override).
857
+ */
858
+ async list(options = {}) {
859
+ const {
860
+ limit = 50,
861
+ offset = 0,
862
+ sort = "created_at",
863
+ order = "desc",
864
+ is_starred,
865
+ is_pinned,
866
+ is_archived,
867
+ include_deleted = false,
868
+ collection_id,
869
+ tags
870
+ } = options;
871
+ const conditions = [];
872
+ const params = [];
873
+ let paramIndex = 1;
874
+ if (!include_deleted) {
875
+ conditions.push("n.deleted_at IS NULL");
876
+ }
877
+ if (is_starred !== void 0) {
878
+ conditions.push(`n.is_starred = $${paramIndex++}`);
879
+ params.push(is_starred);
880
+ }
881
+ if (is_pinned !== void 0) {
882
+ conditions.push(`n.is_pinned = $${paramIndex++}`);
883
+ params.push(is_pinned);
884
+ }
885
+ if (is_archived !== void 0) {
886
+ conditions.push(`n.is_archived = $${paramIndex++}`);
887
+ params.push(is_archived);
888
+ }
889
+ if (collection_id) {
890
+ conditions.push(
891
+ `EXISTS (SELECT 1 FROM collection_note cn WHERE cn.note_id = n.id AND cn.collection_id = $${paramIndex++})`
892
+ );
893
+ params.push(collection_id);
894
+ }
895
+ if (tags?.length) {
896
+ conditions.push(
897
+ `EXISTS (SELECT 1 FROM note_tag nt WHERE nt.note_id = n.id AND nt.tag = ANY($${paramIndex++}))`
898
+ );
899
+ params.push(tags);
900
+ }
901
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
902
+ const validSorts = ["created_at", "updated_at", "title"];
903
+ const sortCol = validSorts.includes(sort) ? sort : "created_at";
904
+ const sortDir = order === "asc" ? "ASC" : "DESC";
905
+ const countResult = await this.db.query(
906
+ `SELECT COUNT(*) AS count FROM note n ${where}`,
907
+ params
908
+ );
909
+ const total = parseInt(countResult.rows[0].count, 10);
910
+ const listParams = [...params, limit, offset];
911
+ const rowsResult = await this.db.query(
912
+ `SELECT n.id, n.title, n.format, n.source, n.visibility,
913
+ n.is_starred, n.is_pinned, n.is_archived,
914
+ n.created_at, n.updated_at, n.deleted_at
915
+ FROM note n ${where}
916
+ ORDER BY n.${sortCol} ${sortDir}
917
+ LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
918
+ listParams
919
+ );
920
+ const noteIds = rowsResult.rows.map((r) => r.id);
921
+ const tagMap = /* @__PURE__ */ new Map();
922
+ if (noteIds.length > 0) {
923
+ const tagsResult = await this.db.query(
924
+ `SELECT note_id, tag FROM note_tag WHERE note_id = ANY($1) ORDER BY tag`,
925
+ [noteIds]
926
+ );
927
+ for (const row of tagsResult.rows) {
928
+ const existing = tagMap.get(row.note_id) ?? [];
929
+ existing.push(row.tag);
930
+ tagMap.set(row.note_id, existing);
1169
931
  }
1170
932
  }
1171
- ];
1172
- }
1173
- function matchRoute(routes, request, url) {
1174
- for (const route of routes) {
1175
- if (request.method !== route.method) continue;
1176
- const match = url.pathname.match(route.pattern);
1177
- if (match) return route;
1178
- }
1179
- return null;
1180
- }
1181
-
1182
- // src/blob-store.ts
1183
- function hashPath(hash) {
1184
- return {
1185
- dir1: hash.slice(0, 2),
1186
- dir2: hash.slice(2, 4),
1187
- filename: hash
1188
- };
1189
- }
1190
- var OpfsBlobStore = class {
1191
- constructor(archiveName) {
1192
- this.archiveName = archiveName;
1193
- }
1194
- async getRoot() {
1195
- const root = await navigator.storage.getDirectory();
1196
- return root.getDirectoryHandle(`fortemi-${this.archiveName}-blobs`, { create: true });
933
+ const items = rowsResult.rows.map((r) => ({
934
+ id: r.id,
935
+ title: r.title,
936
+ format: r.format,
937
+ source: r.source,
938
+ visibility: r.visibility,
939
+ is_starred: r.is_starred,
940
+ is_pinned: r.is_pinned,
941
+ is_archived: r.is_archived,
942
+ created_at: r.created_at,
943
+ updated_at: r.updated_at,
944
+ deleted_at: r.deleted_at,
945
+ tags: tagMap.get(r.id) ?? []
946
+ }));
947
+ return { items, total, limit, offset };
1197
948
  }
1198
- async getFileHandle(hash, create) {
1199
- const { dir1, dir2, filename } = hashPath(hash);
1200
- try {
1201
- const root = await this.getRoot();
1202
- const d1 = await root.getDirectoryHandle(dir1, { create });
1203
- const d2 = await d1.getDirectoryHandle(dir2, { create });
1204
- return d2.getFileHandle(filename, { create });
1205
- } catch (err) {
1206
- if (err instanceof DOMException && err.name === "NotFoundError") {
1207
- return null;
949
+ /**
950
+ * Update mutable note fields.
951
+ * When content changes, the previous current content is saved as a numbered
952
+ * revision before the new content is applied.
953
+ */
954
+ async update(id, input) {
955
+ const countResult = await this.db.query(
956
+ `SELECT COUNT(*) AS count FROM note_revision WHERE note_id = $1`,
957
+ [id]
958
+ );
959
+ const nextRevision = parseInt(countResult.rows[0].count, 10) + 1;
960
+ await this.db.transaction(async (tx) => {
961
+ const setClauses = ["updated_at = now()"];
962
+ const noteParams = [];
963
+ let paramIdx = 1;
964
+ if (input.title !== void 0) {
965
+ setClauses.push(`title = $${paramIdx++}`);
966
+ noteParams.push(input.title);
1208
967
  }
1209
- throw err;
1210
- }
968
+ if (input.format !== void 0) {
969
+ setClauses.push(`format = $${paramIdx++}`);
970
+ noteParams.push(input.format);
971
+ }
972
+ if (input.visibility !== void 0) {
973
+ setClauses.push(`visibility = $${paramIdx++}`);
974
+ noteParams.push(input.visibility);
975
+ }
976
+ noteParams.push(id);
977
+ await tx.query(
978
+ `UPDATE note SET ${setClauses.join(", ")} WHERE id = $${paramIdx}`,
979
+ noteParams
980
+ );
981
+ if (input.content !== void 0) {
982
+ const currentResult = await tx.query(
983
+ `SELECT content FROM note_revised_current WHERE note_id = $1`,
984
+ [id]
985
+ );
986
+ if (currentResult.rows.length > 0) {
987
+ await tx.query(
988
+ `INSERT INTO note_revision (id, note_id, revision_number, type, content)
989
+ VALUES ($1, $2, $3, 'user', $4)`,
990
+ [generateId(), id, nextRevision, currentResult.rows[0].content]
991
+ );
992
+ }
993
+ await tx.query(
994
+ `UPDATE note_revised_current
995
+ SET content = $1, is_user_edited = true, updated_at = now()
996
+ WHERE note_id = $2`,
997
+ [input.content, id]
998
+ );
999
+ }
1000
+ });
1001
+ this.events?.emit("note.updated", { id });
1002
+ return this.get(id);
1211
1003
  }
1212
- async write(hash, data) {
1213
- const fh = await this.getFileHandle(hash, true);
1214
- if (!fh) throw new Error(`OpfsBlobStore: could not create file for hash ${hash}`);
1215
- const writable = await fh.createWritable();
1216
- await writable.write(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
1217
- await writable.close();
1004
+ /**
1005
+ * Soft-delete a note by setting deleted_at to the current timestamp.
1006
+ */
1007
+ async delete(id) {
1008
+ await this.db.query(
1009
+ `UPDATE note SET deleted_at = now(), updated_at = now() WHERE id = $1`,
1010
+ [id]
1011
+ );
1012
+ this.events?.emit("note.deleted", { id });
1218
1013
  }
1219
- async read(hash) {
1220
- const fh = await this.getFileHandle(hash, false);
1221
- if (!fh) return null;
1222
- const file = await fh.getFile();
1223
- const buffer = await file.arrayBuffer();
1224
- return new Uint8Array(buffer);
1014
+ /**
1015
+ * Restore a soft-deleted note by clearing deleted_at.
1016
+ */
1017
+ async restore(id) {
1018
+ await this.db.query(
1019
+ `UPDATE note SET deleted_at = NULL, updated_at = now() WHERE id = $1`,
1020
+ [id]
1021
+ );
1022
+ this.events?.emit("note.restored", { id });
1023
+ return this.get(id);
1225
1024
  }
1226
- async remove(hash) {
1227
- const { dir1, dir2, filename } = hashPath(hash);
1228
- try {
1229
- const root = await this.getRoot();
1230
- const d1 = await root.getDirectoryHandle(dir1, { create: false });
1231
- const d2 = await d1.getDirectoryHandle(dir2, { create: false });
1232
- await d2.removeEntry(filename);
1233
- } catch (err) {
1234
- if (err instanceof DOMException && err.name === "NotFoundError") {
1235
- return;
1236
- }
1237
- throw err;
1238
- }
1025
+ /**
1026
+ * Toggle the is_starred field on a note.
1027
+ */
1028
+ async star(id, starred) {
1029
+ await this.db.query(
1030
+ `UPDATE note SET is_starred = $1, updated_at = now() WHERE id = $2`,
1031
+ [starred, id]
1032
+ );
1033
+ this.events?.emit("note.updated", { id });
1239
1034
  }
1240
- async exists(hash) {
1241
- const fh = await this.getFileHandle(hash, false);
1242
- return fh !== null;
1035
+ /**
1036
+ * Toggle the is_pinned field on a note.
1037
+ */
1038
+ async pin(id, pinned) {
1039
+ await this.db.query(
1040
+ `UPDATE note SET is_pinned = $1, updated_at = now() WHERE id = $2`,
1041
+ [pinned, id]
1042
+ );
1043
+ this.events?.emit("note.updated", { id });
1243
1044
  }
1244
- };
1245
- var IDB_STORE = "blobs";
1246
- var IDB_VERSION = 1;
1247
- function openDb(dbName) {
1248
- return new Promise((resolve, reject) => {
1249
- const req = indexedDB.open(dbName, IDB_VERSION);
1250
- req.onupgradeneeded = () => {
1251
- req.result.createObjectStore(IDB_STORE);
1252
- };
1253
- req.onsuccess = () => resolve(req.result);
1254
- req.onerror = () => reject(req.error);
1255
- });
1256
- }
1257
- var IdbBlobStore = class {
1258
- dbName;
1259
- _db = null;
1260
- constructor(archiveName) {
1261
- this.dbName = `fortemi-${archiveName}-blobs`;
1045
+ /**
1046
+ * Toggle the is_archived field on a note.
1047
+ */
1048
+ async archive(id, archived) {
1049
+ await this.db.query(
1050
+ `UPDATE note SET is_archived = $1, updated_at = now() WHERE id = $2`,
1051
+ [archived, id]
1052
+ );
1053
+ this.events?.emit("note.updated", { id });
1262
1054
  }
1263
- async db() {
1264
- if (!this._db) {
1265
- this._db = await openDb(this.dbName);
1266
- }
1267
- return this._db;
1055
+ /**
1056
+ * Get revision history for a note, ordered by revision_number descending.
1057
+ */
1058
+ async getRevisions(noteId) {
1059
+ const result = await this.db.query(
1060
+ `SELECT id, note_id, revision_number, type, content, ai_metadata, model, created_at
1061
+ FROM note_revision
1062
+ WHERE note_id = $1
1063
+ ORDER BY revision_number DESC`,
1064
+ [noteId]
1065
+ );
1066
+ return result.rows;
1268
1067
  }
1269
- async write(hash, data) {
1270
- const db = await this.db();
1271
- return new Promise((resolve, reject) => {
1272
- const tx = db.transaction(IDB_STORE, "readwrite");
1273
- tx.objectStore(IDB_STORE).put(data, hash);
1274
- tx.oncomplete = () => resolve();
1275
- tx.onerror = () => reject(tx.error);
1276
- });
1068
+ };
1069
+
1070
+ // src/repositories/condition-builder.ts
1071
+ function buildNoteConditions(options, startIdx, includeDeleted = false) {
1072
+ const conditions = [];
1073
+ const params = [];
1074
+ let idx = startIdx;
1075
+ if (!includeDeleted) {
1076
+ conditions.push("n.deleted_at IS NULL");
1277
1077
  }
1278
- async read(hash) {
1279
- const db = await this.db();
1280
- return new Promise((resolve, reject) => {
1281
- const tx = db.transaction(IDB_STORE, "readonly");
1282
- const req = tx.objectStore(IDB_STORE).get(hash);
1283
- req.onsuccess = () => resolve(req.result ?? null);
1284
- req.onerror = () => reject(req.error);
1285
- });
1078
+ if (options.tags?.length) {
1079
+ conditions.push(
1080
+ `EXISTS (SELECT 1 FROM note_tag nt WHERE nt.note_id = n.id AND nt.tag = ANY($${idx++}))`
1081
+ );
1082
+ params.push(options.tags);
1286
1083
  }
1287
- async remove(hash) {
1288
- const db = await this.db();
1289
- return new Promise((resolve, reject) => {
1290
- const tx = db.transaction(IDB_STORE, "readwrite");
1291
- tx.objectStore(IDB_STORE).delete(hash);
1292
- tx.oncomplete = () => resolve();
1293
- tx.onerror = () => reject(tx.error);
1294
- });
1084
+ if (options.collection_id) {
1085
+ conditions.push(
1086
+ `EXISTS (SELECT 1 FROM collection_note cn WHERE cn.note_id = n.id AND cn.collection_id = $${idx++})`
1087
+ );
1088
+ params.push(options.collection_id);
1295
1089
  }
1296
- async exists(hash) {
1297
- const db = await this.db();
1298
- return new Promise((resolve, reject) => {
1299
- const tx = db.transaction(IDB_STORE, "readonly");
1300
- const req = tx.objectStore(IDB_STORE).count(hash);
1301
- req.onsuccess = () => resolve(req.result > 0);
1302
- req.onerror = () => reject(req.error);
1303
- });
1090
+ if (options.date_from) {
1091
+ conditions.push(`n.created_at >= $${idx++}`);
1092
+ params.push(options.date_from);
1304
1093
  }
1305
- };
1306
- var MemoryBlobStore = class {
1307
- store = /* @__PURE__ */ new Map();
1308
- async write(hash, data) {
1309
- this.store.set(hash, data);
1094
+ if (options.date_to) {
1095
+ conditions.push(`n.created_at <= $${idx++}`);
1096
+ params.push(options.date_to);
1310
1097
  }
1311
- async read(hash) {
1312
- return this.store.get(hash) ?? null;
1098
+ if (options.is_starred !== void 0) {
1099
+ conditions.push(`n.is_starred = $${idx++}`);
1100
+ params.push(options.is_starred);
1313
1101
  }
1314
- async remove(hash) {
1315
- this.store.delete(hash);
1102
+ if (options.is_archived !== void 0) {
1103
+ conditions.push(`n.is_archived = $${idx++}`);
1104
+ params.push(options.is_archived);
1316
1105
  }
1317
- async exists(hash) {
1318
- return this.store.has(hash);
1106
+ if (options.format) {
1107
+ conditions.push(`n.format = $${idx++}`);
1108
+ params.push(options.format);
1319
1109
  }
1320
- };
1321
- function createBlobStore(archiveName) {
1322
- if (typeof navigator !== "undefined" && "storage" in navigator && "getDirectory" in navigator.storage) {
1323
- return new OpfsBlobStore(archiveName);
1110
+ if (options.source) {
1111
+ conditions.push(`n.source = $${idx++}`);
1112
+ params.push(options.source);
1324
1113
  }
1325
- return new IdbBlobStore(archiveName);
1114
+ if (options.visibility) {
1115
+ conditions.push(`n.visibility = $${idx++}`);
1116
+ params.push(options.visibility);
1117
+ }
1118
+ return { conditions, params, nextIdx: idx };
1326
1119
  }
1327
1120
 
1328
- // src/repositories/notes-repository.ts
1329
- var NotesRepository = class {
1330
- constructor(db, events) {
1121
+ // src/repositories/embedding-sets-repository.ts
1122
+ var DEFAULT_COMPATIBILITY = {
1123
+ model: "require-same",
1124
+ dimension: "require-same",
1125
+ duplicateVectors: "prefer-set-order",
1126
+ missingVectors: "omit"
1127
+ };
1128
+ function jsonParam(value) {
1129
+ return value == null ? null : JSON.stringify(value);
1130
+ }
1131
+ function asObject(value) {
1132
+ if (value == null) return null;
1133
+ if (typeof value === "string") return JSON.parse(value);
1134
+ return value;
1135
+ }
1136
+ function dateString(value) {
1137
+ if (!value) return void 0;
1138
+ return value instanceof Date ? value.toISOString() : value;
1139
+ }
1140
+ function dateMillis(value) {
1141
+ return value instanceof Date ? value.getTime() : new Date(value).getTime();
1142
+ }
1143
+ function hashJson(value) {
1144
+ return computeHash(new TextEncoder().encode(JSON.stringify(value)));
1145
+ }
1146
+ var EmbeddingSetsRepository = class {
1147
+ constructor(db) {
1331
1148
  this.db = db;
1332
- this.events = events;
1333
1149
  }
1334
- /**
1335
- * Create a new note with its original content record, current revision,
1336
- * optional tags, and an auto-queued title_generation job when no title
1337
- * is provided.
1338
- *
1339
- * All writes happen in a single transaction.
1340
- */
1341
1150
  async create(input) {
1342
- const noteId = generateId();
1343
- const originalId = generateId();
1344
- const contentHash = computeHash(new TextEncoder().encode(input.content));
1345
- await this.db.transaction(async (tx) => {
1346
- await tx.query(
1347
- `INSERT INTO note (id, archive_id, title, format, source, visibility)
1348
- VALUES ($1, $2, $3, $4, $5, $6)`,
1349
- [
1350
- noteId,
1351
- input.archive_id ?? null,
1352
- input.title ?? null,
1353
- input.format ?? "markdown",
1354
- input.source ?? "user",
1355
- input.visibility ?? "private"
1356
- ]
1357
- );
1358
- await tx.query(
1359
- `INSERT INTO note_original (id, note_id, content, content_hash)
1360
- VALUES ($1, $2, $3, $4)`,
1361
- [originalId, noteId, input.content, contentHash]
1362
- );
1363
- await tx.query(
1364
- `INSERT INTO note_revised_current (note_id, content)
1365
- VALUES ($1, $2)`,
1366
- [noteId, input.content]
1367
- );
1368
- if (input.tags?.length) {
1369
- for (const tag of input.tags) {
1370
- await tx.query(
1371
- `INSERT INTO note_tag (id, note_id, tag) VALUES ($1, $2, $3)`,
1372
- [generateId(), noteId, tag]
1373
- );
1374
- }
1375
- }
1376
- await tx.query(
1377
- `INSERT INTO job_queue (id, note_id, job_type, status, priority, required_capability)
1378
- VALUES ($1, $2, 'ai_revision', 'pending', 1, 'llm')`,
1379
- [generateId(), noteId]
1380
- );
1381
- if (!input.title) {
1382
- await tx.query(
1383
- `INSERT INTO job_queue (id, note_id, job_type, status, priority)
1384
- VALUES ($1, $2, 'title_generation', 'pending', 2)`,
1385
- [generateId(), noteId]
1386
- );
1387
- }
1388
- await tx.query(
1389
- `INSERT INTO job_queue (id, note_id, job_type, status, priority, required_capability)
1390
- VALUES ($1, $2, 'embedding', 'pending', 3, 'semantic')`,
1391
- [generateId(), noteId]
1392
- );
1393
- });
1394
- this.events?.emit("note.created", { id: noteId });
1395
- return this.get(noteId);
1151
+ const id = input.id ?? generateId();
1152
+ await this.db.query(
1153
+ `INSERT INTO embedding_set (
1154
+ id, name, purpose, model_name, dimensions, kind, mode,
1155
+ truncate_dimension, criteria_json
1156
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
1157
+ [
1158
+ id,
1159
+ input.name,
1160
+ input.purpose ?? null,
1161
+ input.model_name ?? "all-MiniLM-L6-v2",
1162
+ input.dimensions ?? 384,
1163
+ input.kind ?? "physical",
1164
+ input.mode ?? null,
1165
+ input.truncate_dimension ?? null,
1166
+ jsonParam(input.criteria ?? null)
1167
+ ]
1168
+ );
1169
+ return this.get(id);
1396
1170
  }
1397
- /**
1398
- * Fetch a single note by its ID.
1399
- * Returns NoteFull which includes original content, current revision, and tags.
1400
- * Throws when the note does not exist.
1401
- */
1402
- async get(id) {
1403
- const noteResult = await this.db.query(
1404
- `SELECT n.id, n.archive_id, n.title, n.format, n.source, n.visibility,
1405
- n.revision_mode, n.is_starred, n.is_pinned, n.is_archived,
1406
- n.created_at, n.updated_at, n.deleted_at,
1407
- o.id AS original_id,
1408
- o.content AS original_content,
1409
- o.content_hash,
1410
- o.created_at AS original_created_at,
1411
- c.content AS current_content,
1412
- c.ai_metadata,
1413
- c.generation_count,
1414
- c.model,
1415
- c.is_user_edited,
1416
- c.updated_at AS current_updated_at
1417
- FROM note n
1418
- LEFT JOIN note_original o ON o.note_id = n.id
1419
- LEFT JOIN note_revised_current c ON c.note_id = n.id
1420
- WHERE n.id = $1`,
1421
- [id]
1171
+ async createVirtualDefinition(input) {
1172
+ const id = input.id ?? generateId();
1173
+ await this.db.query(
1174
+ `INSERT INTO embedding_set (
1175
+ id, name, purpose, model_name, dimensions, kind, mode,
1176
+ source_json, compatibility_json, materialization_json, freshness_json, created_at, updated_at
1177
+ ) VALUES ($1, $2, $3, $4, $5, 'virtual', 'auto', $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb,
1178
+ COALESCE($10::timestamptz, now()), COALESCE($11::timestamptz, now()))`,
1179
+ [
1180
+ id,
1181
+ input.name,
1182
+ input.purpose ?? null,
1183
+ this.inferDefinitionModel(input) ?? "virtual",
1184
+ this.inferDefinitionDimension(input) ?? 0,
1185
+ jsonParam(input.source),
1186
+ jsonParam(input.compatibility),
1187
+ jsonParam(input.materialization ?? null),
1188
+ jsonParam({ status: input.materialization?.freshness ?? "unknown" }),
1189
+ input.createdAt ?? null,
1190
+ input.updatedAt ?? null
1191
+ ]
1422
1192
  );
1423
- if (noteResult.rows.length === 0) {
1424
- throw new Error(`Note not found: ${id}`);
1193
+ const row = await this.get(id);
1194
+ if (input.materialization?.allowed) {
1195
+ await this.refreshMaterializedVirtualSet(id);
1196
+ return this.get(id);
1425
1197
  }
1426
- const row = noteResult.rows[0];
1427
- const tagsResult = await this.db.query(
1428
- `SELECT tag FROM note_tag WHERE note_id = $1 ORDER BY tag`,
1198
+ return row;
1199
+ }
1200
+ async ensureDefault() {
1201
+ const existing = await this.db.query(
1202
+ `SELECT * FROM embedding_set WHERE name = $1 AND model_name = $2 AND kind = 'physical' ORDER BY created_at LIMIT 1`,
1203
+ ["Full content", "all-MiniLM-L6-v2"]
1204
+ );
1205
+ if (existing.rows.length > 0) return existing.rows[0];
1206
+ return this.create({
1207
+ name: "Full content",
1208
+ purpose: "Semantic search over full revised note content",
1209
+ model_name: "all-MiniLM-L6-v2",
1210
+ dimensions: 384,
1211
+ kind: "physical"
1212
+ });
1213
+ }
1214
+ async get(id) {
1215
+ const result = await this.db.query(
1216
+ `SELECT * FROM embedding_set WHERE id = $1`,
1429
1217
  [id]
1430
1218
  );
1219
+ if (result.rows.length === 0) throw new Error(`Embedding set not found: ${id}`);
1220
+ return result.rows[0];
1221
+ }
1222
+ async list() {
1223
+ const result = await this.db.query(
1224
+ `SELECT * FROM embedding_set ORDER BY created_at, name`
1225
+ );
1226
+ return result.rows;
1227
+ }
1228
+ async listDescriptors() {
1229
+ const rows = await this.list();
1230
+ return rows.map((row) => this.toDescriptor(row));
1231
+ }
1232
+ toDescriptor(row) {
1431
1233
  return {
1432
1234
  id: row.id,
1433
- archive_id: row.archive_id,
1434
- title: row.title,
1435
- format: row.format,
1436
- source: row.source,
1437
- visibility: row.visibility,
1438
- revision_mode: row.revision_mode,
1439
- is_starred: row.is_starred,
1440
- is_pinned: row.is_pinned,
1441
- is_archived: row.is_archived,
1442
- created_at: row.created_at,
1443
- updated_at: row.updated_at,
1444
- deleted_at: row.deleted_at,
1445
- tags: tagsResult.rows.map((r) => r.tag),
1446
- original: {
1447
- id: row.original_id,
1448
- content: row.original_content,
1449
- content_hash: row.content_hash,
1450
- created_at: row.original_created_at
1451
- },
1452
- current: {
1453
- content: row.current_content,
1454
- ai_metadata: row.ai_metadata,
1455
- generation_count: row.generation_count,
1456
- model: row.model,
1457
- is_user_edited: row.is_user_edited,
1458
- updated_at: row.current_updated_at
1459
- }
1235
+ name: row.name,
1236
+ purpose: row.purpose,
1237
+ kind: row.kind,
1238
+ mode: row.mode ?? void 0,
1239
+ model: row.model_name,
1240
+ dimension: row.dimensions,
1241
+ truncateDimension: row.truncate_dimension,
1242
+ criteria: asObject(row.criteria_json),
1243
+ createdAt: dateString(row.created_at),
1244
+ updatedAt: dateString(row.updated_at),
1245
+ freshness: asObject(row.freshness_json) ?? { status: "fresh" }
1460
1246
  };
1461
1247
  }
1462
- /**
1463
- * List notes with optional filtering, sorting, and pagination.
1464
- * Excludes soft-deleted notes by default (pass include_deleted: true to override).
1465
- */
1466
- async list(options = {}) {
1467
- const {
1468
- limit = 50,
1469
- offset = 0,
1470
- sort = "created_at",
1471
- order = "desc",
1472
- is_starred,
1473
- is_pinned,
1474
- is_archived,
1475
- include_deleted = false,
1476
- collection_id,
1477
- tags
1478
- } = options;
1479
- const conditions = [];
1480
- const params = [];
1481
- let paramIndex = 1;
1482
- if (!include_deleted) {
1483
- conditions.push("n.deleted_at IS NULL");
1484
- }
1485
- if (is_starred !== void 0) {
1486
- conditions.push(`n.is_starred = $${paramIndex++}`);
1487
- params.push(is_starred);
1488
- }
1489
- if (is_pinned !== void 0) {
1490
- conditions.push(`n.is_pinned = $${paramIndex++}`);
1491
- params.push(is_pinned);
1492
- }
1493
- if (is_archived !== void 0) {
1494
- conditions.push(`n.is_archived = $${paramIndex++}`);
1495
- params.push(is_archived);
1496
- }
1497
- if (collection_id) {
1498
- conditions.push(
1499
- `EXISTS (SELECT 1 FROM collection_note cn WHERE cn.note_id = n.id AND cn.collection_id = $${paramIndex++})`
1500
- );
1501
- params.push(collection_id);
1248
+ async putEmbedding(input) {
1249
+ const set = await this.get(input.embedding_set_id);
1250
+ if (set.kind === "virtual") {
1251
+ throw new Error(`Cannot store vectors directly in virtual embedding set: ${set.id}`);
1502
1252
  }
1503
- if (tags?.length) {
1504
- conditions.push(
1505
- `EXISTS (SELECT 1 FROM note_tag nt WHERE nt.note_id = n.id AND nt.tag = ANY($${paramIndex++}))`
1253
+ if (input.vector.length !== set.dimensions) {
1254
+ throw new Error(
1255
+ `Embedding vector has ${input.vector.length} dimensions; set ${set.id} expects ${set.dimensions}`
1506
1256
  );
1507
- params.push(tags);
1508
1257
  }
1509
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1510
- const validSorts = ["created_at", "updated_at", "title"];
1511
- const sortCol = validSorts.includes(sort) ? sort : "created_at";
1512
- const sortDir = order === "asc" ? "ASC" : "DESC";
1513
- const countResult = await this.db.query(
1514
- `SELECT COUNT(*) AS count FROM note n ${where}`,
1515
- params
1258
+ await this.db.query(
1259
+ `DELETE FROM embedding_set_member WHERE note_id = $1 AND embedding_set_id = $2`,
1260
+ [input.note_id, input.embedding_set_id]
1516
1261
  );
1517
- const total = parseInt(countResult.rows[0].count, 10);
1518
- const listParams = [...params, limit, offset];
1519
- const rowsResult = await this.db.query(
1520
- `SELECT n.id, n.title, n.format, n.source, n.visibility,
1521
- n.is_starred, n.is_pinned, n.is_archived,
1522
- n.created_at, n.updated_at, n.deleted_at
1523
- FROM note n ${where}
1524
- ORDER BY n.${sortCol} ${sortDir}
1525
- LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
1526
- listParams
1262
+ await this.db.query(
1263
+ `DELETE FROM embedding WHERE note_id = $1 AND embedding_set_id = $2`,
1264
+ [input.note_id, input.embedding_set_id]
1527
1265
  );
1528
- const noteIds = rowsResult.rows.map((r) => r.id);
1529
- const tagMap = /* @__PURE__ */ new Map();
1530
- if (noteIds.length > 0) {
1531
- const tagsResult = await this.db.query(
1532
- `SELECT note_id, tag FROM note_tag WHERE note_id = ANY($1) ORDER BY tag`,
1533
- [noteIds]
1534
- );
1535
- for (const row of tagsResult.rows) {
1536
- const existing = tagMap.get(row.note_id) ?? [];
1537
- existing.push(row.tag);
1538
- tagMap.set(row.note_id, existing);
1266
+ const embeddingId = input.id ?? generateId();
1267
+ const vector2 = `[${input.vector.join(",")}]`;
1268
+ await this.db.query(
1269
+ `INSERT INTO embedding (id, note_id, embedding_set_id, vector)
1270
+ VALUES ($1, $2, $3, $4::vector)`,
1271
+ [embeddingId, input.note_id, input.embedding_set_id, vector2]
1272
+ );
1273
+ await this.db.query(
1274
+ `INSERT INTO embedding_set_member (embedding_set_id, note_id, embedding_id)
1275
+ VALUES ($1, $2, $3)`,
1276
+ [input.embedding_set_id, input.note_id, embeddingId]
1277
+ );
1278
+ return { id: embeddingId };
1279
+ }
1280
+ async resolveSelector(selector) {
1281
+ if (selector.kind === "default") {
1282
+ const set = await this.ensureDefault();
1283
+ return this.resolvePhysicalSet({ kind: "embedding-set", embeddingSetId: set.id }, set.id);
1284
+ }
1285
+ if (selector.kind === "embedding-set") {
1286
+ if (!selector.embeddingSetId) throw new Error("embedding-set selector requires embeddingSetId");
1287
+ const set = await this.get(selector.embeddingSetId);
1288
+ if (set.kind === "virtual") {
1289
+ const definition = this.definitionFromRow(set);
1290
+ return this.resolveDefinition({ kind: "embedding-set", embeddingSetId: set.id }, definition, set);
1539
1291
  }
1292
+ return this.resolvePhysicalSet(selector, set.id);
1540
1293
  }
1541
- const items = rowsResult.rows.map((r) => ({
1542
- id: r.id,
1543
- title: r.title,
1544
- format: r.format,
1545
- source: r.source,
1546
- visibility: r.visibility,
1547
- is_starred: r.is_starred,
1548
- is_pinned: r.is_pinned,
1549
- is_archived: r.is_archived,
1550
- created_at: r.created_at,
1551
- updated_at: r.updated_at,
1552
- deleted_at: r.deleted_at,
1553
- tags: tagMap.get(r.id) ?? []
1554
- }));
1555
- return { items, total, limit, offset };
1294
+ if (!selector.definition) throw new Error("virtual-definition selector requires definition");
1295
+ return this.resolveDefinition(selector, selector.definition);
1556
1296
  }
1557
- /**
1558
- * Update mutable note fields.
1559
- * When content changes, the previous current content is saved as a numbered
1560
- * revision before the new content is applied.
1561
- */
1562
- async update(id, input) {
1563
- const countResult = await this.db.query(
1564
- `SELECT COUNT(*) AS count FROM note_revision WHERE note_id = $1`,
1565
- [id]
1566
- );
1567
- const nextRevision = parseInt(countResult.rows[0].count, 10) + 1;
1568
- await this.db.transaction(async (tx) => {
1569
- const setClauses = ["updated_at = now()"];
1570
- const noteParams = [];
1571
- let paramIdx = 1;
1572
- if (input.title !== void 0) {
1573
- setClauses.push(`title = $${paramIdx++}`);
1574
- noteParams.push(input.title);
1575
- }
1576
- if (input.format !== void 0) {
1577
- setClauses.push(`format = $${paramIdx++}`);
1578
- noteParams.push(input.format);
1579
- }
1580
- if (input.visibility !== void 0) {
1581
- setClauses.push(`visibility = $${paramIdx++}`);
1582
- noteParams.push(input.visibility);
1583
- }
1584
- noteParams.push(id);
1585
- await tx.query(
1586
- `UPDATE note SET ${setClauses.join(", ")} WHERE id = $${paramIdx}`,
1587
- noteParams
1297
+ async refreshMaterializedVirtualSet(setId) {
1298
+ const set = await this.get(setId);
1299
+ if (set.kind !== "virtual") throw new Error(`Embedding set is not virtual: ${setId}`);
1300
+ const definition = this.definitionFromRow(set);
1301
+ if (!definition.materialization?.allowed) {
1302
+ throw new Error(`Virtual embedding set does not allow materialization: ${setId}`);
1303
+ }
1304
+ const live = await this.resolveDefinition(
1305
+ { kind: "embedding-set", embeddingSetId: setId },
1306
+ definition,
1307
+ set,
1308
+ { forceLive: true }
1309
+ );
1310
+ await this.db.query(`DELETE FROM embedding_set_member WHERE embedding_set_id = $1`, [setId]);
1311
+ for (const row of live.rows) {
1312
+ await this.db.query(
1313
+ `INSERT INTO embedding_set_member (embedding_set_id, note_id, embedding_id)
1314
+ VALUES ($1, $2, $3)
1315
+ ON CONFLICT DO NOTHING`,
1316
+ [setId, row.note_id, row.embedding_id]
1588
1317
  );
1589
- if (input.content !== void 0) {
1590
- const currentResult = await tx.query(
1591
- `SELECT content FROM note_revised_current WHERE note_id = $1`,
1592
- [id]
1593
- );
1594
- if (currentResult.rows.length > 0) {
1595
- await tx.query(
1596
- `INSERT INTO note_revision (id, note_id, revision_number, type, content)
1597
- VALUES ($1, $2, $3, 'user', $4)`,
1598
- [generateId(), id, nextRevision, currentResult.rows[0].content]
1599
- );
1600
- }
1601
- await tx.query(
1602
- `UPDATE note_revised_current
1603
- SET content = $1, is_user_edited = true, updated_at = now()
1604
- WHERE note_id = $2`,
1605
- [input.content, id]
1606
- );
1607
- }
1608
- });
1609
- this.events?.emit("note.updated", { id });
1610
- return this.get(id);
1611
- }
1612
- /**
1613
- * Soft-delete a note by setting deleted_at to the current timestamp.
1614
- */
1615
- async delete(id) {
1318
+ }
1319
+ const inputHash = this.resolutionInputHash(definition, live.rows);
1320
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1321
+ const materialization = {
1322
+ ...definition.materialization,
1323
+ allowed: true,
1324
+ includeResolvedMembers: true,
1325
+ freshness: "fresh",
1326
+ inputHash,
1327
+ generatedAt,
1328
+ resolvedMemberCount: live.rows.length
1329
+ };
1330
+ const freshness = {
1331
+ status: "fresh",
1332
+ sourceHash: inputHash,
1333
+ checkedAt: generatedAt
1334
+ };
1616
1335
  await this.db.query(
1617
- `UPDATE note SET deleted_at = now(), updated_at = now() WHERE id = $1`,
1618
- [id]
1336
+ `UPDATE embedding_set
1337
+ SET materialization_json = $2::jsonb, freshness_json = $3::jsonb, updated_at = now()
1338
+ WHERE id = $1`,
1339
+ [setId, jsonParam(materialization), jsonParam(freshness)]
1619
1340
  );
1620
- this.events?.emit("note.deleted", { id });
1621
- }
1622
- /**
1623
- * Restore a soft-deleted note by clearing deleted_at.
1624
- */
1625
- async restore(id) {
1626
- await this.db.query(
1627
- `UPDATE note SET deleted_at = NULL, updated_at = now() WHERE id = $1`,
1628
- [id]
1341
+ return this.finalizeResolution(
1342
+ { kind: "embedding-set", embeddingSetId: setId },
1343
+ live.rows,
1344
+ live.errors,
1345
+ definition.compatibility,
1346
+ "fresh",
1347
+ "materialized"
1629
1348
  );
1630
- this.events?.emit("note.restored", { id });
1631
- return this.get(id);
1632
1349
  }
1633
- /**
1634
- * Toggle the is_starred field on a note.
1635
- */
1636
- async star(id, starred) {
1350
+ async markVirtualSetStale(setId, reason) {
1351
+ const set = await this.get(setId);
1352
+ if (set.kind !== "virtual") throw new Error(`Embedding set is not virtual: ${setId}`);
1353
+ const definition = this.definitionFromRow(set);
1354
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1355
+ const materialization = definition.materialization ? { ...definition.materialization, freshness: "stale" } : null;
1637
1356
  await this.db.query(
1638
- `UPDATE note SET is_starred = $1, updated_at = now() WHERE id = $2`,
1639
- [starred, id]
1357
+ `UPDATE embedding_set
1358
+ SET materialization_json = $2::jsonb, freshness_json = $3::jsonb, updated_at = now()
1359
+ WHERE id = $1`,
1360
+ [
1361
+ setId,
1362
+ jsonParam(materialization),
1363
+ jsonParam({ status: "stale", sourceHash: definition.materialization?.inputHash, checkedAt: now, reason })
1364
+ ]
1640
1365
  );
1641
- this.events?.emit("note.updated", { id });
1642
1366
  }
1643
- /**
1644
- * Toggle the is_pinned field on a note.
1645
- */
1646
- async pin(id, pinned) {
1647
- await this.db.query(
1648
- `UPDATE note SET is_pinned = $1, updated_at = now() WHERE id = $2`,
1649
- [pinned, id]
1650
- );
1651
- this.events?.emit("note.updated", { id });
1367
+ async resolveDefinition(selector, definition, set, options = {}) {
1368
+ if (!options.forceLive && set && definition.materialization?.allowed && definition.materialization.freshness === "fresh") {
1369
+ const materialized = await this.resolveMaterializedRows(set.id);
1370
+ if (materialized.length > 0 || definition.materialization.resolvedMemberCount === 0) {
1371
+ return this.finalizeResolution(
1372
+ selector,
1373
+ materialized,
1374
+ [],
1375
+ definition.compatibility,
1376
+ "fresh",
1377
+ "materialized"
1378
+ );
1379
+ }
1380
+ }
1381
+ let rows;
1382
+ const errors = [];
1383
+ switch (definition.source.type) {
1384
+ case "criteria":
1385
+ rows = await this.resolveCriteriaSource(definition.source);
1386
+ break;
1387
+ case "set-operation":
1388
+ rows = await this.resolveSetOperationSource(definition.source, definition.compatibility, errors);
1389
+ break;
1390
+ case "fallback":
1391
+ rows = await this.resolveFallbackSource(definition.source.preferredSetIds, definition.compatibility, errors);
1392
+ break;
1393
+ case "latest-compatible":
1394
+ rows = await this.resolveLatestCompatibleSource(definition.source, definition.compatibility, errors);
1395
+ break;
1396
+ case "snapshot":
1397
+ rows = await this.resolvePhysicalRows(definition.source.snapshotId);
1398
+ break;
1399
+ default:
1400
+ rows = [];
1401
+ }
1402
+ return this.finalizeResolution(selector, rows, errors, definition.compatibility, definition.materialization?.freshness ?? "unknown", "live");
1652
1403
  }
1653
- /**
1654
- * Toggle the is_archived field on a note.
1655
- */
1656
- async archive(id, archived) {
1657
- await this.db.query(
1658
- `UPDATE note SET is_archived = $1, updated_at = now() WHERE id = $2`,
1659
- [archived, id]
1660
- );
1661
- this.events?.emit("note.updated", { id });
1404
+ async resolvePhysicalSet(selector, setId) {
1405
+ return this.finalizeResolution(selector, await this.resolvePhysicalRows(setId), [], DEFAULT_COMPATIBILITY, "fresh", "live");
1662
1406
  }
1663
- /**
1664
- * Get revision history for a note, ordered by revision_number descending.
1665
- */
1666
- async getRevisions(noteId) {
1407
+ async resolvePhysicalRows(setId) {
1667
1408
  const result = await this.db.query(
1668
- `SELECT id, note_id, revision_number, type, content, ai_metadata, model, created_at
1669
- FROM note_revision
1670
- WHERE note_id = $1
1671
- ORDER BY revision_number DESC`,
1672
- [noteId]
1409
+ `SELECT note_id, embedding_set_id, id as embedding_id, vector::text as vector, created_at
1410
+ FROM embedding
1411
+ WHERE embedding_set_id = $1
1412
+ ORDER BY note_id, created_at DESC`,
1413
+ [setId]
1673
1414
  );
1674
1415
  return result.rows;
1675
1416
  }
1676
- };
1677
-
1678
- // src/repositories/condition-builder.ts
1679
- function buildNoteConditions(options, startIdx, includeDeleted = false) {
1680
- const conditions = [];
1681
- const params = [];
1682
- let idx = startIdx;
1683
- if (!includeDeleted) {
1684
- conditions.push("n.deleted_at IS NULL");
1685
- }
1686
- if (options.tags?.length) {
1687
- conditions.push(
1688
- `EXISTS (SELECT 1 FROM note_tag nt WHERE nt.note_id = n.id AND nt.tag = ANY($${idx++}))`
1689
- );
1690
- params.push(options.tags);
1691
- }
1692
- if (options.collection_id) {
1693
- conditions.push(
1694
- `EXISTS (SELECT 1 FROM collection_note cn WHERE cn.note_id = n.id AND cn.collection_id = $${idx++})`
1417
+ async resolveMaterializedRows(setId) {
1418
+ const result = await this.db.query(
1419
+ `SELECT e.note_id, e.embedding_set_id, e.id as embedding_id, e.vector::text as vector, e.created_at
1420
+ FROM embedding_set_member m
1421
+ JOIN embedding e ON e.id = m.embedding_id
1422
+ WHERE m.embedding_set_id = $1
1423
+ ORDER BY e.note_id, e.created_at DESC`,
1424
+ [setId]
1695
1425
  );
1696
- params.push(options.collection_id);
1697
- }
1698
- if (options.date_from) {
1699
- conditions.push(`n.created_at >= $${idx++}`);
1700
- params.push(options.date_from);
1701
- }
1702
- if (options.date_to) {
1703
- conditions.push(`n.created_at <= $${idx++}`);
1704
- params.push(options.date_to);
1705
- }
1706
- if (options.is_starred !== void 0) {
1707
- conditions.push(`n.is_starred = $${idx++}`);
1708
- params.push(options.is_starred);
1709
- }
1710
- if (options.is_archived !== void 0) {
1711
- conditions.push(`n.is_archived = $${idx++}`);
1712
- params.push(options.is_archived);
1713
- }
1714
- if (options.format) {
1715
- conditions.push(`n.format = $${idx++}`);
1716
- params.push(options.format);
1717
- }
1718
- if (options.source) {
1719
- conditions.push(`n.source = $${idx++}`);
1720
- params.push(options.source);
1721
- }
1722
- if (options.visibility) {
1723
- conditions.push(`n.visibility = $${idx++}`);
1724
- params.push(options.visibility);
1426
+ return result.rows;
1725
1427
  }
1726
- return { conditions, params, nextIdx: idx };
1727
- }
1728
-
1729
- // src/repositories/embedding-sets-repository.ts
1730
- var DEFAULT_COMPATIBILITY = {
1731
- model: "require-same",
1732
- dimension: "require-same",
1733
- duplicateVectors: "prefer-set-order",
1734
- missingVectors: "omit"
1735
- };
1736
- function jsonParam(value) {
1737
- return value == null ? null : JSON.stringify(value);
1738
- }
1739
- function asObject(value) {
1740
- if (value == null) return null;
1741
- if (typeof value === "string") return JSON.parse(value);
1742
- return value;
1743
- }
1744
- function dateString(value) {
1745
- if (!value) return void 0;
1746
- return value instanceof Date ? value.toISOString() : value;
1747
- }
1748
- function dateMillis(value) {
1749
- return value instanceof Date ? value.getTime() : new Date(value).getTime();
1750
- }
1751
- function hashJson(value) {
1752
- return computeHash(new TextEncoder().encode(JSON.stringify(value)));
1753
- }
1754
- var EmbeddingSetsRepository = class {
1755
- constructor(db) {
1756
- this.db = db;
1757
- }
1758
- async create(input) {
1759
- const id = input.id ?? generateId();
1760
- await this.db.query(
1761
- `INSERT INTO embedding_set (
1762
- id, name, purpose, model_name, dimensions, kind, mode,
1763
- truncate_dimension, criteria_json
1764
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
1765
- [
1766
- id,
1767
- input.name,
1768
- input.purpose ?? null,
1769
- input.model_name ?? "all-MiniLM-L6-v2",
1770
- input.dimensions ?? 384,
1771
- input.kind ?? "physical",
1772
- input.mode ?? null,
1773
- input.truncate_dimension ?? null,
1774
- jsonParam(input.criteria ?? null)
1775
- ]
1776
- );
1777
- return this.get(id);
1778
- }
1779
- async createVirtualDefinition(input) {
1780
- const id = input.id ?? generateId();
1781
- await this.db.query(
1782
- `INSERT INTO embedding_set (
1783
- id, name, purpose, model_name, dimensions, kind, mode,
1784
- source_json, compatibility_json, materialization_json, freshness_json, created_at, updated_at
1785
- ) VALUES ($1, $2, $3, $4, $5, 'virtual', 'auto', $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb,
1786
- COALESCE($10::timestamptz, now()), COALESCE($11::timestamptz, now()))`,
1787
- [
1788
- id,
1789
- input.name,
1790
- input.purpose ?? null,
1791
- this.inferDefinitionModel(input) ?? "virtual",
1792
- this.inferDefinitionDimension(input) ?? 0,
1793
- jsonParam(input.source),
1794
- jsonParam(input.compatibility),
1795
- jsonParam(input.materialization ?? null),
1796
- jsonParam({ status: input.materialization?.freshness ?? "unknown" }),
1797
- input.createdAt ?? null,
1798
- input.updatedAt ?? null
1799
- ]
1800
- );
1801
- const row = await this.get(id);
1802
- if (input.materialization?.allowed) {
1803
- await this.refreshMaterializedVirtualSet(id);
1804
- return this.get(id);
1428
+ async resolveCriteriaSource(source) {
1429
+ const criteria = source.criteria;
1430
+ if (criteria.conceptIds && criteria.conceptIds.length > 0) {
1431
+ throw new Error("Unsupported virtual embedding-set criteria field: conceptIds");
1432
+ }
1433
+ const conditions = ["e.embedding_set_id = $1"];
1434
+ const params = [source.baseSetId];
1435
+ let idx = 2;
1436
+ if (criteria.noteIds?.length) {
1437
+ conditions.push(`n.id = ANY($${idx++})`);
1438
+ params.push(criteria.noteIds);
1439
+ }
1440
+ if (criteria.tags?.length) {
1441
+ conditions.push(`EXISTS (SELECT 1 FROM note_tag nt WHERE nt.note_id = n.id AND nt.tag = ANY($${idx++}))`);
1442
+ params.push(criteria.tags);
1443
+ }
1444
+ if (criteria.collectionIds?.length) {
1445
+ conditions.push(`EXISTS (SELECT 1 FROM collection_note cn WHERE cn.note_id = n.id AND cn.collection_id = ANY($${idx++}))`);
1446
+ params.push(criteria.collectionIds);
1447
+ }
1448
+ if (criteria.sources?.length) {
1449
+ conditions.push(`n.source = ANY($${idx++})`);
1450
+ params.push(criteria.sources);
1451
+ }
1452
+ if (criteria.formats?.length) {
1453
+ conditions.push(`n.format = ANY($${idx++})`);
1454
+ params.push(criteria.formats);
1455
+ }
1456
+ if (criteria.visibilities?.length) {
1457
+ conditions.push(`n.visibility = ANY($${idx++})`);
1458
+ params.push(criteria.visibilities);
1459
+ }
1460
+ if (criteria.isStarred !== void 0) {
1461
+ conditions.push(`n.is_starred = $${idx++}`);
1462
+ params.push(criteria.isStarred);
1463
+ }
1464
+ if (criteria.isArchived !== void 0) {
1465
+ conditions.push(`n.is_archived = $${idx++}`);
1466
+ params.push(criteria.isArchived);
1467
+ }
1468
+ if (criteria.hasTitle !== void 0) {
1469
+ conditions.push(criteria.hasTitle ? `n.title IS NOT NULL AND n.title <> ''` : `(n.title IS NULL OR n.title = '')`);
1470
+ }
1471
+ if (criteria.hasEmbedding === false) {
1472
+ conditions.push("FALSE");
1473
+ }
1474
+ if (criteria.isUserEdited !== void 0) {
1475
+ conditions.push(`COALESCE(c.is_user_edited, false) = $${idx++}`);
1476
+ params.push(criteria.isUserEdited);
1477
+ }
1478
+ if (criteria.hasAiMetadata !== void 0) {
1479
+ conditions.push(criteria.hasAiMetadata ? `c.ai_metadata IS NOT NULL` : `c.ai_metadata IS NULL`);
1480
+ }
1481
+ if (criteria.hasRevisions !== void 0) {
1482
+ conditions.push(criteria.hasRevisions ? `EXISTS (SELECT 1 FROM note_revision nr WHERE nr.note_id = n.id)` : `NOT EXISTS (SELECT 1 FROM note_revision nr WHERE nr.note_id = n.id)`);
1483
+ }
1484
+ if (criteria.minGenerationCount !== void 0) {
1485
+ conditions.push(`COALESCE(c.generation_count, 0) >= $${idx++}`);
1486
+ params.push(criteria.minGenerationCount);
1487
+ }
1488
+ if (criteria.maxGenerationCount !== void 0) {
1489
+ conditions.push(`COALESCE(c.generation_count, 0) <= $${idx++}`);
1490
+ params.push(criteria.maxGenerationCount);
1491
+ }
1492
+ if (criteria.updatedAfter) {
1493
+ conditions.push(`n.updated_at >= $${idx++}`);
1494
+ params.push(criteria.updatedAfter);
1495
+ }
1496
+ if (criteria.updatedBefore) {
1497
+ conditions.push(`n.updated_at <= $${idx++}`);
1498
+ params.push(criteria.updatedBefore);
1499
+ }
1500
+ if (criteria.query?.trim()) {
1501
+ conditions.push(`(n.tsv @@ plainto_tsquery('english', $${idx}) OR to_tsvector('english', coalesce(c.content, '')) @@ plainto_tsquery('english', $${idx}))`);
1502
+ params.push(criteria.query);
1805
1503
  }
1806
- return row;
1807
- }
1808
- async ensureDefault() {
1809
- const existing = await this.db.query(
1810
- `SELECT * FROM embedding_set WHERE name = $1 AND model_name = $2 AND kind = 'physical' ORDER BY created_at LIMIT 1`,
1811
- ["Full content", "all-MiniLM-L6-v2"]
1812
- );
1813
- if (existing.rows.length > 0) return existing.rows[0];
1814
- return this.create({
1815
- name: "Full content",
1816
- purpose: "Semantic search over full revised note content",
1817
- model_name: "all-MiniLM-L6-v2",
1818
- dimensions: 384,
1819
- kind: "physical"
1820
- });
1821
- }
1822
- async get(id) {
1823
- const result = await this.db.query(
1824
- `SELECT * FROM embedding_set WHERE id = $1`,
1825
- [id]
1826
- );
1827
- if (result.rows.length === 0) throw new Error(`Embedding set not found: ${id}`);
1828
- return result.rows[0];
1829
- }
1830
- async list() {
1831
1504
  const result = await this.db.query(
1832
- `SELECT * FROM embedding_set ORDER BY created_at, name`
1505
+ `SELECT e.note_id, e.embedding_set_id, e.id as embedding_id, e.vector::text as vector, e.created_at
1506
+ FROM embedding e
1507
+ JOIN note n ON n.id = e.note_id
1508
+ LEFT JOIN note_revised_current c ON c.note_id = n.id
1509
+ WHERE ${conditions.join(" AND ")}
1510
+ ORDER BY e.note_id, e.created_at DESC`,
1511
+ params
1833
1512
  );
1834
1513
  return result.rows;
1835
1514
  }
1836
- async listDescriptors() {
1837
- const rows = await this.list();
1838
- return rows.map((row) => this.toDescriptor(row));
1839
- }
1840
- toDescriptor(row) {
1841
- return {
1842
- id: row.id,
1843
- name: row.name,
1844
- purpose: row.purpose,
1845
- kind: row.kind,
1846
- mode: row.mode ?? void 0,
1847
- model: row.model_name,
1848
- dimension: row.dimensions,
1849
- truncateDimension: row.truncate_dimension,
1850
- criteria: asObject(row.criteria_json),
1851
- createdAt: dateString(row.created_at),
1852
- updatedAt: dateString(row.updated_at),
1853
- freshness: asObject(row.freshness_json) ?? { status: "fresh" }
1854
- };
1855
- }
1856
- async putEmbedding(input) {
1857
- const set = await this.get(input.embedding_set_id);
1858
- if (set.kind === "virtual") {
1859
- throw new Error(`Cannot store vectors directly in virtual embedding set: ${set.id}`);
1515
+ async resolveSetOperationSource(source, compatibility, errors) {
1516
+ const bySet = /* @__PURE__ */ new Map();
1517
+ for (const setId of source.setIds) bySet.set(setId, await this.resolvePhysicalRows(setId));
1518
+ await this.validateCompatibility(source.setIds, compatibility, errors);
1519
+ const noteSets = source.setIds.map((setId) => new Set((bySet.get(setId) ?? []).map((row) => row.note_id)));
1520
+ const firstRows = bySet.get(source.setIds[0]) ?? [];
1521
+ if (source.operation === "difference") {
1522
+ const excluded = new Set(noteSets.slice(1).flatMap((set) => Array.from(set)));
1523
+ return firstRows.filter((row) => !excluded.has(row.note_id));
1860
1524
  }
1861
- if (input.vector.length !== set.dimensions) {
1862
- throw new Error(
1863
- `Embedding vector has ${input.vector.length} dimensions; set ${set.id} expects ${set.dimensions}`
1864
- );
1525
+ if (source.operation === "intersection") {
1526
+ return firstRows.filter((row) => noteSets.every((set) => set.has(row.note_id)));
1865
1527
  }
1866
- await this.db.query(
1867
- `DELETE FROM embedding_set_member WHERE note_id = $1 AND embedding_set_id = $2`,
1868
- [input.note_id, input.embedding_set_id]
1869
- );
1870
- await this.db.query(
1871
- `DELETE FROM embedding WHERE note_id = $1 AND embedding_set_id = $2`,
1872
- [input.note_id, input.embedding_set_id]
1873
- );
1874
- const embeddingId = input.id ?? generateId();
1875
- const vector2 = `[${input.vector.join(",")}]`;
1876
- await this.db.query(
1877
- `INSERT INTO embedding (id, note_id, embedding_set_id, vector)
1878
- VALUES ($1, $2, $3, $4::vector)`,
1879
- [embeddingId, input.note_id, input.embedding_set_id, vector2]
1880
- );
1881
- await this.db.query(
1882
- `INSERT INTO embedding_set_member (embedding_set_id, note_id, embedding_id)
1883
- VALUES ($1, $2, $3)`,
1884
- [input.embedding_set_id, input.note_id, embeddingId]
1885
- );
1886
- return { id: embeddingId };
1528
+ return this.resolveDuplicateRows(source.setIds.flatMap((setId) => bySet.get(setId) ?? []), compatibility, errors);
1887
1529
  }
1888
- async resolveSelector(selector) {
1889
- if (selector.kind === "default") {
1890
- const set = await this.ensureDefault();
1891
- return this.resolvePhysicalSet({ kind: "embedding-set", embeddingSetId: set.id }, set.id);
1892
- }
1893
- if (selector.kind === "embedding-set") {
1894
- if (!selector.embeddingSetId) throw new Error("embedding-set selector requires embeddingSetId");
1895
- const set = await this.get(selector.embeddingSetId);
1896
- if (set.kind === "virtual") {
1897
- const definition = this.definitionFromRow(set);
1898
- return this.resolveDefinition({ kind: "embedding-set", embeddingSetId: set.id }, definition, set);
1899
- }
1900
- return this.resolvePhysicalSet(selector, set.id);
1901
- }
1902
- if (!selector.definition) throw new Error("virtual-definition selector requires definition");
1903
- return this.resolveDefinition(selector, selector.definition);
1904
- }
1905
- async refreshMaterializedVirtualSet(setId) {
1906
- const set = await this.get(setId);
1907
- if (set.kind !== "virtual") throw new Error(`Embedding set is not virtual: ${setId}`);
1908
- const definition = this.definitionFromRow(set);
1909
- if (!definition.materialization?.allowed) {
1910
- throw new Error(`Virtual embedding set does not allow materialization: ${setId}`);
1911
- }
1912
- const live = await this.resolveDefinition(
1913
- { kind: "embedding-set", embeddingSetId: setId },
1914
- definition,
1915
- set,
1916
- { forceLive: true }
1917
- );
1918
- await this.db.query(`DELETE FROM embedding_set_member WHERE embedding_set_id = $1`, [setId]);
1919
- for (const row of live.rows) {
1920
- await this.db.query(
1921
- `INSERT INTO embedding_set_member (embedding_set_id, note_id, embedding_id)
1922
- VALUES ($1, $2, $3)
1923
- ON CONFLICT DO NOTHING`,
1924
- [setId, row.note_id, row.embedding_id]
1925
- );
1926
- }
1927
- const inputHash = this.resolutionInputHash(definition, live.rows);
1928
- const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1929
- const materialization = {
1930
- ...definition.materialization,
1931
- allowed: true,
1932
- includeResolvedMembers: true,
1933
- freshness: "fresh",
1934
- inputHash,
1935
- generatedAt,
1936
- resolvedMemberCount: live.rows.length
1937
- };
1938
- const freshness = {
1939
- status: "fresh",
1940
- sourceHash: inputHash,
1941
- checkedAt: generatedAt
1942
- };
1943
- await this.db.query(
1944
- `UPDATE embedding_set
1945
- SET materialization_json = $2::jsonb, freshness_json = $3::jsonb, updated_at = now()
1946
- WHERE id = $1`,
1947
- [setId, jsonParam(materialization), jsonParam(freshness)]
1948
- );
1949
- return this.finalizeResolution(
1950
- { kind: "embedding-set", embeddingSetId: setId },
1951
- live.rows,
1952
- live.errors,
1953
- definition.compatibility,
1954
- "fresh",
1955
- "materialized"
1956
- );
1957
- }
1958
- async markVirtualSetStale(setId, reason) {
1959
- const set = await this.get(setId);
1960
- if (set.kind !== "virtual") throw new Error(`Embedding set is not virtual: ${setId}`);
1961
- const definition = this.definitionFromRow(set);
1962
- const now = (/* @__PURE__ */ new Date()).toISOString();
1963
- const materialization = definition.materialization ? { ...definition.materialization, freshness: "stale" } : null;
1964
- await this.db.query(
1965
- `UPDATE embedding_set
1966
- SET materialization_json = $2::jsonb, freshness_json = $3::jsonb, updated_at = now()
1967
- WHERE id = $1`,
1968
- [
1969
- setId,
1970
- jsonParam(materialization),
1971
- jsonParam({ status: "stale", sourceHash: definition.materialization?.inputHash, checkedAt: now, reason })
1972
- ]
1973
- );
1974
- }
1975
- async resolveDefinition(selector, definition, set, options = {}) {
1976
- if (!options.forceLive && set && definition.materialization?.allowed && definition.materialization.freshness === "fresh") {
1977
- const materialized = await this.resolveMaterializedRows(set.id);
1978
- if (materialized.length > 0 || definition.materialization.resolvedMemberCount === 0) {
1979
- return this.finalizeResolution(
1980
- selector,
1981
- materialized,
1982
- [],
1983
- definition.compatibility,
1984
- "fresh",
1985
- "materialized"
1986
- );
1987
- }
1988
- }
1989
- let rows;
1990
- const errors = [];
1991
- switch (definition.source.type) {
1992
- case "criteria":
1993
- rows = await this.resolveCriteriaSource(definition.source);
1994
- break;
1995
- case "set-operation":
1996
- rows = await this.resolveSetOperationSource(definition.source, definition.compatibility, errors);
1997
- break;
1998
- case "fallback":
1999
- rows = await this.resolveFallbackSource(definition.source.preferredSetIds, definition.compatibility, errors);
2000
- break;
2001
- case "latest-compatible":
2002
- rows = await this.resolveLatestCompatibleSource(definition.source, definition.compatibility, errors);
2003
- break;
2004
- case "snapshot":
2005
- rows = await this.resolvePhysicalRows(definition.source.snapshotId);
2006
- break;
2007
- default:
2008
- rows = [];
2009
- }
2010
- return this.finalizeResolution(selector, rows, errors, definition.compatibility, definition.materialization?.freshness ?? "unknown", "live");
2011
- }
2012
- async resolvePhysicalSet(selector, setId) {
2013
- return this.finalizeResolution(selector, await this.resolvePhysicalRows(setId), [], DEFAULT_COMPATIBILITY, "fresh", "live");
2014
- }
2015
- async resolvePhysicalRows(setId) {
2016
- const result = await this.db.query(
2017
- `SELECT note_id, embedding_set_id, id as embedding_id, vector::text as vector, created_at
2018
- FROM embedding
2019
- WHERE embedding_set_id = $1
2020
- ORDER BY note_id, created_at DESC`,
2021
- [setId]
2022
- );
2023
- return result.rows;
2024
- }
2025
- async resolveMaterializedRows(setId) {
2026
- const result = await this.db.query(
2027
- `SELECT e.note_id, e.embedding_set_id, e.id as embedding_id, e.vector::text as vector, e.created_at
2028
- FROM embedding_set_member m
2029
- JOIN embedding e ON e.id = m.embedding_id
2030
- WHERE m.embedding_set_id = $1
2031
- ORDER BY e.note_id, e.created_at DESC`,
2032
- [setId]
2033
- );
2034
- return result.rows;
2035
- }
2036
- async resolveCriteriaSource(source) {
2037
- const criteria = source.criteria;
2038
- if (criteria.conceptIds && criteria.conceptIds.length > 0) {
2039
- throw new Error("Unsupported virtual embedding-set criteria field: conceptIds");
2040
- }
2041
- const conditions = ["e.embedding_set_id = $1"];
2042
- const params = [source.baseSetId];
2043
- let idx = 2;
2044
- if (criteria.noteIds?.length) {
2045
- conditions.push(`n.id = ANY($${idx++})`);
2046
- params.push(criteria.noteIds);
2047
- }
2048
- if (criteria.tags?.length) {
2049
- conditions.push(`EXISTS (SELECT 1 FROM note_tag nt WHERE nt.note_id = n.id AND nt.tag = ANY($${idx++}))`);
2050
- params.push(criteria.tags);
2051
- }
2052
- if (criteria.collectionIds?.length) {
2053
- conditions.push(`EXISTS (SELECT 1 FROM collection_note cn WHERE cn.note_id = n.id AND cn.collection_id = ANY($${idx++}))`);
2054
- params.push(criteria.collectionIds);
2055
- }
2056
- if (criteria.sources?.length) {
2057
- conditions.push(`n.source = ANY($${idx++})`);
2058
- params.push(criteria.sources);
2059
- }
2060
- if (criteria.formats?.length) {
2061
- conditions.push(`n.format = ANY($${idx++})`);
2062
- params.push(criteria.formats);
2063
- }
2064
- if (criteria.visibilities?.length) {
2065
- conditions.push(`n.visibility = ANY($${idx++})`);
2066
- params.push(criteria.visibilities);
2067
- }
2068
- if (criteria.isStarred !== void 0) {
2069
- conditions.push(`n.is_starred = $${idx++}`);
2070
- params.push(criteria.isStarred);
2071
- }
2072
- if (criteria.isArchived !== void 0) {
2073
- conditions.push(`n.is_archived = $${idx++}`);
2074
- params.push(criteria.isArchived);
2075
- }
2076
- if (criteria.hasTitle !== void 0) {
2077
- conditions.push(criteria.hasTitle ? `n.title IS NOT NULL AND n.title <> ''` : `(n.title IS NULL OR n.title = '')`);
2078
- }
2079
- if (criteria.hasEmbedding === false) {
2080
- conditions.push("FALSE");
2081
- }
2082
- if (criteria.isUserEdited !== void 0) {
2083
- conditions.push(`COALESCE(c.is_user_edited, false) = $${idx++}`);
2084
- params.push(criteria.isUserEdited);
2085
- }
2086
- if (criteria.hasAiMetadata !== void 0) {
2087
- conditions.push(criteria.hasAiMetadata ? `c.ai_metadata IS NOT NULL` : `c.ai_metadata IS NULL`);
2088
- }
2089
- if (criteria.hasRevisions !== void 0) {
2090
- conditions.push(criteria.hasRevisions ? `EXISTS (SELECT 1 FROM note_revision nr WHERE nr.note_id = n.id)` : `NOT EXISTS (SELECT 1 FROM note_revision nr WHERE nr.note_id = n.id)`);
2091
- }
2092
- if (criteria.minGenerationCount !== void 0) {
2093
- conditions.push(`COALESCE(c.generation_count, 0) >= $${idx++}`);
2094
- params.push(criteria.minGenerationCount);
2095
- }
2096
- if (criteria.maxGenerationCount !== void 0) {
2097
- conditions.push(`COALESCE(c.generation_count, 0) <= $${idx++}`);
2098
- params.push(criteria.maxGenerationCount);
2099
- }
2100
- if (criteria.updatedAfter) {
2101
- conditions.push(`n.updated_at >= $${idx++}`);
2102
- params.push(criteria.updatedAfter);
2103
- }
2104
- if (criteria.updatedBefore) {
2105
- conditions.push(`n.updated_at <= $${idx++}`);
2106
- params.push(criteria.updatedBefore);
2107
- }
2108
- if (criteria.query?.trim()) {
2109
- conditions.push(`(n.tsv @@ plainto_tsquery('english', $${idx}) OR to_tsvector('english', coalesce(c.content, '')) @@ plainto_tsquery('english', $${idx}))`);
2110
- params.push(criteria.query);
2111
- }
2112
- const result = await this.db.query(
2113
- `SELECT e.note_id, e.embedding_set_id, e.id as embedding_id, e.vector::text as vector, e.created_at
2114
- FROM embedding e
2115
- JOIN note n ON n.id = e.note_id
2116
- LEFT JOIN note_revised_current c ON c.note_id = n.id
2117
- WHERE ${conditions.join(" AND ")}
2118
- ORDER BY e.note_id, e.created_at DESC`,
2119
- params
2120
- );
2121
- return result.rows;
2122
- }
2123
- async resolveSetOperationSource(source, compatibility, errors) {
2124
- const bySet = /* @__PURE__ */ new Map();
2125
- for (const setId of source.setIds) bySet.set(setId, await this.resolvePhysicalRows(setId));
2126
- await this.validateCompatibility(source.setIds, compatibility, errors);
2127
- const noteSets = source.setIds.map((setId) => new Set((bySet.get(setId) ?? []).map((row) => row.note_id)));
2128
- const firstRows = bySet.get(source.setIds[0]) ?? [];
2129
- if (source.operation === "difference") {
2130
- const excluded = new Set(noteSets.slice(1).flatMap((set) => Array.from(set)));
2131
- return firstRows.filter((row) => !excluded.has(row.note_id));
2132
- }
2133
- if (source.operation === "intersection") {
2134
- return firstRows.filter((row) => noteSets.every((set) => set.has(row.note_id)));
2135
- }
2136
- return this.resolveDuplicateRows(source.setIds.flatMap((setId) => bySet.get(setId) ?? []), compatibility, errors);
2137
- }
2138
- async resolveFallbackSource(setIds, compatibility, errors) {
2139
- await this.validateCompatibility(setIds, compatibility, errors);
2140
- const selected = /* @__PURE__ */ new Map();
2141
- for (const setId of setIds) {
2142
- for (const row of await this.resolvePhysicalRows(setId)) {
2143
- if (!selected.has(row.note_id)) selected.set(row.note_id, row);
2144
- }
1530
+ async resolveFallbackSource(setIds, compatibility, errors) {
1531
+ await this.validateCompatibility(setIds, compatibility, errors);
1532
+ const selected = /* @__PURE__ */ new Map();
1533
+ for (const setId of setIds) {
1534
+ for (const row of await this.resolvePhysicalRows(setId)) {
1535
+ if (!selected.has(row.note_id)) selected.set(row.note_id, row);
1536
+ }
2145
1537
  }
2146
1538
  return Array.from(selected.values()).sort((a, b) => a.note_id.localeCompare(b.note_id));
2147
1539
  }
@@ -2593,21 +1985,1032 @@ var SearchRepository = class {
2593
1985
  collections: collResult.rows.map((r) => ({ id: r.id, name: r.name, count: parseInt(r.count, 10) }))
2594
1986
  };
2595
1987
  }
2596
- async fetchTagMap(noteIds) {
2597
- const tagMap = /* @__PURE__ */ new Map();
2598
- if (noteIds.length === 0) return tagMap;
2599
- const tagsResult = await this.db.query(
2600
- `SELECT note_id, tag FROM note_tag WHERE note_id = ANY($1) ORDER BY tag`,
2601
- [noteIds]
2602
- );
2603
- for (const row of tagsResult.rows) {
2604
- const existing = tagMap.get(row.note_id) ?? [];
2605
- existing.push(row.tag);
2606
- tagMap.set(row.note_id, existing);
1988
+ async fetchTagMap(noteIds) {
1989
+ const tagMap = /* @__PURE__ */ new Map();
1990
+ if (noteIds.length === 0) return tagMap;
1991
+ const tagsResult = await this.db.query(
1992
+ `SELECT note_id, tag FROM note_tag WHERE note_id = ANY($1) ORDER BY tag`,
1993
+ [noteIds]
1994
+ );
1995
+ for (const row of tagsResult.rows) {
1996
+ const existing = tagMap.get(row.note_id) ?? [];
1997
+ existing.push(row.tag);
1998
+ tagMap.set(row.note_id, existing);
1999
+ }
2000
+ return tagMap;
2001
+ }
2002
+ };
2003
+ var CaptureKnowledgeInputSchema = z.object({
2004
+ action: z.enum(["create", "bulk_create", "from_template"]),
2005
+ // For create
2006
+ content: z.string().optional(),
2007
+ title: z.string().optional(),
2008
+ format: z.enum(["markdown", "plain", "html"]).default("markdown"),
2009
+ source: z.string().default("user"),
2010
+ visibility: z.enum(["private", "shared", "public"]).default("private"),
2011
+ tags: z.array(z.string()).optional(),
2012
+ archive_id: z.string().optional(),
2013
+ // For bulk_create
2014
+ notes: z.array(
2015
+ z.object({
2016
+ content: z.string(),
2017
+ title: z.string().optional(),
2018
+ format: z.enum(["markdown", "plain", "html"]).default("markdown"),
2019
+ tags: z.array(z.string()).optional()
2020
+ })
2021
+ ).optional(),
2022
+ // For from_template
2023
+ template: z.string().optional(),
2024
+ variables: z.record(z.string()).optional()
2025
+ });
2026
+ var ManageNoteInputSchema = z.object({
2027
+ action: z.enum(["update", "delete", "restore", "archive", "unarchive", "star", "unstar"]),
2028
+ note_id: z.string(),
2029
+ // For update
2030
+ title: z.string().optional(),
2031
+ content: z.string().optional(),
2032
+ format: z.string().optional(),
2033
+ visibility: z.string().optional()
2034
+ });
2035
+ var SearchInputSchema = z.object({
2036
+ query: z.string(),
2037
+ mode: z.enum(["text", "semantic", "hybrid"]).default("text"),
2038
+ limit: z.number().int().min(1).max(100).default(20),
2039
+ offset: z.number().int().min(0).default(0),
2040
+ tags: z.array(z.string()).optional(),
2041
+ collection_id: z.string().optional(),
2042
+ date_from: z.coerce.date().optional(),
2043
+ date_to: z.coerce.date().optional(),
2044
+ is_starred: z.boolean().optional(),
2045
+ is_archived: z.boolean().optional(),
2046
+ format: z.enum(["markdown", "plain", "html"]).optional(),
2047
+ source: z.string().optional(),
2048
+ visibility: z.enum(["private", "shared", "public"]).optional(),
2049
+ include_facets: z.boolean().default(false)
2050
+ });
2051
+
2052
+ // src/tools/manage-note.ts
2053
+ async function manageNote(db, rawInput, events) {
2054
+ const input = ManageNoteInputSchema.parse(rawInput);
2055
+ const repo = new NotesRepository(db, events);
2056
+ switch (input.action) {
2057
+ case "update": {
2058
+ const note = await repo.update(input.note_id, {
2059
+ title: input.title,
2060
+ content: input.content,
2061
+ format: input.format,
2062
+ visibility: input.visibility
2063
+ });
2064
+ return { action: "update", note_id: input.note_id, note };
2065
+ }
2066
+ case "delete": {
2067
+ await repo.delete(input.note_id);
2068
+ return { action: "delete", note_id: input.note_id };
2069
+ }
2070
+ case "restore": {
2071
+ const note = await repo.restore(input.note_id);
2072
+ return { action: "restore", note_id: input.note_id, note };
2073
+ }
2074
+ case "archive": {
2075
+ await repo.archive(input.note_id, true);
2076
+ const note = await repo.get(input.note_id);
2077
+ return { action: "archive", note_id: input.note_id, note };
2078
+ }
2079
+ case "unarchive": {
2080
+ await repo.archive(input.note_id, false);
2081
+ const note = await repo.get(input.note_id);
2082
+ return { action: "unarchive", note_id: input.note_id, note };
2083
+ }
2084
+ case "star": {
2085
+ await repo.star(input.note_id, true);
2086
+ const note = await repo.get(input.note_id);
2087
+ return { action: "star", note_id: input.note_id, note };
2088
+ }
2089
+ case "unstar": {
2090
+ await repo.star(input.note_id, false);
2091
+ const note = await repo.get(input.note_id);
2092
+ return { action: "unstar", note_id: input.note_id, note };
2093
+ }
2094
+ }
2095
+ }
2096
+
2097
+ // src/data-backend.ts
2098
+ var SEMANTIC_RANK = {
2099
+ none: 0,
2100
+ "cosine-small": 1,
2101
+ "ann-full": 2,
2102
+ server: 3
2103
+ };
2104
+ var STARTUP_RANK = {
2105
+ instant: 0,
2106
+ "index-build": 1,
2107
+ network: 2
2108
+ };
2109
+ function missingFor(request, caps) {
2110
+ const missing = [];
2111
+ if (request.read && !caps.read) missing.push("read");
2112
+ if (request.write && !caps.write) missing.push("write");
2113
+ if (request.merge && !caps.merge) missing.push("merge");
2114
+ if (request.multiUser && !caps.multiUser) missing.push("multiUser");
2115
+ if (request.semantic && SEMANTIC_RANK[caps.semantic] < SEMANTIC_RANK[request.semantic]) {
2116
+ missing.push(`semantic:${request.semantic}`);
2117
+ }
2118
+ return missing;
2119
+ }
2120
+ function selectBackend(request, available) {
2121
+ const candidates = available.map((backend) => ({
2122
+ backend,
2123
+ missing: missingFor(request, backend.capabilities)
2124
+ }));
2125
+ if (candidates.length === 0) {
2126
+ return { backend: null, capabilities: null, missing: [], candidates };
2127
+ }
2128
+ const lighter = (a, b) => STARTUP_RANK[a.capabilities.startupCost] - STARTUP_RANK[b.capabilities.startupCost];
2129
+ const fullySatisfying = candidates.filter((c) => c.missing.length === 0);
2130
+ const pool = fullySatisfying.length > 0 ? fullySatisfying : candidates;
2131
+ const chosen = [...pool].sort((a, b) => {
2132
+ if (a.missing.length !== b.missing.length) return a.missing.length - b.missing.length;
2133
+ return lighter(a.backend, b.backend);
2134
+ })[0];
2135
+ return {
2136
+ backend: chosen.backend,
2137
+ capabilities: chosen.backend.capabilities,
2138
+ missing: chosen.missing,
2139
+ candidates
2140
+ };
2141
+ }
2142
+ function toIso(d) {
2143
+ return d instanceof Date ? d.toISOString() : String(d);
2144
+ }
2145
+ function summaryToBackend(s) {
2146
+ return {
2147
+ id: s.id,
2148
+ title: s.title,
2149
+ tags: s.tags,
2150
+ createdAt: toIso(s.created_at),
2151
+ updatedAt: toIso(s.updated_at),
2152
+ source: s.source,
2153
+ starred: s.is_starred,
2154
+ archived: s.is_archived
2155
+ };
2156
+ }
2157
+ function searchResultToBackend(r) {
2158
+ return {
2159
+ id: r.id,
2160
+ title: r.title,
2161
+ tags: r.tags,
2162
+ createdAt: toIso(r.created_at),
2163
+ updatedAt: toIso(r.updated_at)
2164
+ };
2165
+ }
2166
+ function createPGliteBackend(db, options = {}) {
2167
+ const semanticAvailable = options.semanticAvailable ?? false;
2168
+ const notes = new NotesRepository(db);
2169
+ const search = new SearchRepository(db, semanticAvailable);
2170
+ return {
2171
+ id: options.id ?? "pglite",
2172
+ capabilities: {
2173
+ read: true,
2174
+ write: true,
2175
+ merge: true,
2176
+ multiUser: false,
2177
+ semantic: semanticAvailable ? "ann-full" : "none",
2178
+ startupCost: "index-build"
2179
+ },
2180
+ async listNotes(o) {
2181
+ const r = await notes.list({ offset: o?.offset, limit: o?.limit });
2182
+ return { items: r.items.map(summaryToBackend), total: r.total };
2183
+ },
2184
+ async getNote(id) {
2185
+ try {
2186
+ const f = await notes.get(id);
2187
+ return summaryToBackend(f);
2188
+ } catch {
2189
+ return null;
2190
+ }
2191
+ },
2192
+ async search(query, o) {
2193
+ const r = await search.search(query, {
2194
+ limit: o?.limit,
2195
+ offset: o?.offset,
2196
+ tags: o?.tags,
2197
+ source: o?.source?.[0],
2198
+ include_facets: true
2199
+ });
2200
+ const hits = r.results.map((res) => ({
2201
+ note: searchResultToBackend(res),
2202
+ rank: res.rank,
2203
+ snippet: res.snippet
2204
+ }));
2205
+ const facets = r.facets ? {
2206
+ tags: Object.fromEntries(r.facets.tags.map((t) => [t.tag, t.count]))
2207
+ } : void 0;
2208
+ return { hits, total: r.total, facets };
2209
+ },
2210
+ async getNoteFull(id) {
2211
+ try {
2212
+ const f = await notes.get(id);
2213
+ return { ...summaryToBackend(f), content: f.current.content };
2214
+ } catch {
2215
+ return null;
2216
+ }
2217
+ },
2218
+ async manageNote(input) {
2219
+ return manageNote(db, input);
2220
+ }
2221
+ };
2222
+ }
2223
+ function shardNoteToBackend(n) {
2224
+ return {
2225
+ id: n.id,
2226
+ title: n.title,
2227
+ tags: n.tags,
2228
+ createdAt: toIso(n.created_at),
2229
+ updatedAt: toIso(n.updated_at),
2230
+ source: n.source,
2231
+ starred: n.is_starred,
2232
+ archived: n.is_archived
2233
+ };
2234
+ }
2235
+ function createShardBackend(reader, options = {}) {
2236
+ const semantic = options.semantic ?? "none";
2237
+ return {
2238
+ id: options.id ?? "static-file",
2239
+ capabilities: {
2240
+ read: true,
2241
+ write: false,
2242
+ merge: false,
2243
+ multiUser: false,
2244
+ semantic,
2245
+ startupCost: "instant"
2246
+ },
2247
+ async listNotes(o) {
2248
+ const r = await reader.listNotes(o);
2249
+ return { items: r.items.map(shardNoteToBackend), total: r.total };
2250
+ },
2251
+ async getNote(id) {
2252
+ const n = await reader.getNote(id);
2253
+ return n ? shardNoteToBackend(n) : null;
2254
+ },
2255
+ async search(query, o) {
2256
+ const r = await reader.search(query, { ...o, rank: true, snippets: true });
2257
+ const hits = r.rankedItems ? r.rankedItems.map((it) => ({
2258
+ note: shardNoteToBackend(it.note),
2259
+ rank: it.rank,
2260
+ snippet: it.snippet
2261
+ })) : r.items.map((note) => ({ note: shardNoteToBackend(note) }));
2262
+ return { hits, total: r.total, facets: r.facets };
2263
+ },
2264
+ async getNoteFull(id) {
2265
+ const f = await reader.getNoteFull(id);
2266
+ if (!f) return null;
2267
+ return {
2268
+ ...shardNoteToBackend(f.note),
2269
+ content: f.note.revised_content ?? f.note.original_content
2270
+ };
2271
+ },
2272
+ async semantic(query, k) {
2273
+ const r = await reader.semantic(query, k);
2274
+ return r.map(({ note, score }) => ({ note: shardNoteToBackend(note), rank: score }));
2275
+ }
2276
+ };
2277
+ }
2278
+
2279
+ // src/worker/worker-client.ts
2280
+ var PGliteWorkerClient = class {
2281
+ constructor(worker) {
2282
+ this.worker = worker;
2283
+ this.readyPromise = new Promise((resolve) => {
2284
+ this.resolveReady = resolve;
2285
+ });
2286
+ this.worker.addEventListener("message", (e) => {
2287
+ const msg = e.data;
2288
+ if (msg.type === "READY") {
2289
+ this.resolveReady();
2290
+ return;
2291
+ }
2292
+ if (!("id" in msg)) return;
2293
+ const pending = this.pending.get(msg.id);
2294
+ if (!pending) return;
2295
+ this.pending.delete(msg.id);
2296
+ if (msg.type === "ERROR") {
2297
+ pending.reject(new Error(msg.error));
2298
+ } else {
2299
+ pending.resolve(msg);
2300
+ }
2301
+ });
2302
+ }
2303
+ pending = /* @__PURE__ */ new Map();
2304
+ readyPromise;
2305
+ resolveReady;
2306
+ /** Resolves when the worker broadcasts READY after database initialisation. */
2307
+ async waitReady() {
2308
+ return this.readyPromise;
2309
+ }
2310
+ send(request) {
2311
+ const id = generateId();
2312
+ return new Promise((resolve, reject) => {
2313
+ this.pending.set(id, {
2314
+ resolve,
2315
+ reject
2316
+ });
2317
+ this.worker.postMessage({ ...request, id });
2318
+ });
2319
+ }
2320
+ async query(sql, params) {
2321
+ const resp = await this.send({ type: "QUERY", sql, params });
2322
+ return { rows: resp.rows, fields: resp.fields };
2323
+ }
2324
+ async exec(sql) {
2325
+ await this.send({ type: "EXEC", sql });
2326
+ }
2327
+ async transaction(fn) {
2328
+ const resp = await this.send({ type: "BEGIN" });
2329
+ const txId = resp.txId;
2330
+ const proxy = new TransactionProxy(this, txId);
2331
+ try {
2332
+ const result = await fn(proxy);
2333
+ await this.send({ type: "COMMIT", txId });
2334
+ return result;
2335
+ } catch (err) {
2336
+ await this.send({ type: "ROLLBACK", txId }).catch(() => {
2337
+ });
2338
+ throw err;
2339
+ }
2340
+ }
2341
+ /** Forward TX_QUERY for TransactionProxy — not part of the public surface. */
2342
+ async _txQuery(txId, sql, params) {
2343
+ const resp = await this.send({
2344
+ type: "TX_QUERY",
2345
+ txId,
2346
+ sql,
2347
+ params
2348
+ });
2349
+ return { rows: resp.rows };
2350
+ }
2351
+ /** Forward TX_EXEC for TransactionProxy — not part of the public surface. */
2352
+ async _txExec(txId, sql) {
2353
+ await this.send({ type: "TX_EXEC", txId, sql });
2354
+ }
2355
+ async ping() {
2356
+ await this.send({ type: "PING" });
2357
+ }
2358
+ async close() {
2359
+ await this.send({ type: "CLOSE" });
2360
+ this.worker.terminate();
2361
+ }
2362
+ };
2363
+ var TransactionProxy = class {
2364
+ constructor(client, txId) {
2365
+ this.client = client;
2366
+ this.txId = txId;
2367
+ }
2368
+ async query(sql, params) {
2369
+ return this.client._txQuery(this.txId, sql, params);
2370
+ }
2371
+ async exec(sql) {
2372
+ return this.client._txExec(this.txId, sql);
2373
+ }
2374
+ };
2375
+
2376
+ // src/storage-backend.ts
2377
+ var PGliteStorageBackend = class {
2378
+ constructor(id, db) {
2379
+ this.id = id;
2380
+ this.db = db;
2381
+ }
2382
+ mode = "readwrite";
2383
+ query(sql, params) {
2384
+ return this.db.query(sql, params);
2385
+ }
2386
+ exec(sql) {
2387
+ return this.db.exec(sql);
2388
+ }
2389
+ async transaction(fn) {
2390
+ return this.db.transaction((tx) => fn(tx));
2391
+ }
2392
+ close() {
2393
+ return this.db.close();
2394
+ }
2395
+ };
2396
+ var PGliteStorageBackendFactory = class {
2397
+ async open(input) {
2398
+ const db = await createPGliteInstance(input.persistence, input.archiveName);
2399
+ return new PGliteStorageBackend(`pglite:${input.persistence}:${input.archiveName}`, db);
2400
+ }
2401
+ };
2402
+ var defaultStorageBackendFactory = new PGliteStorageBackendFactory();
2403
+ var PGliteWorkerStorageBackend = class {
2404
+ constructor(id, client) {
2405
+ this.id = id;
2406
+ this.client = client;
2407
+ }
2408
+ mode = "readwrite";
2409
+ query(sql, params) {
2410
+ return this.client.query(sql, params);
2411
+ }
2412
+ exec(sql) {
2413
+ return this.client.exec(sql);
2414
+ }
2415
+ transaction(fn) {
2416
+ return this.client.transaction(fn);
2417
+ }
2418
+ close() {
2419
+ return this.client.close();
2420
+ }
2421
+ };
2422
+ var PGliteWorkerStorageBackendFactory = class {
2423
+ constructor(options) {
2424
+ this.options = options;
2425
+ }
2426
+ async open(input) {
2427
+ const worker = this.options.createWorker();
2428
+ const client = new PGliteWorkerClient(worker);
2429
+ worker.postMessage({
2430
+ type: "INIT",
2431
+ persistence: input.persistence,
2432
+ archiveName: input.archiveName
2433
+ });
2434
+ await client.waitReady();
2435
+ return new PGliteWorkerStorageBackend(`pglite-worker:${input.persistence}:${input.archiveName}`, client);
2436
+ }
2437
+ };
2438
+
2439
+ // src/capability-manager.ts
2440
+ var VALID_TRANSITIONS = {
2441
+ enable: ["unloaded", "disabled", "error"],
2442
+ disable: ["ready"],
2443
+ markReady: ["loading"],
2444
+ markError: ["loading"]
2445
+ };
2446
+ function assertTransition(method, name, current) {
2447
+ const allowed = VALID_TRANSITIONS[method];
2448
+ if (!allowed.includes(current)) {
2449
+ throw new Error(
2450
+ `CapabilityManager.${method}('${name}'): invalid transition from state '${current}'. Allowed source states: ${allowed.join(", ")}.`
2451
+ );
2452
+ }
2453
+ }
2454
+ var CapabilityManager = class {
2455
+ constructor(events) {
2456
+ this.events = events;
2457
+ const names = ["semantic", "llm", "audio", "vision", "pdf"];
2458
+ for (const name of names) {
2459
+ this.capabilities.set(name, { name, state: "unloaded" });
2460
+ }
2461
+ }
2462
+ capabilities = /* @__PURE__ */ new Map();
2463
+ loaders = /* @__PURE__ */ new Map();
2464
+ progressMessages = /* @__PURE__ */ new Map();
2465
+ /**
2466
+ * Register an async loader for a capability.
2467
+ * Called by enable(); if no loader is registered the capability transitions
2468
+ * directly to ready (useful for capabilities that require no async init).
2469
+ */
2470
+ registerLoader(name, loader) {
2471
+ this.loaders.set(name, loader);
2472
+ }
2473
+ getState(name) {
2474
+ return this.capabilities.get(name)?.state ?? "unloaded";
2475
+ }
2476
+ isReady(name) {
2477
+ return this.getState(name) === "ready";
2478
+ }
2479
+ /**
2480
+ * Enable a capability.
2481
+ * Valid from: unloaded, disabled, error (retry).
2482
+ * Runs the registered loader if present; transitions to ready on success,
2483
+ * error on failure.
2484
+ */
2485
+ async enable(name) {
2486
+ const entry = this.capabilities.get(name);
2487
+ if (!entry) return;
2488
+ if (entry.state === "ready" || entry.state === "loading") return;
2489
+ assertTransition("enable", name, entry.state);
2490
+ entry.state = "loading";
2491
+ entry.error = void 0;
2492
+ this.events.emit("capability.loading", { name });
2493
+ const loader = this.loaders.get(name);
2494
+ if (!loader) {
2495
+ entry.state = "ready";
2496
+ this.events.emit("capability.ready", { name });
2497
+ return;
2498
+ }
2499
+ try {
2500
+ await loader();
2501
+ if (entry.state === "loading") {
2502
+ entry.state = "ready";
2503
+ this.events.emit("capability.ready", { name });
2504
+ }
2505
+ } catch (err) {
2506
+ if (entry.state === "loading") {
2507
+ const message = err instanceof Error ? err.message : String(err);
2508
+ entry.state = "error";
2509
+ entry.error = message;
2510
+ }
2511
+ }
2512
+ }
2513
+ /**
2514
+ * Disable a ready capability.
2515
+ * Valid from: ready only.
2516
+ */
2517
+ disable(name) {
2518
+ const entry = this.capabilities.get(name);
2519
+ if (!entry) return;
2520
+ assertTransition("disable", name, entry.state);
2521
+ entry.state = "disabled";
2522
+ this.events.emit("capability.disabled", { name });
2523
+ }
2524
+ /**
2525
+ * Mark a loading capability as ready (external use, e.g. bridge protocol).
2526
+ * Valid from: loading only.
2527
+ */
2528
+ markReady(name) {
2529
+ const entry = this.capabilities.get(name);
2530
+ if (!entry) return;
2531
+ assertTransition("markReady", name, entry.state);
2532
+ entry.state = "ready";
2533
+ entry.error = void 0;
2534
+ this.events.emit("capability.ready", { name });
2535
+ }
2536
+ /**
2537
+ * Mark a loading capability as errored (external use, e.g. bridge protocol).
2538
+ * Valid from: loading only.
2539
+ */
2540
+ markError(name, error) {
2541
+ const entry = this.capabilities.get(name);
2542
+ if (!entry) return;
2543
+ assertTransition("markError", name, entry.state);
2544
+ entry.state = "error";
2545
+ entry.error = error;
2546
+ }
2547
+ /**
2548
+ * Report loading progress (0-100).
2549
+ * Emits capability.loading with progress if the capability is currently loading.
2550
+ * No-op if the capability is not in loading state.
2551
+ */
2552
+ reportProgress(name, progress) {
2553
+ const entry = this.capabilities.get(name);
2554
+ if (!entry || entry.state !== "loading") return;
2555
+ this.events.emit("capability.loading", { name, progress });
2556
+ }
2557
+ /** Set a human-readable progress message for a loading capability */
2558
+ setProgress(name, message) {
2559
+ this.progressMessages.set(name, message);
2560
+ this.events.emit("capability.loading", { name, progress: -1 });
2561
+ }
2562
+ /** Get the current progress message for a capability */
2563
+ getProgress(name) {
2564
+ return this.progressMessages.get(name);
2565
+ }
2566
+ getError(name) {
2567
+ return this.capabilities.get(name)?.error;
2568
+ }
2569
+ listAll() {
2570
+ return Array.from(this.capabilities.values()).map(({ name, state }) => ({
2571
+ name,
2572
+ state
2573
+ }));
2574
+ }
2575
+ };
2576
+
2577
+ // src/migration-runner.ts
2578
+ var MigrationRunner = class {
2579
+ constructor(db, events) {
2580
+ this.db = db;
2581
+ this.events = events;
2582
+ }
2583
+ async ensureSchemaTable() {
2584
+ await this.db.exec(`
2585
+ CREATE TABLE IF NOT EXISTS schema_version (
2586
+ version INTEGER NOT NULL,
2587
+ name TEXT NOT NULL,
2588
+ applied_at TIMESTAMPTZ DEFAULT now(),
2589
+ PRIMARY KEY (version)
2590
+ )
2591
+ `);
2592
+ }
2593
+ async getCurrentVersion() {
2594
+ const result = await this.db.query(
2595
+ "SELECT COALESCE(MAX(version), 0) AS version FROM schema_version"
2596
+ );
2597
+ return result.rows[0].version;
2598
+ }
2599
+ async apply(migrations) {
2600
+ await this.ensureSchemaTable();
2601
+ const currentVersion = await this.getCurrentVersion();
2602
+ const pending = migrations.filter((m) => m.version > currentVersion).sort((a, b) => a.version - b.version);
2603
+ let applied = 0;
2604
+ for (const migration of pending) {
2605
+ await this.db.transaction(async (tx) => {
2606
+ await tx.exec(migration.sql);
2607
+ await tx.query(
2608
+ "INSERT INTO schema_version (version, name) VALUES ($1, $2)",
2609
+ [migration.version, migration.name]
2610
+ );
2611
+ });
2612
+ applied++;
2613
+ this.events?.emit("migration.applied", { version: migration.version });
2614
+ }
2615
+ return applied;
2616
+ }
2617
+ async getAppliedMigrations() {
2618
+ const result = await this.db.query(
2619
+ "SELECT version, name FROM schema_version ORDER BY version"
2620
+ );
2621
+ return result.rows;
2622
+ }
2623
+ };
2624
+
2625
+ // src/archive-manager.ts
2626
+ var ArchiveManager = class {
2627
+ constructor(persistenceOrFactory, events, persistenceOverride) {
2628
+ this.events = events;
2629
+ if (typeof persistenceOrFactory === "string") {
2630
+ this.persistence = persistenceOrFactory;
2631
+ this.backendFactory = defaultStorageBackendFactory;
2632
+ } else {
2633
+ this.persistence = persistenceOverride ?? "memory";
2634
+ this.backendFactory = persistenceOrFactory;
2635
+ }
2636
+ this.archives.set("default", {
2637
+ name: "default",
2638
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2639
+ });
2640
+ }
2641
+ currentArchive = "default";
2642
+ db = null;
2643
+ archives = /* @__PURE__ */ new Map();
2644
+ persistence;
2645
+ backendFactory;
2646
+ getCurrentArchiveName() {
2647
+ return this.currentArchive;
2648
+ }
2649
+ getDb() {
2650
+ return this.db;
2651
+ }
2652
+ async open(archiveName = "default") {
2653
+ if (this.db) {
2654
+ await this.db.close();
2655
+ }
2656
+ this.db = await this.backendFactory.open({
2657
+ archiveName,
2658
+ persistence: this.persistence
2659
+ });
2660
+ const runner = new MigrationRunner(this.db, this.events);
2661
+ await runner.apply(allMigrations);
2662
+ this.currentArchive = archiveName;
2663
+ if (!this.archives.has(archiveName)) {
2664
+ this.archives.set(archiveName, {
2665
+ name: archiveName,
2666
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2667
+ });
2668
+ }
2669
+ this.events?.emit("archive.switched", { name: archiveName });
2670
+ return this.db;
2671
+ }
2672
+ /**
2673
+ * Adopt an already-created backend WITHOUT running migrations — for a backend
2674
+ * whose schema is already present, e.g. a PGlite restored from a physical
2675
+ * data-dir snapshot (issue #187, `restoreDbSnapshot`). Running migrations here
2676
+ * would be wrong: the restored dir already carries them (and the HNSW index).
2677
+ */
2678
+ async adopt(backend, archiveName = "default") {
2679
+ if (this.db) {
2680
+ await this.db.close();
2681
+ }
2682
+ this.db = backend;
2683
+ this.currentArchive = archiveName;
2684
+ if (!this.archives.has(archiveName)) {
2685
+ this.archives.set(archiveName, {
2686
+ name: archiveName,
2687
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2688
+ });
2689
+ }
2690
+ this.events?.emit("archive.switched", { name: archiveName });
2691
+ return this.db;
2692
+ }
2693
+ async create(archiveName) {
2694
+ if (this.archives.has(archiveName)) {
2695
+ throw new Error(`Archive '${archiveName}' already exists`);
2696
+ }
2697
+ return this.open(archiveName);
2698
+ }
2699
+ async switchTo(archiveName) {
2700
+ return this.open(archiveName);
2701
+ }
2702
+ async delete(archiveName) {
2703
+ if (archiveName === "default") {
2704
+ throw new Error("Cannot delete the default archive");
2705
+ }
2706
+ if (this.currentArchive === archiveName && this.db) {
2707
+ await this.db.close();
2708
+ this.db = null;
2709
+ }
2710
+ this.archives.delete(archiveName);
2711
+ }
2712
+ listArchives() {
2713
+ return Array.from(this.archives.values());
2714
+ }
2715
+ async close() {
2716
+ if (this.db) {
2717
+ await this.db.close();
2718
+ this.db = null;
2719
+ }
2720
+ }
2721
+ };
2722
+
2723
+ // src/create-fortemi.ts
2724
+ function createFortemi(config) {
2725
+ const events = new TypedEventBus();
2726
+ return {
2727
+ events,
2728
+ config,
2729
+ destroy() {
2730
+ events.removeAllListeners();
2731
+ }
2732
+ };
2733
+ }
2734
+
2735
+ // src/service-worker/register.ts
2736
+ async function registerServiceWorker(swUrl = "/sw.js") {
2737
+ if (!("serviceWorker" in navigator)) {
2738
+ return { registered: false, error: "Service Workers not supported" };
2739
+ }
2740
+ try {
2741
+ const registration = await navigator.serviceWorker.register(swUrl, {
2742
+ type: "module",
2743
+ scope: "/"
2744
+ });
2745
+ if (registration.installing) {
2746
+ await new Promise((resolve) => {
2747
+ registration.installing.addEventListener("statechange", function handler() {
2748
+ if (this.state === "activated") {
2749
+ this.removeEventListener("statechange", handler);
2750
+ resolve();
2751
+ }
2752
+ });
2753
+ });
2754
+ }
2755
+ return { registered: true, registration };
2756
+ } catch (err) {
2757
+ return {
2758
+ registered: false,
2759
+ error: err instanceof Error ? err.message : String(err)
2760
+ };
2761
+ }
2762
+ }
2763
+
2764
+ // src/service-worker/routes.ts
2765
+ async function parseJsonBody(request) {
2766
+ try {
2767
+ return await request.json();
2768
+ } catch {
2769
+ return null;
2770
+ }
2771
+ }
2772
+ function jsonResponse(data, status = 200) {
2773
+ return new Response(JSON.stringify(data), {
2774
+ status,
2775
+ headers: { "Content-Type": "application/json" }
2776
+ });
2777
+ }
2778
+ function errorResponse(message, status) {
2779
+ return jsonResponse({ error: message }, status);
2780
+ }
2781
+ var DB_NOT_CONNECTED = "Database not connected. REST API available in standalone mode only.";
2782
+ function createRoutes() {
2783
+ return [
2784
+ // GET /api/v1/notes — list notes
2785
+ {
2786
+ method: "GET",
2787
+ pattern: /^\/api\/v1\/notes\/?$/,
2788
+ handler: async () => {
2789
+ return errorResponse(DB_NOT_CONNECTED, 503);
2790
+ }
2791
+ },
2792
+ // POST /api/v1/notes — create note
2793
+ {
2794
+ method: "POST",
2795
+ pattern: /^\/api\/v1\/notes\/?$/,
2796
+ handler: async (request) => {
2797
+ const body = await parseJsonBody(request);
2798
+ if (body === null) return errorResponse("Invalid JSON body", 400);
2799
+ return errorResponse(DB_NOT_CONNECTED, 503);
2800
+ }
2801
+ },
2802
+ // POST /api/v1/notes/:id/restore — restore soft-deleted note (must come before /:id)
2803
+ {
2804
+ method: "POST",
2805
+ pattern: /^\/api\/v1\/notes\/([^/]+)\/restore\/?$/,
2806
+ handler: async () => {
2807
+ return errorResponse(DB_NOT_CONNECTED, 503);
2808
+ }
2809
+ },
2810
+ // POST /api/v1/notes/:id/star — star or unstar a note
2811
+ {
2812
+ method: "POST",
2813
+ pattern: /^\/api\/v1\/notes\/([^/]+)\/star\/?$/,
2814
+ handler: async () => {
2815
+ return errorResponse(DB_NOT_CONNECTED, 503);
2816
+ }
2817
+ },
2818
+ // POST /api/v1/notes/:id/archive — archive or unarchive a note
2819
+ {
2820
+ method: "POST",
2821
+ pattern: /^\/api\/v1\/notes\/([^/]+)\/archive\/?$/,
2822
+ handler: async () => {
2823
+ return errorResponse(DB_NOT_CONNECTED, 503);
2824
+ }
2825
+ },
2826
+ // GET /api/v1/notes/:id — get a single note
2827
+ {
2828
+ method: "GET",
2829
+ pattern: /^\/api\/v1\/notes\/([^/]+)\/?$/,
2830
+ handler: async () => {
2831
+ return errorResponse(DB_NOT_CONNECTED, 503);
2832
+ }
2833
+ },
2834
+ // PUT /api/v1/notes/:id — update a note
2835
+ {
2836
+ method: "PUT",
2837
+ pattern: /^\/api\/v1\/notes\/([^/]+)\/?$/,
2838
+ handler: async () => {
2839
+ return errorResponse(DB_NOT_CONNECTED, 503);
2840
+ }
2841
+ },
2842
+ // DELETE /api/v1/notes/:id — soft-delete a note
2843
+ {
2844
+ method: "DELETE",
2845
+ pattern: /^\/api\/v1\/notes\/([^/]+)\/?$/,
2846
+ handler: async () => {
2847
+ return errorResponse(DB_NOT_CONNECTED, 503);
2848
+ }
2849
+ },
2850
+ // GET /api/v1/search — full-text search
2851
+ {
2852
+ method: "GET",
2853
+ pattern: /^\/api\/v1\/search\/?$/,
2854
+ handler: async () => {
2855
+ return errorResponse(DB_NOT_CONNECTED, 503);
2856
+ }
2857
+ }
2858
+ ];
2859
+ }
2860
+ function matchRoute(routes, request, url) {
2861
+ for (const route of routes) {
2862
+ if (request.method !== route.method) continue;
2863
+ const match = url.pathname.match(route.pattern);
2864
+ if (match) return route;
2865
+ }
2866
+ return null;
2867
+ }
2868
+
2869
+ // src/blob-store.ts
2870
+ function hashPath(hash) {
2871
+ return {
2872
+ dir1: hash.slice(0, 2),
2873
+ dir2: hash.slice(2, 4),
2874
+ filename: hash
2875
+ };
2876
+ }
2877
+ var OpfsBlobStore = class {
2878
+ constructor(archiveName) {
2879
+ this.archiveName = archiveName;
2880
+ }
2881
+ async getRoot() {
2882
+ const root = await navigator.storage.getDirectory();
2883
+ return root.getDirectoryHandle(`fortemi-${this.archiveName}-blobs`, { create: true });
2884
+ }
2885
+ async getFileHandle(hash, create) {
2886
+ const { dir1, dir2, filename } = hashPath(hash);
2887
+ try {
2888
+ const root = await this.getRoot();
2889
+ const d1 = await root.getDirectoryHandle(dir1, { create });
2890
+ const d2 = await d1.getDirectoryHandle(dir2, { create });
2891
+ return d2.getFileHandle(filename, { create });
2892
+ } catch (err) {
2893
+ if (err instanceof DOMException && err.name === "NotFoundError") {
2894
+ return null;
2895
+ }
2896
+ throw err;
2897
+ }
2898
+ }
2899
+ async write(hash, data) {
2900
+ const fh = await this.getFileHandle(hash, true);
2901
+ if (!fh) throw new Error(`OpfsBlobStore: could not create file for hash ${hash}`);
2902
+ const writable = await fh.createWritable();
2903
+ await writable.write(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
2904
+ await writable.close();
2905
+ }
2906
+ async read(hash) {
2907
+ const fh = await this.getFileHandle(hash, false);
2908
+ if (!fh) return null;
2909
+ const file = await fh.getFile();
2910
+ const buffer = await file.arrayBuffer();
2911
+ return new Uint8Array(buffer);
2912
+ }
2913
+ async remove(hash) {
2914
+ const { dir1, dir2, filename } = hashPath(hash);
2915
+ try {
2916
+ const root = await this.getRoot();
2917
+ const d1 = await root.getDirectoryHandle(dir1, { create: false });
2918
+ const d2 = await d1.getDirectoryHandle(dir2, { create: false });
2919
+ await d2.removeEntry(filename);
2920
+ } catch (err) {
2921
+ if (err instanceof DOMException && err.name === "NotFoundError") {
2922
+ return;
2923
+ }
2924
+ throw err;
2925
+ }
2926
+ }
2927
+ async exists(hash) {
2928
+ const fh = await this.getFileHandle(hash, false);
2929
+ return fh !== null;
2930
+ }
2931
+ };
2932
+ var IDB_STORE = "blobs";
2933
+ var IDB_VERSION = 1;
2934
+ function openDb(dbName) {
2935
+ return new Promise((resolve, reject) => {
2936
+ const req = indexedDB.open(dbName, IDB_VERSION);
2937
+ req.onupgradeneeded = () => {
2938
+ req.result.createObjectStore(IDB_STORE);
2939
+ };
2940
+ req.onsuccess = () => resolve(req.result);
2941
+ req.onerror = () => reject(req.error);
2942
+ });
2943
+ }
2944
+ var IdbBlobStore = class {
2945
+ dbName;
2946
+ _db = null;
2947
+ constructor(archiveName) {
2948
+ this.dbName = `fortemi-${archiveName}-blobs`;
2949
+ }
2950
+ async db() {
2951
+ if (!this._db) {
2952
+ this._db = await openDb(this.dbName);
2607
2953
  }
2608
- return tagMap;
2954
+ return this._db;
2955
+ }
2956
+ async write(hash, data) {
2957
+ const db = await this.db();
2958
+ return new Promise((resolve, reject) => {
2959
+ const tx = db.transaction(IDB_STORE, "readwrite");
2960
+ tx.objectStore(IDB_STORE).put(data, hash);
2961
+ tx.oncomplete = () => resolve();
2962
+ tx.onerror = () => reject(tx.error);
2963
+ });
2964
+ }
2965
+ async read(hash) {
2966
+ const db = await this.db();
2967
+ return new Promise((resolve, reject) => {
2968
+ const tx = db.transaction(IDB_STORE, "readonly");
2969
+ const req = tx.objectStore(IDB_STORE).get(hash);
2970
+ req.onsuccess = () => resolve(req.result ?? null);
2971
+ req.onerror = () => reject(req.error);
2972
+ });
2973
+ }
2974
+ async remove(hash) {
2975
+ const db = await this.db();
2976
+ return new Promise((resolve, reject) => {
2977
+ const tx = db.transaction(IDB_STORE, "readwrite");
2978
+ tx.objectStore(IDB_STORE).delete(hash);
2979
+ tx.oncomplete = () => resolve();
2980
+ tx.onerror = () => reject(tx.error);
2981
+ });
2982
+ }
2983
+ async exists(hash) {
2984
+ const db = await this.db();
2985
+ return new Promise((resolve, reject) => {
2986
+ const tx = db.transaction(IDB_STORE, "readonly");
2987
+ const req = tx.objectStore(IDB_STORE).count(hash);
2988
+ req.onsuccess = () => resolve(req.result > 0);
2989
+ req.onerror = () => reject(req.error);
2990
+ });
2991
+ }
2992
+ };
2993
+ var MemoryBlobStore = class {
2994
+ store = /* @__PURE__ */ new Map();
2995
+ async write(hash, data) {
2996
+ this.store.set(hash, data);
2997
+ }
2998
+ async read(hash) {
2999
+ return this.store.get(hash) ?? null;
3000
+ }
3001
+ async remove(hash) {
3002
+ this.store.delete(hash);
3003
+ }
3004
+ async exists(hash) {
3005
+ return this.store.has(hash);
2609
3006
  }
2610
3007
  };
3008
+ function createBlobStore(archiveName) {
3009
+ if (typeof navigator !== "undefined" && "storage" in navigator && "getDirectory" in navigator.storage) {
3010
+ return new OpfsBlobStore(archiveName);
3011
+ }
3012
+ return new IdbBlobStore(archiveName);
3013
+ }
2611
3014
 
2612
3015
  // src/repositories/graph-repository.ts
2613
3016
  var SIMILARITY_GRAPH_ALGORITHM = "knn-batched-v1";
@@ -3716,54 +4119,6 @@ var SkosRepository = class {
3716
4119
  return result.rows;
3717
4120
  }
3718
4121
  };
3719
- var CaptureKnowledgeInputSchema = z.object({
3720
- action: z.enum(["create", "bulk_create", "from_template"]),
3721
- // For create
3722
- content: z.string().optional(),
3723
- title: z.string().optional(),
3724
- format: z.enum(["markdown", "plain", "html"]).default("markdown"),
3725
- source: z.string().default("user"),
3726
- visibility: z.enum(["private", "shared", "public"]).default("private"),
3727
- tags: z.array(z.string()).optional(),
3728
- archive_id: z.string().optional(),
3729
- // For bulk_create
3730
- notes: z.array(
3731
- z.object({
3732
- content: z.string(),
3733
- title: z.string().optional(),
3734
- format: z.enum(["markdown", "plain", "html"]).default("markdown"),
3735
- tags: z.array(z.string()).optional()
3736
- })
3737
- ).optional(),
3738
- // For from_template
3739
- template: z.string().optional(),
3740
- variables: z.record(z.string()).optional()
3741
- });
3742
- var ManageNoteInputSchema = z.object({
3743
- action: z.enum(["update", "delete", "restore", "archive", "unarchive", "star", "unstar"]),
3744
- note_id: z.string(),
3745
- // For update
3746
- title: z.string().optional(),
3747
- content: z.string().optional(),
3748
- format: z.string().optional(),
3749
- visibility: z.string().optional()
3750
- });
3751
- var SearchInputSchema = z.object({
3752
- query: z.string(),
3753
- mode: z.enum(["text", "semantic", "hybrid"]).default("text"),
3754
- limit: z.number().int().min(1).max(100).default(20),
3755
- offset: z.number().int().min(0).default(0),
3756
- tags: z.array(z.string()).optional(),
3757
- collection_id: z.string().optional(),
3758
- date_from: z.coerce.date().optional(),
3759
- date_to: z.coerce.date().optional(),
3760
- is_starred: z.boolean().optional(),
3761
- is_archived: z.boolean().optional(),
3762
- format: z.enum(["markdown", "plain", "html"]).optional(),
3763
- source: z.string().optional(),
3764
- visibility: z.enum(["private", "shared", "public"]).optional(),
3765
- include_facets: z.boolean().default(false)
3766
- });
3767
4122
 
3768
4123
  // src/tools/capture-knowledge.ts
3769
4124
  async function captureKnowledge(db, rawInput, events) {
@@ -3816,51 +4171,6 @@ async function captureKnowledge(db, rawInput, events) {
3816
4171
  }
3817
4172
  }
3818
4173
 
3819
- // src/tools/manage-note.ts
3820
- async function manageNote(db, rawInput, events) {
3821
- const input = ManageNoteInputSchema.parse(rawInput);
3822
- const repo = new NotesRepository(db, events);
3823
- switch (input.action) {
3824
- case "update": {
3825
- const note = await repo.update(input.note_id, {
3826
- title: input.title,
3827
- content: input.content,
3828
- format: input.format,
3829
- visibility: input.visibility
3830
- });
3831
- return { action: "update", note_id: input.note_id, note };
3832
- }
3833
- case "delete": {
3834
- await repo.delete(input.note_id);
3835
- return { action: "delete", note_id: input.note_id };
3836
- }
3837
- case "restore": {
3838
- const note = await repo.restore(input.note_id);
3839
- return { action: "restore", note_id: input.note_id, note };
3840
- }
3841
- case "archive": {
3842
- await repo.archive(input.note_id, true);
3843
- const note = await repo.get(input.note_id);
3844
- return { action: "archive", note_id: input.note_id, note };
3845
- }
3846
- case "unarchive": {
3847
- await repo.archive(input.note_id, false);
3848
- const note = await repo.get(input.note_id);
3849
- return { action: "unarchive", note_id: input.note_id, note };
3850
- }
3851
- case "star": {
3852
- await repo.star(input.note_id, true);
3853
- const note = await repo.get(input.note_id);
3854
- return { action: "star", note_id: input.note_id, note };
3855
- }
3856
- case "unstar": {
3857
- await repo.star(input.note_id, false);
3858
- const note = await repo.get(input.note_id);
3859
- return { action: "unstar", note_id: input.note_id, note };
3860
- }
3861
- }
3862
- }
3863
-
3864
4174
  // src/tools/search.ts
3865
4175
  async function searchTool(db, rawInput) {
3866
4176
  const input = SearchInputSchema.parse(rawInput);
@@ -5104,13 +5414,13 @@ var OpenAICompatibleProvider = class {
5104
5414
  throw new Error("Streaming not supported: response body is null");
5105
5415
  }
5106
5416
  const reader = response.body.getReader();
5107
- const decoder2 = new TextDecoder();
5417
+ const decoder4 = new TextDecoder();
5108
5418
  let buffer = "";
5109
5419
  try {
5110
5420
  while (true) {
5111
5421
  const { done, value } = await reader.read();
5112
5422
  if (done) break;
5113
- buffer += decoder2.decode(value, { stream: true });
5423
+ buffer += decoder4.decode(value, { stream: true });
5114
5424
  const lines = buffer.split("\n");
5115
5425
  buffer = lines.pop() ?? "";
5116
5426
  for (const line of lines) {
@@ -6141,8 +6451,21 @@ async function exportShard(db, options) {
6141
6451
  tags: tagsByNote.get(row.id) ?? []
6142
6452
  }));
6143
6453
  const exportedNoteIds = new Set(notes.map((n) => n.id));
6144
- const notesJsonl = notes.map((n) => JSON.stringify(noteToShard(n))).join("\n");
6145
- files.set("notes.jsonl", encoder.encode(notesJsonl));
6454
+ const shardNotes = notes.map((n) => noteToShard(n));
6455
+ let layout;
6456
+ const clusterSize = options?.clusterNotesSize;
6457
+ if (clusterSize && Number.isInteger(clusterSize) && clusterSize > 0 && shardNotes.length > 0) {
6458
+ const clusters = [];
6459
+ for (let offset = 0; offset < shardNotes.length; offset += clusterSize) {
6460
+ const slice = shardNotes.slice(offset, offset + clusterSize);
6461
+ const href = `notes/${String(offset).padStart(6, "0")}.jsonl`;
6462
+ clusters.push({ href, offset, count: slice.length });
6463
+ files.set(href, encoder.encode(slice.map((n) => JSON.stringify(n)).join("\n")));
6464
+ }
6465
+ layout = { clusters: { notes: clusters } };
6466
+ } else {
6467
+ files.set("notes.jsonl", encoder.encode(shardNotes.map((n) => JSON.stringify(n)).join("\n")));
6468
+ }
6146
6469
  components.push("notes");
6147
6470
  counts.notes = notes.length;
6148
6471
  const collectionRows = await db.query(
@@ -6375,7 +6698,8 @@ async function exportShard(db, options) {
6375
6698
  components,
6376
6699
  counts,
6377
6700
  checksums,
6378
- min_reader_version: "1.0.0"
6701
+ min_reader_version: "1.0.0",
6702
+ ...layout ? { layout } : {}
6379
6703
  };
6380
6704
  files.set("manifest.json", encoder.encode(JSON.stringify(manifest, null, 2)));
6381
6705
  return packTarGz(files);
@@ -6489,7 +6813,8 @@ async function importShard(db, data, options) {
6489
6813
  };
6490
6814
  }
6491
6815
  report?.({ phase: "validate", done: 1, total: 1 });
6492
- const parsedNotes = parseJsonl(files.get("notes.jsonl"));
6816
+ const noteClusters = manifest.layout?.clusters?.notes;
6817
+ const parsedNotes = noteClusters && noteClusters.length > 0 ? [...noteClusters].sort((a, b) => a.offset - b.offset).flatMap((ref) => parseJsonl(files.get(ref.href))) : parseJsonl(files.get("notes.jsonl"));
6493
6818
  const parsedCollections = parseJsonArray(files.get("collections.json"));
6494
6819
  parseJsonArray(files.get("tags.json"));
6495
6820
  const parsedLinks = parseJsonl(files.get("links.jsonl"));
@@ -6915,6 +7240,354 @@ function parseJsonArray(data) {
6915
7240
  return JSON.parse(decoder.decode(data));
6916
7241
  }
6917
7242
 
7243
+ // src/shard/shard-reader.ts
7244
+ var decoder2 = new TextDecoder();
7245
+ function parseJsonlBytes(data) {
7246
+ if (!data || data.byteLength === 0) return [];
7247
+ return decoder2.decode(data).split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
7248
+ }
7249
+ function parseJsonArrayBytes(data) {
7250
+ if (!data || data.byteLength === 0) return [];
7251
+ return JSON.parse(decoder2.decode(data));
7252
+ }
7253
+ var DEFAULT_WEIGHTS = { title: 4, content: 1, tag: 2 };
7254
+ async function toBytes(blobOrBytes) {
7255
+ if (blobOrBytes instanceof Uint8Array) return blobOrBytes;
7256
+ return new Uint8Array(await blobOrBytes.arrayBuffer());
7257
+ }
7258
+ var PackedComponentStore = class {
7259
+ manifest;
7260
+ files;
7261
+ constructor(files, manifest) {
7262
+ this.files = files;
7263
+ this.manifest = manifest;
7264
+ }
7265
+ read(filename) {
7266
+ return Promise.resolve(this.files.get(filename));
7267
+ }
7268
+ };
7269
+ var UrlComponentStore = class {
7270
+ manifest;
7271
+ baseUrl;
7272
+ fetchImpl;
7273
+ cache = /* @__PURE__ */ new Map();
7274
+ constructor(baseUrl, fetchImpl, manifest) {
7275
+ this.baseUrl = baseUrl.replace(/\/$/, "");
7276
+ this.fetchImpl = fetchImpl;
7277
+ this.manifest = manifest;
7278
+ }
7279
+ async read(filename) {
7280
+ if (this.cache.has(filename)) return this.cache.get(filename);
7281
+ const response = await this.fetchImpl(`${this.baseUrl}/${filename}`);
7282
+ if (!response.ok) {
7283
+ this.cache.set(filename, void 0);
7284
+ return void 0;
7285
+ }
7286
+ const bytes = new Uint8Array(await response.arrayBuffer());
7287
+ this.cache.set(filename, bytes);
7288
+ return bytes;
7289
+ }
7290
+ };
7291
+ async function resolveStore(source) {
7292
+ if (typeof source === "object" && "baseUrl" in source) {
7293
+ const fetchImpl = source.fetchImpl ?? globalThis.fetch;
7294
+ const base = source.baseUrl.replace(/\/$/, "");
7295
+ const manifestResponse = await fetchImpl(`${base}/manifest.json`);
7296
+ if (!manifestResponse.ok) {
7297
+ throw new Error(`Failed to fetch shard manifest (${manifestResponse.status}): ${base}/manifest.json`);
7298
+ }
7299
+ const manifest2 = await manifestResponse.json();
7300
+ return new UrlComponentStore(base, fetchImpl, manifest2);
7301
+ }
7302
+ const bytes = await toBytes(source);
7303
+ const files = unpackTarGz(bytes);
7304
+ const manifestBytes = files.get("manifest.json");
7305
+ if (!manifestBytes) throw new Error("Missing manifest.json in shard archive");
7306
+ const manifest = JSON.parse(decoder2.decode(manifestBytes));
7307
+ return new PackedComponentStore(files, manifest);
7308
+ }
7309
+ function tokenize(query) {
7310
+ return query.toLowerCase().split(/[^a-z0-9]+/i).filter((token) => token.length > 0);
7311
+ }
7312
+ function noteSearchText(note) {
7313
+ return `${note.title ?? ""} ${note.original_content} ${note.revised_content ?? ""}`.toLowerCase();
7314
+ }
7315
+ function countOccurrences(haystack, needle) {
7316
+ if (!needle) return 0;
7317
+ let count = 0;
7318
+ let index = haystack.indexOf(needle);
7319
+ while (index !== -1) {
7320
+ count += 1;
7321
+ index = haystack.indexOf(needle, index + needle.length);
7322
+ }
7323
+ return count;
7324
+ }
7325
+ function noteMatchesTokens(note, tokens) {
7326
+ if (tokens.length === 0) return true;
7327
+ const text = noteSearchText(note);
7328
+ const tagText = note.tags.join(" ").toLowerCase();
7329
+ return tokens.every((token) => text.includes(token) || tagText.includes(token));
7330
+ }
7331
+ function rankNote(note, tokens, weights) {
7332
+ if (tokens.length === 0) return 0;
7333
+ const title = (note.title ?? "").toLowerCase();
7334
+ const content = `${note.original_content} ${note.revised_content ?? ""}`.toLowerCase();
7335
+ const tagText = note.tags.join(" ").toLowerCase();
7336
+ let score = 0;
7337
+ for (const token of tokens) {
7338
+ score += countOccurrences(title, token) * weights.title;
7339
+ score += countOccurrences(content, token) * weights.content;
7340
+ if (tagText.includes(token)) score += weights.tag;
7341
+ }
7342
+ return score;
7343
+ }
7344
+ function makeSnippet(note, tokens, length) {
7345
+ const content = `${note.original_content} ${note.revised_content ?? ""}`.trim();
7346
+ if (tokens.length === 0) return content.slice(0, length);
7347
+ const lower = content.toLowerCase();
7348
+ const firstAt = tokens.map((token) => lower.indexOf(token)).filter((index) => index !== -1).sort((a, b) => a - b)[0] ?? 0;
7349
+ const start = Math.max(0, firstAt - Math.floor(length / 3));
7350
+ return (start > 0 ? "\u2026" : "") + content.slice(start, start + length) + (start + length < content.length ? "\u2026" : "");
7351
+ }
7352
+ function passesFilters(note, options) {
7353
+ if (!options.includeDeleted && note.deleted_at) return false;
7354
+ if (options.includeArchived === false && note.archived) return false;
7355
+ if (options.tags && !options.tags.every((tag) => note.tags.includes(tag))) return false;
7356
+ if (options.source && !options.source.includes(note.source)) return false;
7357
+ return true;
7358
+ }
7359
+ function facetCounts(notes) {
7360
+ const tags = {};
7361
+ const source = {};
7362
+ for (const note of notes) {
7363
+ for (const tag of note.tags) tags[tag] = (tags[tag] ?? 0) + 1;
7364
+ source[note.source] = (source[note.source] ?? 0) + 1;
7365
+ }
7366
+ return { tags, source };
7367
+ }
7368
+ var ShardReaderImpl = class {
7369
+ manifest;
7370
+ store;
7371
+ options;
7372
+ noteClusterCache = /* @__PURE__ */ new Map();
7373
+ allNotes = null;
7374
+ links = null;
7375
+ noteSkos = null;
7376
+ concepts = null;
7377
+ matchCache = /* @__PURE__ */ new Map();
7378
+ maxCachedMatches;
7379
+ semanticPrepared = false;
7380
+ constructor(store, options) {
7381
+ this.store = store;
7382
+ this.manifest = store.manifest;
7383
+ this.options = options;
7384
+ this.maxCachedMatches = options.maxCachedMatches && options.maxCachedMatches > 0 ? options.maxCachedMatches : 5e3;
7385
+ }
7386
+ noteClusters() {
7387
+ return this.manifest.layout?.clusters?.notes ?? null;
7388
+ }
7389
+ async readCluster(ref) {
7390
+ const cached = this.noteClusterCache.get(ref.href);
7391
+ if (cached) return { notes: cached, fetched: false };
7392
+ const notes = parseJsonlBytes(await this.store.read(ref.href));
7393
+ this.noteClusterCache.set(ref.href, notes);
7394
+ return { notes, fetched: true };
7395
+ }
7396
+ /** Load every note (clustered or monolithic). Returns count of cluster files fetched. */
7397
+ async loadAllNotes() {
7398
+ const clusters = this.noteClusters();
7399
+ if (!clusters) {
7400
+ if (!this.allNotes) {
7401
+ this.allNotes = parseJsonlBytes(await this.store.read("notes.jsonl"));
7402
+ return { notes: this.allNotes, fetched: 1 };
7403
+ }
7404
+ return { notes: this.allNotes, fetched: 0 };
7405
+ }
7406
+ const all = [];
7407
+ let fetched = 0;
7408
+ for (const ref of clusters) {
7409
+ const loaded = await this.readCluster(ref);
7410
+ if (loaded.fetched) fetched += 1;
7411
+ all.push(...loaded.notes);
7412
+ }
7413
+ return { notes: all, fetched };
7414
+ }
7415
+ async listNotes(options = {}) {
7416
+ const offset = options.offset ?? 0;
7417
+ const limit = options.limit ?? Number.MAX_SAFE_INTEGER;
7418
+ const { notes } = await this.loadAllNotes();
7419
+ const filtered = notes.filter((note) => passesFilters(note, options));
7420
+ const page = filtered.slice(offset, offset + limit);
7421
+ return { items: page.map(noteFromShard), total: filtered.length };
7422
+ }
7423
+ async getNote(id) {
7424
+ const { notes } = await this.loadAllNotes();
7425
+ const found = notes.find((note) => note.id === id);
7426
+ return found ? noteFromShard(found) : null;
7427
+ }
7428
+ matchKey(query, options) {
7429
+ return JSON.stringify({
7430
+ q: query.trim().toLowerCase(),
7431
+ tags: options.tags ?? null,
7432
+ source: options.source ?? null,
7433
+ includeArchived: options.includeArchived ?? true,
7434
+ includeDeleted: options.includeDeleted ?? false,
7435
+ weights: { ...DEFAULT_WEIGHTS, ...options.weights }
7436
+ });
7437
+ }
7438
+ cacheMatches(key, notes) {
7439
+ this.matchCache.delete(key);
7440
+ this.matchCache.set(key, notes);
7441
+ let total = 0;
7442
+ for (const set of this.matchCache.values()) total += set.length;
7443
+ while (total > this.maxCachedMatches && this.matchCache.size > 1) {
7444
+ const oldest = this.matchCache.keys().next().value;
7445
+ if (oldest === void 0 || oldest === key) break;
7446
+ total -= this.matchCache.get(oldest)?.length ?? 0;
7447
+ this.matchCache.delete(oldest);
7448
+ }
7449
+ }
7450
+ async search(query, options = {}) {
7451
+ const tokens = tokenize(query);
7452
+ const weights = { ...DEFAULT_WEIGHTS, ...options.weights };
7453
+ const offset = options.offset ?? 0;
7454
+ const limit = options.limit ?? Number.MAX_SAFE_INTEGER;
7455
+ const key = this.matchKey(query, options);
7456
+ let matched = this.matchCache.get(key);
7457
+ let fetchedClusters = 0;
7458
+ if (matched) {
7459
+ this.matchCache.delete(key);
7460
+ this.matchCache.set(key, matched);
7461
+ } else {
7462
+ const { notes, fetched } = await this.loadAllNotes();
7463
+ fetchedClusters = fetched;
7464
+ matched = notes.filter((note) => passesFilters(note, options) && noteMatchesTokens(note, tokens));
7465
+ if (tokens.length > 0 && options.rank !== false) {
7466
+ matched = [...matched].sort((a, b) => rankNote(b, tokens, weights) - rankNote(a, tokens, weights));
7467
+ }
7468
+ this.cacheMatches(key, matched);
7469
+ }
7470
+ const page = matched.slice(offset, offset + limit);
7471
+ const result = {
7472
+ items: page.map(noteFromShard),
7473
+ total: matched.length,
7474
+ facets: facetCounts(matched),
7475
+ fetchedClusters
7476
+ };
7477
+ if (options.rank || options.snippets) {
7478
+ const snippetLength = options.snippetLength ?? 160;
7479
+ result.rankedItems = page.map((note) => ({
7480
+ note: noteFromShard(note),
7481
+ rank: rankNote(note, tokens, weights),
7482
+ ...options.snippets ? { snippet: makeSnippet(note, tokens, snippetLength) } : {}
7483
+ }));
7484
+ }
7485
+ return result;
7486
+ }
7487
+ async loadLinks() {
7488
+ if (!this.links) this.links = parseJsonlBytes(await this.store.read("links.jsonl"));
7489
+ return this.links;
7490
+ }
7491
+ async linksOf(id) {
7492
+ const links = await this.loadLinks();
7493
+ return links.filter((link) => link.from_note_id === id || link.to_note_id === id);
7494
+ }
7495
+ async loadConcepts() {
7496
+ if (!this.noteSkos) {
7497
+ this.noteSkos = parseJsonlBytes(await this.store.read("note_skos_tags.jsonl"));
7498
+ }
7499
+ if (!this.concepts) {
7500
+ const list = parseJsonArrayBytes(await this.store.read("skos_concepts.json"));
7501
+ this.concepts = new Map(list.map((concept) => [concept.id, concept]));
7502
+ }
7503
+ return { noteSkos: this.noteSkos, concepts: this.concepts };
7504
+ }
7505
+ async conceptsOf(id) {
7506
+ const { noteSkos, concepts } = await this.loadConcepts();
7507
+ const out = [];
7508
+ for (const tag of noteSkos) {
7509
+ if (tag.note_id !== id) continue;
7510
+ const concept = concepts.get(tag.concept_id);
7511
+ if (concept) out.push(concept);
7512
+ }
7513
+ return out;
7514
+ }
7515
+ async getNoteFull(id) {
7516
+ const note = await this.getNote(id);
7517
+ if (!note) return null;
7518
+ const [links, concepts] = await Promise.all([this.linksOf(id), this.conceptsOf(id)]);
7519
+ return { note, links, concepts };
7520
+ }
7521
+ async semantic(query, k = 10) {
7522
+ const provider = this.options.semantic;
7523
+ if (!provider) return [];
7524
+ if (!this.semanticPrepared) {
7525
+ await provider.prepare?.(this.store);
7526
+ this.semanticPrepared = true;
7527
+ }
7528
+ const hits = await provider.search(query, k);
7529
+ const { notes } = await this.loadAllNotes();
7530
+ const byId = new Map(notes.map((note) => [note.id, note]));
7531
+ const out = [];
7532
+ for (const hit of hits) {
7533
+ const note = byId.get(hit.id);
7534
+ if (note) out.push({ note: noteFromShard(note), score: hit.score });
7535
+ }
7536
+ return out;
7537
+ }
7538
+ close() {
7539
+ this.noteClusterCache.clear();
7540
+ this.matchCache.clear();
7541
+ this.allNotes = null;
7542
+ this.links = null;
7543
+ this.noteSkos = null;
7544
+ this.concepts = null;
7545
+ }
7546
+ };
7547
+ async function openShard(source, options = {}) {
7548
+ const store = await resolveStore(source);
7549
+ if (store.manifest.min_reader_version && store.manifest.min_reader_version > CURRENT_SHARD_VERSION) {
7550
+ throw new Error(
7551
+ `Shard requires reader version ${store.manifest.min_reader_version}, but this build supports ${CURRENT_SHARD_VERSION}. Import the shard instead.`
7552
+ );
7553
+ }
7554
+ return new ShardReaderImpl(store, options);
7555
+ }
7556
+
7557
+ // src/shard/semantic-providers.ts
7558
+ var decoder3 = new TextDecoder();
7559
+ function cosine(a, b) {
7560
+ let dot = 0;
7561
+ let normA = 0;
7562
+ let normB = 0;
7563
+ const len = Math.min(a.length, b.length);
7564
+ for (let i = 0; i < len; i++) {
7565
+ dot += a[i] * b[i];
7566
+ normA += a[i] * a[i];
7567
+ normB += b[i] * b[i];
7568
+ }
7569
+ if (normA === 0 || normB === 0) return 0;
7570
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
7571
+ }
7572
+ function createCosineSemanticProvider(options) {
7573
+ let vectors = options.vectors ?? [];
7574
+ return {
7575
+ async prepare(store) {
7576
+ if (options.vectors) return;
7577
+ const bytes = await store.read(options.vectorsFile ?? "vectors.jsonl");
7578
+ if (!bytes || bytes.byteLength === 0) {
7579
+ vectors = [];
7580
+ return;
7581
+ }
7582
+ vectors = decoder3.decode(bytes).split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
7583
+ },
7584
+ async search(query, k) {
7585
+ const queryVector = await options.embedQuery(query);
7586
+ return vectors.map((entry) => ({ id: entry.id, score: cosine(queryVector, entry.vector) })).sort((a, b) => b.score - a.score).slice(0, k);
7587
+ }
7588
+ };
7589
+ }
7590
+
6918
7591
  // src/shard/prefetch.ts
6919
7592
  var DEFAULT_CACHE_NAME = "fortemi-shards";
6920
7593
  var warmStore = /* @__PURE__ */ new Map();
@@ -7166,6 +7839,9 @@ function validateAiwgFortemiChunkManifest(value) {
7166
7839
  if (data.detail !== void 0) {
7167
7840
  if (!hasString(data.detail.href)) errors.push("detail.href is required");
7168
7841
  else if (!data.detail.href.includes("{id}")) errors.push("detail.href must contain the {id} placeholder");
7842
+ if (data.detail.encoding !== void 0 && data.detail.encoding !== "uri" && data.detail.encoding !== "base64url") {
7843
+ errors.push("detail.encoding must be 'uri' or 'base64url'");
7844
+ }
7169
7845
  }
7170
7846
  if (!Array.isArray(data?.parts)) errors.push("parts must be an array");
7171
7847
  let expectedOffset = 0;
@@ -7267,10 +7943,20 @@ function createAiwgFetchChunkLoader(baseUrl) {
7267
7943
  return response.json();
7268
7944
  };
7269
7945
  }
7946
+ function encodeAiwgDetailId(id, encoding = "base64url") {
7947
+ if (encoding === "uri") return encodeURIComponent(id);
7948
+ const bytes = new TextEncoder().encode(id);
7949
+ let binary = "";
7950
+ for (const byte of bytes) binary += String.fromCharCode(byte);
7951
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7952
+ }
7953
+ function aiwgDetailHrefForId(detail, id) {
7954
+ return detail.href.replace("{id}", encodeAiwgDetailId(id, detail.encoding ?? "uri"));
7955
+ }
7270
7956
  function createAiwgFetchDetailLoader(baseUrl) {
7271
7957
  return async (id, manifest) => {
7272
7958
  if (!manifest.detail?.href) throw new Error("Manifest has no detail.href for record resolution");
7273
- const relative = manifest.detail.href.replace("{id}", encodeURIComponent(id));
7959
+ const relative = aiwgDetailHrefForId(manifest.detail, id);
7274
7960
  const href = baseUrl ? new URL(relative, baseUrl).toString() : relative;
7275
7961
  const response = await fetch(href);
7276
7962
  if (!response.ok) throw new Error("Failed to fetch AIWG index detail " + href + ": " + response.status);
@@ -7293,6 +7979,8 @@ function getAiwgFortemiFacets(items) {
7293
7979
  function buildAiwgChunkedIndex(index, options = {}) {
7294
7980
  const partSize = hasPositiveInteger(options.partSize) ? options.partSize : 500;
7295
7981
  const projection = options.projection;
7982
+ const idEncoding = options.idEncoding ?? "base64url";
7983
+ const detailHref = options.detailHref ?? "detail/{id}.json";
7296
7984
  const items = index.items;
7297
7985
  const pad = (value) => String(value).padStart(4, "0");
7298
7986
  const project = (record) => {
@@ -7325,12 +8013,16 @@ function buildAiwgChunkedIndex(index, options = {}) {
7325
8013
  part_size: partSize,
7326
8014
  facets: getAiwgFortemiFacets(items),
7327
8015
  parts: partRefs,
7328
- ...projection ? { projection, detail: { href: options.detailHref ?? "detail/{id}.json" } } : {}
8016
+ ...projection ? { projection, detail: { href: detailHref, encoding: idEncoding } } : {}
7329
8017
  };
7330
8018
  return {
7331
8019
  manifest,
7332
8020
  parts,
7333
- details: projection ? items.map((record) => ({ id: record.id, record })) : []
8021
+ details: projection ? items.map((record) => ({
8022
+ id: record.id,
8023
+ href: aiwgDetailHrefForId({ href: detailHref, encoding: idEncoding }, record.id),
8024
+ record
8025
+ })) : []
7334
8026
  };
7335
8027
  }
7336
8028
  function includesAll(actual, expected) {
@@ -7440,6 +8132,34 @@ function clampMaxCachedDetails(value) {
7440
8132
  if (!hasPositiveInteger(value)) return 32;
7441
8133
  return value;
7442
8134
  }
8135
+ function clampMaxCachedMatches(value) {
8136
+ if (!hasPositiveInteger(value)) return 5e3;
8137
+ return value;
8138
+ }
8139
+ function matchSetCacheKey(q, options) {
8140
+ return JSON.stringify({
8141
+ q,
8142
+ types: options.types ?? null,
8143
+ facets: options.facets ?? null,
8144
+ tags: options.tags ?? null,
8145
+ concepts: options.concepts ?? null,
8146
+ privacy: options.privacy ?? null,
8147
+ rel: options.relationshipTargetId ?? null,
8148
+ weights: { ...DEFAULT_QUERY_WEIGHTS, ...options.weights }
8149
+ });
8150
+ }
8151
+ function cacheMatchEntries(runtime, key, entries) {
8152
+ runtime.matchCache.delete(key);
8153
+ runtime.matchCache.set(key, entries);
8154
+ let total = 0;
8155
+ for (const set of runtime.matchCache.values()) total += set.length;
8156
+ while (total > runtime.maxCachedMatches && runtime.matchCache.size > 1) {
8157
+ const oldest = runtime.matchCache.keys().next().value;
8158
+ if (oldest === void 0 || oldest === key) break;
8159
+ total -= runtime.matchCache.get(oldest)?.length ?? 0;
8160
+ runtime.matchCache.delete(oldest);
8161
+ }
8162
+ }
7443
8163
  function isDirectChunkBrowse(query, options) {
7444
8164
  return query.trim() === "" && !options.rank && !options.snippets && !options.includeMatches && !options.types && !options.facets && !options.tags && !options.concepts && !options.privacy && !options.relationshipTargetId;
7445
8165
  }
@@ -7526,6 +8246,19 @@ async function queryChunkedAiwgFortemiIndex(runtime, query = "", options = {}) {
7526
8246
  complete: true
7527
8247
  };
7528
8248
  }
8249
+ const matchKey = matchSetCacheKey(q, options);
8250
+ const cached = runtime.matchCache.get(matchKey);
8251
+ if (cached) {
8252
+ runtime.matchCache.delete(matchKey);
8253
+ runtime.matchCache.set(matchKey, cached);
8254
+ return {
8255
+ ...createQueryResultFromRankedEntries(cached, q, options),
8256
+ manifestTotal: runtime.manifest.total,
8257
+ scannedParts: 0,
8258
+ fetchedParts: 0,
8259
+ complete: true
8260
+ };
8261
+ }
7529
8262
  const entries = [];
7530
8263
  for (const partRef of runtime.manifest.parts) {
7531
8264
  const loaded = await loadChunkPart(runtime, partRef);
@@ -7535,6 +8268,7 @@ async function queryChunkedAiwgFortemiIndex(runtime, query = "", options = {}) {
7535
8268
  entries.push(...createRankedEntries(loaded.part.items, q, options, partRef.offset));
7536
8269
  options.onProgress?.({ phase: "query", done: scannedParts, total: runtime.manifest.parts.length, href: partRef.href });
7537
8270
  }
8271
+ cacheMatchEntries(runtime, matchKey, entries);
7538
8272
  return {
7539
8273
  ...createQueryResultFromRankedEntries(entries, q, options),
7540
8274
  manifestTotal: runtime.manifest.total,
@@ -7605,7 +8339,9 @@ function createAiwgIndexController(initialIndex) {
7605
8339
  partCache: /* @__PURE__ */ new Map(),
7606
8340
  detailLoader: options.detailLoader,
7607
8341
  maxCachedDetails: clampMaxCachedDetails(options.maxCachedDetails),
7608
- detailCache: /* @__PURE__ */ new Map()
8342
+ detailCache: /* @__PURE__ */ new Map(),
8343
+ maxCachedMatches: clampMaxCachedMatches(options.maxCachedMatches),
8344
+ matchCache: /* @__PURE__ */ new Map()
7609
8345
  };
7610
8346
  data = null;
7611
8347
  reviewDecisions = [];
@@ -7665,6 +8401,7 @@ function createAiwgIndexController(initialIndex) {
7665
8401
  clearChunkCache() {
7666
8402
  chunked?.partCache.clear();
7667
8403
  chunked?.detailCache.clear();
8404
+ chunked?.matchCache.clear();
7668
8405
  error = null;
7669
8406
  notify();
7670
8407
  },
@@ -7690,7 +8427,9 @@ function createAiwgIndexController(initialIndex) {
7690
8427
  notify();
7691
8428
  },
7692
8429
  createReviewDecisionExport(generatedAt) {
7693
- return createAiwgReviewDecisionExport(requireIndex(), reviewDecisions, generatedAt);
8430
+ const source = index ?? (chunked ? { schema_version: "aiwg.fortemi.index.export.v1" } : null);
8431
+ if (!source) throw new Error("No AIWG index export or chunked manifest loaded");
8432
+ return createAiwgReviewDecisionExport(source, reviewDecisions, generatedAt);
7694
8433
  },
7695
8434
  subscribe(listener) {
7696
8435
  listeners.add(listener);
@@ -7747,6 +8486,6 @@ function communityIdsFor(item, options) {
7747
8486
  // src/index.ts
7748
8487
  var VERSION = "2026.6.4";
7749
8488
 
7750
- export { AIWG_SCAN_REQUIRED_FIELDS, ArchiveManager, AttachmentsRepository, CURRENT_SHARD_VERSION, CapabilityManager, CaptureKnowledgeInputSchema, CollectionsRepository, CommunitiesRepository, EMBED_REQUEST_KIND, EMBED_RESPONSE_KIND, EmbeddingSetsRepository, FallbackRouter, FortemiToolManifest, GetNoteInputSchema, GraphRepository, JOB_CAPABILITIES, JOB_PRIORITIES, JobQueueWorker, LOCAL_ENDPOINTS, LinksRepository, ListNotesInputSchema, ManageArchiveInputSchema, ManageAttachmentsInputSchema, ManageCapabilitiesInputSchema, ManageCollectionsInputSchema, ManageLinksInputSchema, ManageNoteInputSchema, ManageTagsInputSchema, MemoryBlobStore, MigrationRunner, NotesRepository, OpenAICompatibleProvider, PGliteStorageBackend, PGliteStorageBackendFactory, PGliteWorkerClient, PGliteWorkerStorageBackend, PGliteWorkerStorageBackendFactory, ProviderRegistry, SHARD_FORMAT, SearchInputSchema, SearchRepository, SkosRepository, TagsRepository, TransactionProxy, TypedEventBus, VERSION, aiRevisionHandler, aiwgFortemiIndexToCommunityGraph, allMigrations, appendPluginScript, assertAiwgFortemiChunkManifest, assertAiwgFortemiChunkPart, assertAiwgFortemiIndexExport, buildAiwgChunkedIndex, buildNoteConditions, buildPluginCsp, captureKnowledge, chunkText, classifyError, classifyModel, clearPrefetchedShard, collectionFromShard, collectionToShard, computeHash, computeSri, conceptTaggingHandler, cosineSimilarity, createAiwgFetchChunkLoader, createAiwgFetchDetailLoader, createAiwgIndexController, createAiwgReviewDecisionExport, createBlobStore, createCspReportHandler, createFortemi, createLegacyProvider, createPGliteInstance, createRoutes, createWorkerEmbedFunction, defaultStorageBackendFactory, detectCommunities, detectGpuCapabilities, detectInferenceCapabilities, discoverLocalProviders, embeddingFromShard, embeddingGenerationHandler, embeddingSetFromShard, embeddingSetMemberToShard, embeddingSetToShard, embeddingToShard, enqueueFullWorkflow, enqueueJob, enqueueNoteCreationJobs, estimateModelFit, estimateVramMB, estimateVramTier, exportShard, fetchPluginScript, fortemiManifest, fromPrefetched, generateId, getAiwgFortemiFacets, getEmbedFunction, getFortemiBridge, getFortemiSecretStore, getJobQueueStatus, getLlmFunction, getNote, getPrefetchedSha256, handleEmbedRequests, hasFortemiSecureSecrets, importShard, isPluginScriptAllowed, isShardPrefetched, linkFromShard, linkToShard, linkingHandler, listNotes, manageArchive, manageAttachments, manageCapabilities, manageCollections, manageLinks, manageNote, manageTags, matchRoute, noteFromShard, noteSkosTagToShard, noteToShard, packTarGz, parseCspReport, prefetchShard, provenanceEdgeToShard, queryAiwgFortemiIndex, registerLlmCapability, registerSemanticCapability, registerSemanticCapabilityWorker, registerServiceWorker, searchTool, selectLlmModel, setEmbedFunction, setLlmFunction, sha256Hex, skosConceptToShard, skosRelationToShard, skosSchemeToShard, suggestTags, tagsFromShard, tagsToShard, titleGenerationHandler, unpackTarGz, unregisterLlmCapability, unregisterSemanticCapability, validateAiwgFortemiChunkManifest, validateAiwgFortemiChunkPart, validateAiwgFortemiIndexExport, validateChecksums, verifySri };
8489
+ export { AIWG_SCAN_REQUIRED_FIELDS, ArchiveManager, AttachmentsRepository, CURRENT_MIGRATION_HEAD, CURRENT_SHARD_VERSION, CapabilityManager, CaptureKnowledgeInputSchema, CollectionsRepository, CommunitiesRepository, DB_SNAPSHOT_SCHEMA_VERSION, DbSnapshotVersionError, EMBED_REQUEST_KIND, EMBED_RESPONSE_KIND, EmbeddingSetsRepository, FallbackRouter, FortemiToolManifest, GetNoteInputSchema, GraphRepository, JOB_CAPABILITIES, JOB_PRIORITIES, JobQueueWorker, LOCAL_ENDPOINTS, LinksRepository, ListNotesInputSchema, ManageArchiveInputSchema, ManageAttachmentsInputSchema, ManageCapabilitiesInputSchema, ManageCollectionsInputSchema, ManageLinksInputSchema, ManageNoteInputSchema, ManageTagsInputSchema, MemoryBlobStore, MigrationRunner, NotesRepository, OpenAICompatibleProvider, PGliteStorageBackend, PGliteStorageBackendFactory, PGliteWorkerClient, PGliteWorkerStorageBackend, PGliteWorkerStorageBackendFactory, ProviderRegistry, SHARD_FORMAT, SUPPORTED_PGLITE_VERSION, SearchInputSchema, SearchRepository, SkosRepository, TagsRepository, TransactionProxy, TypedEventBus, VERSION, aiRevisionHandler, aiwgFortemiIndexToCommunityGraph, allMigrations, appendPluginScript, assertAiwgFortemiChunkManifest, assertAiwgFortemiChunkPart, assertAiwgFortemiIndexExport, buildAiwgChunkedIndex, buildNoteConditions, buildPluginCsp, captureKnowledge, chunkText, classifyError, classifyModel, clearPrefetchedShard, collectionFromShard, collectionToShard, computeHash, computeSri, conceptTaggingHandler, cosineSimilarity, createAiwgFetchChunkLoader, createAiwgFetchDetailLoader, createAiwgIndexController, createAiwgReviewDecisionExport, createBlobStore, createCosineSemanticProvider, createCspReportHandler, createFortemi, createLegacyProvider, createPGliteBackend, createPGliteInstance, createRoutes, createShardBackend, createWorkerEmbedFunction, defaultStorageBackendFactory, detectCommunities, detectGpuCapabilities, detectInferenceCapabilities, discoverLocalProviders, dumpDbSnapshot, embeddingFromShard, embeddingGenerationHandler, embeddingSetFromShard, embeddingSetMemberToShard, embeddingSetToShard, embeddingToShard, enqueueFullWorkflow, enqueueJob, enqueueNoteCreationJobs, estimateModelFit, estimateVramMB, estimateVramTier, exportShard, fetchPluginScript, fortemiManifest, fromPrefetched, generateId, getAiwgFortemiFacets, getEmbedFunction, getFortemiBridge, getFortemiSecretStore, getJobQueueStatus, getLlmFunction, getNote, getPrefetchedSha256, handleEmbedRequests, hasFortemiSecureSecrets, importShard, isPluginScriptAllowed, isShardPrefetched, linkFromShard, linkToShard, linkingHandler, listNotes, manageArchive, manageAttachments, manageCapabilities, manageCollections, manageLinks, manageNote, manageTags, matchRoute, noteFromShard, noteSkosTagToShard, noteToShard, openShard, packTarGz, parseCspReport, prefetchShard, provenanceEdgeToShard, queryAiwgFortemiIndex, registerLlmCapability, registerSemanticCapability, registerSemanticCapabilityWorker, registerServiceWorker, restoreDbSnapshot, searchTool, selectBackend, selectLlmModel, setEmbedFunction, setLlmFunction, sha256Hex, skosConceptToShard, skosRelationToShard, skosSchemeToShard, suggestTags, tagsFromShard, tagsToShard, titleGenerationHandler, unpackTarGz, unregisterLlmCapability, unregisterSemanticCapability, validateAiwgFortemiChunkManifest, validateAiwgFortemiChunkPart, validateAiwgFortemiIndexExport, validateChecksums, verifyDbSnapshotMeta, verifySri };
7751
8490
  //# sourceMappingURL=index.js.map
7752
8491
  //# sourceMappingURL=index.js.map