@really-knows-ai/foundry 2.3.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +180 -369
  2. package/dist/.opencode/plugins/foundry-tools/appraiser-tools.js +28 -0
  3. package/dist/.opencode/plugins/foundry-tools/artefact-tools.js +58 -0
  4. package/dist/.opencode/plugins/foundry-tools/assay-tools.js +92 -0
  5. package/dist/.opencode/plugins/foundry-tools/attestation-tools.js +191 -0
  6. package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +128 -0
  7. package/dist/.opencode/plugins/foundry-tools/config-law-tools.js +380 -0
  8. package/dist/.opencode/plugins/foundry-tools/config-tools.js +43 -0
  9. package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +234 -0
  10. package/dist/.opencode/plugins/foundry-tools/git-helpers.js +354 -0
  11. package/dist/.opencode/plugins/foundry-tools/git-tools.js +181 -0
  12. package/dist/.opencode/plugins/foundry-tools/helpers.js +340 -0
  13. package/dist/.opencode/plugins/foundry-tools/history-tools.js +20 -0
  14. package/dist/.opencode/plugins/foundry-tools/memory-admin-tools.js +296 -0
  15. package/dist/.opencode/plugins/foundry-tools/memory-helpers.js +104 -0
  16. package/dist/.opencode/plugins/foundry-tools/memory-tools.js +286 -0
  17. package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +159 -0
  18. package/dist/.opencode/plugins/foundry-tools/snapshot-tools.js +104 -0
  19. package/dist/.opencode/plugins/foundry-tools/stage-tools.js +186 -0
  20. package/dist/.opencode/plugins/foundry-tools/validate-tools.js +263 -0
  21. package/dist/.opencode/plugins/foundry-tools/workfile-tools.js +102 -0
  22. package/dist/.opencode/plugins/foundry.js +105 -0
  23. package/dist/CHANGELOG.md +490 -0
  24. package/dist/LICENSE +21 -0
  25. package/dist/README.md +278 -0
  26. package/dist/docs/README.md +59 -0
  27. package/dist/docs/architecture.md +434 -0
  28. package/dist/docs/concepts.md +396 -0
  29. package/dist/docs/getting-started.md +345 -0
  30. package/dist/docs/memory-maintenance.md +176 -0
  31. package/dist/docs/tools.md +1411 -0
  32. package/dist/docs/work-spec.md +283 -0
  33. package/dist/scripts/lib/artefacts.js +151 -0
  34. package/dist/scripts/lib/assay/loader.js +151 -0
  35. package/dist/scripts/lib/assay/parse-jsonl.js +102 -0
  36. package/dist/scripts/lib/assay/permissions.js +52 -0
  37. package/dist/scripts/lib/assay/run.js +219 -0
  38. package/dist/scripts/lib/assay/spawn-with-timeout.js +138 -0
  39. package/dist/scripts/lib/attestation/attest.js +111 -0
  40. package/dist/scripts/lib/attestation/canonical-json.js +109 -0
  41. package/dist/scripts/lib/attestation/hash.js +17 -0
  42. package/dist/scripts/lib/attestation/parse.js +14 -0
  43. package/dist/scripts/lib/attestation/payload.js +106 -0
  44. package/dist/scripts/lib/attestation/render.js +16 -0
  45. package/dist/scripts/lib/attestation/verify.js +15 -0
  46. package/dist/scripts/lib/branch-guard.js +72 -0
  47. package/dist/scripts/lib/config-creators/appraiser.js +9 -0
  48. package/dist/scripts/lib/config-creators/artefact-type.js +9 -0
  49. package/dist/scripts/lib/config-creators/cycle.js +11 -0
  50. package/dist/scripts/lib/config-creators/factory.js +49 -0
  51. package/dist/scripts/lib/config-creators/flow.js +11 -0
  52. package/dist/scripts/lib/config-validators/appraiser.js +49 -0
  53. package/dist/scripts/lib/config-validators/artefact-type.js +38 -0
  54. package/dist/scripts/lib/config-validators/cycle.js +131 -0
  55. package/dist/scripts/lib/config-validators/flow.js +57 -0
  56. package/dist/scripts/lib/config-validators/helpers.js +96 -0
  57. package/dist/scripts/lib/config-validators/law.js +96 -0
  58. package/dist/scripts/lib/config.js +393 -0
  59. package/dist/scripts/lib/failed-flow.js +131 -0
  60. package/dist/scripts/lib/feedback-store.js +249 -0
  61. package/dist/scripts/lib/feedback-transitions.js +105 -0
  62. package/dist/scripts/lib/finalize.js +70 -0
  63. package/dist/scripts/lib/foundational-guards.js +13 -0
  64. package/dist/scripts/lib/git-bridge.js +77 -0
  65. package/dist/scripts/lib/git-finish/work-finish.js +233 -0
  66. package/dist/scripts/lib/git-policy.js +101 -0
  67. package/dist/scripts/lib/guards.js +125 -0
  68. package/dist/scripts/lib/history.js +132 -0
  69. package/dist/scripts/lib/memory/admin/create-edge-type.js +91 -0
  70. package/dist/scripts/lib/memory/admin/create-entity-type.js +43 -0
  71. package/dist/scripts/lib/memory/admin/create-extractor.js +67 -0
  72. package/dist/scripts/lib/memory/admin/drop-edge-type.js +40 -0
  73. package/dist/scripts/lib/memory/admin/drop-entity-type.js +172 -0
  74. package/dist/scripts/lib/memory/admin/dump.js +47 -0
  75. package/dist/scripts/lib/memory/admin/helpers.js +31 -0
  76. package/dist/scripts/lib/memory/admin/init.js +170 -0
  77. package/dist/scripts/lib/memory/admin/live-store.js +76 -0
  78. package/dist/scripts/lib/memory/admin/reembed.js +285 -0
  79. package/dist/scripts/lib/memory/admin/rename-edge-type.js +54 -0
  80. package/dist/scripts/lib/memory/admin/rename-entity-type.js +151 -0
  81. package/dist/scripts/lib/memory/admin/reset.js +24 -0
  82. package/dist/scripts/lib/memory/admin/vacuum.js +9 -0
  83. package/dist/scripts/lib/memory/admin/validate.js +19 -0
  84. package/dist/scripts/lib/memory/config.js +149 -0
  85. package/dist/scripts/lib/memory/cozo.js +136 -0
  86. package/dist/scripts/lib/memory/drift.js +71 -0
  87. package/dist/scripts/lib/memory/embeddings.js +128 -0
  88. package/dist/scripts/lib/memory/frontmatter.js +75 -0
  89. package/dist/scripts/lib/memory/ndjson.js +84 -0
  90. package/dist/scripts/lib/memory/paths.js +25 -0
  91. package/dist/scripts/lib/memory/permissions.js +41 -0
  92. package/dist/scripts/lib/memory/prompt.js +109 -0
  93. package/dist/scripts/lib/memory/query.js +56 -0
  94. package/dist/scripts/lib/memory/reads.js +109 -0
  95. package/dist/scripts/lib/memory/schema.js +64 -0
  96. package/dist/scripts/lib/memory/search.js +73 -0
  97. package/dist/scripts/lib/memory/singleton.js +49 -0
  98. package/dist/scripts/lib/memory/store.js +162 -0
  99. package/dist/scripts/lib/memory/types.js +93 -0
  100. package/dist/scripts/lib/memory/validate.js +58 -0
  101. package/dist/scripts/lib/memory/writes.js +40 -0
  102. package/{scripts → dist/scripts}/lib/pending.js +7 -2
  103. package/dist/scripts/lib/secret.js +59 -0
  104. package/{scripts → dist/scripts}/lib/slug.js +3 -2
  105. package/dist/scripts/lib/snapshot/finish.js +103 -0
  106. package/dist/scripts/lib/snapshot/inspect.js +253 -0
  107. package/dist/scripts/lib/snapshot/render.js +55 -0
  108. package/dist/scripts/lib/sort-fs-check.js +121 -0
  109. package/dist/scripts/lib/sort-routing.js +101 -0
  110. package/{scripts → dist/scripts}/lib/stage-guard.js +12 -6
  111. package/{scripts → dist/scripts}/lib/state.js +4 -0
  112. package/dist/scripts/lib/token.js +57 -0
  113. package/dist/scripts/lib/tracing.js +59 -0
  114. package/dist/scripts/lib/ulid.js +100 -0
  115. package/dist/scripts/lib/validator-jsonl.js +162 -0
  116. package/{scripts → dist/scripts}/lib/workfile.js +38 -20
  117. package/dist/scripts/orchestrate-cycle.js +215 -0
  118. package/dist/scripts/orchestrate-phases.js +314 -0
  119. package/dist/scripts/orchestrate.js +163 -0
  120. package/dist/scripts/sort.js +278 -0
  121. package/{skills → dist/skills}/add-appraiser/SKILL.md +39 -9
  122. package/{skills → dist/skills}/add-artefact-type/SKILL.md +46 -24
  123. package/{skills → dist/skills}/add-cycle/SKILL.md +57 -17
  124. package/dist/skills/add-extractor/SKILL.md +133 -0
  125. package/{skills → dist/skills}/add-flow/SKILL.md +36 -10
  126. package/dist/skills/add-law/SKILL.md +191 -0
  127. package/dist/skills/add-memory-edge-type/SKILL.md +52 -0
  128. package/dist/skills/add-memory-entity-type/SKILL.md +74 -0
  129. package/{skills → dist/skills}/appraise/SKILL.md +62 -13
  130. package/dist/skills/assay/SKILL.md +72 -0
  131. package/dist/skills/change-embedding-model/SKILL.md +58 -0
  132. package/dist/skills/drop-memory-edge-type/SKILL.md +54 -0
  133. package/dist/skills/drop-memory-entity-type/SKILL.md +57 -0
  134. package/dist/skills/dry-run/SKILL.md +116 -0
  135. package/{skills → dist/skills}/flow/SKILL.md +15 -2
  136. package/dist/skills/forge/SKILL.md +121 -0
  137. package/dist/skills/human-appraise/SKILL.md +153 -0
  138. package/{skills → dist/skills}/init-foundry/SKILL.md +23 -4
  139. package/dist/skills/init-memory/SKILL.md +92 -0
  140. package/{skills → dist/skills}/orchestrate/SKILL.md +30 -4
  141. package/dist/skills/quench/SKILL.md +99 -0
  142. package/{skills → dist/skills}/refresh-agents/SKILL.md +1 -1
  143. package/dist/skills/rename-memory-edge-type/SKILL.md +50 -0
  144. package/dist/skills/rename-memory-entity-type/SKILL.md +51 -0
  145. package/dist/skills/reset-memory/SKILL.md +54 -0
  146. package/dist/skills/upgrade-foundry/SKILL.md +192 -0
  147. package/package.json +34 -17
  148. package/.opencode/plugins/foundry.js +0 -761
  149. package/CHANGELOG.md +0 -100
  150. package/docs/concepts.md +0 -122
  151. package/docs/getting-started.md +0 -187
  152. package/docs/work-spec.md +0 -207
  153. package/scripts/lib/artefacts.js +0 -124
  154. package/scripts/lib/config.js +0 -175
  155. package/scripts/lib/feedback-transitions.js +0 -25
  156. package/scripts/lib/feedback.js +0 -440
  157. package/scripts/lib/finalize.js +0 -41
  158. package/scripts/lib/history.js +0 -59
  159. package/scripts/lib/secret.js +0 -23
  160. package/scripts/lib/tags.js +0 -108
  161. package/scripts/lib/token.js +0 -26
  162. package/scripts/orchestrate.js +0 -418
  163. package/scripts/sort.js +0 -370
  164. package/scripts/validate-tags.js +0 -54
  165. package/skills/add-law/SKILL.md +0 -111
  166. package/skills/forge/SKILL.md +0 -88
  167. package/skills/human-appraise/SKILL.md +0 -82
  168. package/skills/quench/SKILL.md +0 -62
  169. package/skills/upgrade-foundry/SKILL.md +0 -216
  170. /package/{skills → dist/skills}/list-agents/SKILL.md +0 -0
