@kontourai/flow-agents 0.4.0 → 1.0.1
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/.github/workflows/kit-gates-demo.yml +171 -0
- package/CHANGELOG.md +43 -0
- package/CONTEXT.md +1 -1
- package/README.md +13 -2
- package/build/src/cli/flow-kit.js +175 -6
- package/build/src/cli/validate-source-tree.js +19 -2
- package/build/src/flow-kit/validate.js +98 -0
- package/build/src/runtime-adapters.js +1 -1
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/scripts/hooks/config-protection.js +217 -15
- package/docs/fixture-ownership.md +2 -1
- package/docs/index.md +9 -1
- package/docs/kit-authoring-guide.md +126 -0
- package/docs/knowledge-kit.md +69 -0
- package/docs/vision.md +22 -0
- package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
- package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
- package/evals/integration/test_activate_npx_context.sh +134 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +163 -0
- package/evals/integration/test_hook_category_behaviors.sh +51 -0
- package/evals/integration/test_kit_conformance_levels.sh +209 -0
- package/evals/run.sh +2 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +2 -2
- package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
- package/kits/knowledge/adapters/flow-runner/index.js +349 -0
- package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
- package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
- package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
- package/kits/knowledge/adapters/shared/codec.js +325 -0
- package/kits/knowledge/docs/store-contract.md +72 -0
- package/kits/knowledge/evals/entities/demo-acme.js +125 -0
- package/kits/knowledge/evals/entities/suite.test.js +722 -0
- package/kits/knowledge/kit.json +10 -0
- package/kits/release-evidence/fixtures/claims/README.md +14 -0
- package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
- package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
- package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
- package/kits/release-evidence/kit.json +13 -0
- package/package.json +1 -1
- package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
- package/scripts/hooks/config-protection.js +217 -15
- package/src/cli/flow-kit.ts +162 -5
- package/src/cli/validate-source-tree.ts +7 -1
- package/src/flow-kit/validate.ts +127 -0
- package/src/runtime-adapters.ts +1 -1
- package/src/tools/validate-source-tree.ts +3 -2
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Obsidian Store Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the Knowledge Kit store contract where each record on disk
|
|
5
|
+
* is ONE human-canonical Obsidian markdown note.
|
|
6
|
+
*
|
|
7
|
+
* Storage layout:
|
|
8
|
+
* <storeRoot>/
|
|
9
|
+
* <category-as-path>/<title-slug>.md (active records)
|
|
10
|
+
* archive/<category-as-path>/<title-slug>.md (superseded records)
|
|
11
|
+
* graph-index.json (link graph — required by suite §13)
|
|
12
|
+
* .graph-index.json (path index — id→{path,archived})
|
|
13
|
+
*
|
|
14
|
+
* Frontmatter carries ALL contract fields; body rendered for Obsidian readability.
|
|
15
|
+
* Category dots map to directory segments: "eng.api" → "eng/api/".
|
|
16
|
+
* Filename: slugified title, collision-suffixed (-2, -3, …).
|
|
17
|
+
* Superseded records MOVE to archive/ (supersede-not-delete invariant).
|
|
18
|
+
*
|
|
19
|
+
* Zero runtime dependencies beyond Node.js built-ins.
|
|
20
|
+
*
|
|
21
|
+
* @module adapters/obsidian-store
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
missingEvidenceError,
|
|
30
|
+
notFoundError,
|
|
31
|
+
parseMarkdown,
|
|
32
|
+
serializeYaml,
|
|
33
|
+
extractWikilinks,
|
|
34
|
+
mergeLinks,
|
|
35
|
+
loadGraph,
|
|
36
|
+
saveGraph,
|
|
37
|
+
addLinksToGraph,
|
|
38
|
+
removeLinksFromGraph,
|
|
39
|
+
VALID_TYPES,
|
|
40
|
+
VALID_STATUS_TRANSITIONS,
|
|
41
|
+
validateCategory,
|
|
42
|
+
} from "../shared/codec.js";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// ObsidianKnowledgeStore
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export class ObsidianKnowledgeStore {
|
|
49
|
+
/**
|
|
50
|
+
* @param {{ storeRoot: string }} options
|
|
51
|
+
*/
|
|
52
|
+
constructor({ storeRoot, sourcesDir = "sources", dimensions = [] }) {
|
|
53
|
+
if (!storeRoot) throw new Error("storeRoot is required");
|
|
54
|
+
this._sourcesDir = sourcesDir;
|
|
55
|
+
// Named dimensions for category segments AFTER the first (domain) segment,
|
|
56
|
+
// written into frontmatter as derived fields so vault views can filter on
|
|
57
|
+
// them (e.g. dimensions: ["territory","customer","initiative"] turns
|
|
58
|
+
// category sales.east.acme.renewal into territory: east, customer: acme,
|
|
59
|
+
// initiative: renewal). Domain kits supply the names; core stays neutral.
|
|
60
|
+
this._dimensions = dimensions;
|
|
61
|
+
this._root = path.resolve(storeRoot);
|
|
62
|
+
// Link graph (required by suite §13): { schema_version, forward, reverse }
|
|
63
|
+
this._graphPath = path.join(this._root, "graph-index.json");
|
|
64
|
+
// Path index (internal): { by_id: { id: { path, archived } }, by_path: { relPath: id } }
|
|
65
|
+
this._pathIndexPath = path.join(this._root, ".graph-index.json");
|
|
66
|
+
fs.mkdirSync(this._root, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
// Path / slug helpers
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
_slugify(title) {
|
|
74
|
+
return title
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
77
|
+
.replace(/^-+|-+$/g, "")
|
|
78
|
+
|| "untitled";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compute a unique relative path for a record, respecting collision suffix.
|
|
83
|
+
* Category dots → directory separators: "eng.api" → "eng/api".
|
|
84
|
+
*
|
|
85
|
+
* Layout rule: insight records (snapshot, concept) live at the category
|
|
86
|
+
* node root so a human browsing the tree sees the living overviews first;
|
|
87
|
+
* source-level records (raw, compiled) nest one level down in a sources
|
|
88
|
+
* subfolder (name configurable via constructor `sourcesDir`, default
|
|
89
|
+
* "sources" — a domain kit may choose e.g. "meetings").
|
|
90
|
+
*/
|
|
91
|
+
_computeRelPath(category, title, id, pathIndex, type) {
|
|
92
|
+
let catDir;
|
|
93
|
+
if (type === "person") {
|
|
94
|
+
// Person records always go to the top-level people/ folder regardless of
|
|
95
|
+
// category — they are cross-cutting entities, not domain-specific notes.
|
|
96
|
+
catDir = "people";
|
|
97
|
+
} else {
|
|
98
|
+
catDir = category.replace(/\./g, "/");
|
|
99
|
+
if (type === "raw" || type === "compiled") {
|
|
100
|
+
catDir = `${catDir}/${this._sourcesDir}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const baseSlug = this._slugify(title);
|
|
104
|
+
let slug = baseSlug;
|
|
105
|
+
let suffix = 2;
|
|
106
|
+
while (true) {
|
|
107
|
+
const relPath = `${catDir}/${slug}.md`;
|
|
108
|
+
const existingId = pathIndex.by_path[relPath];
|
|
109
|
+
if (!existingId || existingId === id) return relPath;
|
|
110
|
+
slug = `${baseSlug}-${suffix++}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
// Path index I/O
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
_loadPathIndex() {
|
|
119
|
+
if (!fs.existsSync(this._pathIndexPath)) return { by_id: {}, by_path: {} };
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(fs.readFileSync(this._pathIndexPath, "utf8"));
|
|
122
|
+
} catch {
|
|
123
|
+
return { by_id: {}, by_path: {} };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_savePathIndex(index) {
|
|
128
|
+
fs.writeFileSync(this._pathIndexPath, JSON.stringify(index, null, 2) + "\n", "utf8");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
// Record I/O
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Returns the absolute path for a record id, or null if not indexed.
|
|
137
|
+
*/
|
|
138
|
+
_getAbsPath(id, pathIndex) {
|
|
139
|
+
const entry = (pathIndex || this._loadPathIndex()).by_id[id];
|
|
140
|
+
if (!entry) return null;
|
|
141
|
+
return path.join(this._root, entry.path);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read a record by id. Returns the full record object (all fields from
|
|
146
|
+
* frontmatter, with `body` included) or null if not found.
|
|
147
|
+
*/
|
|
148
|
+
_readRecord(id, pathIndex) {
|
|
149
|
+
const absPath = this._getAbsPath(id, pathIndex);
|
|
150
|
+
if (!absPath || !fs.existsSync(absPath)) return null;
|
|
151
|
+
const text = fs.readFileSync(absPath, "utf8");
|
|
152
|
+
const { meta } = parseMarkdown(text);
|
|
153
|
+
if (!meta.id) return null;
|
|
154
|
+
// body is stored in frontmatter as meta.body
|
|
155
|
+
return { ...meta };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write a record to disk.
|
|
160
|
+
*
|
|
161
|
+
* - On first write: computes slug path, registers in path index.
|
|
162
|
+
* - On update with title change: renames file (old deleted, new path used).
|
|
163
|
+
* - All contract fields stored in YAML frontmatter.
|
|
164
|
+
* - Below the frontmatter: Obsidian-readable human body for the note type.
|
|
165
|
+
*/
|
|
166
|
+
_writeRecord(record, pathIndex) {
|
|
167
|
+
const ownedIndex = !pathIndex;
|
|
168
|
+
if (ownedIndex) pathIndex = this._loadPathIndex();
|
|
169
|
+
|
|
170
|
+
const existingEntry = pathIndex.by_id[record.id];
|
|
171
|
+
let targetRelPath;
|
|
172
|
+
|
|
173
|
+
if (existingEntry && existingEntry.archived) {
|
|
174
|
+
// Archived record — keep in archive path (supersede-not-delete writes back there)
|
|
175
|
+
targetRelPath = existingEntry.path;
|
|
176
|
+
} else if (existingEntry) {
|
|
177
|
+
// Existing active record — check if path needs to change (title changed)
|
|
178
|
+
const newRelPath = this._computeRelPath(record.category, record.title, record.id, pathIndex, record.type);
|
|
179
|
+
if (newRelPath !== existingEntry.path) {
|
|
180
|
+
// Move: delete old file, register new path
|
|
181
|
+
const oldAbs = path.join(this._root, existingEntry.path);
|
|
182
|
+
if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs);
|
|
183
|
+
delete pathIndex.by_path[existingEntry.path];
|
|
184
|
+
pathIndex.by_id[record.id] = { path: newRelPath, archived: false };
|
|
185
|
+
pathIndex.by_path[newRelPath] = record.id;
|
|
186
|
+
targetRelPath = newRelPath;
|
|
187
|
+
} else {
|
|
188
|
+
targetRelPath = existingEntry.path;
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// New record
|
|
192
|
+
const newRelPath = this._computeRelPath(record.category, record.title, record.id, pathIndex, record.type);
|
|
193
|
+
pathIndex.by_id[record.id] = { path: newRelPath, archived: false };
|
|
194
|
+
pathIndex.by_path[newRelPath] = record.id;
|
|
195
|
+
targetRelPath = newRelPath;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Render: all contract fields in frontmatter; human body below
|
|
199
|
+
const { body, ...frontmatterFields } = record;
|
|
200
|
+
// Derived dimension fields (territory: east, customer: acme, ...) from
|
|
201
|
+
// category segments after the domain segment — presentation-only, never
|
|
202
|
+
// read back as contract fields (id/category remain canonical).
|
|
203
|
+
const derived = {};
|
|
204
|
+
if (this._dimensions.length && record.category) {
|
|
205
|
+
const segs = record.category.split(".").slice(1);
|
|
206
|
+
this._dimensions.forEach((name, i) => { if (segs[i]) derived[name] = segs[i]; });
|
|
207
|
+
}
|
|
208
|
+
// Store body in frontmatter so round-trip is lossless
|
|
209
|
+
const frontmatter = { ...frontmatterFields, ...derived, body };
|
|
210
|
+
const obsidianBody = this._renderObsidianBody(record, pathIndex);
|
|
211
|
+
const text = `---\n${serializeYaml(frontmatter)}\n---\n\n${obsidianBody}`;
|
|
212
|
+
|
|
213
|
+
const absPath = path.join(this._root, targetRelPath);
|
|
214
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
215
|
+
fs.writeFileSync(absPath, text, "utf8");
|
|
216
|
+
|
|
217
|
+
if (ownedIndex) this._savePathIndex(pathIndex);
|
|
218
|
+
return targetRelPath;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Move an active record to archive/ and mark it archived in path index.
|
|
223
|
+
* Called after writing the superseded-by mutation log entry.
|
|
224
|
+
*/
|
|
225
|
+
_archiveRecord(id, pathIndex) {
|
|
226
|
+
const ownedIndex = !pathIndex;
|
|
227
|
+
if (ownedIndex) pathIndex = this._loadPathIndex();
|
|
228
|
+
|
|
229
|
+
const entry = pathIndex.by_id[id];
|
|
230
|
+
if (!entry || entry.archived) {
|
|
231
|
+
if (ownedIndex) this._savePathIndex(pathIndex);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const archiveRelPath = `archive/${entry.path}`;
|
|
236
|
+
const archiveAbs = path.join(this._root, archiveRelPath);
|
|
237
|
+
fs.mkdirSync(path.dirname(archiveAbs), { recursive: true });
|
|
238
|
+
|
|
239
|
+
const currentAbs = path.join(this._root, entry.path);
|
|
240
|
+
if (fs.existsSync(currentAbs)) fs.renameSync(currentAbs, archiveAbs);
|
|
241
|
+
|
|
242
|
+
delete pathIndex.by_path[entry.path];
|
|
243
|
+
pathIndex.by_id[id] = { path: archiveRelPath, archived: true };
|
|
244
|
+
pathIndex.by_path[archiveRelPath] = id;
|
|
245
|
+
|
|
246
|
+
if (ownedIndex) this._savePathIndex(pathIndex);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -------------------------------------------------------------------------
|
|
250
|
+
// Obsidian body rendering
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Resolve a record id to its filename slug for use as a wikilink target.
|
|
255
|
+
* Falls back to the id itself if not in the index.
|
|
256
|
+
*/
|
|
257
|
+
_idToFilename(id, pathIndex) {
|
|
258
|
+
const entry = pathIndex.by_id[id];
|
|
259
|
+
if (!entry) return id;
|
|
260
|
+
return path.basename(entry.path, ".md");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Render the human-readable Obsidian body below the frontmatter fence.
|
|
265
|
+
* This is decorative — the canonical data lives in frontmatter.
|
|
266
|
+
*/
|
|
267
|
+
_renderObsidianBody(record, pathIndex) {
|
|
268
|
+
const links = record.links || [];
|
|
269
|
+
const relatedLinks = links.filter((l) => l.kind === "related" || l.kind === "refines");
|
|
270
|
+
const sourceLinks = links.filter((l) => l.kind === "source");
|
|
271
|
+
|
|
272
|
+
const wikiLinks = (linkList) =>
|
|
273
|
+
linkList
|
|
274
|
+
.map((l) => {
|
|
275
|
+
// Skip unresolvable targets (bad/missing id) rather than emitting
|
|
276
|
+
// a literal [[undefined]] into the note.
|
|
277
|
+
if (!l.target_id) return null;
|
|
278
|
+
const slug = this._idToFilename(l.target_id, pathIndex);
|
|
279
|
+
if (!slug) return null;
|
|
280
|
+
return l.label ? `[[${slug}|${l.label}]]` : `[[${slug}]]`;
|
|
281
|
+
})
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.join(", ");
|
|
284
|
+
|
|
285
|
+
const sections = [];
|
|
286
|
+
|
|
287
|
+
if (record.type === "raw") {
|
|
288
|
+
sections.push(`> [!note]- Raw Notes\n> ${record.body.replace(/\n/g, "\n> ")}`);
|
|
289
|
+
} else if (record.type === "person") {
|
|
290
|
+
// Person cards: lead with the body (role/org prose), then People links,
|
|
291
|
+
// then Appears In backlinks to sources, then Related.
|
|
292
|
+
sections.push(record.body);
|
|
293
|
+
} else {
|
|
294
|
+
// compiled / concept / snapshot: insight as readable body
|
|
295
|
+
sections.push(record.body);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sourceLinks.length > 0) {
|
|
299
|
+
sections.push(`## Sources\n\n${wikiLinks(sourceLinks)}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Person cards: render appears-in links (backlinks to raw+compiled records)
|
|
303
|
+
const appearsInLinks = links.filter((l) => l.kind === "appears-in");
|
|
304
|
+
if (record.type === "person" && appearsInLinks.length > 0) {
|
|
305
|
+
sections.push(`## Appears In\n\n${wikiLinks(appearsInLinks)}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Compiled/person records: render people links (links to person cards)
|
|
309
|
+
const peopleLinks = links.filter((l) => l.kind === "person");
|
|
310
|
+
if (peopleLinks.length > 0) {
|
|
311
|
+
sections.push(`## People\n\n${wikiLinks(peopleLinks)}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (relatedLinks.length > 0) {
|
|
315
|
+
sections.push(`## Related\n\n${wikiLinks(relatedLinks)}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return sections.join("\n\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// -------------------------------------------------------------------------
|
|
322
|
+
// _allRecords: walk path index
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
_allRecords() {
|
|
326
|
+
const pathIndex = this._loadPathIndex();
|
|
327
|
+
const records = [];
|
|
328
|
+
for (const [id, entry] of Object.entries(pathIndex.by_id)) {
|
|
329
|
+
if (entry.archived) continue;
|
|
330
|
+
const record = this._readRecord(id, pathIndex);
|
|
331
|
+
if (record) records.push(record);
|
|
332
|
+
}
|
|
333
|
+
return records;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_now() {
|
|
337
|
+
return new Date().toISOString();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// =========================================================================
|
|
341
|
+
// Store contract operations
|
|
342
|
+
// =========================================================================
|
|
343
|
+
|
|
344
|
+
// -------------------------------------------------------------------------
|
|
345
|
+
// create
|
|
346
|
+
// -------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
async create(input) {
|
|
349
|
+
if (!input.type) throw missingEvidenceError("create: missing required field: type");
|
|
350
|
+
if (!VALID_TYPES.has(input.type))
|
|
351
|
+
throw missingEvidenceError(`create: type must be one of raw, compiled, concept, snapshot, person; got: ${input.type}`);
|
|
352
|
+
if (!input.title || !input.title.trim())
|
|
353
|
+
throw missingEvidenceError("create: missing required field: title");
|
|
354
|
+
if (!input.body && input.body !== "")
|
|
355
|
+
throw missingEvidenceError("create: missing required field: body");
|
|
356
|
+
if (input.body !== undefined && !input.body.trim && typeof input.body !== "string")
|
|
357
|
+
throw missingEvidenceError("create: body must be a string");
|
|
358
|
+
if (!input.category) throw missingEvidenceError("create: missing required field: category");
|
|
359
|
+
if (!validateCategory(input.category))
|
|
360
|
+
throw missingEvidenceError(`create: invalid category: ${input.category}`);
|
|
361
|
+
if (!input.provenance?.agent)
|
|
362
|
+
throw missingEvidenceError("create: missing required provenance field: provenance.agent");
|
|
363
|
+
|
|
364
|
+
const id = input.id || randomUUID();
|
|
365
|
+
const now = this._now();
|
|
366
|
+
|
|
367
|
+
const explicitLinks = input.links || [];
|
|
368
|
+
const wikilinks = extractWikilinks(input.body || "");
|
|
369
|
+
const links = mergeLinks(explicitLinks, wikilinks);
|
|
370
|
+
|
|
371
|
+
const record = {
|
|
372
|
+
id,
|
|
373
|
+
type: input.type,
|
|
374
|
+
title: input.title,
|
|
375
|
+
category: input.category,
|
|
376
|
+
tags: input.tags || [],
|
|
377
|
+
status: "active",
|
|
378
|
+
created_at: now,
|
|
379
|
+
updated_at: now,
|
|
380
|
+
provenance: {
|
|
381
|
+
agent: input.provenance.agent,
|
|
382
|
+
...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
|
|
383
|
+
...(input.provenance.source_ids?.length ? { source_ids: input.provenance.source_ids } : {}),
|
|
384
|
+
...(input.provenance.note ? { note: input.provenance.note } : {}),
|
|
385
|
+
},
|
|
386
|
+
links,
|
|
387
|
+
mutation_log: [],
|
|
388
|
+
body: input.body || "",
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
this._writeRecord(record);
|
|
392
|
+
|
|
393
|
+
const graph = loadGraph(this._graphPath);
|
|
394
|
+
addLinksToGraph(graph, id, links);
|
|
395
|
+
saveGraph(this._graphPath, graph);
|
|
396
|
+
|
|
397
|
+
return id;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// -------------------------------------------------------------------------
|
|
401
|
+
// update
|
|
402
|
+
// -------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
async update(id, fields, evidence) {
|
|
405
|
+
if (!evidence?.agent)
|
|
406
|
+
throw missingEvidenceError("update: missing required evidence field: agent");
|
|
407
|
+
|
|
408
|
+
const pathIndex = this._loadPathIndex();
|
|
409
|
+
const record = this._readRecord(id, pathIndex);
|
|
410
|
+
if (!record) throw notFoundError(id);
|
|
411
|
+
|
|
412
|
+
const mutableKeys = ["title", "body", "category", "tags", "links"];
|
|
413
|
+
const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
|
|
414
|
+
if (supplied.length === 0)
|
|
415
|
+
throw missingEvidenceError("update: at least one mutable field must be supplied");
|
|
416
|
+
|
|
417
|
+
if (fields.category !== undefined && !validateCategory(fields.category))
|
|
418
|
+
throw missingEvidenceError(`update: invalid category: ${fields.category}`);
|
|
419
|
+
|
|
420
|
+
const now = this._now();
|
|
421
|
+
|
|
422
|
+
let newLinks = record.links || [];
|
|
423
|
+
if (fields.links !== undefined) {
|
|
424
|
+
const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
|
|
425
|
+
newLinks = mergeLinks(fields.links, wikilinks);
|
|
426
|
+
} else if (fields.body !== undefined) {
|
|
427
|
+
const wikilinks = extractWikilinks(fields.body);
|
|
428
|
+
newLinks = mergeLinks(record.links || [], wikilinks);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const updated = {
|
|
432
|
+
...record,
|
|
433
|
+
...(fields.title !== undefined ? { title: fields.title } : {}),
|
|
434
|
+
...(fields.body !== undefined ? { body: fields.body } : {}),
|
|
435
|
+
...(fields.category !== undefined ? { category: fields.category } : {}),
|
|
436
|
+
...(fields.tags !== undefined ? { tags: fields.tags } : {}),
|
|
437
|
+
links: newLinks,
|
|
438
|
+
updated_at: now,
|
|
439
|
+
mutation_log: [
|
|
440
|
+
...(record.mutation_log || []),
|
|
441
|
+
{
|
|
442
|
+
op: "update",
|
|
443
|
+
at: now,
|
|
444
|
+
agent: evidence.agent,
|
|
445
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
446
|
+
evidence: { fields: supplied },
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const graph = loadGraph(this._graphPath);
|
|
452
|
+
removeLinksFromGraph(graph, id);
|
|
453
|
+
addLinksToGraph(graph, id, newLinks);
|
|
454
|
+
saveGraph(this._graphPath, graph);
|
|
455
|
+
|
|
456
|
+
this._writeRecord(updated, pathIndex);
|
|
457
|
+
this._savePathIndex(pathIndex);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// -------------------------------------------------------------------------
|
|
461
|
+
// link
|
|
462
|
+
// -------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
async link(sourceId, links, evidence) {
|
|
465
|
+
if (!evidence?.agent)
|
|
466
|
+
throw missingEvidenceError("link: missing required evidence field: agent");
|
|
467
|
+
if (!links || links.length === 0)
|
|
468
|
+
throw missingEvidenceError("link: links array must be non-empty");
|
|
469
|
+
|
|
470
|
+
const pathIndex = this._loadPathIndex();
|
|
471
|
+
const source = this._readRecord(sourceId, pathIndex);
|
|
472
|
+
if (!source) throw notFoundError(sourceId);
|
|
473
|
+
|
|
474
|
+
for (const l of links) {
|
|
475
|
+
if (!this._readRecord(l.target_id, pathIndex)) throw notFoundError(l.target_id);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const now = this._now();
|
|
479
|
+
const existingLinks = source.links || [];
|
|
480
|
+
|
|
481
|
+
const key = (l) => `${l.target_id}::${l.kind}`;
|
|
482
|
+
const seen = new Set(existingLinks.map(key));
|
|
483
|
+
const newLinks = [...existingLinks];
|
|
484
|
+
for (const l of links) {
|
|
485
|
+
if (!seen.has(key(l))) {
|
|
486
|
+
newLinks.push(l);
|
|
487
|
+
seen.add(key(l));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const updated = {
|
|
492
|
+
...source,
|
|
493
|
+
links: newLinks,
|
|
494
|
+
updated_at: now,
|
|
495
|
+
mutation_log: [
|
|
496
|
+
...(source.mutation_log || []),
|
|
497
|
+
{
|
|
498
|
+
op: "link",
|
|
499
|
+
at: now,
|
|
500
|
+
agent: evidence.agent,
|
|
501
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
502
|
+
evidence: { added: links },
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const graph = loadGraph(this._graphPath);
|
|
508
|
+
removeLinksFromGraph(graph, sourceId);
|
|
509
|
+
addLinksToGraph(graph, sourceId, newLinks);
|
|
510
|
+
saveGraph(this._graphPath, graph);
|
|
511
|
+
|
|
512
|
+
this._writeRecord(updated, pathIndex);
|
|
513
|
+
this._savePathIndex(pathIndex);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// -------------------------------------------------------------------------
|
|
517
|
+
// propose
|
|
518
|
+
// -------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
async propose(conceptId, proposerId, evidence) {
|
|
521
|
+
if (!evidence?.agent)
|
|
522
|
+
throw missingEvidenceError("propose: missing required evidence field: agent");
|
|
523
|
+
if (!evidence?.proposal || !evidence.proposal.trim())
|
|
524
|
+
throw missingEvidenceError("propose: missing required evidence field: proposal");
|
|
525
|
+
|
|
526
|
+
const pathIndex = this._loadPathIndex();
|
|
527
|
+
const concept = this._readRecord(conceptId, pathIndex);
|
|
528
|
+
if (!concept) throw notFoundError(conceptId);
|
|
529
|
+
|
|
530
|
+
const proposer = this._readRecord(proposerId, pathIndex);
|
|
531
|
+
if (!proposer) throw notFoundError(proposerId);
|
|
532
|
+
|
|
533
|
+
const now = this._now();
|
|
534
|
+
|
|
535
|
+
const proposerLinks = proposer.links || [];
|
|
536
|
+
const alreadyLinked = proposerLinks.some(
|
|
537
|
+
(l) => l.target_id === conceptId && l.kind === "proposes"
|
|
538
|
+
);
|
|
539
|
+
if (!alreadyLinked) {
|
|
540
|
+
const updatedProposer = {
|
|
541
|
+
...proposer,
|
|
542
|
+
links: [...proposerLinks, { target_id: conceptId, kind: "proposes" }],
|
|
543
|
+
updated_at: now,
|
|
544
|
+
mutation_log: [
|
|
545
|
+
...(proposer.mutation_log || []),
|
|
546
|
+
{
|
|
547
|
+
op: "propose",
|
|
548
|
+
at: now,
|
|
549
|
+
agent: evidence.agent,
|
|
550
|
+
evidence: { concept_id: conceptId, proposal: evidence.proposal },
|
|
551
|
+
},
|
|
552
|
+
],
|
|
553
|
+
};
|
|
554
|
+
this._writeRecord(updatedProposer, pathIndex);
|
|
555
|
+
|
|
556
|
+
const graph = loadGraph(this._graphPath);
|
|
557
|
+
removeLinksFromGraph(graph, proposerId);
|
|
558
|
+
addLinksToGraph(graph, proposerId, updatedProposer.links);
|
|
559
|
+
saveGraph(this._graphPath, graph);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const updatedConcept = {
|
|
563
|
+
...concept,
|
|
564
|
+
mutation_log: [
|
|
565
|
+
...(concept.mutation_log || []),
|
|
566
|
+
{
|
|
567
|
+
op: "propose",
|
|
568
|
+
at: now,
|
|
569
|
+
agent: evidence.agent,
|
|
570
|
+
evidence: { proposer_id: proposerId, proposal: evidence.proposal },
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
};
|
|
574
|
+
this._writeRecord(updatedConcept, pathIndex);
|
|
575
|
+
this._savePathIndex(pathIndex);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// -------------------------------------------------------------------------
|
|
579
|
+
// apply
|
|
580
|
+
// -------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
async apply(conceptId, proposerId, evidence) {
|
|
583
|
+
if (!evidence?.agent)
|
|
584
|
+
throw missingEvidenceError("apply: missing required evidence field: agent");
|
|
585
|
+
if (!evidence?.new_body && evidence?.new_body !== "")
|
|
586
|
+
throw missingEvidenceError("apply: missing required evidence field: new_body");
|
|
587
|
+
if (!evidence?.new_body?.trim?.())
|
|
588
|
+
throw missingEvidenceError("apply: new_body must be non-empty");
|
|
589
|
+
if (!evidence?.rationale || !evidence.rationale.trim())
|
|
590
|
+
throw missingEvidenceError("apply: missing required evidence field: rationale");
|
|
591
|
+
|
|
592
|
+
const pathIndex = this._loadPathIndex();
|
|
593
|
+
const concept = this._readRecord(conceptId, pathIndex);
|
|
594
|
+
if (!concept) throw notFoundError(conceptId);
|
|
595
|
+
|
|
596
|
+
const proposer = this._readRecord(proposerId, pathIndex);
|
|
597
|
+
if (!proposer) throw notFoundError(proposerId);
|
|
598
|
+
|
|
599
|
+
const proposerLinks = proposer.links || [];
|
|
600
|
+
const hasProposesLink = proposerLinks.some(
|
|
601
|
+
(l) => l.target_id === conceptId && l.kind === "proposes"
|
|
602
|
+
);
|
|
603
|
+
if (!hasProposesLink)
|
|
604
|
+
throw missingEvidenceError(`apply: no "proposes" link from ${proposerId} to ${conceptId}`);
|
|
605
|
+
|
|
606
|
+
const now = this._now();
|
|
607
|
+
const updatedConcept = {
|
|
608
|
+
...concept,
|
|
609
|
+
body: evidence.new_body,
|
|
610
|
+
updated_at: now,
|
|
611
|
+
mutation_log: [
|
|
612
|
+
...(concept.mutation_log || []),
|
|
613
|
+
{
|
|
614
|
+
op: "apply",
|
|
615
|
+
at: now,
|
|
616
|
+
agent: evidence.agent,
|
|
617
|
+
evidence: { proposer_id: proposerId, rationale: evidence.rationale },
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
};
|
|
621
|
+
this._writeRecord(updatedConcept, pathIndex);
|
|
622
|
+
this._savePathIndex(pathIndex);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// -------------------------------------------------------------------------
|
|
626
|
+
// reject
|
|
627
|
+
// -------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
async reject(conceptId, proposerId, evidence) {
|
|
630
|
+
if (!evidence?.agent)
|
|
631
|
+
throw missingEvidenceError("reject: missing required evidence field: agent");
|
|
632
|
+
if (!evidence?.reason || !evidence.reason.trim())
|
|
633
|
+
throw missingEvidenceError("reject: missing required evidence field: reason");
|
|
634
|
+
|
|
635
|
+
const pathIndex = this._loadPathIndex();
|
|
636
|
+
const concept = this._readRecord(conceptId, pathIndex);
|
|
637
|
+
if (!concept) throw notFoundError(conceptId);
|
|
638
|
+
|
|
639
|
+
const proposer = this._readRecord(proposerId, pathIndex);
|
|
640
|
+
if (!proposer) throw notFoundError(proposerId);
|
|
641
|
+
|
|
642
|
+
const proposerLinks = proposer.links || [];
|
|
643
|
+
const hasProposesLink = proposerLinks.some(
|
|
644
|
+
(l) => l.target_id === conceptId && l.kind === "proposes"
|
|
645
|
+
);
|
|
646
|
+
if (!hasProposesLink)
|
|
647
|
+
throw missingEvidenceError(`reject: no "proposes" link from ${proposerId} to ${conceptId}`);
|
|
648
|
+
|
|
649
|
+
const now = this._now();
|
|
650
|
+
const updatedConcept = {
|
|
651
|
+
...concept,
|
|
652
|
+
// updated_at NOT changed — concept body was not mutated
|
|
653
|
+
mutation_log: [
|
|
654
|
+
...(concept.mutation_log || []),
|
|
655
|
+
{
|
|
656
|
+
op: "reject",
|
|
657
|
+
at: now,
|
|
658
|
+
agent: evidence.agent,
|
|
659
|
+
evidence: { proposer_id: proposerId, reason: evidence.reason },
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
};
|
|
663
|
+
this._writeRecord(updatedConcept, pathIndex);
|
|
664
|
+
this._savePathIndex(pathIndex);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// -------------------------------------------------------------------------
|
|
668
|
+
// supersede (Addendum A)
|
|
669
|
+
// -------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
async supersede(newId, supersededIds, evidence) {
|
|
672
|
+
if (!evidence?.agent)
|
|
673
|
+
throw missingEvidenceError("supersede: missing required evidence field: agent");
|
|
674
|
+
if (!evidence?.rationale || !evidence.rationale.trim())
|
|
675
|
+
throw missingEvidenceError("supersede: missing required evidence field: rationale");
|
|
676
|
+
if (!supersededIds || supersededIds.length === 0)
|
|
677
|
+
throw missingEvidenceError("supersede: supersededIds must be a non-empty array");
|
|
678
|
+
|
|
679
|
+
const pathIndex = this._loadPathIndex();
|
|
680
|
+
const newRecord = this._readRecord(newId, pathIndex);
|
|
681
|
+
if (!newRecord) throw notFoundError(newId);
|
|
682
|
+
|
|
683
|
+
for (const sid of supersededIds) {
|
|
684
|
+
const rec = this._readRecord(sid, pathIndex);
|
|
685
|
+
if (!rec) throw notFoundError(sid);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const now = this._now();
|
|
689
|
+
|
|
690
|
+
const supersededLinks = supersededIds.map((sid) => ({
|
|
691
|
+
target_id: sid,
|
|
692
|
+
kind: "supersedes",
|
|
693
|
+
}));
|
|
694
|
+
|
|
695
|
+
const existingLinks = newRecord.links || [];
|
|
696
|
+
const key = (l) => `${l.target_id}::${l.kind}`;
|
|
697
|
+
const seen = new Set(existingLinks.map(key));
|
|
698
|
+
const newLinks = [...existingLinks];
|
|
699
|
+
for (const l of supersededLinks) {
|
|
700
|
+
if (!seen.has(key(l))) {
|
|
701
|
+
newLinks.push(l);
|
|
702
|
+
seen.add(key(l));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const updatedNew = {
|
|
707
|
+
...newRecord,
|
|
708
|
+
links: newLinks,
|
|
709
|
+
updated_at: now,
|
|
710
|
+
mutation_log: [
|
|
711
|
+
...(newRecord.mutation_log || []),
|
|
712
|
+
{
|
|
713
|
+
op: "supersede",
|
|
714
|
+
at: now,
|
|
715
|
+
agent: evidence.agent,
|
|
716
|
+
rationale: evidence.rationale,
|
|
717
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
718
|
+
evidence: { superseded_count: supersededIds.length },
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const graph = loadGraph(this._graphPath);
|
|
724
|
+
removeLinksFromGraph(graph, newId);
|
|
725
|
+
addLinksToGraph(graph, newId, newLinks);
|
|
726
|
+
saveGraph(this._graphPath, graph);
|
|
727
|
+
|
|
728
|
+
this._writeRecord(updatedNew, pathIndex);
|
|
729
|
+
|
|
730
|
+
// Write superseded-by mutation log to each superseded record, then archive
|
|
731
|
+
for (const sid of supersededIds) {
|
|
732
|
+
const supersededRec = this._readRecord(sid, pathIndex);
|
|
733
|
+
if (!supersededRec) continue;
|
|
734
|
+
const updatedSuperseded = {
|
|
735
|
+
...supersededRec,
|
|
736
|
+
// updated_at NOT changed — content not mutated
|
|
737
|
+
mutation_log: [
|
|
738
|
+
...(supersededRec.mutation_log || []),
|
|
739
|
+
{
|
|
740
|
+
op: "superseded-by",
|
|
741
|
+
at: now,
|
|
742
|
+
agent: evidence.agent,
|
|
743
|
+
new_id: newId,
|
|
744
|
+
rationale: evidence.rationale,
|
|
745
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
746
|
+
evidence: { superseded_by_id: newId },
|
|
747
|
+
},
|
|
748
|
+
],
|
|
749
|
+
};
|
|
750
|
+
this._writeRecord(updatedSuperseded, pathIndex);
|
|
751
|
+
// Move superseded file to archive/ (supersede-not-delete invariant)
|
|
752
|
+
this._archiveRecord(sid, pathIndex);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
this._savePathIndex(pathIndex);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// -------------------------------------------------------------------------
|
|
759
|
+
// retire (Addendum B)
|
|
760
|
+
// -------------------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
async retire(id, targetStatus, evidence) {
|
|
763
|
+
if (!evidence?.agent)
|
|
764
|
+
throw missingEvidenceError("retire: missing required evidence field: agent");
|
|
765
|
+
if (!evidence?.rationale || !evidence.rationale.trim())
|
|
766
|
+
throw missingEvidenceError("retire: missing required evidence field: rationale");
|
|
767
|
+
if (targetStatus !== "implemented" && targetStatus !== "retired")
|
|
768
|
+
throw missingEvidenceError(
|
|
769
|
+
`retire: targetStatus must be "implemented" or "retired"; got: ${targetStatus}`
|
|
770
|
+
);
|
|
771
|
+
if (targetStatus === "implemented" && (!evidence.implementedByRef || !evidence.implementedByRef.trim()))
|
|
772
|
+
throw missingEvidenceError(
|
|
773
|
+
'retire: implementedByRef is required when targetStatus is "implemented"'
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
const pathIndex = this._loadPathIndex();
|
|
777
|
+
const record = this._readRecord(id, pathIndex);
|
|
778
|
+
if (!record) throw notFoundError(id);
|
|
779
|
+
|
|
780
|
+
const currentStatus = record.status || "active";
|
|
781
|
+
const allowed = VALID_STATUS_TRANSITIONS[currentStatus];
|
|
782
|
+
if (!allowed || !allowed.has(targetStatus)) {
|
|
783
|
+
throw missingEvidenceError(
|
|
784
|
+
`retire: invalid transition from "${currentStatus}" to "${targetStatus}"`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const now = this._now();
|
|
789
|
+
const updated = {
|
|
790
|
+
...record,
|
|
791
|
+
status: targetStatus,
|
|
792
|
+
updated_at: now,
|
|
793
|
+
mutation_log: [
|
|
794
|
+
...(record.mutation_log || []),
|
|
795
|
+
{
|
|
796
|
+
op: "retire",
|
|
797
|
+
at: now,
|
|
798
|
+
agent: evidence.agent,
|
|
799
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
800
|
+
evidence: {
|
|
801
|
+
targetStatus,
|
|
802
|
+
rationale: evidence.rationale,
|
|
803
|
+
...(evidence.implementedByRef ? { implementedByRef: evidence.implementedByRef } : {}),
|
|
804
|
+
...(evidence.supersededByRef ? { supersededByRef: evidence.supersededByRef } : {}),
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
};
|
|
809
|
+
this._writeRecord(updated, pathIndex);
|
|
810
|
+
this._savePathIndex(pathIndex);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// -------------------------------------------------------------------------
|
|
814
|
+
// get
|
|
815
|
+
// -------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
async get(id) {
|
|
818
|
+
return this._readRecord(id);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// -------------------------------------------------------------------------
|
|
822
|
+
// getLinks
|
|
823
|
+
// -------------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
async getLinks(id) {
|
|
826
|
+
const graph = loadGraph(this._graphPath);
|
|
827
|
+
return {
|
|
828
|
+
forward: (graph.forward[id] || []).map((l) => ({ ...l })),
|
|
829
|
+
reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// -------------------------------------------------------------------------
|
|
834
|
+
// listByCategory
|
|
835
|
+
// -------------------------------------------------------------------------
|
|
836
|
+
|
|
837
|
+
async listByCategory(category, options = {}) {
|
|
838
|
+
const records = this._allRecords();
|
|
839
|
+
const includeRetired = options.includeRetired === true;
|
|
840
|
+
if (options.prefix) {
|
|
841
|
+
return records.filter(
|
|
842
|
+
(r) =>
|
|
843
|
+
(r.category === category || r.category.startsWith(`${category}.`)) &&
|
|
844
|
+
(includeRetired || (r.status || "active") !== "retired")
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
return records.filter(
|
|
848
|
+
(r) =>
|
|
849
|
+
r.category === category &&
|
|
850
|
+
(includeRetired || (r.status || "active") !== "retired")
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// -------------------------------------------------------------------------
|
|
855
|
+
// listByType
|
|
856
|
+
// -------------------------------------------------------------------------
|
|
857
|
+
|
|
858
|
+
async listByType(type, options = {}) {
|
|
859
|
+
const includeRetired = options.includeRetired === true;
|
|
860
|
+
return this._allRecords().filter(
|
|
861
|
+
(r) =>
|
|
862
|
+
r.type === type &&
|
|
863
|
+
(includeRetired || (r.status || "active") !== "retired")
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export default ObsidianKnowledgeStore;
|