@saasontools/strauss-kb 0.1.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/LICENSE +21 -0
- package/README.md +340 -0
- package/dist/chunk-KGM34MYU.js +93 -0
- package/dist/chunk-KGM34MYU.js.map +1 -0
- package/dist/chunk-WFHYWZX5.js +47 -0
- package/dist/chunk-WFHYWZX5.js.map +1 -0
- package/dist/chunk-ZSYSHJVZ.js +1527 -0
- package/dist/chunk-ZSYSHJVZ.js.map +1 -0
- package/dist/cli-main.cjs +1567 -0
- package/dist/cli-main.cjs.map +1 -0
- package/dist/cli-main.d.cts +1 -0
- package/dist/cli-main.d.ts +1 -0
- package/dist/cli-main.js +15 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/index.cjs +1817 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +977 -0
- package/dist/index.d.ts +977 -0
- package/dist/index.js +202 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-main.cjs +1522 -0
- package/dist/mcp-main.cjs.map +1 -0
- package/dist/mcp-main.d.cts +1 -0
- package/dist/mcp-main.d.ts +1 -0
- package/dist/mcp-main.js +15 -0
- package/dist/mcp-main.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/mcp.ts
|
|
27
|
+
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
28
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
29
|
+
|
|
30
|
+
// src/commands.ts
|
|
31
|
+
var import_zod6 = require("zod");
|
|
32
|
+
|
|
33
|
+
// src/compose.ts
|
|
34
|
+
var import_zod2 = require("zod");
|
|
35
|
+
|
|
36
|
+
// src/kb-record.schema.ts
|
|
37
|
+
var import_zod = require("zod");
|
|
38
|
+
var kbSourceSchema = import_zod.z.object({
|
|
39
|
+
id: import_zod.z.string().min(1),
|
|
40
|
+
resource: import_zod.z.string().min(1),
|
|
41
|
+
title: import_zod.z.string().min(1).optional(),
|
|
42
|
+
author: import_zod.z.string().min(1).optional(),
|
|
43
|
+
last_modified: import_zod.z.string().min(1).optional()
|
|
44
|
+
}).passthrough();
|
|
45
|
+
var kbActorStampSchema = import_zod.z.object({
|
|
46
|
+
by: import_zod.z.string().min(1),
|
|
47
|
+
at: import_zod.z.string().min(1)
|
|
48
|
+
}).passthrough();
|
|
49
|
+
var kbAnchorSchema = import_zod.z.object({
|
|
50
|
+
file: import_zod.z.string().min(1),
|
|
51
|
+
symbol: import_zod.z.string().min(1).optional()
|
|
52
|
+
}).strict();
|
|
53
|
+
var KB_RECORD_TYPES = [
|
|
54
|
+
"fact",
|
|
55
|
+
"requirement",
|
|
56
|
+
"constraint",
|
|
57
|
+
"decision",
|
|
58
|
+
"assumption",
|
|
59
|
+
"open-question",
|
|
60
|
+
"risk",
|
|
61
|
+
"contract",
|
|
62
|
+
"flow",
|
|
63
|
+
"affected-system",
|
|
64
|
+
"test-obligation",
|
|
65
|
+
"source-note"
|
|
66
|
+
];
|
|
67
|
+
var KB_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
68
|
+
var KB_CONCEPT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
69
|
+
var kbConceptIdSchema = import_zod.z.string().regex(KB_CONCEPT_ID_PATTERN, {
|
|
70
|
+
message: "concept id must be <type>.<slug>, both kebab-case"
|
|
71
|
+
});
|
|
72
|
+
var KB_RECORD_STATUSES = [
|
|
73
|
+
"draft",
|
|
74
|
+
"proposed",
|
|
75
|
+
"accepted",
|
|
76
|
+
"open",
|
|
77
|
+
"resolved",
|
|
78
|
+
"rejected",
|
|
79
|
+
"superseded"
|
|
80
|
+
];
|
|
81
|
+
var KB_MATERIALITIES = [
|
|
82
|
+
"blocking",
|
|
83
|
+
"important",
|
|
84
|
+
"non-blocking"
|
|
85
|
+
];
|
|
86
|
+
var KB_CONFIDENCES = ["low", "medium", "high"];
|
|
87
|
+
var kbRecordFrontmatterSchema = import_zod.z.object({
|
|
88
|
+
// OKF: the only always-required key. A concept carrying just `type` is
|
|
89
|
+
// fully conformant, so everything below stays optional.
|
|
90
|
+
type: import_zod.z.string().min(1),
|
|
91
|
+
// OKF recommended.
|
|
92
|
+
title: import_zod.z.string().min(1).optional(),
|
|
93
|
+
description: import_zod.z.string().min(1).optional(),
|
|
94
|
+
resource: import_zod.z.string().min(1).optional(),
|
|
95
|
+
tags: import_zod.z.array(import_zod.z.string()).optional(),
|
|
96
|
+
// OKF optional: provenance and freshness.
|
|
97
|
+
sources: import_zod.z.array(kbSourceSchema).optional(),
|
|
98
|
+
generated: kbActorStampSchema.optional(),
|
|
99
|
+
verified: import_zod.z.array(kbActorStampSchema).optional(),
|
|
100
|
+
stale_after: import_zod.z.string().min(1).optional(),
|
|
101
|
+
// strauss extensions — see the module comment.
|
|
102
|
+
strauss_anchors: import_zod.z.array(kbAnchorSchema).optional(),
|
|
103
|
+
strauss_verify: import_zod.z.array(import_zod.z.string().min(1)).optional(),
|
|
104
|
+
// Total after parsing, tolerant before it. Our producers must supply a
|
|
105
|
+
// status — an absent one would leave every reader inventing its own default
|
|
106
|
+
// — but OKF calls a concept carrying only `type` fully conformant, so
|
|
107
|
+
// rejecting a foreign record for the lack of one would put us outside the
|
|
108
|
+
// spec. The default resolves it in the single place that can: here.
|
|
109
|
+
strauss_status: import_zod.z.enum(KB_RECORD_STATUSES).default("draft"),
|
|
110
|
+
strauss_supersedes: import_zod.z.array(import_zod.z.string().min(1)).optional(),
|
|
111
|
+
strauss_superseded_by: import_zod.z.string().min(1).optional(),
|
|
112
|
+
strauss_answered: kbActorStampSchema.optional(),
|
|
113
|
+
strauss_materiality: import_zod.z.enum(KB_MATERIALITIES).optional(),
|
|
114
|
+
strauss_confidence: import_zod.z.enum(KB_CONFIDENCES).optional(),
|
|
115
|
+
strauss_owner: import_zod.z.string().min(1).optional(),
|
|
116
|
+
// "No source exists" as a field rather than a sentinel entry inside
|
|
117
|
+
// `sources`. A sentinel in a reference list is a value doing work a field
|
|
118
|
+
// should do; as a field, `sources` may be legitimately empty.
|
|
119
|
+
strauss_assumption: import_zod.z.boolean().optional()
|
|
120
|
+
}).passthrough();
|
|
121
|
+
|
|
122
|
+
// src/record-types.ts
|
|
123
|
+
var RECORD_TYPES = {
|
|
124
|
+
fact: {
|
|
125
|
+
purpose: "Observed or sourced fact",
|
|
126
|
+
sections: ["Claim", "Evidence", "Implication"],
|
|
127
|
+
initialStatus: "accepted"
|
|
128
|
+
},
|
|
129
|
+
requirement: {
|
|
130
|
+
purpose: "Required behavior or outcome",
|
|
131
|
+
sections: ["Claim", "Evidence", "Implication"],
|
|
132
|
+
initialStatus: "proposed"
|
|
133
|
+
},
|
|
134
|
+
constraint: {
|
|
135
|
+
purpose: "Limitation, compatibility boundary, policy, or restriction",
|
|
136
|
+
sections: ["Claim", "Evidence", "Implication"],
|
|
137
|
+
initialStatus: "accepted"
|
|
138
|
+
},
|
|
139
|
+
decision: {
|
|
140
|
+
purpose: "Chosen or proposed direction",
|
|
141
|
+
sections: ["Decision", "Rationale", "Rejected", "Impact"],
|
|
142
|
+
initialStatus: "accepted"
|
|
143
|
+
},
|
|
144
|
+
assumption: {
|
|
145
|
+
purpose: "Unsourced or not-yet-confirmed working assumption",
|
|
146
|
+
sections: ["Claim", "Why we think so", "What would falsify it"],
|
|
147
|
+
initialStatus: "draft"
|
|
148
|
+
},
|
|
149
|
+
"open-question": {
|
|
150
|
+
purpose: "Question needing resolution",
|
|
151
|
+
sections: ["Question", "Why it matters", "Default assumption"],
|
|
152
|
+
initialStatus: "open"
|
|
153
|
+
},
|
|
154
|
+
risk: {
|
|
155
|
+
purpose: "Something that can go wrong",
|
|
156
|
+
sections: ["Risk", "Why it matters", "Mitigation", "Verification"],
|
|
157
|
+
initialStatus: "open"
|
|
158
|
+
},
|
|
159
|
+
contract: {
|
|
160
|
+
purpose: "API, data, event, schema, or permission contract",
|
|
161
|
+
sections: ["Contract", "Producer", "Consumer", "Compatibility"],
|
|
162
|
+
initialStatus: "proposed"
|
|
163
|
+
},
|
|
164
|
+
flow: {
|
|
165
|
+
purpose: "Sequence, lifecycle, or state behavior",
|
|
166
|
+
sections: ["Flow", "Trigger", "Steps", "Failure modes"],
|
|
167
|
+
initialStatus: "accepted"
|
|
168
|
+
},
|
|
169
|
+
"affected-system": {
|
|
170
|
+
purpose: "Component, service, package, integration, or external system",
|
|
171
|
+
sections: ["System", "How it is affected", "Blast radius"],
|
|
172
|
+
initialStatus: "accepted"
|
|
173
|
+
},
|
|
174
|
+
"test-obligation": {
|
|
175
|
+
purpose: "Behavior or contract that must be verified",
|
|
176
|
+
sections: ["Obligation", "Why it matters", "How to verify"],
|
|
177
|
+
initialStatus: "open"
|
|
178
|
+
},
|
|
179
|
+
"source-note": {
|
|
180
|
+
purpose: "Extracted note from source material",
|
|
181
|
+
sections: ["Note", "Where it came from"],
|
|
182
|
+
initialStatus: "accepted"
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
function isKbRecordType(value) {
|
|
186
|
+
return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/compose.ts
|
|
190
|
+
var composeInputSchema = import_zod2.z.object({
|
|
191
|
+
slug: import_zod2.z.string().min(1),
|
|
192
|
+
/** One line, in the reader's terms. Becomes OKF `title`. */
|
|
193
|
+
title: import_zod2.z.string().min(1),
|
|
194
|
+
/** The consequence — what breaks if this is wrong. Becomes `description`. */
|
|
195
|
+
why: import_zod2.z.string().min(1),
|
|
196
|
+
/** Keyed by section heading from the type's spec. Unknown keys rejected. */
|
|
197
|
+
sections: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string().min(1)).optional(),
|
|
198
|
+
anchors: import_zod2.z.array(kbAnchorSchema).optional(),
|
|
199
|
+
sources: import_zod2.z.array(kbSourceSchema).optional(),
|
|
200
|
+
/** No source exists, as a claim rather than a sentinel in `sources`. */
|
|
201
|
+
assumption: import_zod2.z.boolean().optional(),
|
|
202
|
+
verify: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
|
|
203
|
+
tags: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
|
|
204
|
+
/** Concept ids this record relates to; rendered as body links. */
|
|
205
|
+
relatedConceptIds: import_zod2.z.array(kbConceptIdSchema).optional(),
|
|
206
|
+
/** Concept ids this record replaces. The store settles the backlinks. */
|
|
207
|
+
supersedes: import_zod2.z.array(kbConceptIdSchema).optional(),
|
|
208
|
+
materiality: import_zod2.z.enum(KB_MATERIALITIES).optional(),
|
|
209
|
+
confidence: import_zod2.z.enum(KB_CONFIDENCES).optional(),
|
|
210
|
+
owner: import_zod2.z.string().min(1).optional()
|
|
211
|
+
}).strict();
|
|
212
|
+
function composeRecord(type, input, writtenBy, writtenAt) {
|
|
213
|
+
const parsed = composeInputSchema.parse(input);
|
|
214
|
+
const spec = RECORD_TYPES[type];
|
|
215
|
+
const sections = parsed.sections ?? {};
|
|
216
|
+
const unknown = Object.keys(sections).filter(
|
|
217
|
+
(heading) => !spec.sections.includes(heading)
|
|
218
|
+
);
|
|
219
|
+
if (unknown.length) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`kb: ${type} has no section ${unknown.join(", ")} \u2014 expected one of ${spec.sections.join(", ")}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const frontmatter = {
|
|
225
|
+
title: parsed.title,
|
|
226
|
+
description: parsed.why,
|
|
227
|
+
generated: { by: writtenBy, at: writtenAt },
|
|
228
|
+
// Empty rather than absent: a later verification pass appends here, and an
|
|
229
|
+
// empty list says "not yet verified" where a missing key would only say
|
|
230
|
+
// "this producer didn't think about it".
|
|
231
|
+
verified: [],
|
|
232
|
+
strauss_status: spec.initialStatus
|
|
233
|
+
};
|
|
234
|
+
if (parsed.anchors?.length) frontmatter.strauss_anchors = parsed.anchors;
|
|
235
|
+
if (parsed.verify?.length) frontmatter.strauss_verify = parsed.verify;
|
|
236
|
+
if (parsed.tags?.length) frontmatter.tags = parsed.tags;
|
|
237
|
+
if (parsed.sources?.length) frontmatter.sources = parsed.sources;
|
|
238
|
+
if (parsed.assumption) frontmatter.strauss_assumption = true;
|
|
239
|
+
if (parsed.materiality) frontmatter.strauss_materiality = parsed.materiality;
|
|
240
|
+
if (parsed.confidence) frontmatter.strauss_confidence = parsed.confidence;
|
|
241
|
+
if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
|
|
242
|
+
if (parsed.supersedes?.length)
|
|
243
|
+
frontmatter.strauss_supersedes = parsed.supersedes;
|
|
244
|
+
const blocks = [];
|
|
245
|
+
for (const heading of spec.sections) {
|
|
246
|
+
const text = sections[heading];
|
|
247
|
+
if (text) blocks.push(`## ${heading}
|
|
248
|
+
|
|
249
|
+
${text}`);
|
|
250
|
+
}
|
|
251
|
+
if (!blocks.length) blocks.push(parsed.why);
|
|
252
|
+
for (const related of parsed.relatedConceptIds ?? []) {
|
|
253
|
+
blocks.push(`Relates to [${related}](${related}.md).`);
|
|
254
|
+
}
|
|
255
|
+
if (parsed.sources?.length) {
|
|
256
|
+
blocks.push(
|
|
257
|
+
parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
type,
|
|
262
|
+
slug: parsed.slug,
|
|
263
|
+
frontmatter,
|
|
264
|
+
body: `${blocks.join("\n\n")}
|
|
265
|
+
`
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/decision-record.ts
|
|
270
|
+
var import_zod3 = require("zod");
|
|
271
|
+
var DECISION_TYPE = "decision";
|
|
272
|
+
var NO_DECISION_SLUG = "none";
|
|
273
|
+
var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
|
|
274
|
+
alternative: import_zod3.z.string().min(1).optional(),
|
|
275
|
+
impact: import_zod3.z.string().min(1).optional()
|
|
276
|
+
}).strict();
|
|
277
|
+
function composeDecisionRecord(input, writtenBy, writtenAt) {
|
|
278
|
+
const { alternative, impact, ...rest } = input;
|
|
279
|
+
return composeRecord(
|
|
280
|
+
DECISION_TYPE,
|
|
281
|
+
{
|
|
282
|
+
...rest,
|
|
283
|
+
sections: {
|
|
284
|
+
Decision: input.title,
|
|
285
|
+
Rationale: input.why,
|
|
286
|
+
...alternative ? { Rejected: alternative } : {},
|
|
287
|
+
...impact ? { Impact: impact } : {}
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
writtenBy,
|
|
291
|
+
writtenAt
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
|
|
295
|
+
return composeRecord(
|
|
296
|
+
DECISION_TYPE,
|
|
297
|
+
{
|
|
298
|
+
slug: NO_DECISION_SLUG,
|
|
299
|
+
title: "No decision to record",
|
|
300
|
+
why: reason,
|
|
301
|
+
sections: { Decision: reason }
|
|
302
|
+
},
|
|
303
|
+
writtenBy,
|
|
304
|
+
writtenAt
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/json-schema.ts
|
|
309
|
+
var import_zod5 = require("zod");
|
|
310
|
+
|
|
311
|
+
// src/kb-log.ts
|
|
312
|
+
var import_zod4 = require("zod");
|
|
313
|
+
var LOG_FILE = "log.jsonl";
|
|
314
|
+
var kbLogEntrySchema = import_zod4.z.object({
|
|
315
|
+
at: import_zod4.z.string().min(1),
|
|
316
|
+
by: import_zod4.z.string().min(1),
|
|
317
|
+
operation: import_zod4.z.string().min(1),
|
|
318
|
+
conceptId: import_zod4.z.string().min(1),
|
|
319
|
+
/** Second concept id, where the operation relates two — supersession. */
|
|
320
|
+
target: import_zod4.z.string().min(1).optional()
|
|
321
|
+
}).strict();
|
|
322
|
+
function renderLogEntry(entry) {
|
|
323
|
+
return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
|
|
324
|
+
`;
|
|
325
|
+
}
|
|
326
|
+
function parseLog(raw) {
|
|
327
|
+
const entries = [];
|
|
328
|
+
const malformed = [];
|
|
329
|
+
raw.split("\n").forEach((text, index) => {
|
|
330
|
+
if (!text.trim()) return;
|
|
331
|
+
let value;
|
|
332
|
+
try {
|
|
333
|
+
value = JSON.parse(text);
|
|
334
|
+
} catch {
|
|
335
|
+
malformed.push({ line: index + 1, text });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const parsed = kbLogEntrySchema.safeParse(value);
|
|
339
|
+
if (!parsed.success) {
|
|
340
|
+
malformed.push({ line: index + 1, text });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
entries.push(parsed.data);
|
|
344
|
+
});
|
|
345
|
+
return { entries, malformed };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/json-schema.ts
|
|
349
|
+
function kbJsonSchemas() {
|
|
350
|
+
return {
|
|
351
|
+
recordFrontmatter: import_zod5.z.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
352
|
+
io: "input"
|
|
353
|
+
}),
|
|
354
|
+
composeInput: import_zod5.z.toJSONSchema(composeInputSchema, { io: "input" }),
|
|
355
|
+
logEntry: import_zod5.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/trace.ts
|
|
360
|
+
var TRACE_EDGES = ["supersession", "anchor", "source"];
|
|
361
|
+
function trace(seedId, bundle, options = {}) {
|
|
362
|
+
const edges = options.edges?.length ? options.edges : TRACE_EDGES;
|
|
363
|
+
const maxDepth = options.depth ?? 3;
|
|
364
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
365
|
+
const seed = byId.get(seedId);
|
|
366
|
+
if (!seed) return [];
|
|
367
|
+
const reached = /* @__PURE__ */ new Map([
|
|
368
|
+
[seedId, { record: seed, depth: 0, via: [] }]
|
|
369
|
+
]);
|
|
370
|
+
let frontier = [seed];
|
|
371
|
+
for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
|
|
372
|
+
const next = [];
|
|
373
|
+
for (const from of frontier) {
|
|
374
|
+
for (const edge of edges) {
|
|
375
|
+
for (const record of neighbours(from, bundle, edge)) {
|
|
376
|
+
const existing = reached.get(record.conceptId);
|
|
377
|
+
if (existing) {
|
|
378
|
+
if (existing.depth > 0 && !existing.via.includes(edge)) {
|
|
379
|
+
existing.via.push(edge);
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
reached.set(record.conceptId, { record, depth, via: [edge] });
|
|
384
|
+
next.push(record);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
frontier = next;
|
|
389
|
+
}
|
|
390
|
+
return [...reached.values()].sort(byGeneratedAt);
|
|
391
|
+
}
|
|
392
|
+
function neighbours(from, bundle, edge) {
|
|
393
|
+
switch (edge) {
|
|
394
|
+
case "supersession":
|
|
395
|
+
return bundle.filter(
|
|
396
|
+
(candidate) => candidate.conceptId !== from.conceptId && (candidate.conceptId === from.frontmatter.strauss_superseded_by || from.frontmatter.strauss_supersedes?.includes(
|
|
397
|
+
candidate.conceptId
|
|
398
|
+
) || candidate.frontmatter.strauss_superseded_by === from.conceptId || candidate.frontmatter.strauss_supersedes?.includes(from.conceptId))
|
|
399
|
+
);
|
|
400
|
+
// The edge that answers "why is this code shaped this way": every record
|
|
401
|
+
// attached to the same file or symbol, whatever its standing.
|
|
402
|
+
case "anchor": {
|
|
403
|
+
const mine = from.frontmatter.strauss_anchors ?? [];
|
|
404
|
+
if (!mine.length) return [];
|
|
405
|
+
return bundle.filter(
|
|
406
|
+
(candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.strauss_anchors ?? []).some(
|
|
407
|
+
(theirs) => mine.some((ours) => anchorsTouch(ours, theirs))
|
|
408
|
+
)
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
case "source": {
|
|
412
|
+
const mine = new Set((from.frontmatter.sources ?? []).map((s) => s.id));
|
|
413
|
+
if (!mine.size) return [];
|
|
414
|
+
return bundle.filter(
|
|
415
|
+
(candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.sources ?? []).some(
|
|
416
|
+
(source) => mine.has(source.id)
|
|
417
|
+
)
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function anchorsTouch(left, right) {
|
|
423
|
+
if (left.file !== right.file) return false;
|
|
424
|
+
if (!left.symbol || !right.symbol) return true;
|
|
425
|
+
return left.symbol === right.symbol;
|
|
426
|
+
}
|
|
427
|
+
function byGeneratedAt(left, right) {
|
|
428
|
+
const at = (step) => step.record.frontmatter.generated?.at ?? "";
|
|
429
|
+
return at(left).localeCompare(at(right)) || left.depth - right.depth;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/validate.ts
|
|
433
|
+
function validateBundle(records) {
|
|
434
|
+
const byId = new Map(records.map((record) => [record.conceptId, record]));
|
|
435
|
+
const problems = [];
|
|
436
|
+
const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
|
|
437
|
+
for (const record of records) {
|
|
438
|
+
const { conceptId: conceptId2, frontmatter: fm } = record;
|
|
439
|
+
if (!isKbRecordType(fm.type)) {
|
|
440
|
+
report("type", conceptId2, `unrecognised type "${fm.type}"`);
|
|
441
|
+
}
|
|
442
|
+
if (fm.strauss_status === "superseded") {
|
|
443
|
+
const by = fm.strauss_superseded_by;
|
|
444
|
+
if (!by) {
|
|
445
|
+
report("superseded_by", conceptId2, "superseded with no replacement");
|
|
446
|
+
} else if (!byId.has(by)) {
|
|
447
|
+
report("superseded_by", conceptId2, `replacement ${by} is missing`);
|
|
448
|
+
} else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
|
|
449
|
+
report("backlink", by, `does not list ${conceptId2} in supersedes`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
for (const old of fm.strauss_supersedes ?? []) {
|
|
453
|
+
const previous = byId.get(old);
|
|
454
|
+
if (!previous) {
|
|
455
|
+
report("supersedes", conceptId2, `target ${old} is missing`);
|
|
456
|
+
} else if (previous.frontmatter.strauss_status !== "superseded") {
|
|
457
|
+
report("supersedes", conceptId2, `${old} is not marked superseded`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (fm.strauss_assumption && fm.sources?.length) {
|
|
461
|
+
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return problems;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/commands.ts
|
|
468
|
+
var bundlePath = import_zod6.z.string().min(1).describe("Absolute path to the knowledge base directory.");
|
|
469
|
+
var conceptId = import_zod6.z.string().min(1).describe("e.g. decision.cursor-v2");
|
|
470
|
+
function define(command) {
|
|
471
|
+
return command;
|
|
472
|
+
}
|
|
473
|
+
var KB_COMMANDS = [
|
|
474
|
+
define({
|
|
475
|
+
name: "write",
|
|
476
|
+
tool: "kb_write",
|
|
477
|
+
usage: "write <type> < record.json",
|
|
478
|
+
description: [
|
|
479
|
+
"Write one record. Search first \u2014 the same knowledge filed twice under different slugs is how a base rots, and a duplicate concept id is rejected rather than overwritten. Call kb_types for the sections each type accepts.",
|
|
480
|
+
"",
|
|
481
|
+
"Judgment the tool cannot enforce for you:",
|
|
482
|
+
"- An unsourced claim is an `assumption` record with assumption: true, never a `fact` with a vague source. The distinction is what lets a later reader separate what was established from what was guessed.",
|
|
483
|
+
"- When two records conflict, say so in a `risk`, an `open-question`, or a superseding `decision`. Quietly picking a winner destroys the disagreement, which is usually the useful part.",
|
|
484
|
+
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
485
|
+
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
486
|
+
].join("\n"),
|
|
487
|
+
input: import_zod6.z.object({
|
|
488
|
+
bundlePath,
|
|
489
|
+
type: import_zod6.z.enum(KB_RECORD_TYPES),
|
|
490
|
+
input: composeInputSchema
|
|
491
|
+
}),
|
|
492
|
+
fromArgv: async (argv, path, stdin) => ({
|
|
493
|
+
bundlePath: path,
|
|
494
|
+
type: argv[1],
|
|
495
|
+
input: JSON.parse(await stdin())
|
|
496
|
+
}),
|
|
497
|
+
run: async ({ store, actor, now }, { bundlePath: path, type, input }) => {
|
|
498
|
+
const record = await store.write(
|
|
499
|
+
path,
|
|
500
|
+
composeRecord(type, input, actor, now()),
|
|
501
|
+
actor
|
|
502
|
+
);
|
|
503
|
+
return { conceptId: record.conceptId };
|
|
504
|
+
}
|
|
505
|
+
}),
|
|
506
|
+
define({
|
|
507
|
+
name: "write-decision",
|
|
508
|
+
tool: "kb_write_decision",
|
|
509
|
+
usage: "write-decision < decision.json",
|
|
510
|
+
description: [
|
|
511
|
+
"Write a decision. Takes `alternative` and `impact` as fields rather than free sections, because what was rejected is the part a later reader cannot reconstruct from the code \u2014 a heading is too easy to leave empty.",
|
|
512
|
+
"",
|
|
513
|
+
"What belongs in one:",
|
|
514
|
+
'- Record a decision when a later reader would otherwise "simplify" the constraint away. If the diff already answers the question, there is nothing here to write.',
|
|
515
|
+
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
516
|
+
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
517
|
+
].join("\n"),
|
|
518
|
+
input: import_zod6.z.object({ bundlePath, input: decisionInputSchema }),
|
|
519
|
+
fromArgv: async (_argv, path, stdin) => ({
|
|
520
|
+
bundlePath: path,
|
|
521
|
+
input: JSON.parse(await stdin())
|
|
522
|
+
}),
|
|
523
|
+
run: async ({ store, actor, now }, { bundlePath: path, input }) => {
|
|
524
|
+
const record = await store.write(
|
|
525
|
+
path,
|
|
526
|
+
composeDecisionRecord(input, actor, now()),
|
|
527
|
+
actor
|
|
528
|
+
);
|
|
529
|
+
return { conceptId: record.conceptId };
|
|
530
|
+
}
|
|
531
|
+
}),
|
|
532
|
+
define({
|
|
533
|
+
name: "no-decision",
|
|
534
|
+
tool: "kb_no_decision",
|
|
535
|
+
usage: "no-decision <reason...>",
|
|
536
|
+
description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
|
|
537
|
+
input: import_zod6.z.object({ bundlePath, reason: import_zod6.z.string().min(1) }),
|
|
538
|
+
fromArgv: (argv, path) => ({
|
|
539
|
+
bundlePath: path,
|
|
540
|
+
reason: argv.slice(1).join(" ").trim()
|
|
541
|
+
}),
|
|
542
|
+
run: async ({ store, actor, now }, { bundlePath: path, reason }) => {
|
|
543
|
+
const record = await store.write(
|
|
544
|
+
path,
|
|
545
|
+
{ ...composeNoDecisionRecord(reason, actor, now()), overwrite: true },
|
|
546
|
+
actor
|
|
547
|
+
);
|
|
548
|
+
return { conceptId: record.conceptId };
|
|
549
|
+
}
|
|
550
|
+
}),
|
|
551
|
+
define({
|
|
552
|
+
name: "status",
|
|
553
|
+
tool: "kb_status",
|
|
554
|
+
usage: "status <concept-id> <status>",
|
|
555
|
+
description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
|
|
556
|
+
input: import_zod6.z.object({
|
|
557
|
+
bundlePath,
|
|
558
|
+
conceptId,
|
|
559
|
+
status: import_zod6.z.enum(KB_RECORD_STATUSES)
|
|
560
|
+
}),
|
|
561
|
+
fromArgv: (argv, path) => ({
|
|
562
|
+
bundlePath: path,
|
|
563
|
+
conceptId: argv[1],
|
|
564
|
+
status: argv[2]
|
|
565
|
+
}),
|
|
566
|
+
run: async ({ store, actor }, { bundlePath: path, conceptId: id, status }) => {
|
|
567
|
+
const record = await store.setStatus(path, id, status, actor);
|
|
568
|
+
return { conceptId: record.conceptId, status };
|
|
569
|
+
}
|
|
570
|
+
}),
|
|
571
|
+
define({
|
|
572
|
+
name: "supersede",
|
|
573
|
+
tool: "kb_supersede",
|
|
574
|
+
usage: "supersede <concept-id> <replacement-id>",
|
|
575
|
+
description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
|
|
576
|
+
input: import_zod6.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
577
|
+
fromArgv: (argv, path) => ({
|
|
578
|
+
bundlePath: path,
|
|
579
|
+
conceptId: argv[1],
|
|
580
|
+
replacementId: argv[2]
|
|
581
|
+
}),
|
|
582
|
+
run: async ({ store, actor }, { bundlePath: path, conceptId: id, replacementId }) => {
|
|
583
|
+
await store.supersede(path, id, replacementId, actor);
|
|
584
|
+
return { superseded: id, replacedBy: replacementId };
|
|
585
|
+
}
|
|
586
|
+
}),
|
|
587
|
+
define({
|
|
588
|
+
name: "answer",
|
|
589
|
+
tool: "kb_answer",
|
|
590
|
+
usage: "answer <concept-id> <answer...>",
|
|
591
|
+
description: "Resolve an open question: sets the status, stamps who answered and when, and appends an Answer section. If the answer overturns an assumption or a decision, that is a supersession \u2014 do it explicitly.",
|
|
592
|
+
input: import_zod6.z.object({ bundlePath, conceptId, answer: import_zod6.z.string().min(1) }),
|
|
593
|
+
fromArgv: (argv, path) => ({
|
|
594
|
+
bundlePath: path,
|
|
595
|
+
conceptId: argv[1],
|
|
596
|
+
answer: argv.slice(2).join(" ").trim()
|
|
597
|
+
}),
|
|
598
|
+
run: async ({ store, actor }, { bundlePath: path, conceptId: id, answer }) => {
|
|
599
|
+
const record = await store.answer(path, id, answer, actor);
|
|
600
|
+
return { conceptId: record.conceptId };
|
|
601
|
+
}
|
|
602
|
+
}),
|
|
603
|
+
define({
|
|
604
|
+
name: "load",
|
|
605
|
+
tool: "kb_load",
|
|
606
|
+
usage: "load [type] [--budget N]",
|
|
607
|
+
description: "Load the whole knowledge base at once, each record with its standing. Prefer this over searching: these bases run to a few thousand tokens, and a reader holding all of it has perfect recall and knows why it is asking, which no ranker does. Superseded records arrive under `superseded` as name, replacement and date only \u2014 their bodies no longer hold, and reading one later in a long session is the mistake this prevents; pass the id to kb_trace when you need the history. Rejected and unresolved records arrive whole: what was turned down, and what is still open, is the part a diff cannot show you. Refuses with a count rather than truncating when the base is too large \u2014 a truncated base is indistinguishable from a complete one, and would have you conclude something was never decided from a slice you did not know was a slice.",
|
|
608
|
+
input: import_zod6.z.object({
|
|
609
|
+
bundlePath,
|
|
610
|
+
type: import_zod6.z.enum(KB_RECORD_TYPES).optional(),
|
|
611
|
+
budgetTokens: import_zod6.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
|
|
612
|
+
}),
|
|
613
|
+
fromArgv: (argv, path) => {
|
|
614
|
+
const at = argv.indexOf("--budget");
|
|
615
|
+
return {
|
|
616
|
+
bundlePath: path,
|
|
617
|
+
...argv[1] && argv[1] !== "--budget" ? { type: argv[1] } : {},
|
|
618
|
+
...at !== -1 && argv[at + 1] ? { budgetTokens: Number(argv[at + 1]) } : {}
|
|
619
|
+
};
|
|
620
|
+
},
|
|
621
|
+
run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
|
|
622
|
+
const result = await store.load(path, {
|
|
623
|
+
...type ? { type } : {},
|
|
624
|
+
...budgetTokens ? { budgetTokens } : {}
|
|
625
|
+
});
|
|
626
|
+
if (!result.loaded) return result;
|
|
627
|
+
return {
|
|
628
|
+
...result,
|
|
629
|
+
records: result.records.map((hit) => ({
|
|
630
|
+
conceptId: hit.record.conceptId,
|
|
631
|
+
title: hit.record.frontmatter.title ?? null,
|
|
632
|
+
standing: hit.standing,
|
|
633
|
+
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
634
|
+
warnings: hit.warnings,
|
|
635
|
+
anchors: hit.record.frontmatter.strauss_anchors ?? [],
|
|
636
|
+
body: hit.record.body
|
|
637
|
+
}))
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}),
|
|
641
|
+
define({
|
|
642
|
+
name: "query",
|
|
643
|
+
tool: "kb_query",
|
|
644
|
+
usage: "query <text...>",
|
|
645
|
+
description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted. Prefer this over reading record files directly \u2014 relevance and standing are different questions, and a bare match answers only the first.",
|
|
646
|
+
input: import_zod6.z.object({
|
|
647
|
+
bundlePath,
|
|
648
|
+
text: import_zod6.z.string().optional(),
|
|
649
|
+
type: import_zod6.z.enum(KB_RECORD_TYPES).optional(),
|
|
650
|
+
includeNonCurrent: import_zod6.z.boolean().optional()
|
|
651
|
+
}),
|
|
652
|
+
fromArgv: (argv, path) => ({
|
|
653
|
+
bundlePath: path,
|
|
654
|
+
text: argv.slice(1).join(" ").trim(),
|
|
655
|
+
includeNonCurrent: true
|
|
656
|
+
}),
|
|
657
|
+
run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
|
|
658
|
+
...type ? { type } : {},
|
|
659
|
+
includeNonCurrent: includeNonCurrent === true
|
|
660
|
+
})).map((hit) => ({
|
|
661
|
+
conceptId: hit.record.conceptId,
|
|
662
|
+
title: hit.record.frontmatter.title ?? null,
|
|
663
|
+
description: hit.record.frontmatter.description ?? null,
|
|
664
|
+
standing: hit.standing,
|
|
665
|
+
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
666
|
+
warnings: hit.warnings,
|
|
667
|
+
body: hit.record.body
|
|
668
|
+
}))
|
|
669
|
+
}),
|
|
670
|
+
define({
|
|
671
|
+
name: "trace",
|
|
672
|
+
tool: "kb_trace",
|
|
673
|
+
usage: "trace <concept-id> [edges...]",
|
|
674
|
+
description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now".',
|
|
675
|
+
input: import_zod6.z.object({
|
|
676
|
+
bundlePath,
|
|
677
|
+
conceptId,
|
|
678
|
+
edges: import_zod6.z.array(import_zod6.z.enum(TRACE_EDGES)).optional(),
|
|
679
|
+
depth: import_zod6.z.number().int().positive().optional()
|
|
680
|
+
}),
|
|
681
|
+
fromArgv: (argv, path) => ({
|
|
682
|
+
bundlePath: path,
|
|
683
|
+
conceptId: argv[1],
|
|
684
|
+
edges: argv.slice(2).filter((edge) => TRACE_EDGES.includes(edge))
|
|
685
|
+
}),
|
|
686
|
+
run: async ({ store }, { bundlePath: path, conceptId: id, edges, depth }) => (await store.trace(path, id, {
|
|
687
|
+
...edges?.length ? { edges } : {},
|
|
688
|
+
...depth ? { depth } : {}
|
|
689
|
+
})).map((step) => ({
|
|
690
|
+
conceptId: step.record.conceptId,
|
|
691
|
+
at: step.record.frontmatter.generated?.at ?? null,
|
|
692
|
+
status: step.record.frontmatter.strauss_status,
|
|
693
|
+
title: step.record.frontmatter.title ?? null,
|
|
694
|
+
depth: step.depth,
|
|
695
|
+
via: step.via,
|
|
696
|
+
body: step.record.body
|
|
697
|
+
}))
|
|
698
|
+
}),
|
|
699
|
+
define({
|
|
700
|
+
name: "list",
|
|
701
|
+
tool: "kb_list",
|
|
702
|
+
usage: "list [type]",
|
|
703
|
+
description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
|
|
704
|
+
input: import_zod6.z.object({ bundlePath, type: import_zod6.z.enum(KB_RECORD_TYPES).optional() }),
|
|
705
|
+
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
706
|
+
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
707
|
+
conceptId: record.conceptId,
|
|
708
|
+
title: record.frontmatter.title ?? null,
|
|
709
|
+
description: record.frontmatter.description ?? null,
|
|
710
|
+
status: record.frontmatter.strauss_status,
|
|
711
|
+
anchors: record.frontmatter.strauss_anchors ?? []
|
|
712
|
+
}))
|
|
713
|
+
}),
|
|
714
|
+
define({
|
|
715
|
+
name: "index",
|
|
716
|
+
tool: "kb_index",
|
|
717
|
+
usage: "index",
|
|
718
|
+
description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record.",
|
|
719
|
+
input: import_zod6.z.object({ bundlePath }),
|
|
720
|
+
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
721
|
+
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
722
|
+
}),
|
|
723
|
+
define({
|
|
724
|
+
name: "log",
|
|
725
|
+
tool: "kb_log",
|
|
726
|
+
usage: "log",
|
|
727
|
+
description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
|
|
728
|
+
input: import_zod6.z.object({ bundlePath }),
|
|
729
|
+
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
730
|
+
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
731
|
+
}),
|
|
732
|
+
define({
|
|
733
|
+
name: "validate",
|
|
734
|
+
tool: "kb_validate",
|
|
735
|
+
usage: "validate",
|
|
736
|
+
description: "Check pointers no single record can see: supersession links that disagree between the two records, and assumptions that cite sources. Per-record shape is enforced on every read, so a problem here means someone edited a file by hand.",
|
|
737
|
+
input: import_zod6.z.object({ bundlePath }),
|
|
738
|
+
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
739
|
+
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
740
|
+
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
741
|
+
}),
|
|
742
|
+
define({
|
|
743
|
+
name: "schema",
|
|
744
|
+
tool: "kb_schema",
|
|
745
|
+
usage: "schema",
|
|
746
|
+
description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
|
|
747
|
+
input: import_zod6.z.object({}),
|
|
748
|
+
fromArgv: () => ({}),
|
|
749
|
+
run: () => Promise.resolve(kbJsonSchemas())
|
|
750
|
+
}),
|
|
751
|
+
define({
|
|
752
|
+
name: "types",
|
|
753
|
+
tool: "kb_types",
|
|
754
|
+
usage: "types",
|
|
755
|
+
description: "The twelve record types with their purpose, body sections, and starting status. Read this before writing rather than guessing headings \u2014 a section the type does not define is rejected.",
|
|
756
|
+
input: import_zod6.z.object({}),
|
|
757
|
+
fromArgv: () => ({}),
|
|
758
|
+
run: () => Promise.resolve(RECORD_TYPES)
|
|
759
|
+
})
|
|
760
|
+
];
|
|
761
|
+
var KB_COMMANDS_BY_NAME = new Map(
|
|
762
|
+
KB_COMMANDS.map((command) => [command.name, command])
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// src/kb-store.ts
|
|
766
|
+
var import_node_crypto = require("crypto");
|
|
767
|
+
var import_promises2 = require("fs/promises");
|
|
768
|
+
var import_node_path2 = require("path");
|
|
769
|
+
|
|
770
|
+
// src/markdown.ts
|
|
771
|
+
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
772
|
+
function stringifyMarkdownWithFrontmatter(content, frontmatter) {
|
|
773
|
+
return import_gray_matter.default.stringify(content, frontmatter);
|
|
774
|
+
}
|
|
775
|
+
function splitMarkdownFrontmatter(text) {
|
|
776
|
+
const file = (0, import_gray_matter.default)(text);
|
|
777
|
+
return {
|
|
778
|
+
content: file.content,
|
|
779
|
+
// Everything gray-matter consumed: the fences, the YAML, and the blank line
|
|
780
|
+
// after them. Kept so a caller can rewrite a body without touching the head.
|
|
781
|
+
prefix: text.slice(0, text.length - file.content.length),
|
|
782
|
+
raw: file.data
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function parseMarkdownWithFrontmatter(text, schema) {
|
|
786
|
+
const { content, prefix, raw } = splitMarkdownFrontmatter(text);
|
|
787
|
+
return {
|
|
788
|
+
content,
|
|
789
|
+
prefix,
|
|
790
|
+
raw,
|
|
791
|
+
frontmatter: schema.safeParse(raw)
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/errors.ts
|
|
796
|
+
var BaseError = class extends Error {
|
|
797
|
+
code;
|
|
798
|
+
errorType;
|
|
799
|
+
fault;
|
|
800
|
+
retriable;
|
|
801
|
+
reportToUser;
|
|
802
|
+
details;
|
|
803
|
+
constructor(props) {
|
|
804
|
+
super(props.message);
|
|
805
|
+
this.name = props.name ?? this.constructor.name;
|
|
806
|
+
this.code = props.code ?? 500;
|
|
807
|
+
this.errorType = props.errorType;
|
|
808
|
+
this.fault = props.fault;
|
|
809
|
+
this.retriable = props.retriable ?? true;
|
|
810
|
+
this.reportToUser = props.reportToUser ?? false;
|
|
811
|
+
this.details = props.details;
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// src/kb-errors.ts
|
|
816
|
+
var KbRecordAlreadyExistsError = class extends BaseError {
|
|
817
|
+
constructor(conceptId2) {
|
|
818
|
+
super({
|
|
819
|
+
message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
|
|
820
|
+
errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
|
|
821
|
+
code: 409,
|
|
822
|
+
fault: "User" /* User */,
|
|
823
|
+
retriable: false,
|
|
824
|
+
reportToUser: true,
|
|
825
|
+
details: { conceptId: conceptId2 }
|
|
826
|
+
});
|
|
827
|
+
this.conceptId = conceptId2;
|
|
828
|
+
}
|
|
829
|
+
conceptId;
|
|
830
|
+
};
|
|
831
|
+
var KbRecordNotFoundError = class extends BaseError {
|
|
832
|
+
constructor(conceptId2) {
|
|
833
|
+
super({
|
|
834
|
+
message: `kb: ${conceptId2} does not exist`,
|
|
835
|
+
errorType: "KbRecordNotFound" /* KbRecordNotFound */,
|
|
836
|
+
code: 404,
|
|
837
|
+
fault: "User" /* User */,
|
|
838
|
+
retriable: false,
|
|
839
|
+
reportToUser: true,
|
|
840
|
+
details: { conceptId: conceptId2 }
|
|
841
|
+
});
|
|
842
|
+
this.conceptId = conceptId2;
|
|
843
|
+
}
|
|
844
|
+
conceptId;
|
|
845
|
+
};
|
|
846
|
+
var KbWriteConflictError = class extends BaseError {
|
|
847
|
+
constructor(conceptId2) {
|
|
848
|
+
super({
|
|
849
|
+
message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
|
|
850
|
+
errorType: "KbWriteConflict" /* KbWriteConflict */,
|
|
851
|
+
code: 409,
|
|
852
|
+
fault: "System" /* System */,
|
|
853
|
+
retriable: true,
|
|
854
|
+
reportToUser: true,
|
|
855
|
+
details: { conceptId: conceptId2 }
|
|
856
|
+
});
|
|
857
|
+
this.conceptId = conceptId2;
|
|
858
|
+
}
|
|
859
|
+
conceptId;
|
|
860
|
+
};
|
|
861
|
+
var KbInvalidConceptIdError = class extends BaseError {
|
|
862
|
+
constructor(message, details) {
|
|
863
|
+
super({
|
|
864
|
+
message: `kb: ${message}`,
|
|
865
|
+
errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
|
|
866
|
+
code: 400,
|
|
867
|
+
fault: "User" /* User */,
|
|
868
|
+
retriable: false,
|
|
869
|
+
reportToUser: true,
|
|
870
|
+
details
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
// src/kb-index.ts
|
|
876
|
+
var INDEX_FILE = "INDEX.md";
|
|
877
|
+
var HEADING = "# KB Index";
|
|
878
|
+
function renderIndex(records) {
|
|
879
|
+
const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map((record) => {
|
|
880
|
+
const { frontmatter: fm } = record;
|
|
881
|
+
const parts = [fm.type, fm.strauss_status];
|
|
882
|
+
if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
|
|
883
|
+
if (fm.description) parts.push(fm.description);
|
|
884
|
+
return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
|
|
885
|
+
});
|
|
886
|
+
return `${HEADING}
|
|
887
|
+
|
|
888
|
+
${lines.join("\n")}
|
|
889
|
+
`;
|
|
890
|
+
}
|
|
891
|
+
function indexIsStale(stored, expected) {
|
|
892
|
+
return stored !== expected;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// src/adjudicate.ts
|
|
896
|
+
var STANDING = {
|
|
897
|
+
accepted: "current",
|
|
898
|
+
resolved: "current",
|
|
899
|
+
draft: "unsettled",
|
|
900
|
+
proposed: "unsettled",
|
|
901
|
+
open: "open",
|
|
902
|
+
rejected: "rejected",
|
|
903
|
+
superseded: "superseded"
|
|
904
|
+
};
|
|
905
|
+
function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
|
|
906
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
907
|
+
return hits.map((record) => {
|
|
908
|
+
const status = record.frontmatter.strauss_status;
|
|
909
|
+
const warnings = [];
|
|
910
|
+
let heads = [];
|
|
911
|
+
if (status === "superseded") {
|
|
912
|
+
const resolved = resolveHeads(record, byId);
|
|
913
|
+
heads = resolved.heads;
|
|
914
|
+
warnings.push(...resolved.warnings);
|
|
915
|
+
if (heads.length) {
|
|
916
|
+
warnings.push({
|
|
917
|
+
kind: "superseded",
|
|
918
|
+
by: heads.map((head) => head.conceptId)
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
} else if (status === "rejected") {
|
|
922
|
+
warnings.push({ kind: "rejected" });
|
|
923
|
+
} else if (status === "draft" || status === "proposed") {
|
|
924
|
+
warnings.push({ kind: "unsettled", status });
|
|
925
|
+
} else if (status === "open") {
|
|
926
|
+
warnings.push({ kind: "unresolved-question" });
|
|
927
|
+
}
|
|
928
|
+
const staleAfter = record.frontmatter.stale_after;
|
|
929
|
+
if (staleAfter && Date.parse(staleAfter) < now.getTime()) {
|
|
930
|
+
warnings.push({ kind: "stale", staleAfter });
|
|
931
|
+
}
|
|
932
|
+
if (!record.frontmatter.verified?.length) {
|
|
933
|
+
warnings.push({ kind: "unverified" });
|
|
934
|
+
}
|
|
935
|
+
return { record, standing: STANDING[status], heads, warnings };
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
function resolveHeads(from, byId) {
|
|
939
|
+
const warnings = [];
|
|
940
|
+
const heads = /* @__PURE__ */ new Map();
|
|
941
|
+
const seen = /* @__PURE__ */ new Set([from.conceptId]);
|
|
942
|
+
const queue = [from];
|
|
943
|
+
while (queue.length) {
|
|
944
|
+
const current = queue.shift();
|
|
945
|
+
const next = successors(current, byId);
|
|
946
|
+
for (const missing of next.missing) {
|
|
947
|
+
warnings.push({ kind: "broken-chain", missing });
|
|
948
|
+
}
|
|
949
|
+
if (!next.records.length) {
|
|
950
|
+
if (current.conceptId !== from.conceptId)
|
|
951
|
+
heads.set(current.conceptId, current);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
for (const record of next.records) {
|
|
955
|
+
if (seen.has(record.conceptId)) {
|
|
956
|
+
warnings.push({ kind: "chain-cycle", through: [...seen] });
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
seen.add(record.conceptId);
|
|
960
|
+
queue.push(record);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (heads.size > 1) {
|
|
964
|
+
warnings.push({ kind: "forked-chain", heads: [...heads.keys()] });
|
|
965
|
+
}
|
|
966
|
+
return { heads: [...heads.values()], warnings };
|
|
967
|
+
}
|
|
968
|
+
function successors(record, byId) {
|
|
969
|
+
const ids = /* @__PURE__ */ new Set();
|
|
970
|
+
const forward = record.frontmatter.strauss_superseded_by;
|
|
971
|
+
if (forward) ids.add(forward);
|
|
972
|
+
for (const [id, candidate] of byId) {
|
|
973
|
+
if (candidate.frontmatter.strauss_supersedes?.includes(record.conceptId)) {
|
|
974
|
+
ids.add(id);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const records = [];
|
|
978
|
+
const missing = [];
|
|
979
|
+
for (const id of ids) {
|
|
980
|
+
const found = byId.get(id);
|
|
981
|
+
if (found) records.push(found);
|
|
982
|
+
else missing.push(id);
|
|
983
|
+
}
|
|
984
|
+
return { records, missing };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/search-index.ts
|
|
988
|
+
var import_promises = require("fs/promises");
|
|
989
|
+
var import_node_path = require("path");
|
|
990
|
+
var SEARCH_INDEX_FILE = ".index.sqlite";
|
|
991
|
+
var COLLECTION = "kb";
|
|
992
|
+
async function searchBase(bundlePath2, query, options = {}) {
|
|
993
|
+
const qmd = options.qmd ?? await loadQmd(options.logger);
|
|
994
|
+
if (!qmd) return null;
|
|
995
|
+
let store = null;
|
|
996
|
+
try {
|
|
997
|
+
store = await qmd.createStore({
|
|
998
|
+
dbPath: (0, import_node_path.join)(bundlePath2, SEARCH_INDEX_FILE),
|
|
999
|
+
config: {
|
|
1000
|
+
collections: {
|
|
1001
|
+
[COLLECTION]: {
|
|
1002
|
+
path: bundlePath2,
|
|
1003
|
+
pattern: "**/*.md",
|
|
1004
|
+
// Both store-owned files are markdown and neither is a record.
|
|
1005
|
+
ignore: [INDEX_FILE, LOG_FILE]
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
if (await isStale(bundlePath2)) {
|
|
1011
|
+
await store.update({ collections: [COLLECTION] });
|
|
1012
|
+
}
|
|
1013
|
+
const hits = await store.searchLex(query, {
|
|
1014
|
+
collection: COLLECTION,
|
|
1015
|
+
...options.limit ? { limit: options.limit } : {}
|
|
1016
|
+
});
|
|
1017
|
+
return hits.map((hit) => ({
|
|
1018
|
+
displayPath: hit.displayPath ?? hit.filepath ?? "",
|
|
1019
|
+
score: hit.score ?? 0
|
|
1020
|
+
})).filter((hit) => hit.displayPath.length > 0);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
options.logger?.warn?.({
|
|
1023
|
+
operation: "kb.search",
|
|
1024
|
+
outcome: "unavailable",
|
|
1025
|
+
error: error instanceof Error ? error.message : "unknown"
|
|
1026
|
+
});
|
|
1027
|
+
return null;
|
|
1028
|
+
} finally {
|
|
1029
|
+
await store?.close().catch(() => void 0);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async function isStale(bundlePath2) {
|
|
1033
|
+
const indexAt = await (0, import_promises.stat)((0, import_node_path.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
|
|
1034
|
+
if (!indexAt) return true;
|
|
1035
|
+
const { readdir: readdir2 } = await import("fs/promises");
|
|
1036
|
+
const names = await readdir2(bundlePath2).catch(() => []);
|
|
1037
|
+
for (const name of names) {
|
|
1038
|
+
if (!name.endsWith(".md") || name === INDEX_FILE) continue;
|
|
1039
|
+
const at = await (0, import_promises.stat)((0, import_node_path.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
|
|
1040
|
+
if (at > indexAt) return true;
|
|
1041
|
+
}
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
function resolveHits(hits, records) {
|
|
1045
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1046
|
+
for (const record of records) {
|
|
1047
|
+
const name = flatten(record.conceptId);
|
|
1048
|
+
if (byName.has(name)) byName.delete(name);
|
|
1049
|
+
else byName.set(name, record);
|
|
1050
|
+
}
|
|
1051
|
+
const resolved = [];
|
|
1052
|
+
for (const hit of hits) {
|
|
1053
|
+
const file = hit.displayPath.split("/").pop() ?? "";
|
|
1054
|
+
const name = file.endsWith(".md") ? file.slice(0, -".md".length) : file;
|
|
1055
|
+
const record = byName.get(flatten(name));
|
|
1056
|
+
if (record) resolved.push(record);
|
|
1057
|
+
}
|
|
1058
|
+
return resolved;
|
|
1059
|
+
}
|
|
1060
|
+
function flatten(value) {
|
|
1061
|
+
return value.replace(/[.]/g, "-").toLowerCase();
|
|
1062
|
+
}
|
|
1063
|
+
var QMD_MODULE = "@tobilu/qmd";
|
|
1064
|
+
async function loadQmd(logger) {
|
|
1065
|
+
try {
|
|
1066
|
+
return await import(QMD_MODULE);
|
|
1067
|
+
} catch {
|
|
1068
|
+
logger?.warn?.({ operation: "kb.search", outcome: "qmd-unavailable" });
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// src/kb-store.ts
|
|
1074
|
+
var KB_DIR = (0, import_node_path2.join)(".strauss", "kb");
|
|
1075
|
+
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
1076
|
+
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
1077
|
+
var KbStore = class {
|
|
1078
|
+
constructor(logger = {}) {
|
|
1079
|
+
this.logger = logger;
|
|
1080
|
+
}
|
|
1081
|
+
logger;
|
|
1082
|
+
/**
|
|
1083
|
+
* Writes one record. `type` and `slug` compose both the filename and the
|
|
1084
|
+
* concept id, so a caller cannot produce a file whose identity disagrees with
|
|
1085
|
+
* its contents.
|
|
1086
|
+
*/
|
|
1087
|
+
async write(bundlePath2, input, actor = "unknown") {
|
|
1088
|
+
if (!KB_SLUG_PATTERN.test(input.slug)) {
|
|
1089
|
+
throw new KbInvalidConceptIdError("slug must be kebab-case", {
|
|
1090
|
+
slug: input.slug
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
if (!KB_SLUG_PATTERN.test(input.type)) {
|
|
1094
|
+
throw new KbInvalidConceptIdError("type must be kebab-case", {
|
|
1095
|
+
type: input.type
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
const frontmatter = kbRecordFrontmatterSchema.parse({
|
|
1099
|
+
...input.frontmatter,
|
|
1100
|
+
type: input.type
|
|
1101
|
+
});
|
|
1102
|
+
const conceptId2 = `${input.type}.${input.slug}`;
|
|
1103
|
+
const root = this.root(bundlePath2);
|
|
1104
|
+
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1105
|
+
await (0, import_promises2.mkdir)(root, { recursive: true });
|
|
1106
|
+
await this.publish(
|
|
1107
|
+
target,
|
|
1108
|
+
stringifyMarkdownWithFrontmatter(input.body, frontmatter),
|
|
1109
|
+
input.overwrite ?? false,
|
|
1110
|
+
conceptId2
|
|
1111
|
+
);
|
|
1112
|
+
await this.record(root, {
|
|
1113
|
+
operation: input.overwrite ? "overwrite" : "write",
|
|
1114
|
+
conceptId: conceptId2,
|
|
1115
|
+
by: actor
|
|
1116
|
+
});
|
|
1117
|
+
this.logger.info?.({
|
|
1118
|
+
operation: "kb.write",
|
|
1119
|
+
bundlePath: root,
|
|
1120
|
+
conceptId: conceptId2,
|
|
1121
|
+
anchors: frontmatter.strauss_anchors?.length ?? 0
|
|
1122
|
+
});
|
|
1123
|
+
return { conceptId: conceptId2, frontmatter, body: input.body };
|
|
1124
|
+
}
|
|
1125
|
+
/** One record by concept id, or null when it does not exist. */
|
|
1126
|
+
async read(bundlePath2, conceptId2) {
|
|
1127
|
+
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1128
|
+
let raw;
|
|
1129
|
+
try {
|
|
1130
|
+
raw = await (0, import_promises2.readFile)(target, "utf8");
|
|
1131
|
+
} catch {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
return this.parse(conceptId2, raw);
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Every record in the bundle, optionally narrowed to one type.
|
|
1138
|
+
*
|
|
1139
|
+
* A file that fails to parse is skipped and logged rather than thrown: one
|
|
1140
|
+
* malformed record — hand-edited, or written by a producer we don't know —
|
|
1141
|
+
* must not make the whole bundle unreadable.
|
|
1142
|
+
*/
|
|
1143
|
+
async list(bundlePath2, type) {
|
|
1144
|
+
const root = this.root(bundlePath2);
|
|
1145
|
+
let names;
|
|
1146
|
+
try {
|
|
1147
|
+
names = await (0, import_promises2.readdir)(root);
|
|
1148
|
+
} catch {
|
|
1149
|
+
return [];
|
|
1150
|
+
}
|
|
1151
|
+
const wanted = names.sort().filter((name) => name.endsWith(".md") && !STORE_OWNED.has(name)).map((name) => ({ name, conceptId: name.slice(0, -".md".length) })).filter(({ conceptId: conceptId2 }) => !type || conceptId2.startsWith(`${type}.`));
|
|
1152
|
+
const records = await Promise.all(
|
|
1153
|
+
wanted.map(
|
|
1154
|
+
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises2.readFile)((0, import_node_path2.join)(root, name), "utf8"))
|
|
1155
|
+
)
|
|
1156
|
+
);
|
|
1157
|
+
return records.filter((record) => record !== null);
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Moves a record's status, preserving everything else.
|
|
1161
|
+
*
|
|
1162
|
+
* Read-modify-write on one file is the one place two agents genuinely race,
|
|
1163
|
+
* and the fix is a compare-and-swap rather than a lock: hash on read, verify
|
|
1164
|
+
* the file is unchanged immediately before writing, fail if it moved. A lock
|
|
1165
|
+
* would buy the same guarantee and add a stale-lock failure mode — a writer
|
|
1166
|
+
* killed mid-hold blocks every later one until someone reasons about
|
|
1167
|
+
* timeouts.
|
|
1168
|
+
*/
|
|
1169
|
+
async setStatus(bundlePath2, conceptId2, status, actor = "unknown") {
|
|
1170
|
+
return this.mutate(
|
|
1171
|
+
bundlePath2,
|
|
1172
|
+
conceptId2,
|
|
1173
|
+
(frontmatter) => ({ ...frontmatter, strauss_status: status }),
|
|
1174
|
+
{ operation: `status:${status}`, by: actor }
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Marks `conceptId` superseded by `replacementId`, and links both directions.
|
|
1179
|
+
*
|
|
1180
|
+
* Writing one side and letting a validator notice the other is missing was
|
|
1181
|
+
* the previous arrangement; doing both here means the backlink cannot drift
|
|
1182
|
+
* in normal use, and validation drops to catching hand-edits.
|
|
1183
|
+
*/
|
|
1184
|
+
async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
|
|
1185
|
+
const replacement = await this.read(bundlePath2, replacementId);
|
|
1186
|
+
if (!replacement) throw new KbRecordNotFoundError(replacementId);
|
|
1187
|
+
const superseded = await this.mutate(
|
|
1188
|
+
bundlePath2,
|
|
1189
|
+
conceptId2,
|
|
1190
|
+
(frontmatter) => ({
|
|
1191
|
+
...frontmatter,
|
|
1192
|
+
strauss_status: "superseded",
|
|
1193
|
+
strauss_superseded_by: replacementId
|
|
1194
|
+
}),
|
|
1195
|
+
{ operation: "supersede", by: actor, target: replacementId }
|
|
1196
|
+
);
|
|
1197
|
+
await this.mutate(
|
|
1198
|
+
bundlePath2,
|
|
1199
|
+
replacementId,
|
|
1200
|
+
(frontmatter) => ({
|
|
1201
|
+
...frontmatter,
|
|
1202
|
+
strauss_supersedes: [
|
|
1203
|
+
.../* @__PURE__ */ new Set([...frontmatter.strauss_supersedes ?? [], conceptId2])
|
|
1204
|
+
]
|
|
1205
|
+
}),
|
|
1206
|
+
{ operation: "supersedes", by: actor, target: conceptId2 }
|
|
1207
|
+
);
|
|
1208
|
+
return superseded;
|
|
1209
|
+
}
|
|
1210
|
+
/** Resolves an open question, stamping who answered and when. */
|
|
1211
|
+
async answer(bundlePath2, conceptId2, answer, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
1212
|
+
return this.mutate(
|
|
1213
|
+
bundlePath2,
|
|
1214
|
+
conceptId2,
|
|
1215
|
+
(frontmatter) => ({
|
|
1216
|
+
...frontmatter,
|
|
1217
|
+
strauss_status: "resolved",
|
|
1218
|
+
strauss_answered: { by: actor, at }
|
|
1219
|
+
}),
|
|
1220
|
+
{ operation: "answer", by: actor },
|
|
1221
|
+
(body) => `${body.trimEnd()}
|
|
1222
|
+
|
|
1223
|
+
## Answer
|
|
1224
|
+
|
|
1225
|
+
${answer}
|
|
1226
|
+
`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Records matching a text query, each carrying its standing.
|
|
1231
|
+
*
|
|
1232
|
+
* Relevance comes from qmd's BM25 where an index is available and from a
|
|
1233
|
+
* substring scan where it is not. What never moves to the ranker is the
|
|
1234
|
+
* adjudication below it: a ranker answers relevance, and relevance is not
|
|
1235
|
+
* standing — a superseded record is the older, longer, more general one, so
|
|
1236
|
+
* ranking alone prefers what is no longer true.
|
|
1237
|
+
*
|
|
1238
|
+
* The fallback is deliberate. A search index is an optimisation, so losing it
|
|
1239
|
+
* degrades recall and must never change the answer's shape or fail the call.
|
|
1240
|
+
*/
|
|
1241
|
+
async query(bundlePath2, text, options = {}) {
|
|
1242
|
+
const bundle = await this.list(bundlePath2);
|
|
1243
|
+
const needle = text.trim();
|
|
1244
|
+
const hits = needle ? await this.rank(bundlePath2, needle, bundle) : bundle;
|
|
1245
|
+
const adjudicated = adjudicate(
|
|
1246
|
+
options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits,
|
|
1247
|
+
bundle
|
|
1248
|
+
);
|
|
1249
|
+
if (options.includeNonCurrent) return adjudicated;
|
|
1250
|
+
const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
|
|
1251
|
+
return adjudicated.filter(
|
|
1252
|
+
(hit) => hit.standing !== "superseded" || !hit.heads.some((head) => present.has(head.conceptId))
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
async rank(bundlePath2, needle, bundle) {
|
|
1256
|
+
const ranked = await searchBase(this.root(bundlePath2), needle, {
|
|
1257
|
+
logger: this.logger
|
|
1258
|
+
});
|
|
1259
|
+
if (ranked) {
|
|
1260
|
+
const found = resolveHits(ranked, bundle);
|
|
1261
|
+
if (found.length) return found;
|
|
1262
|
+
}
|
|
1263
|
+
const lowered = needle.toLowerCase();
|
|
1264
|
+
return bundle.filter((record) => matches(record, lowered));
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* The whole base, adjudicated, when it is small enough to hand over.
|
|
1268
|
+
*
|
|
1269
|
+
* At the sizes these reach — twenty records is about three thousand tokens —
|
|
1270
|
+
* loading everything beats searching it, and measurably: on nine questions
|
|
1271
|
+
* whose wording appears in no record, a reader holding the base answered
|
|
1272
|
+
* eight against an embedding search's four. Two of those differences are
|
|
1273
|
+
* structural. A reader can say no record answers the question; vector search
|
|
1274
|
+
* returns its nearest neighbour whatever the distance. And a reader picks the
|
|
1275
|
+
* record that answers the question rather than the one nearest the topic.
|
|
1276
|
+
* See the README's retrieval section for the measurements.
|
|
1277
|
+
*
|
|
1278
|
+
* Load it for a question, not for a session — a base read into a long
|
|
1279
|
+
* conversation is summarised away by the end of it.
|
|
1280
|
+
*
|
|
1281
|
+
* Refuses rather than truncates when the base is too large. A truncated base
|
|
1282
|
+
* is indistinguishable from a complete one, so a caller would answer "that
|
|
1283
|
+
* was never decided" from a slice it did not know was a slice.
|
|
1284
|
+
*/
|
|
1285
|
+
async load(bundlePath2, options = {}) {
|
|
1286
|
+
const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
|
|
1287
|
+
const bundle = await this.list(bundlePath2);
|
|
1288
|
+
const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
|
|
1289
|
+
const adjudicated = adjudicate(wanted, bundle);
|
|
1290
|
+
const records = adjudicated.filter((hit) => hit.standing !== "superseded");
|
|
1291
|
+
const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
|
|
1292
|
+
const approxTokens = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
|
|
1293
|
+
if (approxTokens > budgetTokens) {
|
|
1294
|
+
return {
|
|
1295
|
+
loaded: false,
|
|
1296
|
+
recordCount: wanted.length,
|
|
1297
|
+
approxTokens,
|
|
1298
|
+
budgetTokens
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
return {
|
|
1302
|
+
loaded: true,
|
|
1303
|
+
recordCount: wanted.length,
|
|
1304
|
+
approxTokens,
|
|
1305
|
+
budgetTokens,
|
|
1306
|
+
records,
|
|
1307
|
+
superseded
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
/** How a position was arrived at, as a timeline. See `trace.ts`. */
|
|
1311
|
+
async trace(bundlePath2, seedId, options = {}) {
|
|
1312
|
+
return trace(seedId, await this.list(bundlePath2), options);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* The stored index, rebuilt if it disagrees with the records.
|
|
1316
|
+
*
|
|
1317
|
+
* Repair on read is what makes the lock-free write path safe: a writer whose
|
|
1318
|
+
* scan predated another writer's record publishes a momentarily stale index,
|
|
1319
|
+
* and the next reader through here settles it.
|
|
1320
|
+
*/
|
|
1321
|
+
async readIndex(bundlePath2) {
|
|
1322
|
+
const root = this.root(bundlePath2);
|
|
1323
|
+
const expected = renderIndex(await this.list(bundlePath2));
|
|
1324
|
+
const stored = await (0, import_promises2.readFile)((0, import_node_path2.join)(root, INDEX_FILE), "utf8").catch(
|
|
1325
|
+
() => null
|
|
1326
|
+
);
|
|
1327
|
+
if (indexIsStale(stored, expected)) {
|
|
1328
|
+
await this.publish((0, import_node_path2.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
|
|
1329
|
+
this.logger.info?.({
|
|
1330
|
+
operation: "kb.index.repair",
|
|
1331
|
+
bundlePath: root,
|
|
1332
|
+
reason: stored === null ? "missing" : "stale"
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
return expected;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* The log, with unparseable lines reported rather than repaired.
|
|
1339
|
+
*
|
|
1340
|
+
* The log is the bundle's only artifact that cannot be reconstructed — the
|
|
1341
|
+
* records rebuild the index, and the code outlives both, but nothing else
|
|
1342
|
+
* knows which agent touched what. So a bad line is surfaced and left alone.
|
|
1343
|
+
*/
|
|
1344
|
+
async readLog(bundlePath2) {
|
|
1345
|
+
const raw = await (0, import_promises2.readFile)(
|
|
1346
|
+
(0, import_node_path2.join)(this.root(bundlePath2), LOG_FILE),
|
|
1347
|
+
"utf8"
|
|
1348
|
+
).catch(() => "");
|
|
1349
|
+
const result = parseLog(raw);
|
|
1350
|
+
for (const bad of result.malformed) {
|
|
1351
|
+
this.logger.warn?.({
|
|
1352
|
+
operation: "kb.log.parse",
|
|
1353
|
+
line: bad.line,
|
|
1354
|
+
outcome: "skipped"
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
return result;
|
|
1358
|
+
}
|
|
1359
|
+
async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
|
|
1360
|
+
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1361
|
+
const before = await (0, import_promises2.readFile)(target, "utf8").catch(() => null);
|
|
1362
|
+
if (before === null) throw new KbRecordNotFoundError(conceptId2);
|
|
1363
|
+
const parsed = this.parse(conceptId2, before);
|
|
1364
|
+
if (!parsed) throw new KbRecordNotFoundError(conceptId2);
|
|
1365
|
+
const frontmatter = change(parsed.frontmatter);
|
|
1366
|
+
const body = changeBody(parsed.body);
|
|
1367
|
+
const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
|
|
1368
|
+
const witness = await (0, import_promises2.readFile)(target, "utf8").catch(() => null);
|
|
1369
|
+
if (witness === null || digest(witness) !== digest(before)) {
|
|
1370
|
+
throw new KbWriteConflictError(conceptId2);
|
|
1371
|
+
}
|
|
1372
|
+
await this.publish(target, contents, true, conceptId2);
|
|
1373
|
+
await this.record(this.root(bundlePath2), { ...entry, conceptId: conceptId2 });
|
|
1374
|
+
return { conceptId: conceptId2, frontmatter, body };
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Two guarantees, both about writers running in parallel.
|
|
1378
|
+
*
|
|
1379
|
+
* The record is written to a staging file and only then published, so a
|
|
1380
|
+
* concurrent reader sees the whole record or no record — never half of one. A
|
|
1381
|
+
* plain write is not atomic, and `list()` skips what it cannot parse, so a
|
|
1382
|
+
* torn read would be silently reported as a malformed record.
|
|
1383
|
+
*
|
|
1384
|
+
* Publishing uses `link` rather than `rename` unless the caller asked to
|
|
1385
|
+
* overwrite: `link` fails with EEXIST instead of replacing, which turns "two
|
|
1386
|
+
* writers chose the same concept id" from silent data loss into a collision
|
|
1387
|
+
* the caller has to answer. Both are atomic; only `rename` clobbers.
|
|
1388
|
+
*
|
|
1389
|
+
* The staging name deliberately does not end in `.md` — `list()` would
|
|
1390
|
+
* otherwise try to read it mid-write.
|
|
1391
|
+
*/
|
|
1392
|
+
async publish(target, contents, overwrite, conceptId2) {
|
|
1393
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
1394
|
+
await (0, import_promises2.writeFile)(staging, contents, "utf8");
|
|
1395
|
+
try {
|
|
1396
|
+
if (overwrite) {
|
|
1397
|
+
await (0, import_promises2.rename)(staging, target);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
await (0, import_promises2.link)(staging, target);
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
if (error.code === "EEXIST") {
|
|
1403
|
+
throw new KbRecordAlreadyExistsError(conceptId2);
|
|
1404
|
+
}
|
|
1405
|
+
throw error;
|
|
1406
|
+
} finally {
|
|
1407
|
+
await (0, import_promises2.unlink)(staging).catch(() => void 0);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
/** Appends one log line. Failing to log must not fail the mutation. */
|
|
1411
|
+
async record(root, entry) {
|
|
1412
|
+
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
1413
|
+
await (0, import_promises2.appendFile)((0, import_node_path2.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
1414
|
+
this.logger.warn?.({
|
|
1415
|
+
operation: "kb.log.append",
|
|
1416
|
+
outcome: "failed",
|
|
1417
|
+
error: error instanceof Error ? error.message : "unknown"
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
parse(conceptId2, raw) {
|
|
1422
|
+
const parsed = parseMarkdownWithFrontmatter(raw, kbRecordFrontmatterSchema);
|
|
1423
|
+
if (!parsed.frontmatter.success) {
|
|
1424
|
+
this.logger.warn?.({
|
|
1425
|
+
operation: "kb.parse",
|
|
1426
|
+
conceptId: conceptId2,
|
|
1427
|
+
outcome: "skipped",
|
|
1428
|
+
error: parsed.frontmatter.error.issues[0]?.message ?? "invalid"
|
|
1429
|
+
});
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
return {
|
|
1433
|
+
conceptId: conceptId2,
|
|
1434
|
+
frontmatter: parsed.frontmatter.data,
|
|
1435
|
+
body: parsed.content
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
root(bundlePath2) {
|
|
1439
|
+
return (0, import_node_path2.resolve)(bundlePath2);
|
|
1440
|
+
}
|
|
1441
|
+
// Concept ids are `<type>.<slug>` and map to a single file directly under the
|
|
1442
|
+
// bundle root; anything carrying a separator would escape it.
|
|
1443
|
+
recordPath(bundlePath2, conceptId2) {
|
|
1444
|
+
if (conceptId2.includes(import_node_path2.sep) || conceptId2.includes("/")) {
|
|
1445
|
+
throw new KbInvalidConceptIdError(
|
|
1446
|
+
"concept id must not contain a path separator",
|
|
1447
|
+
{ conceptId: conceptId2 }
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
return (0, import_node_path2.join)(this.root(bundlePath2), `${conceptId2}.md`);
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
function estimateTokens(record) {
|
|
1454
|
+
return Math.ceil(
|
|
1455
|
+
(record.body.length + JSON.stringify(record.frontmatter).length) / 4
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
function estimateStubTokens(entry) {
|
|
1459
|
+
return Math.ceil(JSON.stringify(entry).length / 4);
|
|
1460
|
+
}
|
|
1461
|
+
function stub(hit) {
|
|
1462
|
+
return {
|
|
1463
|
+
conceptId: hit.record.conceptId,
|
|
1464
|
+
title: hit.record.frontmatter.title ?? null,
|
|
1465
|
+
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
1466
|
+
at: hit.record.frontmatter.generated?.at ?? null
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function matches(record, needle) {
|
|
1470
|
+
const { title, description } = record.frontmatter;
|
|
1471
|
+
return [record.conceptId, title, description, record.body].some(
|
|
1472
|
+
(field) => field?.toLowerCase().includes(needle)
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
function digest(contents) {
|
|
1476
|
+
return (0, import_node_crypto.createHash)("sha256").update(contents).digest("hex");
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// src/mcp.ts
|
|
1480
|
+
function createKbMcpServer() {
|
|
1481
|
+
const server = new import_mcp.McpServer({ name: "strauss-kb", version: "0.1.0" });
|
|
1482
|
+
const store = new KbStore({
|
|
1483
|
+
warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
|
|
1484
|
+
`)
|
|
1485
|
+
});
|
|
1486
|
+
const ctx = {
|
|
1487
|
+
store,
|
|
1488
|
+
actor: process.env.STRAUSS_KB_ACTOR ?? "mcp",
|
|
1489
|
+
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
1490
|
+
};
|
|
1491
|
+
for (const command of KB_COMMANDS) {
|
|
1492
|
+
server.registerTool(
|
|
1493
|
+
command.tool,
|
|
1494
|
+
{ description: command.description, inputSchema: command.input.shape },
|
|
1495
|
+
async (args) => {
|
|
1496
|
+
const result = await command.run(ctx, command.input.parse(args));
|
|
1497
|
+
return {
|
|
1498
|
+
content: [
|
|
1499
|
+
{
|
|
1500
|
+
type: "text",
|
|
1501
|
+
text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
1502
|
+
}
|
|
1503
|
+
]
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
return server;
|
|
1509
|
+
}
|
|
1510
|
+
async function runKbMcpServer() {
|
|
1511
|
+
await createKbMcpServer().connect(new import_stdio.StdioServerTransport());
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// src/mcp-main.ts
|
|
1515
|
+
runKbMcpServer().catch((error) => {
|
|
1516
|
+
process.stderr.write(
|
|
1517
|
+
`${error instanceof Error ? error.message : String(error)}
|
|
1518
|
+
`
|
|
1519
|
+
);
|
|
1520
|
+
process.exit(1);
|
|
1521
|
+
});
|
|
1522
|
+
//# sourceMappingURL=mcp-main.cjs.map
|