@@ -0,0 +1,56 @@
1
+ // Block write assertions (rule heads that modify a relation).
2
+ const WRITE_ASSERTIONS = /(^|\s)(:put|:rm|:create|:replace|:ensure|:ensure_not)(\s|$)/;
3
+
4
+ // All `::foo` system ops go through an allowlist. Anything not listed here —
5
+ // including `::remove`, `::hnsw create|drop`, `::index create|drop`, `::fts …`,
6
+ // `::kill` — is rejected. Add entries here after confirming they are
7
+ // side-effect-free.
8
+ const SYSTEM_OP = /(^|\s)(::[a-zA-Z_]+)/g;
9
+ const ALLOWED_SYSTEM_OPS = new Set([
10
+ '::relations',
11
+ '::columns',
12
+ '::describe',
13
+ '::indices',
14
+ '::compact',
15
+ '::explain',
16
+ '::running',
17
+ '::show_triggers',
18
+ ]);
19
+
20
+ function validateQuery(query) {
21
+ if (typeof query !== 'string') throw new Error('query must be a string');
22
+ if (WRITE_ASSERTIONS.test(query)) {
23
+ throw new Error('query is read-only; write assertions (:put, :rm, :create, etc.) are not permitted');
24
+ }
25
+ for (const m of query.matchAll(SYSTEM_OP)) {
26
+ const op = m[2];
27
+ if (!ALLOWED_SYSTEM_OPS.has(op)) {
28
+ throw new Error(`query is read-only; system op ${op} is not permitted (allowed: ${[...ALLOWED_SYSTEM_OPS].sort().join(', ')})`);
29
+ }
30
+ }
31
+ }
32
+
33
+ function formatResult(res) {
34
+ const headers = res.headers ?? [];
35
+ const rows = (res.rows ?? []).map((row) => {
36
+ const obj = {};
37
+ headers.forEach((h, i) => { obj[h] = row[i]; });
38
+ return obj;
39
+ });
40
+ return { headers, rows };
41
+ }
42
+
43
+ function errorMessage(err) {
44
+ return err?.display ?? err?.message ?? String(err);
45
+ }
46
+
47
+ export async function runQuery(store, query) {
48
+ validateQuery(query);
49
+ let res;
50
+ try {
51
+ res = await store.db.run(query);
52
+ } catch (err) {
53
+ throw new Error(`query error: ${errorMessage(err)}`, { cause: err });
54
+ }
55
+ return formatResult(res);
56
+ }
@@ -0,0 +1,109 @@
1
+ import { entRelName, edgeRelName, cozoStringLit as cozoLit } from './cozo.js';
2
+
3
+ export async function getEntity(store, { type, name }) {
4
+ const rel = entRelName(type);
5
+ const res = await store.db.run(`?[v] := *${rel}{name: ${cozoLit(name)}, value: v}`);
6
+ if (res.rows.length === 0) return null;
7
+ return { type, name, value: res.rows[0][0] };
8
+ }
9
+
10
+ export async function listEntities(store, { type }) {
11
+ const rel = entRelName(type);
12
+ const res = await store.db.run(`?[n, v] := *${rel}{name: n, value: v}`);
13
+ return res.rows.map(([name, value]) => ({ type, name, value }));
14
+ }
15
+
16
+ function filterEdgeTypes(edge_types, vocabulary) {
17
+ if (edge_types && edge_types.length > 0) {
18
+ return edge_types.filter((t) => vocabulary.edges[t]);
19
+ }
20
+ return Object.keys(vocabulary.edges);
21
+ }
22
+
23
+ async function queryOutgoing(store, rel, et, node) {
24
+ const res = await store.db.run(
25
+ `?[tt, tn] := *${rel}{from_type: ${cozoLit(node.type)}, from_name: ${cozoLit(node.name)}, to_type: tt, to_name: tn}`,
26
+ );
27
+ return res.rows.map(([tt, tn]) => ({
28
+ edge: { edge_type: et, from_type: node.type, from_name: node.name, to_type: tt, to_name: tn },
29
+ target: { type: tt, name: tn },
30
+ }));
31
+ }
32
+
33
+ async function queryIncoming(store, rel, et, node) {
34
+ const res = await store.db.run(
35
+ `?[ft, fn] := *${rel}{from_type: ft, from_name: fn, to_type: ${cozoLit(node.type)}, to_name: ${cozoLit(node.name)}}`,
36
+ );
37
+ return res.rows.map(([ft, fn]) => ({
38
+ edge: { edge_type: et, from_type: ft, from_name: fn, to_type: node.type, to_name: node.name },
39
+ target: { type: ft, name: fn },
40
+ }));
41
+ }
42
+
43
+ async function processDepthLevel(store, frontier, edgeTypes, visited) {
44
+ const edgesOut = [];
45
+ const nextFrontier = new Map();
46
+
47
+ for (const et of edgeTypes) {
48
+ const rel = edgeRelName(et);
49
+ for (const [, node] of frontier) {
50
+ const outgoing = await queryOutgoing(store, rel, et, node);
51
+ const incoming = await queryIncoming(store, rel, et, node);
52
+
53
+ for (const result of [...outgoing, ...incoming]) {
54
+ edgesOut.push(result.edge);
55
+ const key = `${result.target.type}/${result.target.name}`;
56
+ if (!visited.has(key)) {
57
+ nextFrontier.set(key, result.target);
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ return { edgesOut, nextFrontier };
64
+ }
65
+
66
+ function deduplicateEdges(edgesOut) {
67
+ const edgeKey = (e) => [e.edge_type, e.from_type, e.from_name, e.to_type, e.to_name].join('\u0000');
68
+ const seen = new Set();
69
+ const edges = [];
70
+ for (const e of edgesOut) {
71
+ const k = edgeKey(e);
72
+ if (seen.has(k)) continue;
73
+ seen.add(k);
74
+ edges.push(e);
75
+ }
76
+ return edges;
77
+ }
78
+
79
+ async function expandFrontier(store, frontier, edgeTypes, visited, edgesOut) {
80
+ const { edgesOut: levelEdges, nextFrontier } = await processDepthLevel(store, frontier, edgeTypes, visited);
81
+ edgesOut.push(...levelEdges);
82
+
83
+ for (const [key, node] of nextFrontier) {
84
+ const ent = await getEntity(store, node);
85
+ visited.set(key, ent ?? { ...node, value: null });
86
+ }
87
+
88
+ frontier.clear();
89
+ for (const [k, v] of nextFrontier) frontier.set(k, v);
90
+ return frontier.size > 0;
91
+ }
92
+
93
+ export async function neighbours(store, { type, name, depth = 1, edge_types }, vocabulary) {
94
+ const edgeTypes = filterEdgeTypes(edge_types, vocabulary);
95
+ const visited = new Map();
96
+ const edgesOut = [];
97
+ const frontier = new Map();
98
+ frontier.set(`${type}/${name}`, { type, name });
99
+
100
+ const start = await getEntity(store, { type, name });
101
+ visited.set(`${type}/${name}`, start ?? { type, name, value: null });
102
+
103
+ for (let d = 0; d < depth; d++) {
104
+ const hasMore = await expandFrontier(store, frontier, edgeTypes, visited, edgesOut);
105
+ if (!hasMore) break;
106
+ }
107
+
108
+ return { entities: [...visited.values()], edges: deduplicateEdges(edgesOut) };
109
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { memoryPaths } from './paths.js';
3
+
4
+ export function emptySchema() {
5
+ return { version: 1, entities: {}, edges: {}, embeddings: null };
6
+ }
7
+
8
+ function applyDefaults(parsed) {
9
+ return {
10
+ version: parsed.version ?? 1,
11
+ entities: parsed.entities ?? {},
12
+ edges: parsed.edges ?? {},
13
+ embeddings: parsed.embeddings ?? null,
14
+ };
15
+ }
16
+
17
+ export async function loadSchema(foundryDir, io) {
18
+ const p = memoryPaths(foundryDir);
19
+ if (!(await io.exists(p.schema))) return emptySchema();
20
+ const text = await io.readFile(p.schema);
21
+ return applyDefaults(JSON.parse(text));
22
+ }
23
+
24
+ function normaliseForWrite(schema) {
25
+ // Full deep canonicalisation: sorts keys at every level so nested objects
26
+ // (e.g. `entities.<type> = { frontmatterHash: ..., other: ... }`) also
27
+ // stabilise. Previously only top-level `entities` and `edges` keys were
28
+ // sorted, leaving any per-type record order dependent on insertion order —
29
+ // which produced meaningless diffs across runs.
30
+ return canonicalise({
31
+ version: schema.version,
32
+ entities: schema.entities,
33
+ edges: schema.edges,
34
+ embeddings: schema.embeddings,
35
+ });
36
+ }
37
+
38
+ export async function writeSchema(foundryDir, schema, io) {
39
+ const p = memoryPaths(foundryDir);
40
+ if (!(await io.exists(p.root))) await io.mkdir(p.root);
41
+ const out = normaliseForWrite(schema);
42
+ const text = JSON.stringify(out, null, 2) + '\n';
43
+ await io.writeFile(p.schema, text);
44
+ }
45
+
46
+ export function bumpVersion(schema) {
47
+ schema.version = (schema.version ?? 0) + 1;
48
+ return schema.version;
49
+ }
50
+
51
+ function canonicalise(value) {
52
+ if (Array.isArray(value)) return value.map(canonicalise);
53
+ if (value && typeof value === 'object') {
54
+ const out = {};
55
+ for (const k of Object.keys(value).sort()) out[k] = canonicalise(value[k]);
56
+ return out;
57
+ }
58
+ return value;
59
+ }
60
+
61
+ export function hashFrontmatter(fm) {
62
+ const canon = JSON.stringify(canonicalise(fm ?? {}));
63
+ return createHash('sha256').update(canon).digest('hex');
64
+ }
@@ -0,0 +1,73 @@
1
+ import { entRelName } from './cozo.js';
2
+
3
+ function asCozoVector(v) {
4
+ return `vec([${v.map((n) => Number(n).toString()).join(', ')}])`;
5
+ }
6
+
7
+ function isIndexNotFoundError(err) {
8
+ if (!err) return false;
9
+ const msg = String(err.display || err.message || err);
10
+ return /index|not found|no such|does not exist|stored relation/i.test(msg);
11
+ }
12
+
13
+ async function searchOneType(db, type, queryVec, k) {
14
+ const rel = entRelName(type);
15
+ try {
16
+ const q = `?[name, value, dist] := ~${rel}:vec{ name, value | query: ${asCozoVector(queryVec)}, k: ${k}, bind_distance: dist, ef: 64 }`;
17
+ const res = await db.run(q);
18
+ return res.rows.map(([name, value, dist]) => ({ type, name, value, distance: dist }));
19
+ } catch (err) {
20
+ if (isIndexNotFoundError(err)) return [];
21
+ throw err;
22
+ }
23
+ }
24
+
25
+ function resolveTypes(typeFilter, schema) {
26
+ if (typeFilter && typeFilter.length > 0) return typeFilter;
27
+ return Object.keys(schema.entities);
28
+ }
29
+
30
+ function validateSearchParams(embedder, queryText) {
31
+ if (!embedder) throw new Error('search requires an embedder');
32
+ if (typeof queryText !== 'string' || !queryText) throw new Error('query_text required');
33
+ }
34
+
35
+ /**
36
+ * Semantic nearest-neighbour search over entity values.
37
+ *
38
+ * Performance characteristics:
39
+ * - With N entity types and k requested results, this fetches k results from
40
+ * each type (N×k vectors total), then returns the global top-k.
41
+ * - This is necessary to get semantically correct results when top matches are
42
+ * distributed across multiple entity types.
43
+ * - Use `type_filter` to reduce N when you know which types are relevant,
44
+ * limiting the amplification factor.
45
+ * - Example: 10 types, k=20, no filter → fetches 200 vectors, returns 20.
46
+ * - Example: 10 types, k=20, filter=['class', 'finding'] → fetches 40 vectors, returns 20.
47
+ *
48
+ * @param {object} params
49
+ * @param {object} params.store - Memory store with schema and db
50
+ * @param {string} params.query_text - Text to search for
51
+ * @param {number} [params.k=5] - Number of results to return
52
+ * @param {string[]} [params.type_filter] - Restrict search to specific entity types
53
+ * @param {Function} params.embedder - Function to embed query text
54
+ * @returns {Promise<Array<{type: string, name: string, value: string, distance: number}>>}
55
+ */
56
+ export async function search({ store, query_text, k = 5, type_filter, embedder }) {
57
+ validateSearchParams(embedder, query_text);
58
+
59
+ const types = resolveTypes(type_filter, store.schema);
60
+ const [queryVec] = await embedder([query_text]);
61
+
62
+ // K-amplification: fetch k from each type to ensure global top-k correctness.
63
+ // With N types, this fetches N×k results. The alternative (fetching k/N per
64
+ // type) would miss results when top matches are unevenly distributed.
65
+ // Callers can use type_filter to reduce N when type relevance is known.
66
+ const all = [];
67
+ for (const t of types) {
68
+ const hits = await searchOneType(store.db, t, queryVec, k);
69
+ all.push(...hits);
70
+ }
71
+ all.sort((a, b) => a.distance - b.distance);
72
+ return all.slice(0, k);
73
+ }
@@ -0,0 +1,49 @@
1
+ import { join } from 'node:path';
2
+ import { loadMemoryConfig } from './config.js';
3
+ import { loadSchema } from './schema.js';
4
+ import { loadVocabulary } from './types.js';
5
+ import { detectDrift } from './drift.js';
6
+ import { openStore, closeStore } from './store.js';
7
+ import { memoryPaths } from './paths.js';
8
+
9
+ const stores = new Map(); // worktreeRoot -> { store, vocabulary, config, schema }
10
+
11
+ export async function getOrOpenStore({ worktreeRoot, io }) {
12
+ if (stores.has(worktreeRoot)) return stores.get(worktreeRoot).store;
13
+
14
+ const config = await loadMemoryConfig('foundry', io);
15
+ if (!config.enabled) {
16
+ throw new Error('memory is not enabled in foundry/memory/config.md');
17
+ }
18
+ const schema = await loadSchema('foundry', io);
19
+ const vocabulary = await loadVocabulary('foundry', io);
20
+ const drift = detectDrift({ vocabulary, schema });
21
+ if (drift.hasDrift) {
22
+ const msg = drift.items
23
+ .map((d) => ` - [${d.typeFamily}] ${d.typeName}: ${d.message} → use skill: ${d.suggestedSkills.join(' or ')}`)
24
+ .join('\n');
25
+ throw new Error(`memory schema drift detected; refusing to open store:\n${msg}`);
26
+ }
27
+
28
+ const dbAbsolutePath = join(worktreeRoot, memoryPaths('foundry').db);
29
+ const store = await openStore({ foundryDir: 'foundry', schema, io, dbAbsolutePath });
30
+ stores.set(worktreeRoot, { store, vocabulary, config, schema });
31
+ return store;
32
+ }
33
+
34
+ export function getContext(worktreeRoot) {
35
+ return stores.get(worktreeRoot) ?? null;
36
+ }
37
+
38
+ export function disposeStores() {
39
+ for (const [, ctx] of stores) closeStore(ctx.store);
40
+ stores.clear();
41
+ }
42
+
43
+ export function invalidateStore(worktreeRoot) {
44
+ const ctx = stores.get(worktreeRoot);
45
+ if (ctx) {
46
+ try { closeStore(ctx.store); } catch { /* ignore */ }
47
+ stores.delete(worktreeRoot);
48
+ }
49
+ }
@@ -0,0 +1,162 @@
1
+ import { memoryPaths } from './paths.js';
2
+ import * as cozoModule from './cozo.js';
3
+ import {
4
+ entRelName,
5
+ edgeRelName,
6
+ listRelations,
7
+ dropRelation,
8
+ dropHnswIndex,
9
+ cozoStringLit,
10
+ checkpoint,
11
+ closeMemoryDb,
12
+ } from './cozo.js';
13
+ import { serialiseEntityRows, serialiseEdgeRows, parseEntityRows, parseEdgeRows } from './ndjson.js';
14
+
15
+ function vecLit(v) {
16
+ return `vec([${v.map((n) => Number(n).toString()).join(', ')}])`;
17
+ }
18
+
19
+ async function importRelation(db, relName, rows, kind) {
20
+ if (rows.length === 0) return;
21
+ if (kind === 'entity') {
22
+ // Partition by presence of embedding so the two column shapes are written
23
+ // with matching :put headers.
24
+ const withVec = rows.filter((r) => Array.isArray(r.embedding));
25
+ const plain = rows.filter((r) => !Array.isArray(r.embedding));
26
+ if (plain.length > 0) {
27
+ const data = plain.map((r) => `[${cozoStringLit(r.name)}, ${cozoStringLit(r.value)}]`).join(', ');
28
+ await db.run(`?[name, value] <- [${data}]\n:put ${relName} { name => value }`);
29
+ }
30
+ if (withVec.length > 0) {
31
+ const data = withVec
32
+ .map((r) => `[${cozoStringLit(r.name)}, ${cozoStringLit(r.value)}, ${vecLit(r.embedding)}]`)
33
+ .join(', ');
34
+ await db.run(`?[name, value, embedding] <- [${data}]\n:put ${relName} { name => value, embedding }`);
35
+ }
36
+ } else {
37
+ const data = rows.map((r) => `[${cozoStringLit(r.from_type)}, ${cozoStringLit(r.from_name)}, ${cozoStringLit(r.to_type)}, ${cozoStringLit(r.to_name)}]`).join(', ');
38
+ await db.run(`?[from_type, from_name, to_type, to_name] <- [${data}]\n:put ${relName} { from_type, from_name, to_type, to_name }`);
39
+ }
40
+ }
41
+
42
+ async function exportEntityRelation(db, type) {
43
+ const res = await db.run(`?[name, value, embedding] := *ent_${type}{name, value, embedding}`);
44
+ return res.rows.map(([name, value, embedding]) => {
45
+ const row = { name, value };
46
+ if (Array.isArray(embedding) && embedding.length > 0) row.embedding = embedding;
47
+ return row;
48
+ });
49
+ }
50
+
51
+ async function exportEdgeRelation(db, type) {
52
+ const res = await db.run(`?[ft, fn, tt, tn] := *edge_${type}{from_type: ft, from_name: fn, to_type: tt, to_name: tn}`);
53
+ return res.rows.map(([ft, fn, tt, tn]) => ({ from_type: ft, from_name: fn, to_type: tt, to_name: tn }));
54
+ }
55
+
56
+ async function ensureDirs(p, io) {
57
+ if (!(await io.exists(p.root))) await io.mkdir(p.root);
58
+ if (!(await io.exists(p.relationsDir))) await io.mkdir(p.relationsDir);
59
+ }
60
+
61
+ async function setupEntityTypes(db, { schema, embeddingsDim, paths: p, io, cozo }) {
62
+ for (const type of Object.keys(schema.entities)) {
63
+ await cozo.createEntityRelation(db, type, embeddingsDim ? { dim: embeddingsDim } : {});
64
+ if (embeddingsDim) {
65
+ await cozo.createHnswIndex(db, entRelName(type), { dim: embeddingsDim });
66
+ }
67
+ const file = p.relationFile(type);
68
+ if (await io.exists(file)) {
69
+ const text = await io.readFile(file);
70
+ const rows = parseEntityRows(text);
71
+ await importRelation(db, entRelName(type), rows, 'entity');
72
+ }
73
+ }
74
+ }
75
+
76
+ async function setupEdgeTypes(db, { schema, paths: p, io, cozo }) {
77
+ for (const type of Object.keys(schema.edges)) {
78
+ await cozo.createEdgeRelation(db, type);
79
+ const file = p.relationFile(type);
80
+ if (await io.exists(file)) {
81
+ const text = await io.readFile(file);
82
+ const rows = parseEdgeRows(text);
83
+ await importRelation(db, edgeRelName(type), rows, 'edge');
84
+ }
85
+ }
86
+ }
87
+
88
+ export async function openStore({ foundryDir, schema, io, dbAbsolutePath, cozo = cozoModule }) {
89
+ const p = memoryPaths(foundryDir);
90
+ await ensureDirs(p, io);
91
+
92
+ const db = cozo.openMemoryDb(dbAbsolutePath);
93
+
94
+ // Schema setup and NDJSON import can throw (disk error, corrupt NDJSON,
95
+ // Cozo parse error, etc). If anything after openMemoryDb fails, the handle
96
+ // is otherwise unreachable — close it before rethrowing so its native
97
+ // resources (file descriptors, WAL lock across processes) are released.
98
+ try {
99
+ // Reconcile the on-disk Cozo database with the declared schema. Admin
100
+ // operations (drop-*, rename-*) update the schema + type files + NDJSON on
101
+ // disk and invalidate the singleton, leaving the .db file outdated until
102
+ // reopen. On reopen, any `ent_<t>` or `edge_<t>` relation missing from
103
+ // `schema` is dropped so `::relations` stays consistent with the schema
104
+ // and disk footprint stays bounded.
105
+ await reconcileRelations(db, schema);
106
+
107
+ const embeddingsDim = schema.embeddings && schema.embeddings.dimensions;
108
+ await setupEntityTypes(db, { schema, embeddingsDim, paths: p, io, cozo });
109
+ await setupEdgeTypes(db, { schema, paths: p, io, cozo });
110
+ } catch (err) {
111
+ try {
112
+ cozo.closeMemoryDb(db);
113
+ } catch {
114
+ // Best-effort cleanup; propagate the original error.
115
+ }
116
+ throw err;
117
+ }
118
+
119
+ return { db, foundryDir, schema, paths: p };
120
+ }
121
+
122
+ async function dropUnexpectedRelation(db, rel, expected) {
123
+ if (!/^(ent|edge)_[^:]+$/.test(rel)) return;
124
+ if (expected.has(rel)) return;
125
+ if (rel.startsWith('ent_')) {
126
+ await dropHnswIndex(db, rel); // no-op if absent
127
+ }
128
+ await dropRelation(db, rel);
129
+ }
130
+
131
+ async function reconcileRelations(db, schema) {
132
+ const expected = new Set([
133
+ ...Object.keys(schema.entities).map(entRelName),
134
+ ...Object.keys(schema.edges).map(edgeRelName),
135
+ ]);
136
+ let existing;
137
+ try {
138
+ existing = await listRelations(db);
139
+ } catch {
140
+ return; // fresh db; ::relations may error before any :create.
141
+ }
142
+ for (const rel of existing) {
143
+ await dropUnexpectedRelation(db, rel, expected);
144
+ }
145
+ }
146
+
147
+ export async function syncStore({ store, io }) {
148
+ const { db, schema, paths: p } = store;
149
+ await checkpoint(db);
150
+ for (const type of Object.keys(schema.entities)) {
151
+ const rows = await exportEntityRelation(db, type);
152
+ await io.writeFile(p.relationFile(type), serialiseEntityRows(rows));
153
+ }
154
+ for (const type of Object.keys(schema.edges)) {
155
+ const rows = await exportEdgeRelation(db, type);
156
+ await io.writeFile(p.relationFile(type), serialiseEdgeRows(rows));
157
+ }
158
+ }
159
+
160
+ export function closeStore(store) {
161
+ closeMemoryDb(store.db);
162
+ }
@@ -0,0 +1,93 @@
1
+ import { join, basename, extname } from 'node:path';
2
+ import { memoryPaths } from './paths.js';
3
+ import { parseFrontmatter } from './frontmatter.js';
4
+
5
+ function splitFrontmatter(text, filename) {
6
+ const parsed = parseFrontmatter(text, { filename });
7
+ return { frontmatter: parsed.frontmatter, body: parsed.body.trim() };
8
+ }
9
+
10
+ function validateEntity(filename, parsed) {
11
+ const stem = basename(filename, extname(filename));
12
+ const fm = parsed.frontmatter;
13
+ if (!fm.type || typeof fm.type !== 'string') {
14
+ throw new Error(`entity type ${filename}: missing frontmatter 'type'`);
15
+ }
16
+ if (fm.type !== stem) {
17
+ throw new Error(`entity type ${filename}: frontmatter type '${fm.type}' does not match filename stem '${stem}'`);
18
+ }
19
+ if (!parsed.body) {
20
+ throw new Error(`entity type ${filename}: empty body is not allowed`);
21
+ }
22
+ }
23
+
24
+ function isNonEmptyStringArray(v) {
25
+ return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === 'string' && s);
26
+ }
27
+
28
+ function validateEdgeField(filename, key, value) {
29
+ if (value === undefined) throw new Error(`edge type ${filename}: missing frontmatter '${key}'`);
30
+ if (value === 'any') return;
31
+ if (!isNonEmptyStringArray(value)) {
32
+ throw new Error(`edge type ${filename}: '${key}' must be 'any' or a non-empty list of strings`);
33
+ }
34
+ }
35
+
36
+ function validateEdge(filename, parsed) {
37
+ const stem = basename(filename, extname(filename));
38
+ const fm = parsed.frontmatter;
39
+ if (!fm.type || typeof fm.type !== 'string') {
40
+ throw new Error(`edge type ${filename}: missing frontmatter 'type'`);
41
+ }
42
+ if (fm.type !== stem) {
43
+ throw new Error(`edge type ${filename}: frontmatter type '${fm.type}' does not match filename stem '${stem}'`);
44
+ }
45
+ validateEdgeField(filename, 'sources', fm.sources);
46
+ validateEdgeField(filename, 'targets', fm.targets);
47
+ if (!parsed.body) {
48
+ throw new Error(`edge type ${filename}: empty body is not allowed`);
49
+ }
50
+ }
51
+
52
+ async function loadDir(dir, io) {
53
+ if (!(await io.exists(dir))) return [];
54
+ const entries = await io.readDir(dir);
55
+ return entries.filter((e) => e.endsWith('.md') && e !== '.gitkeep').sort();
56
+ }
57
+
58
+ export async function loadVocabulary(foundryDir, io) {
59
+ const p = memoryPaths(foundryDir);
60
+ const vocab = { entities: {}, edges: {} };
61
+
62
+ for (const file of await loadDir(p.entitiesDir, io)) {
63
+ const fullPath = join(p.entitiesDir, file);
64
+ const text = await io.readFile(fullPath);
65
+ const parsed = splitFrontmatter(text, fullPath);
66
+ validateEntity(file, parsed);
67
+ const { type } = parsed.frontmatter;
68
+ vocab.entities[type] = {
69
+ type,
70
+ body: parsed.body,
71
+ frontmatter: parsed.frontmatter,
72
+ file: fullPath,
73
+ };
74
+ }
75
+
76
+ for (const file of await loadDir(p.edgesDir, io)) {
77
+ const fullPath = join(p.edgesDir, file);
78
+ const text = await io.readFile(fullPath);
79
+ const parsed = splitFrontmatter(text, fullPath);
80
+ validateEdge(file, parsed);
81
+ const { type, sources, targets } = parsed.frontmatter;
82
+ vocab.edges[type] = {
83
+ type,
84
+ sources,
85
+ targets,
86
+ body: parsed.body,
87
+ frontmatter: parsed.frontmatter,
88
+ file: fullPath,
89
+ };
90
+ }
91
+
92
+ return vocab;
93
+ }
@@ -0,0 +1,58 @@
1
+ export const MAX_VALUE_BYTES = 4096;
2
+
3
+ // Names are the primary key, get embedded in NDJSON serialisation and Cozo
4
+ // query literals, and appear in error messages. Reject newline, CR, tab, and
5
+ // NUL so round-trips stay lossless and users can't craft names that would
6
+ // split across lines in the relation files.
7
+ const FORBIDDEN_CHARS = /[\n\r\t]/;
8
+
9
+ function assertValidName(label, v) {
10
+ if (typeof v !== 'string' || v.length === 0) {
11
+ throw new Error(`${label} must be a non-empty string`);
12
+ }
13
+ if (FORBIDDEN_CHARS.test(v) || v.includes('\0')) {
14
+ throw new Error(`${label} must not contain newline, carriage return, tab, or NUL`);
15
+ }
16
+ }
17
+
18
+ function byteLen(s) {
19
+ return Buffer.byteLength(s, 'utf8');
20
+ }
21
+
22
+ export function validateEntityWrite({ type, name, value }, vocabulary) {
23
+ if (!vocabulary.entities[type]) {
24
+ throw new Error(`entity type '${type}' is not declared`);
25
+ }
26
+ assertValidName('entity name', name);
27
+ if (typeof value !== 'string') {
28
+ throw new Error(`entity value must be a string`);
29
+ }
30
+ if (value.includes('\0')) {
31
+ throw new Error(`entity value must not contain NUL`);
32
+ }
33
+ if (byteLen(value) > MAX_VALUE_BYTES) {
34
+ throw new Error(`entity value is too large: ${byteLen(value)} bytes exceeds 4KB limit`);
35
+ }
36
+ }
37
+
38
+ function checkEdgeRole(entities, allowed, actualType, label, edgeType) {
39
+ if (allowed !== 'any') {
40
+ if (!entities[actualType]) {
41
+ throw new Error(`edge ${label} type '${actualType}' is not a declared entity type`);
42
+ }
43
+ if (!allowed.includes(actualType)) {
44
+ throw new Error(`edge '${edgeType}' does not permit ${label} type '${actualType}' (allowed: ${allowed.join(', ')})`);
45
+ }
46
+ }
47
+ }
48
+
49
+ export function validateEdgeWrite({ edge_type, from_type, from_name, to_type, to_name }, vocabulary) {
50
+ const edge = vocabulary.edges[edge_type];
51
+ if (!edge) {
52
+ throw new Error(`edge type '${edge_type}' is not declared`);
53
+ }
54
+ checkEdgeRole(vocabulary.entities, edge.sources, from_type, 'source', edge_type);
55
+ checkEdgeRole(vocabulary.entities, edge.targets, to_type, 'target', edge_type);
56
+ assertValidName('from_name', from_name);
57
+ assertValidName('to_name', to_name);
58
+ }