@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.
- package/README.md +180 -369
- package/dist/.opencode/plugins/foundry-tools/appraiser-tools.js +28 -0
- package/dist/.opencode/plugins/foundry-tools/artefact-tools.js +58 -0
- package/dist/.opencode/plugins/foundry-tools/assay-tools.js +92 -0
- package/dist/.opencode/plugins/foundry-tools/attestation-tools.js +191 -0
- package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +128 -0
- package/dist/.opencode/plugins/foundry-tools/config-law-tools.js +380 -0
- package/dist/.opencode/plugins/foundry-tools/config-tools.js +43 -0
- package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +234 -0
- package/dist/.opencode/plugins/foundry-tools/git-helpers.js +354 -0
- package/dist/.opencode/plugins/foundry-tools/git-tools.js +181 -0
- package/dist/.opencode/plugins/foundry-tools/helpers.js +340 -0
- package/dist/.opencode/plugins/foundry-tools/history-tools.js +20 -0
- package/dist/.opencode/plugins/foundry-tools/memory-admin-tools.js +296 -0
- package/dist/.opencode/plugins/foundry-tools/memory-helpers.js +104 -0
- package/dist/.opencode/plugins/foundry-tools/memory-tools.js +286 -0
- package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +159 -0
- package/dist/.opencode/plugins/foundry-tools/snapshot-tools.js +104 -0
- package/dist/.opencode/plugins/foundry-tools/stage-tools.js +186 -0
- package/dist/.opencode/plugins/foundry-tools/validate-tools.js +263 -0
- package/dist/.opencode/plugins/foundry-tools/workfile-tools.js +102 -0
- package/dist/.opencode/plugins/foundry.js +105 -0
- package/dist/CHANGELOG.md +490 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +278 -0
- package/dist/docs/README.md +59 -0
- package/dist/docs/architecture.md +434 -0
- package/dist/docs/concepts.md +396 -0
- package/dist/docs/getting-started.md +345 -0
- package/dist/docs/memory-maintenance.md +176 -0
- package/dist/docs/tools.md +1411 -0
- package/dist/docs/work-spec.md +283 -0
- package/dist/scripts/lib/artefacts.js +151 -0
- package/dist/scripts/lib/assay/loader.js +151 -0
- package/dist/scripts/lib/assay/parse-jsonl.js +102 -0
- package/dist/scripts/lib/assay/permissions.js +52 -0
- package/dist/scripts/lib/assay/run.js +219 -0
- package/dist/scripts/lib/assay/spawn-with-timeout.js +138 -0
- package/dist/scripts/lib/attestation/attest.js +111 -0
- package/dist/scripts/lib/attestation/canonical-json.js +109 -0
- package/dist/scripts/lib/attestation/hash.js +17 -0
- package/dist/scripts/lib/attestation/parse.js +14 -0
- package/dist/scripts/lib/attestation/payload.js +106 -0
- package/dist/scripts/lib/attestation/render.js +16 -0
- package/dist/scripts/lib/attestation/verify.js +15 -0
- package/dist/scripts/lib/branch-guard.js +72 -0
- package/dist/scripts/lib/config-creators/appraiser.js +9 -0
- package/dist/scripts/lib/config-creators/artefact-type.js +9 -0
- package/dist/scripts/lib/config-creators/cycle.js +11 -0
- package/dist/scripts/lib/config-creators/factory.js +49 -0
- package/dist/scripts/lib/config-creators/flow.js +11 -0
- package/dist/scripts/lib/config-validators/appraiser.js +49 -0
- package/dist/scripts/lib/config-validators/artefact-type.js +38 -0
- package/dist/scripts/lib/config-validators/cycle.js +131 -0
- package/dist/scripts/lib/config-validators/flow.js +57 -0
- package/dist/scripts/lib/config-validators/helpers.js +96 -0
- package/dist/scripts/lib/config-validators/law.js +96 -0
- package/dist/scripts/lib/config.js +393 -0
- package/dist/scripts/lib/failed-flow.js +131 -0
- package/dist/scripts/lib/feedback-store.js +249 -0
- package/dist/scripts/lib/feedback-transitions.js +105 -0
- package/dist/scripts/lib/finalize.js +70 -0
- package/dist/scripts/lib/foundational-guards.js +13 -0
- package/dist/scripts/lib/git-bridge.js +77 -0
- package/dist/scripts/lib/git-finish/work-finish.js +233 -0
- package/dist/scripts/lib/git-policy.js +101 -0
- package/dist/scripts/lib/guards.js +125 -0
- package/dist/scripts/lib/history.js +132 -0
- package/dist/scripts/lib/memory/admin/create-edge-type.js +91 -0
- package/dist/scripts/lib/memory/admin/create-entity-type.js +43 -0
- package/dist/scripts/lib/memory/admin/create-extractor.js +67 -0
- package/dist/scripts/lib/memory/admin/drop-edge-type.js +40 -0
- package/dist/scripts/lib/memory/admin/drop-entity-type.js +172 -0
- package/dist/scripts/lib/memory/admin/dump.js +47 -0
- package/dist/scripts/lib/memory/admin/helpers.js +31 -0
- package/dist/scripts/lib/memory/admin/init.js +170 -0
- package/dist/scripts/lib/memory/admin/live-store.js +76 -0
- package/dist/scripts/lib/memory/admin/reembed.js +285 -0
- package/dist/scripts/lib/memory/admin/rename-edge-type.js +54 -0
- package/dist/scripts/lib/memory/admin/rename-entity-type.js +151 -0
- package/dist/scripts/lib/memory/admin/reset.js +24 -0
- package/dist/scripts/lib/memory/admin/vacuum.js +9 -0
- package/dist/scripts/lib/memory/admin/validate.js +19 -0
- package/dist/scripts/lib/memory/config.js +149 -0
- package/dist/scripts/lib/memory/cozo.js +136 -0
- package/dist/scripts/lib/memory/drift.js +71 -0
- package/dist/scripts/lib/memory/embeddings.js +128 -0
- package/dist/scripts/lib/memory/frontmatter.js +75 -0
- package/dist/scripts/lib/memory/ndjson.js +84 -0
- package/dist/scripts/lib/memory/paths.js +25 -0
- package/dist/scripts/lib/memory/permissions.js +41 -0
- package/dist/scripts/lib/memory/prompt.js +109 -0
- package/dist/scripts/lib/memory/query.js +56 -0
- package/dist/scripts/lib/memory/reads.js +109 -0
- package/dist/scripts/lib/memory/schema.js +64 -0
- package/dist/scripts/lib/memory/search.js +73 -0
- package/dist/scripts/lib/memory/singleton.js +49 -0
- package/dist/scripts/lib/memory/store.js +162 -0
- package/dist/scripts/lib/memory/types.js +93 -0
- package/dist/scripts/lib/memory/validate.js +58 -0
- package/dist/scripts/lib/memory/writes.js +40 -0
- package/{scripts → dist/scripts}/lib/pending.js +7 -2
- package/dist/scripts/lib/secret.js +59 -0
- package/{scripts → dist/scripts}/lib/slug.js +3 -2
- package/dist/scripts/lib/snapshot/finish.js +103 -0
- package/dist/scripts/lib/snapshot/inspect.js +253 -0
- package/dist/scripts/lib/snapshot/render.js +55 -0
- package/dist/scripts/lib/sort-fs-check.js +121 -0
- package/dist/scripts/lib/sort-routing.js +101 -0
- package/{scripts → dist/scripts}/lib/stage-guard.js +12 -6
- package/{scripts → dist/scripts}/lib/state.js +4 -0
- package/dist/scripts/lib/token.js +57 -0
- package/dist/scripts/lib/tracing.js +59 -0
- package/dist/scripts/lib/ulid.js +100 -0
- package/dist/scripts/lib/validator-jsonl.js +162 -0
- package/{scripts → dist/scripts}/lib/workfile.js +38 -20
- package/dist/scripts/orchestrate-cycle.js +215 -0
- package/dist/scripts/orchestrate-phases.js +314 -0
- package/dist/scripts/orchestrate.js +163 -0
- package/dist/scripts/sort.js +278 -0
- package/{skills → dist/skills}/add-appraiser/SKILL.md +39 -9
- package/{skills → dist/skills}/add-artefact-type/SKILL.md +46 -24
- package/{skills → dist/skills}/add-cycle/SKILL.md +57 -17
- package/dist/skills/add-extractor/SKILL.md +133 -0
- package/{skills → dist/skills}/add-flow/SKILL.md +36 -10
- package/dist/skills/add-law/SKILL.md +191 -0
- package/dist/skills/add-memory-edge-type/SKILL.md +52 -0
- package/dist/skills/add-memory-entity-type/SKILL.md +74 -0
- package/{skills → dist/skills}/appraise/SKILL.md +62 -13
- package/dist/skills/assay/SKILL.md +72 -0
- package/dist/skills/change-embedding-model/SKILL.md +58 -0
- package/dist/skills/drop-memory-edge-type/SKILL.md +54 -0
- package/dist/skills/drop-memory-entity-type/SKILL.md +57 -0
- package/dist/skills/dry-run/SKILL.md +116 -0
- package/{skills → dist/skills}/flow/SKILL.md +15 -2
- package/dist/skills/forge/SKILL.md +121 -0
- package/dist/skills/human-appraise/SKILL.md +153 -0
- package/{skills → dist/skills}/init-foundry/SKILL.md +23 -4
- package/dist/skills/init-memory/SKILL.md +92 -0
- package/{skills → dist/skills}/orchestrate/SKILL.md +30 -4
- package/dist/skills/quench/SKILL.md +99 -0
- package/{skills → dist/skills}/refresh-agents/SKILL.md +1 -1
- package/dist/skills/rename-memory-edge-type/SKILL.md +50 -0
- package/dist/skills/rename-memory-entity-type/SKILL.md +51 -0
- package/dist/skills/reset-memory/SKILL.md +54 -0
- package/dist/skills/upgrade-foundry/SKILL.md +192 -0
- package/package.json +34 -17
- package/.opencode/plugins/foundry.js +0 -761
- package/CHANGELOG.md +0 -100
- package/docs/concepts.md +0 -122
- package/docs/getting-started.md +0 -187
- package/docs/work-spec.md +0 -207
- package/scripts/lib/artefacts.js +0 -124
- package/scripts/lib/config.js +0 -175
- package/scripts/lib/feedback-transitions.js +0 -25
- package/scripts/lib/feedback.js +0 -440
- package/scripts/lib/finalize.js +0 -41
- package/scripts/lib/history.js +0 -59
- package/scripts/lib/secret.js +0 -23
- package/scripts/lib/tags.js +0 -108
- package/scripts/lib/token.js +0 -26
- package/scripts/orchestrate.js +0 -418
- package/scripts/sort.js +0 -370
- package/scripts/validate-tags.js +0 -54
- package/skills/add-law/SKILL.md +0 -111
- package/skills/forge/SKILL.md +0 -88
- package/skills/human-appraise/SKILL.md +0 -82
- package/skills/quench/SKILL.md +0 -62
- package/skills/upgrade-foundry/SKILL.md +0 -216
- /package/{skills → dist/skills}/list-agents/SKILL.md +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { memoryPaths } from '../paths.js';
|
|
2
|
+
import { loadSchema, writeSchema, bumpVersion, hashFrontmatter } from '../schema.js';
|
|
3
|
+
import { invalidateStore } from '../singleton.js';
|
|
4
|
+
import { withLiveMemoryDb, createLiveEntityType } from './live-store.js';
|
|
5
|
+
|
|
6
|
+
const IDENT = /^[a-z][a-z0-9_]*$/;
|
|
7
|
+
|
|
8
|
+
function validateInputs(name, body) {
|
|
9
|
+
if (!IDENT.test(name)) throw new Error(`invalid identifier: '${name}' (expected lowercase snake_case)`);
|
|
10
|
+
if (typeof body !== 'string' || !body.trim()) throw new Error(`body must be a non-empty string`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function createEntityType({ worktreeRoot, io, name, body }) {
|
|
14
|
+
validateInputs(name, body);
|
|
15
|
+
|
|
16
|
+
// Use relative path 'foundry' since the IO abstraction joins with worktreeRoot.
|
|
17
|
+
// worktreeRoot is used for invalidateStore() and withLiveMemoryDb() calls.
|
|
18
|
+
const foundryDir = 'foundry';
|
|
19
|
+
const p = memoryPaths(foundryDir);
|
|
20
|
+
const schema = await loadSchema(foundryDir, io);
|
|
21
|
+
|
|
22
|
+
if (schema.entities[name]) throw new Error(`entity type '${name}' already exists in schema`);
|
|
23
|
+
if (schema.edges[name]) throw new Error(`'${name}' is already declared as an edge type`);
|
|
24
|
+
if (await io.exists(p.entityTypeFile(name))) throw new Error(`entity type file already exists on disk`);
|
|
25
|
+
|
|
26
|
+
const frontmatter = { type: name };
|
|
27
|
+
const fileContent = `---\ntype: ${name}\n---\n\n${body.trim()}\n`;
|
|
28
|
+
await io.writeFile(p.entityTypeFile(name), fileContent);
|
|
29
|
+
await io.writeFile(p.relationFile(name), '');
|
|
30
|
+
|
|
31
|
+
schema.entities[name] = { frontmatterHash: hashFrontmatter(frontmatter) };
|
|
32
|
+
bumpVersion(schema);
|
|
33
|
+
await writeSchema(foundryDir, schema, io);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await withLiveMemoryDb({ worktreeRoot, io }, async (db) => {
|
|
37
|
+
await createLiveEntityType(db, name, { embeddingsDim: schema.embeddings?.dimensions });
|
|
38
|
+
});
|
|
39
|
+
} finally {
|
|
40
|
+
invalidateStore(worktreeRoot);
|
|
41
|
+
}
|
|
42
|
+
return { type: name };
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { memoryPaths } from '../paths.js';
|
|
2
|
+
import { loadSchema } from '../schema.js';
|
|
3
|
+
|
|
4
|
+
const IDENT = /^[a-z][a-z0-9_-]*$/;
|
|
5
|
+
|
|
6
|
+
function validateName(name) {
|
|
7
|
+
if (!IDENT.test(name)) throw new Error(`invalid identifier: '${name}' (expected lowercase kebab/snake)`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function validateCommand(command) {
|
|
11
|
+
if (typeof command !== 'string' || !command.trim()) throw new Error(`command must be a non-empty string`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function validateMemoryWrite(memoryWrite) {
|
|
15
|
+
if (!Array.isArray(memoryWrite) || memoryWrite.length === 0) {
|
|
16
|
+
throw new Error(`memoryWrite must be a non-empty array of entity type names`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateBody(body) {
|
|
21
|
+
if (typeof body !== 'string' || !body.trim()) throw new Error(`body must be a non-empty string`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function validateSchemaTypes(foundryDir, io, memoryWrite) {
|
|
25
|
+
const schema = await loadSchema(foundryDir, io);
|
|
26
|
+
const undeclared = memoryWrite.filter((t) => !schema.entities[t]);
|
|
27
|
+
if (undeclared.length) {
|
|
28
|
+
throw new Error(`memoryWrite includes ${undeclared.join(', ')} which ${undeclared.length > 1 ? 'are' : 'is'} not declared in the project vocabulary (create entity types with add-memory-entity-type)`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function ensureExtractorsDir(p, io) {
|
|
33
|
+
if (!(await io.exists(p.extractorsDir))) {
|
|
34
|
+
await io.mkdir(p.extractorsDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createExtractor({ worktreeRoot, io, name, command, memoryWrite, timeout, body }) {
|
|
39
|
+
validateName(name);
|
|
40
|
+
validateCommand(command);
|
|
41
|
+
validateMemoryWrite(memoryWrite);
|
|
42
|
+
validateBody(body);
|
|
43
|
+
|
|
44
|
+
const foundryDir = 'foundry';
|
|
45
|
+
await validateSchemaTypes(foundryDir, io, memoryWrite);
|
|
46
|
+
|
|
47
|
+
const p = memoryPaths(foundryDir);
|
|
48
|
+
const path = p.extractorFile(name);
|
|
49
|
+
if (await io.exists(path)) throw new Error(`extractor already exists: ${name} (${path})`);
|
|
50
|
+
|
|
51
|
+
await ensureExtractorsDir(p, io);
|
|
52
|
+
|
|
53
|
+
const writeLine = ` write: [${memoryWrite.join(', ')}]`;
|
|
54
|
+
const timeoutLine = timeout ? `timeout: ${timeout}\n` : '';
|
|
55
|
+
const fileContent =
|
|
56
|
+
`---\n` +
|
|
57
|
+
`command: ${command}\n` +
|
|
58
|
+
`memory:\n` +
|
|
59
|
+
`${writeLine}\n` +
|
|
60
|
+
timeoutLine +
|
|
61
|
+
`---\n\n` +
|
|
62
|
+
`# ${name}\n\n` +
|
|
63
|
+
`${body.trim()}\n`;
|
|
64
|
+
|
|
65
|
+
await io.writeFile(path, fileContent);
|
|
66
|
+
return { path };
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
import { memoryPaths } from '../paths.js';
|
|
3
|
+
import { loadSchema, writeSchema, bumpVersion } from '../schema.js';
|
|
4
|
+
import { invalidateStore } from '../singleton.js';
|
|
5
|
+
import { withLiveMemoryDb, dropLiveEdgeType } from './live-store.js';
|
|
6
|
+
|
|
7
|
+
export async function dropEdgeType({ worktreeRoot, io, name, confirm }) {
|
|
8
|
+
const foundryDir = 'foundry';
|
|
9
|
+
const p = memoryPaths(foundryDir);
|
|
10
|
+
const schema = await loadSchema(foundryDir, io);
|
|
11
|
+
if (!schema.edges[name]) throw new Error(`edge type '${name}' not declared`);
|
|
12
|
+
|
|
13
|
+
if (confirm !== true) {
|
|
14
|
+
let rows = 0;
|
|
15
|
+
const relFile = p.relationFile(name);
|
|
16
|
+
if (await io.exists(relFile)) {
|
|
17
|
+
const text = await io.readFile(relFile);
|
|
18
|
+
rows = text.split('\n').filter((l) => l.trim() !== '').length;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
requiresConfirm: true,
|
|
22
|
+
preview: { type: 'edge', name, rows },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await io.unlink(p.edgeTypeFile(name));
|
|
27
|
+
await io.unlink(p.relationFile(name));
|
|
28
|
+
delete schema.edges[name];
|
|
29
|
+
bumpVersion(schema);
|
|
30
|
+
await writeSchema(foundryDir, schema, io);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await withLiveMemoryDb({ worktreeRoot, io }, async (db) => {
|
|
34
|
+
await dropLiveEdgeType(db, name);
|
|
35
|
+
});
|
|
36
|
+
} finally {
|
|
37
|
+
invalidateStore(worktreeRoot);
|
|
38
|
+
}
|
|
39
|
+
return { dropped: name };
|
|
40
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
|
|
2
|
+
import { memoryPaths } from '../paths.js';
|
|
3
|
+
import { loadSchema, writeSchema, bumpVersion, hashFrontmatter } from '../schema.js';
|
|
4
|
+
import { parseEdgeRows, serialiseEdgeRows } from '../ndjson.js';
|
|
5
|
+
import { invalidateStore } from '../singleton.js';
|
|
6
|
+
import { parseFrontmatter } from '../frontmatter.js';
|
|
7
|
+
import {
|
|
8
|
+
withLiveMemoryDb,
|
|
9
|
+
dropLiveEntityType,
|
|
10
|
+
dropLiveEdgeType,
|
|
11
|
+
replaceLiveEdgeRows,
|
|
12
|
+
} from './live-store.js';
|
|
13
|
+
import { renderEdgeFrontmatter, composeMarkdown } from './helpers.js';
|
|
14
|
+
|
|
15
|
+
function adjustFmKeyValue(fm, key, name) {
|
|
16
|
+
if (fm[key] === 'any') return fm[key];
|
|
17
|
+
if (!Array.isArray(fm[key])) return fm[key];
|
|
18
|
+
const filtered = fm[key].filter((t) => t !== name);
|
|
19
|
+
return filtered.length > 0 ? filtered : fm[key];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function wouldCascadeForKey(fm, key, name) {
|
|
23
|
+
if (fm[key] === 'any') return false;
|
|
24
|
+
if (!Array.isArray(fm[key])) return false;
|
|
25
|
+
const filtered = fm[key].filter((t) => t !== name);
|
|
26
|
+
return filtered.length === 0 && fm[key].includes(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function computeEdgeFmAdjustment(fm, name) {
|
|
30
|
+
const nextFm = { ...fm };
|
|
31
|
+
let wouldCascade = false;
|
|
32
|
+
for (const key of ['sources', 'targets']) {
|
|
33
|
+
nextFm[key] = adjustFmKeyValue(fm, key, name);
|
|
34
|
+
if (wouldCascadeForKey(fm, key, name)) wouldCascade = true;
|
|
35
|
+
}
|
|
36
|
+
return { nextFm, wouldCascade };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function edgeReferencesType(fm, name) {
|
|
40
|
+
return (
|
|
41
|
+
(Array.isArray(fm.sources) && fm.sources.includes(name)) ||
|
|
42
|
+
(Array.isArray(fm.targets) && fm.targets.includes(name))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function countAffectedRows(io, p, edgeName, name) {
|
|
47
|
+
const relFile = p.relationFile(edgeName);
|
|
48
|
+
if (!(await io.exists(relFile))) return 0;
|
|
49
|
+
const rows = parseEdgeRows(await io.readFile(relFile));
|
|
50
|
+
return rows.filter((r) => r.from_type === name || r.to_type === name).length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function analyseEdgeForDrop({ io, p, name, edgeName }) {
|
|
54
|
+
const edgeFile = p.edgeTypeFile(edgeName);
|
|
55
|
+
if (!(await io.exists(edgeFile))) return null;
|
|
56
|
+
const edgeText = await io.readFile(edgeFile);
|
|
57
|
+
const parsed = parseFrontmatter(edgeText, { filename: edgeFile });
|
|
58
|
+
if (!parsed.hasFrontmatter) return null;
|
|
59
|
+
const fm = parsed.frontmatter;
|
|
60
|
+
|
|
61
|
+
const { nextFm, wouldCascade } = computeEdgeFmAdjustment(fm, name);
|
|
62
|
+
if (wouldCascade) return { name: edgeName, action: 'cascadeDrop' };
|
|
63
|
+
if (!edgeReferencesType(fm, name)) return null;
|
|
64
|
+
|
|
65
|
+
const rowsAffected = await countAffectedRows(io, p, edgeName, name);
|
|
66
|
+
return { name: edgeName, action: 'prune', rowsAffected, nextFm };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function analyseEntityDrop({ io, p, name, schema }) {
|
|
70
|
+
const entityRelFile = p.relationFile(name);
|
|
71
|
+
let entityRows = 0;
|
|
72
|
+
if (await io.exists(entityRelFile)) {
|
|
73
|
+
const text = await io.readFile(entityRelFile);
|
|
74
|
+
entityRows = text.split('\n').filter((l) => l.trim() !== '').length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const affectedEdges = [];
|
|
78
|
+
for (const edgeName of Object.keys(schema.edges)) {
|
|
79
|
+
const result = await analyseEdgeForDrop({ io, p, name, edgeName });
|
|
80
|
+
if (result) affectedEdges.push(result);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { entityRows, affectedEdges };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildPreview(analysis, name) {
|
|
87
|
+
return {
|
|
88
|
+
type: 'entity',
|
|
89
|
+
name,
|
|
90
|
+
entityRows: analysis.entityRows,
|
|
91
|
+
affectedEdges: analysis.affectedEdges.map((e) => {
|
|
92
|
+
const base = { name: e.name, action: e.action };
|
|
93
|
+
if (e.action === 'prune') base.rowsAffected = e.rowsAffected;
|
|
94
|
+
return base;
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function processAffectedEdgesDisk(io, p, analysis, schema, name) {
|
|
100
|
+
for (const edge of analysis.affectedEdges) {
|
|
101
|
+
const edgeFile = p.edgeTypeFile(edge.name);
|
|
102
|
+
if (edge.action === 'cascadeDrop') {
|
|
103
|
+
await io.unlink(edgeFile);
|
|
104
|
+
await io.unlink(p.relationFile(edge.name));
|
|
105
|
+
delete schema.edges[edge.name];
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const edgeText = await io.readFile(edgeFile);
|
|
110
|
+
const parsed = parseFrontmatter(edgeText, { filename: edgeFile });
|
|
111
|
+
const body = parsed.body;
|
|
112
|
+
const nextFm = edge.nextFm;
|
|
113
|
+
await io.writeFile(edgeFile, composeMarkdown(renderEdgeFrontmatter(nextFm), body));
|
|
114
|
+
schema.edges[edge.name].frontmatterHash = hashFrontmatter({
|
|
115
|
+
type: edge.name,
|
|
116
|
+
sources: nextFm.sources,
|
|
117
|
+
targets: nextFm.targets,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const relFile = p.relationFile(edge.name);
|
|
121
|
+
if (await io.exists(relFile)) {
|
|
122
|
+
const rows = parseEdgeRows(await io.readFile(relFile));
|
|
123
|
+
const kept = rows.filter((r) => r.from_type !== name && r.to_type !== name);
|
|
124
|
+
await io.writeFile(relFile, serialiseEdgeRows(kept));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function updateLiveDb(worktreeRoot, io, p, analysis, name) {
|
|
130
|
+
await withLiveMemoryDb({ worktreeRoot, io }, async (db) => {
|
|
131
|
+
for (const edge of analysis.affectedEdges) {
|
|
132
|
+
if (edge.action === 'cascadeDrop') {
|
|
133
|
+
await dropLiveEdgeType(db, edge.name);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const relFile = p.relationFile(edge.name);
|
|
137
|
+
const rows = await io.exists(relFile)
|
|
138
|
+
? parseEdgeRows(await io.readFile(relFile))
|
|
139
|
+
: [];
|
|
140
|
+
await replaceLiveEdgeRows(db, edge.name, rows);
|
|
141
|
+
}
|
|
142
|
+
await dropLiveEntityType(db, name);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function dropEntityType({ worktreeRoot, io, name, confirm }) {
|
|
147
|
+
const foundryDir = 'foundry';
|
|
148
|
+
const p = memoryPaths(foundryDir);
|
|
149
|
+
const schema = await loadSchema(foundryDir, io);
|
|
150
|
+
if (!schema.entities[name]) throw new Error(`entity type '${name}' not declared`);
|
|
151
|
+
|
|
152
|
+
const analysis = await analyseEntityDrop({ io, p, name, schema });
|
|
153
|
+
|
|
154
|
+
if (confirm !== true) {
|
|
155
|
+
return { requiresConfirm: true, preview: buildPreview(analysis, name) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await io.unlink(p.entityTypeFile(name));
|
|
159
|
+
await io.unlink(p.relationFile(name));
|
|
160
|
+
await processAffectedEdgesDisk(io, p, analysis, schema, name);
|
|
161
|
+
|
|
162
|
+
delete schema.entities[name];
|
|
163
|
+
bumpVersion(schema);
|
|
164
|
+
await writeSchema(foundryDir, schema, io);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await updateLiveDb(worktreeRoot, io, p, analysis, name);
|
|
168
|
+
} finally {
|
|
169
|
+
invalidateStore(worktreeRoot);
|
|
170
|
+
}
|
|
171
|
+
return { dropped: name };
|
|
172
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { listEntities, getEntity, neighbours } from '../reads.js';
|
|
2
|
+
|
|
3
|
+
async function dumpSingleEntity(store, vocabulary, type, name) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
const ent = await getEntity(store, { type, name });
|
|
6
|
+
if (!ent) return `(no entity found: ${type}/${name})`;
|
|
7
|
+
lines.push(`# ${type}/${name}`);
|
|
8
|
+
lines.push('');
|
|
9
|
+
lines.push(ent.value);
|
|
10
|
+
lines.push('');
|
|
11
|
+
const nbrs = await neighbours(store, { type, name, depth: 1 }, vocabulary);
|
|
12
|
+
if (nbrs.edges.length > 0) {
|
|
13
|
+
lines.push('## Edges');
|
|
14
|
+
for (const e of nbrs.edges) {
|
|
15
|
+
lines.push(`- ${e.from_type}/${e.from_name} --${e.edge_type}--> ${e.to_type}/${e.to_name}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return lines.join('\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function dumpTypeList(store, type) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
const rows = await listEntities(store, { type });
|
|
24
|
+
lines.push(`# entities of type '${type}' (${rows.length})`);
|
|
25
|
+
for (const r of rows) lines.push(`- ${r.name}`);
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function dumpSummary(store, vocabulary) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
lines.push('# memory summary');
|
|
32
|
+
for (const t of Object.keys(vocabulary.entities)) {
|
|
33
|
+
const rows = await listEntities(store, { type: t });
|
|
34
|
+
lines.push(`- entity ${t}: ${rows.length} rows`);
|
|
35
|
+
}
|
|
36
|
+
for (const t of Object.keys(vocabulary.edges)) {
|
|
37
|
+
const res = await store.db.run(`?[ft, fn, tt, tn] := *edge_${t}{from_type: ft, from_name: fn, to_type: tt, to_name: tn}`);
|
|
38
|
+
lines.push(`- edge ${t}: ${res.rows.length} rows`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function dumpMemory({ store, vocabulary, type, name, depth = 1 }) {
|
|
44
|
+
if (type && name) return dumpSingleEntity(store, vocabulary, type, name);
|
|
45
|
+
if (type) return dumpTypeList(store, type);
|
|
46
|
+
return dumpSummary(store, vocabulary);
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for memory admin operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render edge frontmatter as YAML.
|
|
7
|
+
* @param {Object} fm - Frontmatter object with type, sources, targets
|
|
8
|
+
* @returns {string} YAML frontmatter content (without delimiters)
|
|
9
|
+
*/
|
|
10
|
+
export function renderEdgeFrontmatter(fm) {
|
|
11
|
+
const lines = [`type: ${fm.type}`];
|
|
12
|
+
for (const key of ['sources', 'targets']) {
|
|
13
|
+
const v = fm[key];
|
|
14
|
+
lines.push(v === 'any' ? `${key}: any` : `${key}: [${v.join(', ')}]`);
|
|
15
|
+
}
|
|
16
|
+
return lines.join('\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compose a markdown file from frontmatter and body.
|
|
21
|
+
* Handles the "strip leading newline from body if present" pattern
|
|
22
|
+
* used throughout memory admin modules.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} frontmatter - YAML frontmatter content (without delimiters)
|
|
25
|
+
* @param {string} body - Markdown body content
|
|
26
|
+
* @returns {string} Complete markdown file with frontmatter delimiters
|
|
27
|
+
*/
|
|
28
|
+
export function composeMarkdown(frontmatter, body) {
|
|
29
|
+
const normalizedBody = body.startsWith('\n') ? body.slice(1) : body;
|
|
30
|
+
return `---\n${frontmatter}\n---\n${normalizedBody}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { memoryPaths } from '../paths.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../config.js';
|
|
4
|
+
import { renderMarkdown } from '../frontmatter.js';
|
|
5
|
+
import { probeEmbeddings } from '../embeddings.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG_BODY = `
|
|
8
|
+
# Memory configuration
|
|
9
|
+
|
|
10
|
+
This project uses Foundry flow memory. Add prose notes here if helpful.
|
|
11
|
+
|
|
12
|
+
The embedding provider defaults to a local Ollama instance. Edit the
|
|
13
|
+
frontmatter to point at a different OpenAI-compatible endpoint, or set
|
|
14
|
+
\`embeddings.enabled: false\` if you don't want vector search.
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
// Gitignore only the runtime DB files under foundry/memory/.
|
|
18
|
+
// foundry-memory/relations/ (NDJSON row data) is intentionally tracked.
|
|
19
|
+
const DEFAULT_GITIGNORE_ENTRIES = [
|
|
20
|
+
'foundry/memory/memory.db',
|
|
21
|
+
'foundry/memory/memory.db-wal',
|
|
22
|
+
'foundry/memory/memory.db-shm',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function buildEmbeddingsBlock(embeddingsEnabled) {
|
|
26
|
+
const d = DEFAULT_CONFIG.embeddings;
|
|
27
|
+
return {
|
|
28
|
+
enabled: embeddingsEnabled,
|
|
29
|
+
baseURL: d.baseURL,
|
|
30
|
+
model: d.model,
|
|
31
|
+
dimensions: d.dimensions,
|
|
32
|
+
apiKey: null,
|
|
33
|
+
batchSize: d.batchSize,
|
|
34
|
+
timeoutMs: d.timeoutMs,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildConfigFm(embeddingsBlock) {
|
|
39
|
+
return {
|
|
40
|
+
enabled: true,
|
|
41
|
+
validation: 'strict',
|
|
42
|
+
embeddings: embeddingsBlock,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createGitkeeps(io, dirs, created) {
|
|
47
|
+
for (const d of dirs) {
|
|
48
|
+
const f = join(d, '.gitkeep');
|
|
49
|
+
await io.writeFile(f, '');
|
|
50
|
+
created.push(f);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleProbeError(err) {
|
|
55
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runProbe(embeddingsBlock) {
|
|
59
|
+
try {
|
|
60
|
+
const result = await probeEmbeddings({ config: embeddingsBlock });
|
|
61
|
+
if (result.ok) {
|
|
62
|
+
return { probeResult: result, actualDimensions: result.dimensions };
|
|
63
|
+
}
|
|
64
|
+
return { probeResult: result, actualDimensions: DEFAULT_CONFIG.embeddings.dimensions };
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { probeResult: handleProbeError(err), actualDimensions: DEFAULT_CONFIG.embeddings.dimensions };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function probeEmbeddingsIfNeeded(embeddingsEnabled, probe, embeddingsBlock) {
|
|
71
|
+
if (!probe || !embeddingsEnabled) {
|
|
72
|
+
return { probeResult: null, actualDimensions: DEFAULT_CONFIG.embeddings.dimensions };
|
|
73
|
+
}
|
|
74
|
+
return runProbe(embeddingsBlock);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildSchema(embeddingsEnabled, model, dimensions) {
|
|
78
|
+
return {
|
|
79
|
+
version: 1,
|
|
80
|
+
entities: {},
|
|
81
|
+
edges: {},
|
|
82
|
+
embeddings: embeddingsEnabled
|
|
83
|
+
? { model, dimensions }
|
|
84
|
+
: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function validatePrerequisites(io, p) {
|
|
89
|
+
if (!(await io.exists('foundry'))) {
|
|
90
|
+
throw new Error('foundry/ does not exist; run init-foundry first');
|
|
91
|
+
}
|
|
92
|
+
if (await io.exists(p.root)) {
|
|
93
|
+
throw new Error('foundry/memory/ already exists');
|
|
94
|
+
}
|
|
95
|
+
if (await io.exists('foundry-memory')) {
|
|
96
|
+
throw new Error('foundry-memory/ already exists');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function writeConfigFile(io, configPath, embeddingsEnabled) {
|
|
101
|
+
const embeddingsBlock = buildEmbeddingsBlock(embeddingsEnabled);
|
|
102
|
+
const configFm = buildConfigFm(embeddingsBlock);
|
|
103
|
+
await io.writeFile(configPath, renderMarkdown(configFm, CONFIG_BODY));
|
|
104
|
+
return embeddingsBlock;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Scaffold `foundry/memory/` and `foundry-memory/relations/` deterministically.
|
|
109
|
+
*
|
|
110
|
+
* Creates:
|
|
111
|
+
* - entities/.gitkeep, edges/.gitkeep under foundry/memory/
|
|
112
|
+
* - foundry-memory/relations/.gitkeep (sibling of foundry/, holds row data)
|
|
113
|
+
* - config.md (frontmatter derived from DEFAULT_CONFIG)
|
|
114
|
+
* - schema.json (version 1, empty entities/edges, embeddings block
|
|
115
|
+
* populated from probe when enabled, null otherwise)
|
|
116
|
+
* - appends .gitignore entries (idempotent)
|
|
117
|
+
*
|
|
118
|
+
* When `embeddingsEnabled && probe`, probes the embedding provider first, then
|
|
119
|
+
* writes schema.json with the actual dimensions returned by the probe (if
|
|
120
|
+
* successful). If probe fails or is disabled, uses DEFAULT_CONFIG dimensions.
|
|
121
|
+
* The caller decides whether to surface probe failure to the user — initMemory
|
|
122
|
+
* itself does not fail on a bad probe (config.md already on disk with sensible
|
|
123
|
+
* defaults can be edited).
|
|
124
|
+
*
|
|
125
|
+
* @param {object} opts
|
|
126
|
+
* @param {object} opts.io memory-style IO (exists, readFile, writeFile, mkdir)
|
|
127
|
+
* @param {boolean} [opts.embeddingsEnabled] default true
|
|
128
|
+
* @param {boolean} [opts.probe] default true; only runs if embeddingsEnabled
|
|
129
|
+
* @returns {Promise<{ created: string[], gitignoreAdded: string[], probe: object|null }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function initMemory({ io, embeddingsEnabled = true, probe = true }) {
|
|
132
|
+
const p = memoryPaths('foundry');
|
|
133
|
+
await validatePrerequisites(io, p);
|
|
134
|
+
|
|
135
|
+
const created = [];
|
|
136
|
+
|
|
137
|
+
await io.mkdir(p.entitiesDir);
|
|
138
|
+
await io.mkdir(p.edgesDir);
|
|
139
|
+
await io.mkdir(p.relationsDir);
|
|
140
|
+
await createGitkeeps(io, [p.entitiesDir, p.edgesDir, p.relationsDir], created);
|
|
141
|
+
|
|
142
|
+
const embeddingsBlock = await writeConfigFile(io, p.config, embeddingsEnabled);
|
|
143
|
+
created.push(p.config);
|
|
144
|
+
|
|
145
|
+
const gitignoreAdded = await appendGitignore(io, DEFAULT_GITIGNORE_ENTRIES);
|
|
146
|
+
|
|
147
|
+
const { probeResult, actualDimensions } = await probeEmbeddingsIfNeeded(
|
|
148
|
+
embeddingsEnabled,
|
|
149
|
+
probe,
|
|
150
|
+
embeddingsBlock,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const schema = buildSchema(embeddingsEnabled, DEFAULT_CONFIG.embeddings.model, actualDimensions);
|
|
154
|
+
await io.writeFile(p.schema, JSON.stringify(schema, null, 2) + '\n');
|
|
155
|
+
created.push(p.schema);
|
|
156
|
+
|
|
157
|
+
return { created, gitignoreAdded, probe: probeResult };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function appendGitignore(io, entries) {
|
|
161
|
+
const path = '.gitignore';
|
|
162
|
+
const exists = await io.exists(path);
|
|
163
|
+
const current = exists ? await io.readFile(path) : '';
|
|
164
|
+
const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
165
|
+
const toAdd = entries.filter((e) => !present.has(e));
|
|
166
|
+
if (toAdd.length === 0) return [];
|
|
167
|
+
const tail = current.length === 0 || current.endsWith('\n') ? '' : '\n';
|
|
168
|
+
await io.writeFile(path, current + tail + toAdd.join('\n') + '\n');
|
|
169
|
+
return toAdd;
|
|
170
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
openMemoryDb,
|
|
4
|
+
closeMemoryDb,
|
|
5
|
+
createEntityRelation,
|
|
6
|
+
createEdgeRelation,
|
|
7
|
+
createHnswIndex,
|
|
8
|
+
dropRelation,
|
|
9
|
+
dropHnswIndex,
|
|
10
|
+
entRelName,
|
|
11
|
+
edgeRelName,
|
|
12
|
+
cozoStringLit,
|
|
13
|
+
} from '../cozo.js';
|
|
14
|
+
import { memoryPaths } from '../paths.js';
|
|
15
|
+
|
|
16
|
+
function errDisplayMessage(err) {
|
|
17
|
+
return String(err && (err.display || err.message || err));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function dropRelationIfPresent(db, relationName) {
|
|
21
|
+
try {
|
|
22
|
+
await dropRelation(db, relationName);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (/not found|does not exist|no such/i.test(errDisplayMessage(err))) return;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function withLiveMemoryDb({ worktreeRoot, io }, fn) {
|
|
30
|
+
const foundryDir = 'foundry';
|
|
31
|
+
const paths = memoryPaths(foundryDir);
|
|
32
|
+
if (!(await io.exists(paths.db))) return;
|
|
33
|
+
|
|
34
|
+
const db = openMemoryDb(join(worktreeRoot, paths.db));
|
|
35
|
+
try {
|
|
36
|
+
await fn(db);
|
|
37
|
+
} finally {
|
|
38
|
+
closeMemoryDb(db);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createLiveEntityType(db, name, { embeddingsDim } = {}) {
|
|
43
|
+
await createEntityRelation(db, name, embeddingsDim ? { dim: embeddingsDim } : {});
|
|
44
|
+
if (embeddingsDim) {
|
|
45
|
+
await createHnswIndex(db, entRelName(name), { dim: embeddingsDim });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createLiveEdgeType(db, name) {
|
|
50
|
+
await createEdgeRelation(db, name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function dropLiveEntityType(db, name) {
|
|
54
|
+
await dropHnswIndex(db, entRelName(name));
|
|
55
|
+
await dropRelationIfPresent(db, entRelName(name));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function dropLiveEdgeType(db, name) {
|
|
59
|
+
await dropRelationIfPresent(db, edgeRelName(name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function replaceLiveEdgeRows(db, name, rows) {
|
|
63
|
+
const relationName = edgeRelName(name);
|
|
64
|
+
await dropRelationIfPresent(db, relationName);
|
|
65
|
+
await createEdgeRelation(db, name);
|
|
66
|
+
if (rows.length === 0) return;
|
|
67
|
+
|
|
68
|
+
const data = rows
|
|
69
|
+
.map((row) => `[` +
|
|
70
|
+
`${cozoStringLit(row.from_type)}, ${cozoStringLit(row.from_name)}, ` +
|
|
71
|
+
`${cozoStringLit(row.to_type)}, ${cozoStringLit(row.to_name)}]`)
|
|
72
|
+
.join(', ');
|
|
73
|
+
await db.run(
|
|
74
|
+
`?[from_type, from_name, to_type, to_name] <- [${data}]\n:put ${relationName} { from_type, from_name, to_type, to_name }`,
|
|
75
|
+
);
|
|
76
|
+
}
|