fuli-context 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,32 @@
1
+ import { validateLegacyRecords } from './legacy-record-validation.js';
2
+
3
+ const REQUIRED_COLLECTIONS = ['spaces', 'subscriptions', 'episodes', 'facts', 'candidates'];
4
+ const OPTIONAL_COLLECTIONS = ['outbox', 'imports'];
5
+
6
+ export function validateLegacySnapshot(snapshot) {
7
+ if (!isRecord(snapshot)) throw new TypeError('Snapshot must be a JSON object');
8
+ const collections = requireCollections(snapshot);
9
+ validateLegacyRecords(collections);
10
+ return collections;
11
+ }
12
+
13
+ function requireCollections(snapshot) {
14
+ const collections = {};
15
+ for (const name of REQUIRED_COLLECTIONS) collections[name] = requireArray(snapshot, name);
16
+ for (const name of OPTIONAL_COLLECTIONS) {
17
+ if (Object.hasOwn(snapshot, name)) collections[name] = requireArray(snapshot, name);
18
+ }
19
+ return collections;
20
+ }
21
+
22
+ function requireArray(snapshot, name) {
23
+ if (!Array.isArray(snapshot[name])) throw new TypeError(`Snapshot ${name} must be an array`);
24
+ return snapshot[name].map((record, index) => {
25
+ if (!isRecord(record)) throw new TypeError(`Snapshot ${name}[${index}] must be an object`);
26
+ return record;
27
+ });
28
+ }
29
+
30
+ function isRecord(value) {
31
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
32
+ }
@@ -0,0 +1,128 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { nowIso } from '../models.js';
6
+ import { isFileDatabase } from './sqlite/open-database.js';
7
+
8
+ const DEFAULT_MIGRATIONS_DIRECTORY = new URL('./migrations/', import.meta.url);
9
+ const CHECKSUM_UPGRADE_MIGRATION = '002-storage-integrity.sql';
10
+
11
+ export function configureDatabase(db, filePath) {
12
+ db.pragma('foreign_keys = ON');
13
+ db.pragma('busy_timeout = 5000');
14
+ if (isFileDatabase(filePath)) db.pragma('journal_mode = WAL');
15
+ }
16
+
17
+ export function runMigrations(
18
+ db,
19
+ { migrationsDirectory = DEFAULT_MIGRATIONS_DIRECTORY } = {}
20
+ ) {
21
+ db.pragma('foreign_keys = ON');
22
+ const migrations = discoverMigrations(migrationsDirectory);
23
+ for (const migration of migrations) {
24
+ const migrate = db.transaction(() => {
25
+ const state = appliedMigrations(db);
26
+ verifyAppliedMigrations(migrations, state.records, {
27
+ allowMissingChecksums: hasPendingChecksumUpgrade(migrations, state)
28
+ });
29
+ if (state.records.has(migration.version)) return;
30
+ db.exec(migration.sql);
31
+ recordMigration(db, migration);
32
+ });
33
+ migrate.immediate();
34
+ }
35
+ verifyAppliedMigrations(migrations, appliedMigrations(db).records);
36
+ }
37
+
38
+ function discoverMigrations(directory) {
39
+ const migrations = readdirSync(directory)
40
+ .filter((name) => name.endsWith('.sql'))
41
+ .map((name) => {
42
+ const match = /^(\d+)-.+\.sql$/.exec(name);
43
+ if (!match) throw new Error(`Invalid migration filename: ${name}`);
44
+ const sql = readFileSync(migrationPath(directory, name), 'utf8');
45
+ return {
46
+ version: Number.parseInt(match[1], 10),
47
+ name,
48
+ sql,
49
+ checksum: createHash('sha256').update(sql).digest('hex')
50
+ };
51
+ })
52
+ .sort((left, right) => left.version - right.version || left.name.localeCompare(right.name));
53
+
54
+ for (let index = 1; index < migrations.length; index += 1) {
55
+ if (migrations[index - 1].version === migrations[index].version) {
56
+ throw new Error(`Duplicate migration version: ${migrations[index].version}`);
57
+ }
58
+ }
59
+ return migrations;
60
+ }
61
+
62
+ function appliedMigrations(db) {
63
+ const columns = db.prepare('PRAGMA table_info(schema_migrations)').all();
64
+ if (columns.length === 0) return { records: new Map(), supportsChecksums: false };
65
+
66
+ const supportsChecksums = columns.some((column) => column.name === 'checksum');
67
+ const rows = supportsChecksums
68
+ ? db.prepare(`
69
+ SELECT version, checksum FROM schema_migrations ORDER BY version
70
+ `).all()
71
+ : db.prepare(`
72
+ SELECT version, NULL AS checksum FROM schema_migrations ORDER BY version
73
+ `).all();
74
+ return {
75
+ records: new Map(rows.map((row) => [row.version, row.checksum])),
76
+ supportsChecksums
77
+ };
78
+ }
79
+
80
+ function verifyAppliedMigrations(
81
+ migrations,
82
+ applied,
83
+ { allowMissingChecksums = false } = {}
84
+ ) {
85
+ const available = new Map(migrations.map((migration) => [migration.version, migration]));
86
+ for (const [version, checksum] of applied) {
87
+ const migration = available.get(version);
88
+ if (!migration) {
89
+ throw new Error(
90
+ `Applied migration version ${version} is missing from the migration directory`
91
+ );
92
+ }
93
+ if (checksum === null && allowMissingChecksums) continue;
94
+ if (checksum === null) {
95
+ throw new Error(`Applied migration version ${version} is missing a checksum`);
96
+ }
97
+ if (migration.checksum !== checksum) {
98
+ throw new Error(`Migration checksum mismatch for version ${version}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ function hasPendingChecksumUpgrade(migrations, state) {
104
+ if (state.supportsChecksums) return false;
105
+ const migration = migrations.find(
106
+ (candidate) => candidate.name === CHECKSUM_UPGRADE_MIGRATION
107
+ );
108
+ return migration !== undefined && !state.records.has(migration.version);
109
+ }
110
+
111
+ function recordMigration(db, migration) {
112
+ const supportsChecksums = db.prepare('PRAGMA table_info(schema_migrations)').all()
113
+ .some((column) => column.name === 'checksum');
114
+ if (supportsChecksums) {
115
+ db.prepare(`
116
+ INSERT INTO schema_migrations (version, checksum, applied_at)
117
+ VALUES (?, ?, ?)
118
+ `).run(migration.version, migration.checksum, nowIso());
119
+ return;
120
+ }
121
+ db.prepare(`
122
+ INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)
123
+ `).run(migration.version, nowIso());
124
+ }
125
+
126
+ function migrationPath(directory, name) {
127
+ return directory instanceof URL ? new URL(name, directory) : join(directory, name);
128
+ }
@@ -0,0 +1,83 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE IF NOT EXISTS schema_migrations (
4
+ version INTEGER PRIMARY KEY,
5
+ applied_at TEXT NOT NULL
6
+ );
7
+
8
+ CREATE TABLE spaces (
9
+ id TEXT PRIMARY KEY,
10
+ name TEXT NOT NULL UNIQUE,
11
+ kind TEXT NOT NULL CHECK (kind IN ('personal', 'public')),
12
+ description TEXT,
13
+ created_at TEXT NOT NULL
14
+ );
15
+
16
+ CREATE TABLE subscriptions (
17
+ personal_space_id TEXT NOT NULL REFERENCES spaces(id),
18
+ space_id TEXT NOT NULL REFERENCES spaces(id),
19
+ mode TEXT NOT NULL DEFAULT 'latest',
20
+ created_at TEXT NOT NULL,
21
+ PRIMARY KEY (personal_space_id, space_id)
22
+ );
23
+
24
+ CREATE TABLE episodes (
25
+ id TEXT PRIMARY KEY,
26
+ space_id TEXT NOT NULL REFERENCES spaces(id),
27
+ source_kind TEXT NOT NULL,
28
+ body TEXT NOT NULL,
29
+ source_uri TEXT,
30
+ metadata_json TEXT NOT NULL DEFAULT '{}',
31
+ created_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE facts (
35
+ id TEXT PRIMARY KEY,
36
+ space_id TEXT NOT NULL REFERENCES spaces(id),
37
+ subject TEXT NOT NULL,
38
+ predicate TEXT NOT NULL,
39
+ object TEXT NOT NULL,
40
+ source_episode_id TEXT NOT NULL REFERENCES episodes(id),
41
+ status TEXT NOT NULL,
42
+ confidence REAL NOT NULL DEFAULT 1.0,
43
+ sensitivity TEXT NOT NULL DEFAULT 'normal',
44
+ scope TEXT NOT NULL DEFAULT 'personal',
45
+ valid_at TEXT NOT NULL,
46
+ invalid_at TEXT,
47
+ replaced_by_fact_id TEXT REFERENCES facts(id)
48
+ );
49
+
50
+ CREATE INDEX facts_current_idx ON facts(space_id, predicate, invalid_at);
51
+ CREATE INDEX facts_source_idx ON facts(source_episode_id);
52
+
53
+ CREATE TABLE candidates (
54
+ id TEXT PRIMARY KEY,
55
+ personal_space_id TEXT NOT NULL REFERENCES spaces(id),
56
+ target_space_id TEXT REFERENCES spaces(id),
57
+ episode_id TEXT NOT NULL REFERENCES episodes(id),
58
+ reason TEXT NOT NULL,
59
+ status TEXT NOT NULL,
60
+ created_at TEXT NOT NULL,
61
+ decided_at TEXT
62
+ );
63
+
64
+ CREATE TABLE outbox (
65
+ id TEXT PRIMARY KEY,
66
+ kind TEXT NOT NULL,
67
+ aggregate_id TEXT NOT NULL,
68
+ payload_json TEXT NOT NULL,
69
+ status TEXT NOT NULL DEFAULT 'pending',
70
+ attempts INTEGER NOT NULL DEFAULT 0,
71
+ next_attempt_at TEXT,
72
+ created_at TEXT NOT NULL,
73
+ sent_at TEXT,
74
+ last_error TEXT
75
+ );
76
+
77
+ CREATE INDEX outbox_pending_idx ON outbox(status, next_attempt_at, created_at);
78
+
79
+ CREATE TABLE imports (
80
+ content_hash TEXT PRIMARY KEY,
81
+ source_path TEXT NOT NULL,
82
+ imported_at TEXT NOT NULL
83
+ );
@@ -0,0 +1,113 @@
1
+ CREATE TABLE schema_migrations_new (
2
+ version INTEGER PRIMARY KEY,
3
+ checksum TEXT NOT NULL,
4
+ applied_at TEXT NOT NULL
5
+ );
6
+
7
+ INSERT INTO schema_migrations_new (version, checksum, applied_at)
8
+ SELECT
9
+ version,
10
+ CASE version
11
+ WHEN 1 THEN 'ab393d736d8ff18fde75478002b2b4b3b4d7e906974cd724058e31436fba0ea9'
12
+ END,
13
+ applied_at
14
+ FROM schema_migrations;
15
+
16
+ DROP TABLE schema_migrations;
17
+ ALTER TABLE schema_migrations_new RENAME TO schema_migrations;
18
+
19
+ CREATE TABLE facts_new (
20
+ id TEXT PRIMARY KEY,
21
+ space_id TEXT NOT NULL REFERENCES spaces(id),
22
+ subject TEXT NOT NULL,
23
+ predicate TEXT NOT NULL,
24
+ object TEXT NOT NULL,
25
+ source_episode_id TEXT NOT NULL REFERENCES episodes(id),
26
+ status TEXT NOT NULL CHECK (
27
+ status IN ('confirmed', 'observed', 'suggested', 'rejected', 'deprecated')
28
+ ),
29
+ confidence REAL NOT NULL DEFAULT 1.0 CHECK (
30
+ typeof(confidence) IN ('integer', 'real') AND confidence >= 0.0 AND confidence <= 1.0
31
+ ),
32
+ sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK (
33
+ sensitivity IN ('normal', 'private', 'restricted')
34
+ ),
35
+ scope TEXT NOT NULL DEFAULT 'personal' CHECK (scope IN ('personal', 'public')),
36
+ valid_at TEXT NOT NULL,
37
+ invalid_at TEXT,
38
+ replaced_by_fact_id TEXT REFERENCES facts_new(id)
39
+ );
40
+
41
+ INSERT INTO facts_new (
42
+ id, space_id, subject, predicate, object, source_episode_id, status,
43
+ confidence, sensitivity, scope, valid_at, invalid_at, replaced_by_fact_id
44
+ )
45
+ SELECT
46
+ id, space_id, subject, predicate, object, source_episode_id, status,
47
+ confidence, sensitivity, scope, valid_at, invalid_at, NULL
48
+ FROM facts;
49
+
50
+ UPDATE facts_new
51
+ SET replaced_by_fact_id = (
52
+ SELECT facts.replaced_by_fact_id
53
+ FROM facts
54
+ WHERE facts.id = facts_new.id
55
+ );
56
+
57
+ DROP TABLE facts;
58
+ ALTER TABLE facts_new RENAME TO facts;
59
+ CREATE INDEX facts_current_idx ON facts(space_id, predicate, invalid_at);
60
+ CREATE INDEX facts_source_idx ON facts(source_episode_id);
61
+
62
+ CREATE TABLE candidates_new (
63
+ id TEXT PRIMARY KEY,
64
+ personal_space_id TEXT NOT NULL REFERENCES spaces(id),
65
+ target_space_id TEXT REFERENCES spaces(id),
66
+ episode_id TEXT NOT NULL REFERENCES episodes(id),
67
+ reason TEXT NOT NULL,
68
+ status TEXT NOT NULL CHECK (
69
+ status IN ('pending', 'synced', 'personal_only', 'ignored')
70
+ ),
71
+ created_at TEXT NOT NULL,
72
+ decided_at TEXT
73
+ );
74
+
75
+ INSERT INTO candidates_new (
76
+ id, personal_space_id, target_space_id, episode_id, reason,
77
+ status, created_at, decided_at
78
+ )
79
+ SELECT
80
+ id, personal_space_id, target_space_id, episode_id, reason,
81
+ status, created_at, decided_at
82
+ FROM candidates;
83
+
84
+ DROP TABLE candidates;
85
+ ALTER TABLE candidates_new RENAME TO candidates;
86
+
87
+ CREATE TABLE outbox_new (
88
+ id TEXT PRIMARY KEY,
89
+ kind TEXT NOT NULL,
90
+ aggregate_id TEXT NOT NULL,
91
+ payload_json TEXT NOT NULL,
92
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent')),
93
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK (
94
+ typeof(attempts) = 'integer' AND attempts >= 0
95
+ ),
96
+ next_attempt_at TEXT,
97
+ created_at TEXT NOT NULL,
98
+ sent_at TEXT,
99
+ last_error TEXT
100
+ );
101
+
102
+ INSERT INTO outbox_new (
103
+ id, kind, aggregate_id, payload_json, status, attempts,
104
+ next_attempt_at, created_at, sent_at, last_error
105
+ )
106
+ SELECT
107
+ id, kind, aggregate_id, payload_json, status, attempts,
108
+ next_attempt_at, created_at, sent_at, last_error
109
+ FROM outbox;
110
+
111
+ DROP TABLE outbox;
112
+ ALTER TABLE outbox_new RENAME TO outbox;
113
+ CREATE INDEX outbox_pending_idx ON outbox(status, next_attempt_at, created_at);
@@ -0,0 +1,46 @@
1
+ import { CandidateStatus, FactScope, FactStatus, Sensitivity } from '../models.js';
2
+
3
+ const FACT_STATUSES = new Set(Object.values(FactStatus));
4
+ const CANDIDATE_STATUSES = new Set(Object.values(CandidateStatus));
5
+ const SENSITIVITIES = new Set(Object.values(Sensitivity));
6
+ const SCOPES = new Set(Object.values(FactScope));
7
+ const OUTBOX_STATUSES = new Set(['pending', 'sent']);
8
+
9
+ export function validateFactRecord(fact) {
10
+ requireMember('fact status', fact.status, FACT_STATUSES);
11
+ if (
12
+ typeof fact.confidence !== 'number' ||
13
+ !Number.isFinite(fact.confidence) ||
14
+ fact.confidence < 0 ||
15
+ fact.confidence > 1
16
+ ) {
17
+ throw new TypeError('Fact confidence must be between 0 and 1');
18
+ }
19
+ requireMember('fact sensitivity', fact.sensitivity, SENSITIVITIES);
20
+ requireMember('fact scope', fact.scope, SCOPES);
21
+ return fact;
22
+ }
23
+
24
+ export function validateCandidateRecord(candidate) {
25
+ requireMember('candidate status', candidate.status, CANDIDATE_STATUSES);
26
+ return candidate;
27
+ }
28
+
29
+ export function validateOutboxRecord(entry) {
30
+ requireMember('outbox status', entry.status, OUTBOX_STATUSES);
31
+ if (!Number.isInteger(entry.attempts) || entry.attempts < 0) {
32
+ throw new TypeError('Outbox attempts must be a nonnegative integer');
33
+ }
34
+ return entry;
35
+ }
36
+
37
+ export function validateSnapshotRecords(snapshot) {
38
+ for (const fact of snapshot.facts) validateFactRecord(fact);
39
+ for (const candidate of snapshot.candidates) validateCandidateRecord(candidate);
40
+ for (const entry of snapshot.outbox) validateOutboxRecord(entry);
41
+ return snapshot;
42
+ }
43
+
44
+ function requireMember(label, value, allowed) {
45
+ if (!allowed.has(value)) throw new TypeError(`Invalid ${label}: ${value}`);
46
+ }
@@ -0,0 +1,54 @@
1
+ import { CandidateStatus, FactStatus, nowIso } from '../models.js';
2
+ import { validateSnapshotRecords } from './record-validation.js';
3
+
4
+ export function normalizeSnapshot(snapshot = {}, { outbox = [], imports = [] } = {}) {
5
+ const input = cloneSnapshot(snapshot);
6
+ const normalized = {
7
+ spaces: (input.spaces ?? []).map((space) => ({
8
+ ...space,
9
+ description: space.description ?? null,
10
+ createdAt: space.createdAt ?? nowIso()
11
+ })),
12
+ subscriptions: (input.subscriptions ?? []).map((subscription) => ({
13
+ ...subscription,
14
+ mode: subscription.mode ?? 'latest',
15
+ createdAt: subscription.createdAt ?? nowIso()
16
+ })),
17
+ episodes: (input.episodes ?? []).map((episode) => ({
18
+ ...episode,
19
+ sourceUri: episode.sourceUri ?? null,
20
+ metadata: episode.metadata ?? {},
21
+ createdAt: episode.createdAt ?? nowIso()
22
+ })),
23
+ facts: (input.facts ?? []).map((fact) => ({
24
+ ...fact,
25
+ status: fact.status ?? FactStatus.CONFIRMED,
26
+ confidence: fact.confidence ?? 1,
27
+ sensitivity: fact.sensitivity ?? 'normal',
28
+ scope: fact.scope ?? 'personal',
29
+ validAt: fact.validAt ?? nowIso(),
30
+ invalidAt: fact.invalidAt ?? null,
31
+ replacedByFactId: fact.replacedByFactId ?? null
32
+ })),
33
+ candidates: (input.candidates ?? []).map(normalizeCandidate),
34
+ outbox: Object.hasOwn(input, 'outbox') ? input.outbox : cloneSnapshot(outbox),
35
+ imports: Object.hasOwn(input, 'imports') ? input.imports : cloneSnapshot(imports)
36
+ };
37
+ return validateSnapshotRecords(normalized);
38
+ }
39
+
40
+ export function cloneSnapshot(value) {
41
+ return JSON.parse(JSON.stringify(value));
42
+ }
43
+
44
+ function normalizeCandidate(candidate) {
45
+ const status = candidate.status ?? CandidateStatus.PENDING;
46
+ const createdAt = candidate.createdAt ?? nowIso();
47
+ return {
48
+ ...candidate,
49
+ targetSpaceId: candidate.targetSpaceId ?? null,
50
+ status,
51
+ createdAt,
52
+ decidedAt: candidate.decidedAt ?? (status === CandidateStatus.PENDING ? null : createdAt)
53
+ };
54
+ }
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { CandidateStatus, nowIso } from '../../models.js';
4
+ import { validateCandidateRecord } from '../record-validation.js';
5
+ import { mapCandidate } from './mapper.js';
6
+
7
+ export class CandidatesRepository {
8
+ constructor(db) {
9
+ this.insertStatement = db.prepare(`
10
+ INSERT INTO candidates (
11
+ id, personal_space_id, target_space_id, episode_id, reason,
12
+ status, created_at, decided_at
13
+ ) VALUES (
14
+ @id, @personalSpaceId, @targetSpaceId, @episodeId, @reason,
15
+ @status, @createdAt, @decidedAt
16
+ )
17
+ `);
18
+ this.getStatement = db.prepare('SELECT * FROM candidates WHERE id = ?');
19
+ this.listStatement = db.prepare('SELECT * FROM candidates ORDER BY rowid');
20
+ this.pendingStatement = db.prepare(`
21
+ SELECT * FROM candidates
22
+ WHERE personal_space_id = ? AND status = 'pending'
23
+ ORDER BY rowid
24
+ `);
25
+ this.updateStatusStatement = db.prepare(`
26
+ UPDATE candidates SET status = ?, decided_at = ? WHERE id = ?
27
+ `);
28
+ this.deleteStatement = db.prepare('DELETE FROM candidates');
29
+ }
30
+
31
+ add(candidate) {
32
+ if (candidate.id && this.get(candidate.id)) {
33
+ throw new Error(`Duplicate candidate id: ${candidate.id}`);
34
+ }
35
+
36
+ const status = candidate.status ?? CandidateStatus.PENDING;
37
+ const createdAt = candidate.createdAt ?? nowIso();
38
+ const stored = {
39
+ id: candidate.id || randomUUID(),
40
+ personalSpaceId: candidate.personalSpaceId,
41
+ targetSpaceId: candidate.targetSpaceId ?? null,
42
+ episodeId: candidate.episodeId,
43
+ reason: candidate.reason,
44
+ status,
45
+ createdAt,
46
+ decidedAt: candidate.decidedAt ?? (
47
+ status === CandidateStatus.PENDING ? null : createdAt
48
+ )
49
+ };
50
+ validateCandidateRecord(stored);
51
+ this.insertStatement.run(stored);
52
+ return this.get(stored.id);
53
+ }
54
+
55
+ get(id) {
56
+ return mapCandidate(this.getStatement.get(id));
57
+ }
58
+
59
+ list() {
60
+ return this.listStatement.all().map(mapCandidate);
61
+ }
62
+
63
+ pending(personalSpaceId) {
64
+ return this.pendingStatement.all(personalSpaceId).map(mapCandidate);
65
+ }
66
+
67
+ updateStatus(id, status) {
68
+ const candidate = this.get(id);
69
+ if (!candidate) throw new Error(`Candidate not found: ${id}`);
70
+ const decidedAt = status === CandidateStatus.PENDING ? null : nowIso();
71
+ validateCandidateRecord({ ...candidate, status, decidedAt });
72
+ this.updateStatusStatement.run(status, decidedAt, id);
73
+ return this.get(id);
74
+ }
75
+
76
+ insertSnapshot(candidate) {
77
+ validateCandidateRecord(candidate);
78
+ this.insertStatement.run(candidate);
79
+ }
80
+
81
+ deleteAll() {
82
+ this.deleteStatement.run();
83
+ }
84
+ }
@@ -0,0 +1,154 @@
1
+ import {
2
+ MAX_EPISODE_METADATA_BYTES,
3
+ MAX_EPISODE_METADATA_FIELD_BYTES,
4
+ normalizeCorrectionEvidencePreviewOptions,
5
+ normalizeEvidencePreviewOptions,
6
+ previewEpisode
7
+ } from '../episode-evidence-preview.js';
8
+ import { sqliteEpisodeRestrictedSql } from './episode-privacy-sql.js';
9
+
10
+ export class EpisodeEvidenceRepository {
11
+ constructor(db) {
12
+ this.db = db;
13
+ this.correctionStatements = new Map();
14
+ this.sourceStatement = db.prepare(sourceQuery());
15
+ }
16
+
17
+ source(spaceId, episodeId, options = {}) {
18
+ const normalized = normalizeEvidencePreviewOptions(options);
19
+ const row = this.sourceStatement.get({
20
+ spaceId, episodeId,
21
+ bodyChars: normalized.maxBodyBytes,
22
+ bodyBytes: normalized.maxBodyBytes,
23
+ metadataChars: MAX_EPISODE_METADATA_BYTES,
24
+ metadataBytes: MAX_EPISODE_METADATA_BYTES,
25
+ includeRestricted: Number(normalized.includeRestricted)
26
+ });
27
+ return previewEpisode(mapSourceRow(row), normalized);
28
+ }
29
+
30
+ corrections(spaceId, factIds, options = {}) {
31
+ const normalized = normalizeCorrectionEvidencePreviewOptions(options);
32
+ if (factIds.length === 0) return [];
33
+ const statement = this.#correctionStatement(factIds.length);
34
+ const parameters = {
35
+ spaceId,
36
+ bodyChars: normalized.maxBodyBytes,
37
+ bodyBytes: normalized.maxBodyBytes,
38
+ fieldChars: MAX_EPISODE_METADATA_FIELD_BYTES,
39
+ fieldBytes: MAX_EPISODE_METADATA_FIELD_BYTES,
40
+ correctionLimit: normalized.maxCorrectionsPerFact,
41
+ includeRestricted: Number(normalized.includeRestricted)
42
+ };
43
+ factIds.forEach((factId, index) => { parameters[`factId${index}`] = factId; });
44
+ const groups = [...new Set(factIds)].map((factId) => ({
45
+ factId,
46
+ episodes: [],
47
+ truncated: false
48
+ }));
49
+ const byFactId = new Map(groups.map((group) => [group.factId, group]));
50
+ for (const row of statement.all(parameters)) {
51
+ const group = byFactId.get(row.fact_id);
52
+ group.episodes.push(previewEpisode(
53
+ mapCorrectionRow(row),
54
+ normalized,
55
+ { correction: true }
56
+ ));
57
+ group.truncated = row.correction_count > normalized.maxCorrectionsPerFact;
58
+ }
59
+ return groups;
60
+ }
61
+
62
+ #correctionStatement(factCount) {
63
+ if (!this.correctionStatements.has(factCount)) {
64
+ this.correctionStatements.set(factCount, this.db.prepare(correctionQuery(factCount)));
65
+ }
66
+ return this.correctionStatements.get(factCount);
67
+ }
68
+ }
69
+
70
+ function sourceQuery() {
71
+ return `
72
+ SELECT id, space_id, source_kind,
73
+ substr(body, 1, @bodyChars) AS body_preview,
74
+ length(CAST(body AS BLOB)) > @bodyBytes AS body_truncated,
75
+ source_uri,
76
+ substr(metadata_json, 1, @metadataChars) AS metadata_preview,
77
+ length(CAST(metadata_json AS BLOB)) > @metadataBytes AS metadata_truncated,
78
+ created_at
79
+ FROM episodes source
80
+ WHERE id = @episodeId AND space_id = @spaceId
81
+ AND (@includeRestricted = 1 OR NOT ${sqliteEpisodeRestrictedSql('source')})
82
+ `;
83
+ }
84
+
85
+ function correctionQuery(factCount) {
86
+ const factIds = Array.from({ length: factCount }, (_, index) => `@factId${index}`).join(', ');
87
+ const fields = ['kind', 'factId', 'action'];
88
+ const selected = fields.map((field) => `
89
+ substr(CAST(json_extract(metadata_json, '$.${field}') AS TEXT), 1, @fieldChars)
90
+ AS metadata_${field.toLowerCase()}`
91
+ ).join(',');
92
+ const clipped = fields.map((field) => `
93
+ length(CAST(json_extract(metadata_json, '$.${field}') AS BLOB)) > @fieldBytes`
94
+ ).join(' OR ');
95
+ return `
96
+ WITH ranked AS (
97
+ SELECT correction.rowid AS correction_rowid,
98
+ json_extract(metadata_json, '$.factId') AS fact_id,
99
+ row_number() OVER (
100
+ PARTITION BY json_extract(metadata_json, '$.factId')
101
+ ORDER BY created_at DESC, correction.rowid DESC
102
+ ) AS correction_rank,
103
+ count(*) OVER (
104
+ PARTITION BY json_extract(metadata_json, '$.factId')
105
+ ) AS correction_count
106
+ FROM episodes correction
107
+ WHERE space_id = @spaceId
108
+ AND json_extract(metadata_json, '$.kind') = 'lens_correction'
109
+ AND json_extract(metadata_json, '$.factId') IN (${factIds})
110
+ AND (@includeRestricted = 1 OR NOT ${sqliteEpisodeRestrictedSql('correction')})
111
+ )
112
+ SELECT correction.id, correction.space_id, correction.source_kind,
113
+ substr(body, 1, @bodyChars) AS body_preview,
114
+ length(CAST(body AS BLOB)) > @bodyBytes AS body_truncated,
115
+ source_uri,${selected}, ranked.fact_id, ranked.correction_count,
116
+ ((${clipped}) OR EXISTS (
117
+ SELECT 1 FROM json_each(metadata_json)
118
+ WHERE key NOT IN ('kind', 'factId', 'action')
119
+ )) AS metadata_truncated,
120
+ correction.created_at
121
+ FROM ranked
122
+ JOIN episodes correction ON correction.rowid = ranked.correction_rowid
123
+ WHERE ranked.correction_rank <= @correctionLimit
124
+ ORDER BY ranked.fact_id, ranked.correction_rank
125
+ `;
126
+ }
127
+
128
+ function mapSourceRow(row) {
129
+ if (!row) return null;
130
+ return mapRow(row, row.metadata_truncated ? {} : JSON.parse(row.metadata_preview));
131
+ }
132
+
133
+ function mapCorrectionRow(row) {
134
+ if (!row) return null;
135
+ const metadata = {};
136
+ for (const [key, column] of [
137
+ ['kind', 'metadata_kind'], ['factId', 'metadata_factid'], ['action', 'metadata_action']
138
+ ]) if (typeof row[column] === 'string') metadata[key] = row[column];
139
+ return mapRow(row, metadata);
140
+ }
141
+
142
+ function mapRow(row, metadata) {
143
+ return {
144
+ id: row.id,
145
+ spaceId: row.space_id,
146
+ sourceKind: row.source_kind,
147
+ body: row.body_preview,
148
+ bodyTruncated: row.body_truncated === 1,
149
+ sourceUri: row.source_uri,
150
+ metadata,
151
+ metadataTruncated: row.metadata_truncated === 1,
152
+ createdAt: row.created_at
153
+ };
154
+ }