@saasontools/strauss-kb 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -6
- package/dist/chunk-EDH43Z7J.js +2377 -0
- package/dist/chunk-EDH43Z7J.js.map +1 -0
- package/dist/{chunk-WFHYWZX5.js → chunk-TS26G7TL.js} +3 -2
- package/dist/chunk-TS26G7TL.js.map +1 -0
- package/dist/{chunk-KGM34MYU.js → chunk-Y5C7Z2HG.js} +3 -2
- package/dist/chunk-Y5C7Z2HG.js.map +1 -0
- package/dist/cli-main.cjs +1303 -459
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +1185 -308
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +298 -13
- package/dist/index.d.ts +298 -13
- package/dist/index.js +45 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +1299 -455
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-KGM34MYU.js.map +0 -1
- package/dist/chunk-WFHYWZX5.js.map +0 -1
- package/dist/chunk-ZSYSHJVZ.js +0 -1527
- package/dist/chunk-ZSYSHJVZ.js.map +0 -1
package/dist/cli-main.cjs
CHANGED
|
@@ -24,10 +24,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
));
|
|
25
25
|
|
|
26
26
|
// src/cli.ts
|
|
27
|
-
var
|
|
27
|
+
var import_node_path7 = require("path");
|
|
28
28
|
|
|
29
|
-
// src/
|
|
30
|
-
var
|
|
29
|
+
// src/decision-record.ts
|
|
30
|
+
var import_zod3 = require("zod");
|
|
31
31
|
|
|
32
32
|
// src/compose.ts
|
|
33
33
|
var import_zod2 = require("zod");
|
|
@@ -198,12 +198,22 @@ var composeInputSchema = import_zod2.z.object({
|
|
|
198
198
|
sources: import_zod2.z.array(kbSourceSchema).optional(),
|
|
199
199
|
/** No source exists, as a claim rather than a sentinel in `sources`. */
|
|
200
200
|
assumption: import_zod2.z.boolean().optional(),
|
|
201
|
+
/**
|
|
202
|
+
* OKF `stale_after`: the absolute date this record stops being trusted.
|
|
203
|
+
* Anything the outside world can change — pricing, quotas, versions,
|
|
204
|
+
* reception counts — should carry one.
|
|
205
|
+
*/
|
|
206
|
+
stale_after: import_zod2.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
|
|
207
|
+
message: "stale_after must be YYYY-MM-DD"
|
|
208
|
+
}).refine((date) => !Number.isNaN(Date.parse(date)), {
|
|
209
|
+
message: "stale_after must be a real date"
|
|
210
|
+
}).optional(),
|
|
201
211
|
verify: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
|
|
202
212
|
tags: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
|
|
203
213
|
/** Concept ids this record relates to; rendered as body links. */
|
|
204
214
|
relatedConceptIds: import_zod2.z.array(kbConceptIdSchema).optional(),
|
|
205
215
|
/** Concept ids this record replaces. The store settles the backlinks. */
|
|
206
|
-
supersedes: import_zod2.z.array(kbConceptIdSchema).optional(),
|
|
216
|
+
supersedes: import_zod2.z.array(kbConceptIdSchema).max(32).optional(),
|
|
207
217
|
materiality: import_zod2.z.enum(KB_MATERIALITIES).optional(),
|
|
208
218
|
confidence: import_zod2.z.enum(KB_CONFIDENCES).optional(),
|
|
209
219
|
owner: import_zod2.z.string().min(1).optional()
|
|
@@ -230,6 +240,7 @@ function composeRecord(type, input, writtenBy, writtenAt) {
|
|
|
230
240
|
verified: [],
|
|
231
241
|
strauss_status: spec.initialStatus
|
|
232
242
|
};
|
|
243
|
+
if (parsed.stale_after) frontmatter.stale_after = parsed.stale_after;
|
|
233
244
|
if (parsed.anchors?.length) frontmatter.strauss_anchors = parsed.anchors;
|
|
234
245
|
if (parsed.verify?.length) frontmatter.strauss_verify = parsed.verify;
|
|
235
246
|
if (parsed.tags?.length) frontmatter.tags = parsed.tags;
|
|
@@ -266,7 +277,6 @@ ${text}`);
|
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
// src/decision-record.ts
|
|
269
|
-
var import_zod3 = require("zod");
|
|
270
280
|
var DECISION_TYPE = "decision";
|
|
271
281
|
var NO_DECISION_SLUG = "none";
|
|
272
282
|
var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
|
|
@@ -304,19 +314,952 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
|
|
|
304
314
|
);
|
|
305
315
|
}
|
|
306
316
|
|
|
317
|
+
// src/commands/answer.ts
|
|
318
|
+
var import_zod6 = require("zod");
|
|
319
|
+
|
|
320
|
+
// src/kb-pins/budgets.ts
|
|
321
|
+
function asBudgets(value) {
|
|
322
|
+
if (value === null || typeof value !== "object") return {};
|
|
323
|
+
const table = value;
|
|
324
|
+
const pick = (key, min) => {
|
|
325
|
+
const raw = table[key];
|
|
326
|
+
return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
|
|
327
|
+
};
|
|
328
|
+
const budgetTokens = pick("budgetTokens", 1);
|
|
329
|
+
const fullUnderTokens = pick("fullUnderTokens", 0);
|
|
330
|
+
return {
|
|
331
|
+
...budgetTokens ? { budgetTokens } : {},
|
|
332
|
+
...fullUnderTokens !== void 0 ? { fullUnderTokens } : {}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function contextProfileBudgets(manifest, profile) {
|
|
336
|
+
const table = manifest.context;
|
|
337
|
+
if (table === null || typeof table !== "object") return {};
|
|
338
|
+
const entries = table;
|
|
339
|
+
return {
|
|
340
|
+
...asBudgets(entries["default"]),
|
|
341
|
+
...profile ? asBudgets(entries[profile]) : {}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function mergedContextBudgets(merged, profile) {
|
|
345
|
+
const layered = ["user", "local", "project"].map((layer) => {
|
|
346
|
+
const manifest = merged.manifests[layer];
|
|
347
|
+
return manifest ? contextProfileBudgets(manifest, profile) : {};
|
|
348
|
+
});
|
|
349
|
+
return { ...layered[0], ...layered[1], ...layered[2] };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/kb-pins/errors.ts
|
|
353
|
+
var KbPinsMalformedError = class extends Error {
|
|
354
|
+
constructor(file, cause) {
|
|
355
|
+
super(`pin manifest ${file} is not readable (${cause}) \u2014 fix or remove it`);
|
|
356
|
+
this.name = "KbPinsMalformedError";
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
var KbBaseFrozenError = class extends Error {
|
|
360
|
+
constructor(bundlePath2, layer) {
|
|
361
|
+
super(
|
|
362
|
+
`${bundlePath2} is frozen (read-only) by this workspace's ${layer} pin manifest \u2014 re-pin with --unfreeze, or unpin, to change it`
|
|
363
|
+
);
|
|
364
|
+
this.name = "KbBaseFrozenError";
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// src/kb-pins/frozen.ts
|
|
369
|
+
var import_node_path3 = require("path");
|
|
370
|
+
|
|
371
|
+
// src/kb-pins/layers.ts
|
|
372
|
+
var import_promises = require("fs/promises");
|
|
373
|
+
var import_node_os = require("os");
|
|
374
|
+
var import_node_path2 = require("path");
|
|
375
|
+
|
|
376
|
+
// src/kb-pins/model.ts
|
|
377
|
+
var import_node_path = require("path");
|
|
378
|
+
var import_zod4 = require("zod");
|
|
379
|
+
var PINS_FILE = (0, import_node_path.join)(".strauss", "kb-pins.json");
|
|
380
|
+
var PINS_LOCAL_FILE = (0, import_node_path.join)(".strauss", "kb-pins.local.json");
|
|
381
|
+
var PIN_LAYERS = ["project", "local", "user"];
|
|
382
|
+
var pinSchema = import_zod4.z.object({
|
|
383
|
+
/** Relative to the manifest's root, so the file is committable. */
|
|
384
|
+
path: import_zod4.z.string().min(1),
|
|
385
|
+
pinnedAt: import_zod4.z.string().min(1).optional(),
|
|
386
|
+
/**
|
|
387
|
+
* How `context` renders this base. `full` preloads the whole base into
|
|
388
|
+
* the block regardless of the full-under threshold — for a base whose
|
|
389
|
+
* contents should simply be present, the way an ADR base should be —
|
|
390
|
+
* still answering to the block budget, with an index fallback that says
|
|
391
|
+
* so when it cannot fit. `index` never upgrades, whatever the threshold.
|
|
392
|
+
* Absent: the profile's full-under threshold decides. Invalid values
|
|
393
|
+
* degrade to absent rather than failing the manifest.
|
|
394
|
+
*/
|
|
395
|
+
mode: import_zod4.z.enum(["full", "index"]).optional().catch(void 0),
|
|
396
|
+
/**
|
|
397
|
+
* Context profiles this pin surfaces in (e.g. only at session-start,
|
|
398
|
+
* not per turn). Absent: every profile. A run without a profile sees
|
|
399
|
+
* every pin. A base that only matters to one skill is better loaded by
|
|
400
|
+
* that skill at point of use than pinned at all — pins are what every
|
|
401
|
+
* session should see.
|
|
402
|
+
*/
|
|
403
|
+
profiles: import_zod4.z.array(import_zod4.z.string()).optional().catch(void 0),
|
|
404
|
+
/**
|
|
405
|
+
* The base is concluded — a finished piece of research, a frozen ADR
|
|
406
|
+
* set. Write commands against it refuse while this workspace holds the
|
|
407
|
+
* pin, and `context` labels it read-only. Workspace policy, not base
|
|
408
|
+
* state: the base itself stays copyable and writable elsewhere.
|
|
409
|
+
*/
|
|
410
|
+
frozen: import_zod4.z.boolean().optional().catch(void 0)
|
|
411
|
+
}).passthrough();
|
|
412
|
+
var pinsManifestSchema = import_zod4.z.object({
|
|
413
|
+
pins: import_zod4.z.array(pinSchema).default([]),
|
|
414
|
+
/**
|
|
415
|
+
* Per-repo budgets for the `context` command, keyed by profile —
|
|
416
|
+
* `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
|
|
417
|
+
* them. Deliberately untyped here: a typo'd budget must degrade to the
|
|
418
|
+
* built-in default, not make the whole manifest unreadable and silence
|
|
419
|
+
* the index at every session start. `contextProfileBudgets` does the
|
|
420
|
+
* tolerant read.
|
|
421
|
+
*/
|
|
422
|
+
context: import_zod4.z.unknown().optional()
|
|
423
|
+
}).passthrough();
|
|
424
|
+
|
|
425
|
+
// src/kb-pins/layers.ts
|
|
426
|
+
function userRoot() {
|
|
427
|
+
return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
|
|
428
|
+
}
|
|
429
|
+
function layerRoot(workspaceDir, layer) {
|
|
430
|
+
return layer === "user" ? userRoot() : (0, import_node_path2.resolve)(workspaceDir);
|
|
431
|
+
}
|
|
432
|
+
function layerFile(workspaceDir, layer) {
|
|
433
|
+
return (0, import_node_path2.join)(
|
|
434
|
+
layerRoot(workspaceDir, layer),
|
|
435
|
+
layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
async function readPinsLayer(workspaceDir, layer) {
|
|
439
|
+
const file = layerFile(workspaceDir, layer);
|
|
440
|
+
let raw;
|
|
441
|
+
try {
|
|
442
|
+
raw = await (0, import_promises.readFile)(file, "utf8");
|
|
443
|
+
} catch {
|
|
444
|
+
return { pins: [] };
|
|
445
|
+
}
|
|
446
|
+
let parsed;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(raw);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
throw new KbPinsMalformedError(
|
|
451
|
+
file,
|
|
452
|
+
error instanceof Error ? error.message : "invalid JSON"
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const manifest = pinsManifestSchema.safeParse(parsed);
|
|
456
|
+
if (!manifest.success) {
|
|
457
|
+
throw new KbPinsMalformedError(
|
|
458
|
+
file,
|
|
459
|
+
manifest.error.issues[0]?.message ?? "invalid shape"
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
return manifest.data;
|
|
463
|
+
}
|
|
464
|
+
async function writePinsLayer(workspaceDir, layer, manifest) {
|
|
465
|
+
const file = layerFile(workspaceDir, layer);
|
|
466
|
+
await (0, import_promises.mkdir)((0, import_node_path2.dirname)(file), { recursive: true });
|
|
467
|
+
await (0, import_promises.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
|
|
468
|
+
`, "utf8");
|
|
469
|
+
}
|
|
470
|
+
function resolvePinPath(rootDir, path) {
|
|
471
|
+
return (0, import_node_path2.isAbsolute)(path) ? (0, import_node_path2.resolve)(path) : (0, import_node_path2.resolve)(rootDir, path.split("/").join(import_node_path2.sep));
|
|
472
|
+
}
|
|
473
|
+
function storablePath(rootDir, bundlePath2) {
|
|
474
|
+
const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(rootDir), (0, import_node_path2.resolve)(bundlePath2));
|
|
475
|
+
return (rel === "" ? "." : rel).split(import_node_path2.sep).join("/");
|
|
476
|
+
}
|
|
477
|
+
async function readMergedPins(workspaceDir) {
|
|
478
|
+
const manifests = {};
|
|
479
|
+
const pins = [];
|
|
480
|
+
const seen = /* @__PURE__ */ new Set();
|
|
481
|
+
for (const layer of PIN_LAYERS) {
|
|
482
|
+
let manifest;
|
|
483
|
+
try {
|
|
484
|
+
manifest = await readPinsLayer(workspaceDir, layer);
|
|
485
|
+
} catch {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
manifests[layer] = manifest;
|
|
489
|
+
const root = layerRoot(workspaceDir, layer);
|
|
490
|
+
for (const entry of manifest.pins) {
|
|
491
|
+
const absolutePath = resolvePinPath(root, entry.path);
|
|
492
|
+
if (seen.has(absolutePath)) continue;
|
|
493
|
+
seen.add(absolutePath);
|
|
494
|
+
pins.push({ ...entry, layer, absolutePath });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { pins, manifests };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/kb-pins/frozen.ts
|
|
501
|
+
async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
|
|
502
|
+
const merged = await readMergedPins(workspaceDir);
|
|
503
|
+
const absolute = (0, import_node_path3.resolve)(bundlePath2);
|
|
504
|
+
const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
|
|
505
|
+
if (pin?.frozen === true) {
|
|
506
|
+
throw new KbBaseFrozenError(pin.path, pin.layer);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/kb-pins/list.ts
|
|
511
|
+
async function listPins(store, workspaceDir) {
|
|
512
|
+
const merged = await readMergedPins(workspaceDir);
|
|
513
|
+
return Promise.all(
|
|
514
|
+
merged.pins.map(async (entry) => {
|
|
515
|
+
const records = await store.list(entry.absolutePath);
|
|
516
|
+
return {
|
|
517
|
+
path: entry.path,
|
|
518
|
+
layer: entry.layer,
|
|
519
|
+
pinnedAt: entry.pinnedAt ?? null,
|
|
520
|
+
absolutePath: entry.absolutePath,
|
|
521
|
+
valid: records.length > 0,
|
|
522
|
+
recordCount: records.length,
|
|
523
|
+
mode: entry.mode ?? null,
|
|
524
|
+
profiles: entry.profiles ?? null,
|
|
525
|
+
frozen: entry.frozen === true
|
|
526
|
+
};
|
|
527
|
+
})
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/kb-pins/pin.ts
|
|
532
|
+
async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
|
|
533
|
+
const layer = options.layer ?? "project";
|
|
534
|
+
const root = layerRoot(workspaceDir, layer);
|
|
535
|
+
const manifest = await readPinsLayer(workspaceDir, layer);
|
|
536
|
+
const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
|
|
537
|
+
const existing = manifest.pins.find(
|
|
538
|
+
(entry2) => resolvePinPath(root, entry2.path) === absolute
|
|
539
|
+
);
|
|
540
|
+
const records = await store.list(absolute);
|
|
541
|
+
const warning = records.length === 0 ? `no records found at ${absolute} \u2014 pinned anyway; bases are routinely pinned before they are populated` : void 0;
|
|
542
|
+
const fields = {
|
|
543
|
+
...options.mode ? { mode: options.mode } : {},
|
|
544
|
+
...options.profiles?.length ? { profiles: options.profiles } : {},
|
|
545
|
+
...options.frozen !== void 0 ? { frozen: options.frozen } : {}
|
|
546
|
+
};
|
|
547
|
+
if (existing) {
|
|
548
|
+
const updated = { ...existing, ...fields };
|
|
549
|
+
if (Object.keys(fields).length) {
|
|
550
|
+
await writePinsLayer(workspaceDir, layer, {
|
|
551
|
+
...manifest,
|
|
552
|
+
pins: manifest.pins.map(
|
|
553
|
+
(entry2) => entry2 === existing ? updated : entry2
|
|
554
|
+
)
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
path: existing.path,
|
|
559
|
+
layer,
|
|
560
|
+
pinnedAt: existing.pinnedAt ?? at,
|
|
561
|
+
alreadyPinned: true,
|
|
562
|
+
...updated.mode ? { mode: updated.mode } : {},
|
|
563
|
+
...updated.profiles ? { profiles: updated.profiles } : {},
|
|
564
|
+
...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
|
|
565
|
+
...warning ? { warning } : {}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const entry = {
|
|
569
|
+
path: storablePath(root, bundlePath2),
|
|
570
|
+
pinnedAt: at,
|
|
571
|
+
...fields
|
|
572
|
+
};
|
|
573
|
+
await writePinsLayer(workspaceDir, layer, {
|
|
574
|
+
...manifest,
|
|
575
|
+
pins: [...manifest.pins, entry]
|
|
576
|
+
});
|
|
577
|
+
return {
|
|
578
|
+
path: entry.path,
|
|
579
|
+
layer,
|
|
580
|
+
pinnedAt: at,
|
|
581
|
+
alreadyPinned: false,
|
|
582
|
+
...fields,
|
|
583
|
+
...warning ? { warning } : {}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/kb-pins/unpin.ts
|
|
588
|
+
var import_node_path4 = require("path");
|
|
589
|
+
async function unpinBase(workspaceDir, bundlePath2) {
|
|
590
|
+
const layers = [];
|
|
591
|
+
for (const layer of PIN_LAYERS) {
|
|
592
|
+
const root = layerRoot(workspaceDir, layer);
|
|
593
|
+
let manifest;
|
|
594
|
+
try {
|
|
595
|
+
manifest = await readPinsLayer(workspaceDir, layer);
|
|
596
|
+
} catch {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
|
|
600
|
+
const kept = manifest.pins.filter(
|
|
601
|
+
(entry) => resolvePinPath(root, entry.path) !== absolute
|
|
602
|
+
);
|
|
603
|
+
if (kept.length !== manifest.pins.length) {
|
|
604
|
+
await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
|
|
605
|
+
layers.push(layer);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
path: storablePath((0, import_node_path4.resolve)(workspaceDir), bundlePath2),
|
|
610
|
+
removed: layers.length > 0,
|
|
611
|
+
layers
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/commands/model.ts
|
|
616
|
+
var import_zod5 = require("zod");
|
|
617
|
+
var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
|
|
618
|
+
var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
|
|
619
|
+
function define(command) {
|
|
620
|
+
return command;
|
|
621
|
+
}
|
|
622
|
+
function argvFlag(argv, name) {
|
|
623
|
+
const at = argv.indexOf(name);
|
|
624
|
+
return at !== -1 ? argv[at + 1] : void 0;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/commands/answer.ts
|
|
628
|
+
var answerCommand = define({
|
|
629
|
+
name: "answer",
|
|
630
|
+
tool: "kb_answer",
|
|
631
|
+
usage: "answer <concept-id> <answer...>",
|
|
632
|
+
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.",
|
|
633
|
+
input: import_zod6.z.object({ bundlePath, conceptId, answer: import_zod6.z.string().min(1) }),
|
|
634
|
+
fromArgv: (argv, path) => ({
|
|
635
|
+
bundlePath: path,
|
|
636
|
+
conceptId: argv[1],
|
|
637
|
+
answer: argv.slice(2).join(" ").trim()
|
|
638
|
+
}),
|
|
639
|
+
run: async ({ store, actor }, { bundlePath: path, conceptId: id, answer }) => {
|
|
640
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
641
|
+
const record = await store.answer(path, id, answer, actor);
|
|
642
|
+
return { conceptId: record.conceptId };
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// src/commands/context.ts
|
|
647
|
+
var import_zod7 = require("zod");
|
|
648
|
+
|
|
649
|
+
// src/kb-context.ts
|
|
650
|
+
var import_promises2 = require("fs/promises");
|
|
651
|
+
|
|
652
|
+
// src/adjudicate.ts
|
|
653
|
+
var STANDING = {
|
|
654
|
+
accepted: "current",
|
|
655
|
+
resolved: "current",
|
|
656
|
+
draft: "unsettled",
|
|
657
|
+
proposed: "unsettled",
|
|
658
|
+
open: "open",
|
|
659
|
+
rejected: "rejected",
|
|
660
|
+
superseded: "superseded"
|
|
661
|
+
};
|
|
662
|
+
function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
|
|
663
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
664
|
+
return hits.map((record) => {
|
|
665
|
+
const status = record.frontmatter.strauss_status;
|
|
666
|
+
const warnings = [];
|
|
667
|
+
let heads = [];
|
|
668
|
+
if (status === "superseded") {
|
|
669
|
+
const resolved = resolveHeads(record, byId);
|
|
670
|
+
heads = resolved.heads;
|
|
671
|
+
warnings.push(...resolved.warnings);
|
|
672
|
+
if (heads.length) {
|
|
673
|
+
warnings.push({
|
|
674
|
+
kind: "superseded",
|
|
675
|
+
by: heads.map((head) => head.conceptId)
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
} else if (status === "rejected") {
|
|
679
|
+
warnings.push({ kind: "rejected" });
|
|
680
|
+
} else if (status === "draft" || status === "proposed") {
|
|
681
|
+
warnings.push({ kind: "unsettled", status });
|
|
682
|
+
} else if (status === "open") {
|
|
683
|
+
warnings.push({ kind: "unresolved-question" });
|
|
684
|
+
}
|
|
685
|
+
const staleAfter = record.frontmatter.stale_after;
|
|
686
|
+
if (staleAfter && Date.parse(staleAfter) < now.getTime()) {
|
|
687
|
+
warnings.push({ kind: "stale", staleAfter });
|
|
688
|
+
}
|
|
689
|
+
if (!record.frontmatter.verified?.length) {
|
|
690
|
+
warnings.push({ kind: "unverified" });
|
|
691
|
+
}
|
|
692
|
+
return { record, standing: STANDING[status], heads, warnings };
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
function resolveHeads(from, byId) {
|
|
696
|
+
const warnings = [];
|
|
697
|
+
const heads = /* @__PURE__ */ new Map();
|
|
698
|
+
const seen = /* @__PURE__ */ new Set([from.conceptId]);
|
|
699
|
+
const queue = [from];
|
|
700
|
+
while (queue.length) {
|
|
701
|
+
const current = queue.shift();
|
|
702
|
+
const next = successors(current, byId);
|
|
703
|
+
for (const missing of next.missing) {
|
|
704
|
+
warnings.push({ kind: "broken-chain", missing });
|
|
705
|
+
}
|
|
706
|
+
if (!next.records.length) {
|
|
707
|
+
if (current.conceptId !== from.conceptId)
|
|
708
|
+
heads.set(current.conceptId, current);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
for (const record of next.records) {
|
|
712
|
+
if (seen.has(record.conceptId)) {
|
|
713
|
+
warnings.push({ kind: "chain-cycle", through: [...seen] });
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
seen.add(record.conceptId);
|
|
717
|
+
queue.push(record);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (heads.size > 1) {
|
|
721
|
+
warnings.push({ kind: "forked-chain", heads: [...heads.keys()] });
|
|
722
|
+
}
|
|
723
|
+
return { heads: [...heads.values()], warnings };
|
|
724
|
+
}
|
|
725
|
+
function successors(record, byId) {
|
|
726
|
+
const ids = /* @__PURE__ */ new Set();
|
|
727
|
+
const forward = record.frontmatter.strauss_superseded_by;
|
|
728
|
+
if (forward) ids.add(forward);
|
|
729
|
+
for (const [id, candidate] of byId) {
|
|
730
|
+
if (candidate.frontmatter.strauss_supersedes?.includes(record.conceptId)) {
|
|
731
|
+
ids.add(id);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const records = [];
|
|
735
|
+
const missing = [];
|
|
736
|
+
for (const id of ids) {
|
|
737
|
+
const found = byId.get(id);
|
|
738
|
+
if (found) records.push(found);
|
|
739
|
+
else missing.push(id);
|
|
740
|
+
}
|
|
741
|
+
return { records, missing };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/kb-index.ts
|
|
745
|
+
var INDEX_FILE = "INDEX.md";
|
|
746
|
+
var HEADING = "# KB Index";
|
|
747
|
+
function renderIndex(records) {
|
|
748
|
+
const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
|
|
749
|
+
return `${HEADING}
|
|
750
|
+
|
|
751
|
+
${lines.join("\n")}
|
|
752
|
+
`;
|
|
753
|
+
}
|
|
754
|
+
function renderIndexLine(record) {
|
|
755
|
+
const { frontmatter: fm } = record;
|
|
756
|
+
const parts = [fm.type, fm.strauss_status];
|
|
757
|
+
if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
|
|
758
|
+
if (fm.description) parts.push(fm.description);
|
|
759
|
+
return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
|
|
760
|
+
}
|
|
761
|
+
function indexIsStale(stored, expected) {
|
|
762
|
+
return stored !== expected;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/kb-context.ts
|
|
766
|
+
var HEADING2 = "## Knowledge bases (pinned)";
|
|
767
|
+
var DEFAULT_CONTEXT_BUDGET = 4e3;
|
|
768
|
+
var CONTEXT_PROFILES = {
|
|
769
|
+
"session-start": { fullUnderTokens: 1500 },
|
|
770
|
+
compact: { budgetTokens: 2500 },
|
|
771
|
+
turn: { budgetTokens: 2500 }
|
|
772
|
+
};
|
|
773
|
+
function approxTokens(text) {
|
|
774
|
+
return Math.ceil(text.length / 4);
|
|
775
|
+
}
|
|
776
|
+
function preamble() {
|
|
777
|
+
return [
|
|
778
|
+
HEADING2,
|
|
779
|
+
"",
|
|
780
|
+
"What follows is an index of this workspace's pinned knowledge bases \u2014",
|
|
781
|
+
"concept ids, titles and standing only. The record bodies are NOT in this",
|
|
782
|
+
"context.",
|
|
783
|
+
"",
|
|
784
|
+
"Consult records only through the strauss-kb MCP tools: `kb_load` (the",
|
|
785
|
+
"preferred first call), `kb_query`, and `kb_trace`, passing the",
|
|
786
|
+
"`bundlePath` listed with each base. Do not read record files directly:",
|
|
787
|
+
"a raw file read bypasses supersession resolution, and a superseded or",
|
|
788
|
+
"rejected record file reads exactly like a current one \u2014 only the store",
|
|
789
|
+
"resolves chains and standing.",
|
|
790
|
+
"",
|
|
791
|
+
"KB content loaded earlier in a long session may have been compacted",
|
|
792
|
+
"away. Before answering a question one of these bases governs, load it",
|
|
793
|
+
"again at the point of use \u2014 reloading a small base costs a few thousand",
|
|
794
|
+
"tokens."
|
|
795
|
+
].join("\n");
|
|
796
|
+
}
|
|
797
|
+
async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens) {
|
|
798
|
+
const bundle = await store.list(absolutePath);
|
|
799
|
+
if (bundle.length === 0) {
|
|
800
|
+
return {
|
|
801
|
+
path,
|
|
802
|
+
absolutePath,
|
|
803
|
+
mode: "empty",
|
|
804
|
+
body: "No readable records yet \u2014 pinned ahead of being populated."
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
|
|
808
|
+
let degradedFrom;
|
|
809
|
+
if (fullCap > 0) {
|
|
810
|
+
const full = await store.load(absolutePath, {
|
|
811
|
+
budgetTokens: fullCap
|
|
812
|
+
});
|
|
813
|
+
if (!full.loaded && pinMode === "full") {
|
|
814
|
+
degradedFrom = { approxTokens: full.approxTokens };
|
|
815
|
+
}
|
|
816
|
+
if (full.loaded) {
|
|
817
|
+
const records = full.records.map(
|
|
818
|
+
(hit) => [
|
|
819
|
+
`#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
|
|
820
|
+
"",
|
|
821
|
+
hit.record.body.trim()
|
|
822
|
+
].join("\n")
|
|
823
|
+
);
|
|
824
|
+
const superseded2 = full.superseded.map(
|
|
825
|
+
(entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
|
|
826
|
+
);
|
|
827
|
+
return {
|
|
828
|
+
path,
|
|
829
|
+
absolutePath,
|
|
830
|
+
mode: "full",
|
|
831
|
+
body: [
|
|
832
|
+
...records,
|
|
833
|
+
...superseded2.length ? [
|
|
834
|
+
"#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
|
|
835
|
+
...superseded2
|
|
836
|
+
] : []
|
|
837
|
+
].join("\n\n")
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const adjudicated = adjudicate(bundle, bundle);
|
|
842
|
+
const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
|
|
843
|
+
const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
|
|
844
|
+
(hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
|
|
845
|
+
);
|
|
846
|
+
return {
|
|
847
|
+
path,
|
|
848
|
+
absolutePath,
|
|
849
|
+
mode: "index",
|
|
850
|
+
body: [...lines, ...superseded].join("\n"),
|
|
851
|
+
...degradedFrom ? { degradedFrom } : {}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
async function buildContext(store, workspaceDir, options = {}) {
|
|
855
|
+
const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
|
|
856
|
+
let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
|
|
857
|
+
let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
|
|
858
|
+
const merged = await readMergedPins(workspaceDir);
|
|
859
|
+
const fromManifest = mergedContextBudgets(merged, options.profile);
|
|
860
|
+
budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
|
|
861
|
+
fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
|
|
862
|
+
const pins = merged.pins.filter(
|
|
863
|
+
(pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
|
|
864
|
+
);
|
|
865
|
+
if (pins.length === 0) {
|
|
866
|
+
return {
|
|
867
|
+
block: "",
|
|
868
|
+
refused: false,
|
|
869
|
+
approxTokens: 0,
|
|
870
|
+
budgetTokens,
|
|
871
|
+
bases: []
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
const sections = await Promise.all(
|
|
875
|
+
pins.map(async (pin) => ({
|
|
876
|
+
section: await renderBase(
|
|
877
|
+
store,
|
|
878
|
+
pin.path,
|
|
879
|
+
pin.absolutePath,
|
|
880
|
+
fullUnderTokens,
|
|
881
|
+
pin.mode,
|
|
882
|
+
budgetTokens
|
|
883
|
+
),
|
|
884
|
+
frozen: pin.frozen === true
|
|
885
|
+
}))
|
|
886
|
+
);
|
|
887
|
+
const modeLabel = {
|
|
888
|
+
index: "index only \u2014 record bodies are not here",
|
|
889
|
+
full: "full records \u2014 this base arrives whole",
|
|
890
|
+
empty: "empty"
|
|
891
|
+
};
|
|
892
|
+
for (const { section } of sections) {
|
|
893
|
+
if (section.degradedFrom) {
|
|
894
|
+
options.warn?.({
|
|
895
|
+
operation: "kb.context.full-pin-degraded",
|
|
896
|
+
path: section.path,
|
|
897
|
+
approxTokens: section.degradedFrom.approxTokens,
|
|
898
|
+
budgetTokens
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const rendered = sections.map(({ section, frozen }) => {
|
|
903
|
+
const label = section.degradedFrom ? `index only \u2014 pinned \`mode: full\`, but its ~${section.degradedFrom.approxTokens} tokens exceed this block's ${budgetTokens}-token budget; kb_load it directly (load's budget is separate), or raise this profile's budget` : modeLabel[section.mode];
|
|
904
|
+
return [
|
|
905
|
+
`### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
|
|
906
|
+
"",
|
|
907
|
+
`bundlePath: \`${section.absolutePath}\``,
|
|
908
|
+
"",
|
|
909
|
+
section.body
|
|
910
|
+
].join("\n");
|
|
911
|
+
});
|
|
912
|
+
const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
|
|
913
|
+
const bases = sections.map(({ section }) => ({
|
|
914
|
+
path: section.path,
|
|
915
|
+
absolutePath: section.absolutePath,
|
|
916
|
+
approxTokens: approxTokens(section.body)
|
|
917
|
+
}));
|
|
918
|
+
const total = approxTokens(block);
|
|
919
|
+
if (total > budgetTokens) {
|
|
920
|
+
options.warn?.({
|
|
921
|
+
operation: "kb.context.refused",
|
|
922
|
+
approxTokens: total,
|
|
923
|
+
budgetTokens,
|
|
924
|
+
bases: bases.map((base) => base.path)
|
|
925
|
+
});
|
|
926
|
+
const refusal = [
|
|
927
|
+
HEADING2,
|
|
928
|
+
"",
|
|
929
|
+
`The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
|
|
930
|
+
"budget, and was not emitted \u2014 a truncated index is indistinguishable",
|
|
931
|
+
"from a complete one. The pinned bases:",
|
|
932
|
+
"",
|
|
933
|
+
...bases.map(
|
|
934
|
+
(base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
|
|
935
|
+
),
|
|
936
|
+
"",
|
|
937
|
+
"For the question at hand, read what you need now \u2014 `kb_load` a base",
|
|
938
|
+
"(its own budget is separate), or `kb_index` for one base's shape.",
|
|
939
|
+
"",
|
|
940
|
+
"To bring this block back under budget, in order of preference:",
|
|
941
|
+
"- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
|
|
942
|
+
"- force a large base to index lines: `strauss-kb pin <path> --mode index`",
|
|
943
|
+
"- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
|
|
944
|
+
"- raise this profile's budget under `context` in .strauss/kb-pins.json",
|
|
945
|
+
"- unpin what no session actually needs",
|
|
946
|
+
""
|
|
947
|
+
].join("\n");
|
|
948
|
+
return {
|
|
949
|
+
block: refusal,
|
|
950
|
+
refused: true,
|
|
951
|
+
approxTokens: total,
|
|
952
|
+
budgetTokens,
|
|
953
|
+
bases
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
return { block, refused: false, approxTokens: total, budgetTokens, bases };
|
|
957
|
+
}
|
|
958
|
+
function toHookJson(block, event) {
|
|
959
|
+
return JSON.stringify({
|
|
960
|
+
hookSpecificOutput: {
|
|
961
|
+
hookEventName: event,
|
|
962
|
+
additionalContext: block
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
|
|
967
|
+
var CONTEXT_END = "<!-- strauss-kb:end -->";
|
|
968
|
+
async function syncInstructions(file, block) {
|
|
969
|
+
const existing = await (0, import_promises2.readFile)(file, "utf8").catch(() => null);
|
|
970
|
+
const region = block ? `${CONTEXT_BEGIN}
|
|
971
|
+
${block.trim()}
|
|
972
|
+
${CONTEXT_END}` : null;
|
|
973
|
+
if (existing === null) {
|
|
974
|
+
if (!region) return { file, action: "unchanged" };
|
|
975
|
+
await (0, import_promises2.writeFile)(file, `${region}
|
|
976
|
+
`, "utf8");
|
|
977
|
+
return { file, action: "created" };
|
|
978
|
+
}
|
|
979
|
+
const begin = existing.indexOf(CONTEXT_BEGIN);
|
|
980
|
+
const end = existing.indexOf(CONTEXT_END);
|
|
981
|
+
if (begin !== -1 && end !== -1 && end >= begin) {
|
|
982
|
+
const before = existing.slice(0, begin);
|
|
983
|
+
const after = existing.slice(end + CONTEXT_END.length);
|
|
984
|
+
const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
|
|
985
|
+
if (next === existing) return { file, action: "unchanged" };
|
|
986
|
+
await (0, import_promises2.writeFile)(file, next, "utf8");
|
|
987
|
+
return { file, action: region ? "replaced" : "removed" };
|
|
988
|
+
}
|
|
989
|
+
if (!region) return { file, action: "unchanged" };
|
|
990
|
+
await (0, import_promises2.writeFile)(
|
|
991
|
+
file,
|
|
992
|
+
`${existing.replace(/\n*$/, "\n\n")}${region}
|
|
993
|
+
`,
|
|
994
|
+
"utf8"
|
|
995
|
+
);
|
|
996
|
+
return { file, action: "appended" };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/commands/context.ts
|
|
1000
|
+
var contextCommand = define({
|
|
1001
|
+
name: "context",
|
|
1002
|
+
tool: "kb_context",
|
|
1003
|
+
usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
|
|
1004
|
+
description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
|
|
1005
|
+
input: import_zod7.z.object({
|
|
1006
|
+
budgetTokens: import_zod7.z.number().int().positive().optional().describe(
|
|
1007
|
+
"Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
|
|
1008
|
+
),
|
|
1009
|
+
fullUnderTokens: import_zod7.z.number().int().positive().optional().describe(
|
|
1010
|
+
"Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
|
|
1011
|
+
),
|
|
1012
|
+
profile: import_zod7.z.string().optional().describe(
|
|
1013
|
+
"Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
|
|
1014
|
+
),
|
|
1015
|
+
format: import_zod7.z.enum(["markdown", "json"]).optional().describe(
|
|
1016
|
+
"CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
|
|
1017
|
+
),
|
|
1018
|
+
event: import_zod7.z.string().optional().describe(
|
|
1019
|
+
"hookEventName stamped into the JSON envelope. Only meaningful with format=json."
|
|
1020
|
+
)
|
|
1021
|
+
}),
|
|
1022
|
+
fromArgv: (argv) => {
|
|
1023
|
+
const budget = argvFlag(argv, "--budget");
|
|
1024
|
+
const fullUnder = argvFlag(argv, "--full-under");
|
|
1025
|
+
const profile = argvFlag(argv, "--profile");
|
|
1026
|
+
const format = argvFlag(argv, "--format");
|
|
1027
|
+
const event = argvFlag(argv, "--event");
|
|
1028
|
+
return {
|
|
1029
|
+
...budget ? { budgetTokens: Number(budget) } : {},
|
|
1030
|
+
...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
|
|
1031
|
+
...profile ? { profile } : {},
|
|
1032
|
+
...format ? { format } : {},
|
|
1033
|
+
...event ? { event } : {}
|
|
1034
|
+
};
|
|
1035
|
+
},
|
|
1036
|
+
run: async ({ store }, { budgetTokens, fullUnderTokens, profile, format, event }) => {
|
|
1037
|
+
const result = await buildContext(store, process.cwd(), {
|
|
1038
|
+
...budgetTokens ? { budgetTokens } : {},
|
|
1039
|
+
...fullUnderTokens ? { fullUnderTokens } : {},
|
|
1040
|
+
...profile ? { profile } : {},
|
|
1041
|
+
// Degradations — a full pin that could not fit, a refused block — go
|
|
1042
|
+
// to stderr as well as into the block itself: stderr is diagnostics on
|
|
1043
|
+
// both surfaces (hooks discard it, MCP logs it), so an operator can
|
|
1044
|
+
// see budget pressure without reading injected context.
|
|
1045
|
+
warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
|
|
1046
|
+
`)
|
|
1047
|
+
});
|
|
1048
|
+
if (!result.block) return "";
|
|
1049
|
+
return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// src/commands/list.ts
|
|
1054
|
+
var import_zod8 = require("zod");
|
|
1055
|
+
var listCommand = define({
|
|
1056
|
+
name: "list",
|
|
1057
|
+
tool: "kb_list",
|
|
1058
|
+
usage: "list [type]",
|
|
1059
|
+
description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
|
|
1060
|
+
input: import_zod8.z.object({ bundlePath, type: import_zod8.z.enum(KB_RECORD_TYPES).optional() }),
|
|
1061
|
+
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
1062
|
+
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
1063
|
+
conceptId: record.conceptId,
|
|
1064
|
+
title: record.frontmatter.title ?? null,
|
|
1065
|
+
description: record.frontmatter.description ?? null,
|
|
1066
|
+
status: record.frontmatter.strauss_status,
|
|
1067
|
+
anchors: record.frontmatter.strauss_anchors ?? []
|
|
1068
|
+
}))
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// src/commands/load.ts
|
|
1072
|
+
var import_zod9 = require("zod");
|
|
1073
|
+
var loadCommand = define({
|
|
1074
|
+
name: "load",
|
|
1075
|
+
tool: "kb_load",
|
|
1076
|
+
usage: "load [type] [--budget N]",
|
|
1077
|
+
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. Call at the point of use, not once per session: a base loaded early is summarised away by compaction, so if the visible context holds no records from this base and the question at hand is one it might govern, load before answering \u2014 never conclude nothing was decided from a context with no KB content in it. This tool (with kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
|
|
1078
|
+
input: import_zod9.z.object({
|
|
1079
|
+
bundlePath,
|
|
1080
|
+
type: import_zod9.z.enum(KB_RECORD_TYPES).optional(),
|
|
1081
|
+
budgetTokens: import_zod9.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
|
|
1082
|
+
}),
|
|
1083
|
+
fromArgv: (argv, path) => {
|
|
1084
|
+
const budget = argvFlag(argv, "--budget");
|
|
1085
|
+
return {
|
|
1086
|
+
bundlePath: path,
|
|
1087
|
+
...argv[1] && argv[1] !== "--budget" ? { type: argv[1] } : {},
|
|
1088
|
+
...budget ? { budgetTokens: Number(budget) } : {}
|
|
1089
|
+
};
|
|
1090
|
+
},
|
|
1091
|
+
run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
|
|
1092
|
+
const result = await store.load(path, {
|
|
1093
|
+
...type ? { type } : {},
|
|
1094
|
+
...budgetTokens ? { budgetTokens } : {}
|
|
1095
|
+
});
|
|
1096
|
+
if (!result.loaded) return result;
|
|
1097
|
+
return {
|
|
1098
|
+
...result,
|
|
1099
|
+
records: result.records.map((hit) => ({
|
|
1100
|
+
conceptId: hit.record.conceptId,
|
|
1101
|
+
title: hit.record.frontmatter.title ?? null,
|
|
1102
|
+
standing: hit.standing,
|
|
1103
|
+
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
1104
|
+
warnings: hit.warnings,
|
|
1105
|
+
anchors: hit.record.frontmatter.strauss_anchors ?? [],
|
|
1106
|
+
body: hit.record.body
|
|
1107
|
+
}))
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// src/commands/log.ts
|
|
1113
|
+
var import_zod10 = require("zod");
|
|
1114
|
+
var logCommand = define({
|
|
1115
|
+
name: "log",
|
|
1116
|
+
tool: "kb_log",
|
|
1117
|
+
usage: "log",
|
|
1118
|
+
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.",
|
|
1119
|
+
input: import_zod10.z.object({ bundlePath }),
|
|
1120
|
+
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1121
|
+
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// src/commands/no-decision.ts
|
|
1125
|
+
var import_zod11 = require("zod");
|
|
1126
|
+
var noDecisionCommand = define({
|
|
1127
|
+
name: "no-decision",
|
|
1128
|
+
tool: "kb_no_decision",
|
|
1129
|
+
usage: "no-decision <reason...>",
|
|
1130
|
+
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.',
|
|
1131
|
+
input: import_zod11.z.object({ bundlePath, reason: import_zod11.z.string().min(1) }),
|
|
1132
|
+
fromArgv: (argv, path) => ({
|
|
1133
|
+
bundlePath: path,
|
|
1134
|
+
reason: argv.slice(1).join(" ").trim()
|
|
1135
|
+
}),
|
|
1136
|
+
run: async ({ store, actor, now }, { bundlePath: path, reason }) => {
|
|
1137
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
1138
|
+
const record = await store.write(
|
|
1139
|
+
path,
|
|
1140
|
+
{ ...composeNoDecisionRecord(reason, actor, now()), overwrite: true },
|
|
1141
|
+
actor
|
|
1142
|
+
);
|
|
1143
|
+
return { conceptId: record.conceptId };
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// src/commands/pin.ts
|
|
1148
|
+
var import_zod12 = require("zod");
|
|
1149
|
+
var pinCommand = define({
|
|
1150
|
+
name: "pin",
|
|
1151
|
+
tool: "kb_pin",
|
|
1152
|
+
usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
|
|
1153
|
+
description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
|
|
1154
|
+
input: import_zod12.z.object({
|
|
1155
|
+
bundlePath,
|
|
1156
|
+
mode: import_zod12.z.enum(["full", "index"]).optional().describe(
|
|
1157
|
+
"full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
|
|
1158
|
+
),
|
|
1159
|
+
profiles: import_zod12.z.array(import_zod12.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
|
|
1160
|
+
layer: import_zod12.z.enum(["project", "local", "user"]).optional().describe(
|
|
1161
|
+
"Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
|
|
1162
|
+
),
|
|
1163
|
+
frozen: import_zod12.z.boolean().optional().describe(
|
|
1164
|
+
"true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
|
|
1165
|
+
)
|
|
1166
|
+
}),
|
|
1167
|
+
fromArgv: (argv, path) => {
|
|
1168
|
+
const positional = argv[1] && !argv[1].startsWith("--") ? argv[1] : path;
|
|
1169
|
+
const mode = argvFlag(argv, "--mode");
|
|
1170
|
+
const profiles = argvFlag(argv, "--profiles");
|
|
1171
|
+
const layer = argv.includes("--user") ? "user" : argv.includes("--local") ? "local" : void 0;
|
|
1172
|
+
const frozen = argv.includes("--frozen") ? true : argv.includes("--unfreeze") ? false : void 0;
|
|
1173
|
+
return {
|
|
1174
|
+
bundlePath: positional,
|
|
1175
|
+
...mode ? { mode } : {},
|
|
1176
|
+
...profiles ? {
|
|
1177
|
+
profiles: profiles.split(",").map((p) => p.trim()).filter(Boolean)
|
|
1178
|
+
} : {},
|
|
1179
|
+
...layer ? { layer } : {},
|
|
1180
|
+
...frozen !== void 0 ? { frozen } : {}
|
|
1181
|
+
};
|
|
1182
|
+
},
|
|
1183
|
+
run: ({ store, now }, { bundlePath: path, mode, profiles, layer, frozen }) => pinBase(store, process.cwd(), path, now(), {
|
|
1184
|
+
...mode ? { mode } : {},
|
|
1185
|
+
...profiles ? { profiles } : {},
|
|
1186
|
+
...layer ? { layer } : {},
|
|
1187
|
+
...frozen !== void 0 ? { frozen } : {}
|
|
1188
|
+
})
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// src/commands/pins.ts
|
|
1192
|
+
var import_zod13 = require("zod");
|
|
1193
|
+
var pinsCommand = define({
|
|
1194
|
+
name: "pins",
|
|
1195
|
+
tool: "kb_pins",
|
|
1196
|
+
usage: "pins",
|
|
1197
|
+
description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
|
|
1198
|
+
input: import_zod13.z.object({}),
|
|
1199
|
+
fromArgv: () => ({}),
|
|
1200
|
+
run: ({ store }) => listPins(store, process.cwd())
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// src/commands/query.ts
|
|
1204
|
+
var import_zod14 = require("zod");
|
|
1205
|
+
var queryCommand = define({
|
|
1206
|
+
name: "query",
|
|
1207
|
+
tool: "kb_query",
|
|
1208
|
+
usage: "query <text...>",
|
|
1209
|
+
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 kb_load when the base fits its budget: on this package's measurements, a reader holding the whole base answered eight of nine questions whose wording appears in no record, where embedding search answered four. Never read record files directly \u2014 this tool (with kb_load and kb_trace) is the only supported way to read a base; a file read bypasses supersession resolution and returns replaced records as if current.",
|
|
1210
|
+
input: import_zod14.z.object({
|
|
1211
|
+
bundlePath,
|
|
1212
|
+
text: import_zod14.z.string().optional(),
|
|
1213
|
+
type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
|
|
1214
|
+
includeNonCurrent: import_zod14.z.boolean().optional()
|
|
1215
|
+
}),
|
|
1216
|
+
fromArgv: (argv, path) => ({
|
|
1217
|
+
bundlePath: path,
|
|
1218
|
+
text: argv.slice(1).join(" ").trim(),
|
|
1219
|
+
includeNonCurrent: true
|
|
1220
|
+
}),
|
|
1221
|
+
run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
|
|
1222
|
+
...type ? { type } : {},
|
|
1223
|
+
includeNonCurrent: includeNonCurrent === true
|
|
1224
|
+
})).map((hit) => ({
|
|
1225
|
+
conceptId: hit.record.conceptId,
|
|
1226
|
+
title: hit.record.frontmatter.title ?? null,
|
|
1227
|
+
description: hit.record.frontmatter.description ?? null,
|
|
1228
|
+
standing: hit.standing,
|
|
1229
|
+
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
1230
|
+
warnings: hit.warnings,
|
|
1231
|
+
body: hit.record.body
|
|
1232
|
+
}))
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// src/commands/read-index.ts
|
|
1236
|
+
var import_zod15 = require("zod");
|
|
1237
|
+
var readIndexCommand = define({
|
|
1238
|
+
name: "index",
|
|
1239
|
+
tool: "kb_index",
|
|
1240
|
+
usage: "index",
|
|
1241
|
+
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. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
|
|
1242
|
+
input: import_zod15.z.object({ bundlePath }),
|
|
1243
|
+
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1244
|
+
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// src/commands/schema.ts
|
|
1248
|
+
var import_zod18 = require("zod");
|
|
1249
|
+
|
|
307
1250
|
// src/json-schema.ts
|
|
308
|
-
var
|
|
1251
|
+
var import_zod17 = require("zod");
|
|
309
1252
|
|
|
310
1253
|
// src/kb-log.ts
|
|
311
|
-
var
|
|
1254
|
+
var import_zod16 = require("zod");
|
|
312
1255
|
var LOG_FILE = "log.jsonl";
|
|
313
|
-
var kbLogEntrySchema =
|
|
314
|
-
at:
|
|
315
|
-
by:
|
|
316
|
-
operation:
|
|
317
|
-
conceptId:
|
|
1256
|
+
var kbLogEntrySchema = import_zod16.z.object({
|
|
1257
|
+
at: import_zod16.z.string().min(1),
|
|
1258
|
+
by: import_zod16.z.string().min(1),
|
|
1259
|
+
operation: import_zod16.z.string().min(1),
|
|
1260
|
+
conceptId: import_zod16.z.string().min(1),
|
|
318
1261
|
/** Second concept id, where the operation relates two — supersession. */
|
|
319
|
-
target:
|
|
1262
|
+
target: import_zod16.z.string().min(1).optional()
|
|
320
1263
|
}).strict();
|
|
321
1264
|
function renderLogEntry(entry) {
|
|
322
1265
|
return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
|
|
@@ -347,14 +1290,107 @@ function parseLog(raw) {
|
|
|
347
1290
|
// src/json-schema.ts
|
|
348
1291
|
function kbJsonSchemas() {
|
|
349
1292
|
return {
|
|
350
|
-
recordFrontmatter:
|
|
1293
|
+
recordFrontmatter: import_zod17.z.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
351
1294
|
io: "input"
|
|
352
1295
|
}),
|
|
353
|
-
composeInput:
|
|
354
|
-
logEntry:
|
|
1296
|
+
composeInput: import_zod17.z.toJSONSchema(composeInputSchema, { io: "input" }),
|
|
1297
|
+
logEntry: import_zod17.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
|
|
355
1298
|
};
|
|
356
1299
|
}
|
|
357
1300
|
|
|
1301
|
+
// src/commands/schema.ts
|
|
1302
|
+
var schemaCommand = define({
|
|
1303
|
+
name: "schema",
|
|
1304
|
+
tool: "kb_schema",
|
|
1305
|
+
usage: "schema",
|
|
1306
|
+
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.",
|
|
1307
|
+
input: import_zod18.z.object({}),
|
|
1308
|
+
fromArgv: () => ({}),
|
|
1309
|
+
run: () => Promise.resolve(kbJsonSchemas())
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
// src/commands/status.ts
|
|
1313
|
+
var import_zod19 = require("zod");
|
|
1314
|
+
var statusCommand = define({
|
|
1315
|
+
name: "status",
|
|
1316
|
+
tool: "kb_status",
|
|
1317
|
+
usage: "status <concept-id> <status>",
|
|
1318
|
+
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.",
|
|
1319
|
+
input: import_zod19.z.object({
|
|
1320
|
+
bundlePath,
|
|
1321
|
+
conceptId,
|
|
1322
|
+
status: import_zod19.z.enum(KB_RECORD_STATUSES)
|
|
1323
|
+
}),
|
|
1324
|
+
fromArgv: (argv, path) => ({
|
|
1325
|
+
bundlePath: path,
|
|
1326
|
+
conceptId: argv[1],
|
|
1327
|
+
status: argv[2]
|
|
1328
|
+
}),
|
|
1329
|
+
run: async ({ store, actor }, { bundlePath: path, conceptId: id, status }) => {
|
|
1330
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
1331
|
+
const record = await store.setStatus(path, id, status, actor);
|
|
1332
|
+
return { conceptId: record.conceptId, status };
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
// src/commands/supersede.ts
|
|
1337
|
+
var import_zod20 = require("zod");
|
|
1338
|
+
var supersedeCommand = define({
|
|
1339
|
+
name: "supersede",
|
|
1340
|
+
tool: "kb_supersede",
|
|
1341
|
+
usage: "supersede <concept-id> <replacement-id>",
|
|
1342
|
+
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.",
|
|
1343
|
+
input: import_zod20.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
1344
|
+
fromArgv: (argv, path) => ({
|
|
1345
|
+
bundlePath: path,
|
|
1346
|
+
conceptId: argv[1],
|
|
1347
|
+
replacementId: argv[2]
|
|
1348
|
+
}),
|
|
1349
|
+
run: async ({ store, actor }, { bundlePath: path, conceptId: id, replacementId }) => {
|
|
1350
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
1351
|
+
await store.supersede(path, id, replacementId, actor);
|
|
1352
|
+
return { superseded: id, replacedBy: replacementId };
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// src/commands/sync-instructions.ts
|
|
1357
|
+
var import_zod21 = require("zod");
|
|
1358
|
+
var syncInstructionsCommand = define({
|
|
1359
|
+
name: "sync-instructions",
|
|
1360
|
+
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
1361
|
+
description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
|
|
1362
|
+
input: import_zod21.z.object({
|
|
1363
|
+
file: import_zod21.z.string().min(1).describe("The instruction file to edit in place."),
|
|
1364
|
+
budgetTokens: import_zod21.z.number().int().positive().optional(),
|
|
1365
|
+
fullUnderTokens: import_zod21.z.number().int().positive().optional(),
|
|
1366
|
+
profile: import_zod21.z.string().optional()
|
|
1367
|
+
}),
|
|
1368
|
+
fromArgv: (argv) => {
|
|
1369
|
+
const budget = argvFlag(argv, "--budget");
|
|
1370
|
+
const fullUnder = argvFlag(argv, "--full-under");
|
|
1371
|
+
const profile = argvFlag(argv, "--profile");
|
|
1372
|
+
return {
|
|
1373
|
+
file: argv[1],
|
|
1374
|
+
...budget ? { budgetTokens: Number(budget) } : {},
|
|
1375
|
+
...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
|
|
1376
|
+
...profile ? { profile } : {}
|
|
1377
|
+
};
|
|
1378
|
+
},
|
|
1379
|
+
run: async ({ store }, { file, budgetTokens, fullUnderTokens, profile }) => {
|
|
1380
|
+
const result = await buildContext(store, process.cwd(), {
|
|
1381
|
+
...budgetTokens ? { budgetTokens } : {},
|
|
1382
|
+
...fullUnderTokens ? { fullUnderTokens } : {},
|
|
1383
|
+
...profile ? { profile } : {},
|
|
1384
|
+
warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
|
|
1385
|
+
`)
|
|
1386
|
+
});
|
|
1387
|
+
return syncInstructions(file, result.block);
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
// src/commands/trace.ts
|
|
1392
|
+
var import_zod22 = require("zod");
|
|
1393
|
+
|
|
358
1394
|
// src/trace.ts
|
|
359
1395
|
var TRACE_EDGES = ["supersession", "anchor", "source"];
|
|
360
1396
|
function trace(seedId, bundle, options = {}) {
|
|
@@ -428,6 +1464,64 @@ function byGeneratedAt(left, right) {
|
|
|
428
1464
|
return at(left).localeCompare(at(right)) || left.depth - right.depth;
|
|
429
1465
|
}
|
|
430
1466
|
|
|
1467
|
+
// src/commands/trace.ts
|
|
1468
|
+
var traceCommand = define({
|
|
1469
|
+
name: "trace",
|
|
1470
|
+
tool: "kb_trace",
|
|
1471
|
+
usage: "trace <concept-id> [edges...]",
|
|
1472
|
+
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". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
|
|
1473
|
+
input: import_zod22.z.object({
|
|
1474
|
+
bundlePath,
|
|
1475
|
+
conceptId,
|
|
1476
|
+
edges: import_zod22.z.array(import_zod22.z.enum(TRACE_EDGES)).optional(),
|
|
1477
|
+
depth: import_zod22.z.number().int().positive().optional()
|
|
1478
|
+
}),
|
|
1479
|
+
fromArgv: (argv, path) => ({
|
|
1480
|
+
bundlePath: path,
|
|
1481
|
+
conceptId: argv[1],
|
|
1482
|
+
edges: argv.slice(2).filter((edge) => TRACE_EDGES.includes(edge))
|
|
1483
|
+
}),
|
|
1484
|
+
run: async ({ store }, { bundlePath: path, conceptId: id, edges, depth }) => (await store.trace(path, id, {
|
|
1485
|
+
...edges?.length ? { edges } : {},
|
|
1486
|
+
...depth ? { depth } : {}
|
|
1487
|
+
})).map((step) => ({
|
|
1488
|
+
conceptId: step.record.conceptId,
|
|
1489
|
+
at: step.record.frontmatter.generated?.at ?? null,
|
|
1490
|
+
status: step.record.frontmatter.strauss_status,
|
|
1491
|
+
title: step.record.frontmatter.title ?? null,
|
|
1492
|
+
depth: step.depth,
|
|
1493
|
+
via: step.via,
|
|
1494
|
+
body: step.record.body
|
|
1495
|
+
}))
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
// src/commands/types.ts
|
|
1499
|
+
var import_zod23 = require("zod");
|
|
1500
|
+
var typesCommand = define({
|
|
1501
|
+
name: "types",
|
|
1502
|
+
tool: "kb_types",
|
|
1503
|
+
usage: "types",
|
|
1504
|
+
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.",
|
|
1505
|
+
input: import_zod23.z.object({}),
|
|
1506
|
+
fromArgv: () => ({}),
|
|
1507
|
+
run: () => Promise.resolve(RECORD_TYPES)
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
// src/commands/unpin.ts
|
|
1511
|
+
var import_zod24 = require("zod");
|
|
1512
|
+
var unpinCommand = define({
|
|
1513
|
+
name: "unpin",
|
|
1514
|
+
tool: "kb_unpin",
|
|
1515
|
+
usage: "unpin [bundle-path]",
|
|
1516
|
+
description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
|
|
1517
|
+
input: import_zod24.z.object({ bundlePath }),
|
|
1518
|
+
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
1519
|
+
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
// src/commands/validate.ts
|
|
1523
|
+
var import_zod25 = require("zod");
|
|
1524
|
+
|
|
431
1525
|
// src/validate.ts
|
|
432
1526
|
function validateBundle(records) {
|
|
433
1527
|
const byId = new Map(records.map((record) => [record.conceptId, record]));
|
|
@@ -463,299 +1557,114 @@ function validateBundle(records) {
|
|
|
463
1557
|
return problems;
|
|
464
1558
|
}
|
|
465
1559
|
|
|
466
|
-
// src/commands.ts
|
|
467
|
-
var
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const record = await store.write(
|
|
498
|
-
path,
|
|
499
|
-
composeRecord(type, input, actor, now()),
|
|
500
|
-
actor
|
|
501
|
-
);
|
|
502
|
-
return { conceptId: record.conceptId };
|
|
503
|
-
}
|
|
504
|
-
}),
|
|
505
|
-
define({
|
|
506
|
-
name: "write-decision",
|
|
507
|
-
tool: "kb_write_decision",
|
|
508
|
-
usage: "write-decision < decision.json",
|
|
509
|
-
description: [
|
|
510
|
-
"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.",
|
|
511
|
-
"",
|
|
512
|
-
"What belongs in one:",
|
|
513
|
-
'- 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.',
|
|
514
|
-
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
515
|
-
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
516
|
-
].join("\n"),
|
|
517
|
-
input: import_zod6.z.object({ bundlePath, input: decisionInputSchema }),
|
|
518
|
-
fromArgv: async (_argv, path, stdin) => ({
|
|
519
|
-
bundlePath: path,
|
|
520
|
-
input: JSON.parse(await stdin())
|
|
521
|
-
}),
|
|
522
|
-
run: async ({ store, actor, now }, { bundlePath: path, input }) => {
|
|
523
|
-
const record = await store.write(
|
|
524
|
-
path,
|
|
525
|
-
composeDecisionRecord(input, actor, now()),
|
|
526
|
-
actor
|
|
527
|
-
);
|
|
528
|
-
return { conceptId: record.conceptId };
|
|
529
|
-
}
|
|
530
|
-
}),
|
|
531
|
-
define({
|
|
532
|
-
name: "no-decision",
|
|
533
|
-
tool: "kb_no_decision",
|
|
534
|
-
usage: "no-decision <reason...>",
|
|
535
|
-
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.',
|
|
536
|
-
input: import_zod6.z.object({ bundlePath, reason: import_zod6.z.string().min(1) }),
|
|
537
|
-
fromArgv: (argv, path) => ({
|
|
538
|
-
bundlePath: path,
|
|
539
|
-
reason: argv.slice(1).join(" ").trim()
|
|
540
|
-
}),
|
|
541
|
-
run: async ({ store, actor, now }, { bundlePath: path, reason }) => {
|
|
542
|
-
const record = await store.write(
|
|
543
|
-
path,
|
|
544
|
-
{ ...composeNoDecisionRecord(reason, actor, now()), overwrite: true },
|
|
545
|
-
actor
|
|
546
|
-
);
|
|
547
|
-
return { conceptId: record.conceptId };
|
|
548
|
-
}
|
|
549
|
-
}),
|
|
550
|
-
define({
|
|
551
|
-
name: "status",
|
|
552
|
-
tool: "kb_status",
|
|
553
|
-
usage: "status <concept-id> <status>",
|
|
554
|
-
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.",
|
|
555
|
-
input: import_zod6.z.object({
|
|
556
|
-
bundlePath,
|
|
557
|
-
conceptId,
|
|
558
|
-
status: import_zod6.z.enum(KB_RECORD_STATUSES)
|
|
559
|
-
}),
|
|
560
|
-
fromArgv: (argv, path) => ({
|
|
561
|
-
bundlePath: path,
|
|
562
|
-
conceptId: argv[1],
|
|
563
|
-
status: argv[2]
|
|
564
|
-
}),
|
|
565
|
-
run: async ({ store, actor }, { bundlePath: path, conceptId: id, status }) => {
|
|
566
|
-
const record = await store.setStatus(path, id, status, actor);
|
|
567
|
-
return { conceptId: record.conceptId, status };
|
|
568
|
-
}
|
|
569
|
-
}),
|
|
570
|
-
define({
|
|
571
|
-
name: "supersede",
|
|
572
|
-
tool: "kb_supersede",
|
|
573
|
-
usage: "supersede <concept-id> <replacement-id>",
|
|
574
|
-
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.",
|
|
575
|
-
input: import_zod6.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
576
|
-
fromArgv: (argv, path) => ({
|
|
577
|
-
bundlePath: path,
|
|
578
|
-
conceptId: argv[1],
|
|
579
|
-
replacementId: argv[2]
|
|
580
|
-
}),
|
|
581
|
-
run: async ({ store, actor }, { bundlePath: path, conceptId: id, replacementId }) => {
|
|
582
|
-
await store.supersede(path, id, replacementId, actor);
|
|
583
|
-
return { superseded: id, replacedBy: replacementId };
|
|
584
|
-
}
|
|
585
|
-
}),
|
|
586
|
-
define({
|
|
587
|
-
name: "answer",
|
|
588
|
-
tool: "kb_answer",
|
|
589
|
-
usage: "answer <concept-id> <answer...>",
|
|
590
|
-
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.",
|
|
591
|
-
input: import_zod6.z.object({ bundlePath, conceptId, answer: import_zod6.z.string().min(1) }),
|
|
592
|
-
fromArgv: (argv, path) => ({
|
|
593
|
-
bundlePath: path,
|
|
594
|
-
conceptId: argv[1],
|
|
595
|
-
answer: argv.slice(2).join(" ").trim()
|
|
596
|
-
}),
|
|
597
|
-
run: async ({ store, actor }, { bundlePath: path, conceptId: id, answer }) => {
|
|
598
|
-
const record = await store.answer(path, id, answer, actor);
|
|
599
|
-
return { conceptId: record.conceptId };
|
|
600
|
-
}
|
|
601
|
-
}),
|
|
602
|
-
define({
|
|
603
|
-
name: "load",
|
|
604
|
-
tool: "kb_load",
|
|
605
|
-
usage: "load [type] [--budget N]",
|
|
606
|
-
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.",
|
|
607
|
-
input: import_zod6.z.object({
|
|
608
|
-
bundlePath,
|
|
609
|
-
type: import_zod6.z.enum(KB_RECORD_TYPES).optional(),
|
|
610
|
-
budgetTokens: import_zod6.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
|
|
611
|
-
}),
|
|
612
|
-
fromArgv: (argv, path) => {
|
|
613
|
-
const at = argv.indexOf("--budget");
|
|
614
|
-
return {
|
|
615
|
-
bundlePath: path,
|
|
616
|
-
...argv[1] && argv[1] !== "--budget" ? { type: argv[1] } : {},
|
|
617
|
-
...at !== -1 && argv[at + 1] ? { budgetTokens: Number(argv[at + 1]) } : {}
|
|
618
|
-
};
|
|
619
|
-
},
|
|
620
|
-
run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
|
|
621
|
-
const result = await store.load(path, {
|
|
622
|
-
...type ? { type } : {},
|
|
623
|
-
...budgetTokens ? { budgetTokens } : {}
|
|
624
|
-
});
|
|
625
|
-
if (!result.loaded) return result;
|
|
626
|
-
return {
|
|
627
|
-
...result,
|
|
628
|
-
records: result.records.map((hit) => ({
|
|
629
|
-
conceptId: hit.record.conceptId,
|
|
630
|
-
title: hit.record.frontmatter.title ?? null,
|
|
631
|
-
standing: hit.standing,
|
|
632
|
-
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
633
|
-
warnings: hit.warnings,
|
|
634
|
-
anchors: hit.record.frontmatter.strauss_anchors ?? [],
|
|
635
|
-
body: hit.record.body
|
|
636
|
-
}))
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
}),
|
|
640
|
-
define({
|
|
641
|
-
name: "query",
|
|
642
|
-
tool: "kb_query",
|
|
643
|
-
usage: "query <text...>",
|
|
644
|
-
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.",
|
|
645
|
-
input: import_zod6.z.object({
|
|
646
|
-
bundlePath,
|
|
647
|
-
text: import_zod6.z.string().optional(),
|
|
648
|
-
type: import_zod6.z.enum(KB_RECORD_TYPES).optional(),
|
|
649
|
-
includeNonCurrent: import_zod6.z.boolean().optional()
|
|
650
|
-
}),
|
|
651
|
-
fromArgv: (argv, path) => ({
|
|
652
|
-
bundlePath: path,
|
|
653
|
-
text: argv.slice(1).join(" ").trim(),
|
|
654
|
-
includeNonCurrent: true
|
|
655
|
-
}),
|
|
656
|
-
run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
|
|
657
|
-
...type ? { type } : {},
|
|
658
|
-
includeNonCurrent: includeNonCurrent === true
|
|
659
|
-
})).map((hit) => ({
|
|
660
|
-
conceptId: hit.record.conceptId,
|
|
661
|
-
title: hit.record.frontmatter.title ?? null,
|
|
662
|
-
description: hit.record.frontmatter.description ?? null,
|
|
663
|
-
standing: hit.standing,
|
|
664
|
-
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
665
|
-
warnings: hit.warnings,
|
|
666
|
-
body: hit.record.body
|
|
667
|
-
}))
|
|
1560
|
+
// src/commands/validate.ts
|
|
1561
|
+
var validateCommand = define({
|
|
1562
|
+
name: "validate",
|
|
1563
|
+
tool: "kb_validate",
|
|
1564
|
+
usage: "validate",
|
|
1565
|
+
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.",
|
|
1566
|
+
input: import_zod25.z.object({ bundlePath }),
|
|
1567
|
+
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1568
|
+
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
1569
|
+
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
// src/commands/write.ts
|
|
1573
|
+
var import_zod26 = require("zod");
|
|
1574
|
+
var writeCommand = define({
|
|
1575
|
+
name: "write",
|
|
1576
|
+
tool: "kb_write",
|
|
1577
|
+
usage: "write <type> < record.json",
|
|
1578
|
+
description: [
|
|
1579
|
+
"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.",
|
|
1580
|
+
"",
|
|
1581
|
+
"Judgment the tool cannot enforce for you:",
|
|
1582
|
+
"- 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.",
|
|
1583
|
+
"- 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.",
|
|
1584
|
+
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
1585
|
+
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
1586
|
+
].join("\n"),
|
|
1587
|
+
input: import_zod26.z.object({
|
|
1588
|
+
bundlePath,
|
|
1589
|
+
type: import_zod26.z.enum(KB_RECORD_TYPES),
|
|
1590
|
+
input: composeInputSchema
|
|
668
1591
|
}),
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
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".',
|
|
674
|
-
input: import_zod6.z.object({
|
|
675
|
-
bundlePath,
|
|
676
|
-
conceptId,
|
|
677
|
-
edges: import_zod6.z.array(import_zod6.z.enum(TRACE_EDGES)).optional(),
|
|
678
|
-
depth: import_zod6.z.number().int().positive().optional()
|
|
679
|
-
}),
|
|
680
|
-
fromArgv: (argv, path) => ({
|
|
681
|
-
bundlePath: path,
|
|
682
|
-
conceptId: argv[1],
|
|
683
|
-
edges: argv.slice(2).filter((edge) => TRACE_EDGES.includes(edge))
|
|
684
|
-
}),
|
|
685
|
-
run: async ({ store }, { bundlePath: path, conceptId: id, edges, depth }) => (await store.trace(path, id, {
|
|
686
|
-
...edges?.length ? { edges } : {},
|
|
687
|
-
...depth ? { depth } : {}
|
|
688
|
-
})).map((step) => ({
|
|
689
|
-
conceptId: step.record.conceptId,
|
|
690
|
-
at: step.record.frontmatter.generated?.at ?? null,
|
|
691
|
-
status: step.record.frontmatter.strauss_status,
|
|
692
|
-
title: step.record.frontmatter.title ?? null,
|
|
693
|
-
depth: step.depth,
|
|
694
|
-
via: step.via,
|
|
695
|
-
body: step.record.body
|
|
696
|
-
}))
|
|
1592
|
+
fromArgv: async (argv, path, stdin) => ({
|
|
1593
|
+
bundlePath: path,
|
|
1594
|
+
type: argv[1],
|
|
1595
|
+
input: JSON.parse(await stdin())
|
|
697
1596
|
}),
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1597
|
+
run: async ({ store, actor, now }, { bundlePath: path, type, input }) => {
|
|
1598
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
1599
|
+
const record = await store.write(
|
|
1600
|
+
path,
|
|
1601
|
+
composeRecord(type, input, actor, now()),
|
|
1602
|
+
actor
|
|
1603
|
+
);
|
|
1604
|
+
return {
|
|
706
1605
|
conceptId: record.conceptId,
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
define({
|
|
732
|
-
name: "validate",
|
|
733
|
-
tool: "kb_validate",
|
|
734
|
-
usage: "validate",
|
|
735
|
-
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.",
|
|
736
|
-
input: import_zod6.z.object({ bundlePath }),
|
|
737
|
-
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
738
|
-
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
739
|
-
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
740
|
-
}),
|
|
741
|
-
define({
|
|
742
|
-
name: "schema",
|
|
743
|
-
tool: "kb_schema",
|
|
744
|
-
usage: "schema",
|
|
745
|
-
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.",
|
|
746
|
-
input: import_zod6.z.object({}),
|
|
747
|
-
fromArgv: () => ({}),
|
|
748
|
-
run: () => Promise.resolve(kbJsonSchemas())
|
|
1606
|
+
action: record.action,
|
|
1607
|
+
supersededIds: record.supersededIds
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
// src/commands/write-decision.ts
|
|
1613
|
+
var import_zod27 = require("zod");
|
|
1614
|
+
var writeDecisionCommand = define({
|
|
1615
|
+
name: "write-decision",
|
|
1616
|
+
tool: "kb_write_decision",
|
|
1617
|
+
usage: "write-decision < decision.json",
|
|
1618
|
+
description: [
|
|
1619
|
+
"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.",
|
|
1620
|
+
"",
|
|
1621
|
+
"What belongs in one:",
|
|
1622
|
+
'- 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.',
|
|
1623
|
+
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
1624
|
+
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
1625
|
+
].join("\n"),
|
|
1626
|
+
input: import_zod27.z.object({ bundlePath, input: decisionInputSchema }),
|
|
1627
|
+
fromArgv: async (_argv, path, stdin) => ({
|
|
1628
|
+
bundlePath: path,
|
|
1629
|
+
input: JSON.parse(await stdin())
|
|
749
1630
|
}),
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1631
|
+
run: async ({ store, actor, now }, { bundlePath: path, input }) => {
|
|
1632
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
1633
|
+
const record = await store.write(
|
|
1634
|
+
path,
|
|
1635
|
+
composeDecisionRecord(input, actor, now()),
|
|
1636
|
+
actor
|
|
1637
|
+
);
|
|
1638
|
+
return {
|
|
1639
|
+
conceptId: record.conceptId,
|
|
1640
|
+
action: record.action,
|
|
1641
|
+
supersededIds: record.supersededIds
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
// src/commands/index.ts
|
|
1647
|
+
var KB_COMMANDS = [
|
|
1648
|
+
writeCommand,
|
|
1649
|
+
writeDecisionCommand,
|
|
1650
|
+
noDecisionCommand,
|
|
1651
|
+
statusCommand,
|
|
1652
|
+
supersedeCommand,
|
|
1653
|
+
answerCommand,
|
|
1654
|
+
loadCommand,
|
|
1655
|
+
queryCommand,
|
|
1656
|
+
traceCommand,
|
|
1657
|
+
listCommand,
|
|
1658
|
+
readIndexCommand,
|
|
1659
|
+
logCommand,
|
|
1660
|
+
validateCommand,
|
|
1661
|
+
schemaCommand,
|
|
1662
|
+
pinCommand,
|
|
1663
|
+
unpinCommand,
|
|
1664
|
+
pinsCommand,
|
|
1665
|
+
contextCommand,
|
|
1666
|
+
syncInstructionsCommand,
|
|
1667
|
+
typesCommand
|
|
759
1668
|
];
|
|
760
1669
|
var KB_COMMANDS_BY_NAME = new Map(
|
|
761
1670
|
KB_COMMANDS.map((command) => [command.name, command])
|
|
@@ -763,8 +1672,8 @@ var KB_COMMANDS_BY_NAME = new Map(
|
|
|
763
1672
|
|
|
764
1673
|
// src/kb-store.ts
|
|
765
1674
|
var import_node_crypto = require("crypto");
|
|
766
|
-
var
|
|
767
|
-
var
|
|
1675
|
+
var import_promises4 = require("fs/promises");
|
|
1676
|
+
var import_node_path6 = require("path");
|
|
768
1677
|
|
|
769
1678
|
// src/markdown.ts
|
|
770
1679
|
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
@@ -821,7 +1730,7 @@ var KbRecordAlreadyExistsError = class extends BaseError {
|
|
|
821
1730
|
fault: "User" /* User */,
|
|
822
1731
|
retriable: false,
|
|
823
1732
|
reportToUser: true,
|
|
824
|
-
details: { conceptId: conceptId2 }
|
|
1733
|
+
details: { conceptId: conceptId2, action: "refused" }
|
|
825
1734
|
});
|
|
826
1735
|
this.conceptId = conceptId2;
|
|
827
1736
|
}
|
|
@@ -871,121 +1780,9 @@ var KbInvalidConceptIdError = class extends BaseError {
|
|
|
871
1780
|
}
|
|
872
1781
|
};
|
|
873
1782
|
|
|
874
|
-
// src/kb-index.ts
|
|
875
|
-
var INDEX_FILE = "INDEX.md";
|
|
876
|
-
var HEADING = "# KB Index";
|
|
877
|
-
function renderIndex(records) {
|
|
878
|
-
const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map((record) => {
|
|
879
|
-
const { frontmatter: fm } = record;
|
|
880
|
-
const parts = [fm.type, fm.strauss_status];
|
|
881
|
-
if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
|
|
882
|
-
if (fm.description) parts.push(fm.description);
|
|
883
|
-
return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
|
|
884
|
-
});
|
|
885
|
-
return `${HEADING}
|
|
886
|
-
|
|
887
|
-
${lines.join("\n")}
|
|
888
|
-
`;
|
|
889
|
-
}
|
|
890
|
-
function indexIsStale(stored, expected) {
|
|
891
|
-
return stored !== expected;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// src/adjudicate.ts
|
|
895
|
-
var STANDING = {
|
|
896
|
-
accepted: "current",
|
|
897
|
-
resolved: "current",
|
|
898
|
-
draft: "unsettled",
|
|
899
|
-
proposed: "unsettled",
|
|
900
|
-
open: "open",
|
|
901
|
-
rejected: "rejected",
|
|
902
|
-
superseded: "superseded"
|
|
903
|
-
};
|
|
904
|
-
function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
|
|
905
|
-
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
906
|
-
return hits.map((record) => {
|
|
907
|
-
const status = record.frontmatter.strauss_status;
|
|
908
|
-
const warnings = [];
|
|
909
|
-
let heads = [];
|
|
910
|
-
if (status === "superseded") {
|
|
911
|
-
const resolved = resolveHeads(record, byId);
|
|
912
|
-
heads = resolved.heads;
|
|
913
|
-
warnings.push(...resolved.warnings);
|
|
914
|
-
if (heads.length) {
|
|
915
|
-
warnings.push({
|
|
916
|
-
kind: "superseded",
|
|
917
|
-
by: heads.map((head) => head.conceptId)
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
} else if (status === "rejected") {
|
|
921
|
-
warnings.push({ kind: "rejected" });
|
|
922
|
-
} else if (status === "draft" || status === "proposed") {
|
|
923
|
-
warnings.push({ kind: "unsettled", status });
|
|
924
|
-
} else if (status === "open") {
|
|
925
|
-
warnings.push({ kind: "unresolved-question" });
|
|
926
|
-
}
|
|
927
|
-
const staleAfter = record.frontmatter.stale_after;
|
|
928
|
-
if (staleAfter && Date.parse(staleAfter) < now.getTime()) {
|
|
929
|
-
warnings.push({ kind: "stale", staleAfter });
|
|
930
|
-
}
|
|
931
|
-
if (!record.frontmatter.verified?.length) {
|
|
932
|
-
warnings.push({ kind: "unverified" });
|
|
933
|
-
}
|
|
934
|
-
return { record, standing: STANDING[status], heads, warnings };
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
function resolveHeads(from, byId) {
|
|
938
|
-
const warnings = [];
|
|
939
|
-
const heads = /* @__PURE__ */ new Map();
|
|
940
|
-
const seen = /* @__PURE__ */ new Set([from.conceptId]);
|
|
941
|
-
const queue = [from];
|
|
942
|
-
while (queue.length) {
|
|
943
|
-
const current = queue.shift();
|
|
944
|
-
const next = successors(current, byId);
|
|
945
|
-
for (const missing of next.missing) {
|
|
946
|
-
warnings.push({ kind: "broken-chain", missing });
|
|
947
|
-
}
|
|
948
|
-
if (!next.records.length) {
|
|
949
|
-
if (current.conceptId !== from.conceptId)
|
|
950
|
-
heads.set(current.conceptId, current);
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
for (const record of next.records) {
|
|
954
|
-
if (seen.has(record.conceptId)) {
|
|
955
|
-
warnings.push({ kind: "chain-cycle", through: [...seen] });
|
|
956
|
-
continue;
|
|
957
|
-
}
|
|
958
|
-
seen.add(record.conceptId);
|
|
959
|
-
queue.push(record);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
if (heads.size > 1) {
|
|
963
|
-
warnings.push({ kind: "forked-chain", heads: [...heads.keys()] });
|
|
964
|
-
}
|
|
965
|
-
return { heads: [...heads.values()], warnings };
|
|
966
|
-
}
|
|
967
|
-
function successors(record, byId) {
|
|
968
|
-
const ids = /* @__PURE__ */ new Set();
|
|
969
|
-
const forward = record.frontmatter.strauss_superseded_by;
|
|
970
|
-
if (forward) ids.add(forward);
|
|
971
|
-
for (const [id, candidate] of byId) {
|
|
972
|
-
if (candidate.frontmatter.strauss_supersedes?.includes(record.conceptId)) {
|
|
973
|
-
ids.add(id);
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
const records = [];
|
|
977
|
-
const missing = [];
|
|
978
|
-
for (const id of ids) {
|
|
979
|
-
const found = byId.get(id);
|
|
980
|
-
if (found) records.push(found);
|
|
981
|
-
else missing.push(id);
|
|
982
|
-
}
|
|
983
|
-
return { records, missing };
|
|
984
|
-
}
|
|
985
|
-
|
|
986
1783
|
// src/search-index.ts
|
|
987
|
-
var
|
|
988
|
-
var
|
|
1784
|
+
var import_promises3 = require("fs/promises");
|
|
1785
|
+
var import_node_path5 = require("path");
|
|
989
1786
|
var SEARCH_INDEX_FILE = ".index.sqlite";
|
|
990
1787
|
var COLLECTION = "kb";
|
|
991
1788
|
async function searchBase(bundlePath2, query, options = {}) {
|
|
@@ -994,7 +1791,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
994
1791
|
let store = null;
|
|
995
1792
|
try {
|
|
996
1793
|
store = await qmd.createStore({
|
|
997
|
-
dbPath: (0,
|
|
1794
|
+
dbPath: (0, import_node_path5.join)(bundlePath2, SEARCH_INDEX_FILE),
|
|
998
1795
|
config: {
|
|
999
1796
|
collections: {
|
|
1000
1797
|
[COLLECTION]: {
|
|
@@ -1029,13 +1826,13 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
1029
1826
|
}
|
|
1030
1827
|
}
|
|
1031
1828
|
async function isStale(bundlePath2) {
|
|
1032
|
-
const indexAt = await (0,
|
|
1829
|
+
const indexAt = await (0, import_promises3.stat)((0, import_node_path5.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
|
|
1033
1830
|
if (!indexAt) return true;
|
|
1034
1831
|
const { readdir: readdir2 } = await import("fs/promises");
|
|
1035
1832
|
const names = await readdir2(bundlePath2).catch(() => []);
|
|
1036
1833
|
for (const name of names) {
|
|
1037
1834
|
if (!name.endsWith(".md") || name === INDEX_FILE) continue;
|
|
1038
|
-
const at = await (0,
|
|
1835
|
+
const at = await (0, import_promises3.stat)((0, import_node_path5.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
|
|
1039
1836
|
if (at > indexAt) return true;
|
|
1040
1837
|
}
|
|
1041
1838
|
return false;
|
|
@@ -1070,7 +1867,7 @@ async function loadQmd(logger) {
|
|
|
1070
1867
|
}
|
|
1071
1868
|
|
|
1072
1869
|
// src/kb-store.ts
|
|
1073
|
-
var KB_DIR = (0,
|
|
1870
|
+
var KB_DIR = (0, import_node_path6.join)(".strauss", "kb");
|
|
1074
1871
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
1075
1872
|
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
1076
1873
|
var KbStore = class {
|
|
@@ -1101,7 +1898,7 @@ var KbStore = class {
|
|
|
1101
1898
|
const conceptId2 = `${input.type}.${input.slug}`;
|
|
1102
1899
|
const root = this.root(bundlePath2);
|
|
1103
1900
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1104
|
-
await (0,
|
|
1901
|
+
await (0, import_promises4.mkdir)(root, { recursive: true });
|
|
1105
1902
|
await this.publish(
|
|
1106
1903
|
target,
|
|
1107
1904
|
stringifyMarkdownWithFrontmatter(input.body, frontmatter),
|
|
@@ -1113,20 +1910,34 @@ var KbStore = class {
|
|
|
1113
1910
|
conceptId: conceptId2,
|
|
1114
1911
|
by: actor
|
|
1115
1912
|
});
|
|
1913
|
+
const targets = new Set(frontmatter.strauss_supersedes ?? []);
|
|
1914
|
+
targets.delete(conceptId2);
|
|
1915
|
+
const supersededIds = [];
|
|
1916
|
+
for (const old of targets) {
|
|
1917
|
+
if (await this.markSupersededRetrying(bundlePath2, old, conceptId2, actor)) {
|
|
1918
|
+
supersededIds.push(old);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1116
1921
|
this.logger.info?.({
|
|
1117
1922
|
operation: "kb.write",
|
|
1118
1923
|
bundlePath: root,
|
|
1119
1924
|
conceptId: conceptId2,
|
|
1120
1925
|
anchors: frontmatter.strauss_anchors?.length ?? 0
|
|
1121
1926
|
});
|
|
1122
|
-
return {
|
|
1927
|
+
return {
|
|
1928
|
+
conceptId: conceptId2,
|
|
1929
|
+
frontmatter,
|
|
1930
|
+
body: input.body,
|
|
1931
|
+
action: supersededIds.length ? "superseded-prior" : "created",
|
|
1932
|
+
supersededIds
|
|
1933
|
+
};
|
|
1123
1934
|
}
|
|
1124
1935
|
/** One record by concept id, or null when it does not exist. */
|
|
1125
1936
|
async read(bundlePath2, conceptId2) {
|
|
1126
1937
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1127
1938
|
let raw;
|
|
1128
1939
|
try {
|
|
1129
|
-
raw = await (0,
|
|
1940
|
+
raw = await (0, import_promises4.readFile)(target, "utf8");
|
|
1130
1941
|
} catch {
|
|
1131
1942
|
return null;
|
|
1132
1943
|
}
|
|
@@ -1143,14 +1954,14 @@ var KbStore = class {
|
|
|
1143
1954
|
const root = this.root(bundlePath2);
|
|
1144
1955
|
let names;
|
|
1145
1956
|
try {
|
|
1146
|
-
names = await (0,
|
|
1957
|
+
names = await (0, import_promises4.readdir)(root);
|
|
1147
1958
|
} catch {
|
|
1148
1959
|
return [];
|
|
1149
1960
|
}
|
|
1150
1961
|
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}.`));
|
|
1151
1962
|
const records = await Promise.all(
|
|
1152
1963
|
wanted.map(
|
|
1153
|
-
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0,
|
|
1964
|
+
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path6.join)(root, name), "utf8"))
|
|
1154
1965
|
)
|
|
1155
1966
|
);
|
|
1156
1967
|
return records.filter((record) => record !== null);
|
|
@@ -1183,15 +1994,11 @@ var KbStore = class {
|
|
|
1183
1994
|
async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
|
|
1184
1995
|
const replacement = await this.read(bundlePath2, replacementId);
|
|
1185
1996
|
if (!replacement) throw new KbRecordNotFoundError(replacementId);
|
|
1186
|
-
const superseded = await this.
|
|
1997
|
+
const superseded = await this.markSuperseded(
|
|
1187
1998
|
bundlePath2,
|
|
1188
1999
|
conceptId2,
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
strauss_status: "superseded",
|
|
1192
|
-
strauss_superseded_by: replacementId
|
|
1193
|
-
}),
|
|
1194
|
-
{ operation: "supersede", by: actor, target: replacementId }
|
|
2000
|
+
replacementId,
|
|
2001
|
+
actor
|
|
1195
2002
|
);
|
|
1196
2003
|
await this.mutate(
|
|
1197
2004
|
bundlePath2,
|
|
@@ -1288,19 +2095,19 @@ ${answer}
|
|
|
1288
2095
|
const adjudicated = adjudicate(wanted, bundle);
|
|
1289
2096
|
const records = adjudicated.filter((hit) => hit.standing !== "superseded");
|
|
1290
2097
|
const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
|
|
1291
|
-
const
|
|
1292
|
-
if (
|
|
2098
|
+
const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
|
|
2099
|
+
if (approxTokens2 > budgetTokens) {
|
|
1293
2100
|
return {
|
|
1294
2101
|
loaded: false,
|
|
1295
2102
|
recordCount: wanted.length,
|
|
1296
|
-
approxTokens,
|
|
2103
|
+
approxTokens: approxTokens2,
|
|
1297
2104
|
budgetTokens
|
|
1298
2105
|
};
|
|
1299
2106
|
}
|
|
1300
2107
|
return {
|
|
1301
2108
|
loaded: true,
|
|
1302
2109
|
recordCount: wanted.length,
|
|
1303
|
-
approxTokens,
|
|
2110
|
+
approxTokens: approxTokens2,
|
|
1304
2111
|
budgetTokens,
|
|
1305
2112
|
records,
|
|
1306
2113
|
superseded
|
|
@@ -1320,11 +2127,11 @@ ${answer}
|
|
|
1320
2127
|
async readIndex(bundlePath2) {
|
|
1321
2128
|
const root = this.root(bundlePath2);
|
|
1322
2129
|
const expected = renderIndex(await this.list(bundlePath2));
|
|
1323
|
-
const stored = await (0,
|
|
2130
|
+
const stored = await (0, import_promises4.readFile)((0, import_node_path6.join)(root, INDEX_FILE), "utf8").catch(
|
|
1324
2131
|
() => null
|
|
1325
2132
|
);
|
|
1326
2133
|
if (indexIsStale(stored, expected)) {
|
|
1327
|
-
await this.publish((0,
|
|
2134
|
+
await this.publish((0, import_node_path6.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
|
|
1328
2135
|
this.logger.info?.({
|
|
1329
2136
|
operation: "kb.index.repair",
|
|
1330
2137
|
bundlePath: root,
|
|
@@ -1341,8 +2148,8 @@ ${answer}
|
|
|
1341
2148
|
* knows which agent touched what. So a bad line is surfaced and left alone.
|
|
1342
2149
|
*/
|
|
1343
2150
|
async readLog(bundlePath2) {
|
|
1344
|
-
const raw = await (0,
|
|
1345
|
-
(0,
|
|
2151
|
+
const raw = await (0, import_promises4.readFile)(
|
|
2152
|
+
(0, import_node_path6.join)(this.root(bundlePath2), LOG_FILE),
|
|
1346
2153
|
"utf8"
|
|
1347
2154
|
).catch(() => "");
|
|
1348
2155
|
const result = parseLog(raw);
|
|
@@ -1355,16 +2162,52 @@ ${answer}
|
|
|
1355
2162
|
}
|
|
1356
2163
|
return result;
|
|
1357
2164
|
}
|
|
2165
|
+
/**
|
|
2166
|
+
* `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
|
|
2167
|
+
* a missing target (a broken link, legal per compose.ts) or a CAS conflict
|
|
2168
|
+
* from a concurrent writer touching the same target. A conflict is retried
|
|
2169
|
+
* a bounded number of times — each attempt re-reads the target fresh — and
|
|
2170
|
+
* on the last, `false` reports "not marked" rather than throwing: the
|
|
2171
|
+
* caller's own record is already published, so failing here would leave
|
|
2172
|
+
* that publish unreported instead of undone. kb_validate's existing
|
|
2173
|
+
* "not marked superseded" check is what surfaces the residue.
|
|
2174
|
+
*/
|
|
2175
|
+
async markSupersededRetrying(bundlePath2, conceptId2, replacementId, actor, retries = 3) {
|
|
2176
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
2177
|
+
try {
|
|
2178
|
+
await this.markSuperseded(bundlePath2, conceptId2, replacementId, actor);
|
|
2179
|
+
return true;
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
if (error instanceof KbRecordNotFoundError) return false;
|
|
2182
|
+
if (!(error instanceof KbWriteConflictError)) throw error;
|
|
2183
|
+
if (attempt === retries) return false;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
return false;
|
|
2187
|
+
}
|
|
2188
|
+
/** The one-directional half of `supersede`: marks `conceptId` superseded. */
|
|
2189
|
+
async markSuperseded(bundlePath2, conceptId2, replacementId, actor) {
|
|
2190
|
+
return this.mutate(
|
|
2191
|
+
bundlePath2,
|
|
2192
|
+
conceptId2,
|
|
2193
|
+
(frontmatter) => ({
|
|
2194
|
+
...frontmatter,
|
|
2195
|
+
strauss_status: "superseded",
|
|
2196
|
+
strauss_superseded_by: replacementId
|
|
2197
|
+
}),
|
|
2198
|
+
{ operation: "supersede", by: actor, target: replacementId }
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
1358
2201
|
async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
|
|
1359
2202
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1360
|
-
const before = await (0,
|
|
2203
|
+
const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
|
|
1361
2204
|
if (before === null) throw new KbRecordNotFoundError(conceptId2);
|
|
1362
2205
|
const parsed = this.parse(conceptId2, before);
|
|
1363
2206
|
if (!parsed) throw new KbRecordNotFoundError(conceptId2);
|
|
1364
2207
|
const frontmatter = change(parsed.frontmatter);
|
|
1365
2208
|
const body = changeBody(parsed.body);
|
|
1366
2209
|
const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
|
|
1367
|
-
const witness = await (0,
|
|
2210
|
+
const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
|
|
1368
2211
|
if (witness === null || digest(witness) !== digest(before)) {
|
|
1369
2212
|
throw new KbWriteConflictError(conceptId2);
|
|
1370
2213
|
}
|
|
@@ -1390,26 +2233,26 @@ ${answer}
|
|
|
1390
2233
|
*/
|
|
1391
2234
|
async publish(target, contents, overwrite, conceptId2) {
|
|
1392
2235
|
const staging = `${target}.${process.pid}.tmp`;
|
|
1393
|
-
await (0,
|
|
2236
|
+
await (0, import_promises4.writeFile)(staging, contents, "utf8");
|
|
1394
2237
|
try {
|
|
1395
2238
|
if (overwrite) {
|
|
1396
|
-
await (0,
|
|
2239
|
+
await (0, import_promises4.rename)(staging, target);
|
|
1397
2240
|
return;
|
|
1398
2241
|
}
|
|
1399
|
-
await (0,
|
|
2242
|
+
await (0, import_promises4.link)(staging, target);
|
|
1400
2243
|
} catch (error) {
|
|
1401
2244
|
if (error.code === "EEXIST") {
|
|
1402
2245
|
throw new KbRecordAlreadyExistsError(conceptId2);
|
|
1403
2246
|
}
|
|
1404
2247
|
throw error;
|
|
1405
2248
|
} finally {
|
|
1406
|
-
await (0,
|
|
2249
|
+
await (0, import_promises4.unlink)(staging).catch(() => void 0);
|
|
1407
2250
|
}
|
|
1408
2251
|
}
|
|
1409
2252
|
/** Appends one log line. Failing to log must not fail the mutation. */
|
|
1410
2253
|
async record(root, entry) {
|
|
1411
2254
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
1412
|
-
await (0,
|
|
2255
|
+
await (0, import_promises4.appendFile)((0, import_node_path6.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
1413
2256
|
this.logger.warn?.({
|
|
1414
2257
|
operation: "kb.log.append",
|
|
1415
2258
|
outcome: "failed",
|
|
@@ -1435,18 +2278,18 @@ ${answer}
|
|
|
1435
2278
|
};
|
|
1436
2279
|
}
|
|
1437
2280
|
root(bundlePath2) {
|
|
1438
|
-
return (0,
|
|
2281
|
+
return (0, import_node_path6.resolve)(bundlePath2);
|
|
1439
2282
|
}
|
|
1440
2283
|
// Concept ids are `<type>.<slug>` and map to a single file directly under the
|
|
1441
2284
|
// bundle root; anything carrying a separator would escape it.
|
|
1442
2285
|
recordPath(bundlePath2, conceptId2) {
|
|
1443
|
-
if (conceptId2.includes(
|
|
2286
|
+
if (conceptId2.includes(import_node_path6.sep) || conceptId2.includes("/")) {
|
|
1444
2287
|
throw new KbInvalidConceptIdError(
|
|
1445
2288
|
"concept id must not contain a path separator",
|
|
1446
2289
|
{ conceptId: conceptId2 }
|
|
1447
2290
|
);
|
|
1448
2291
|
}
|
|
1449
|
-
return (0,
|
|
2292
|
+
return (0, import_node_path6.join)(this.root(bundlePath2), `${conceptId2}.md`);
|
|
1450
2293
|
}
|
|
1451
2294
|
};
|
|
1452
2295
|
function estimateTokens(record) {
|
|
@@ -1505,6 +2348,7 @@ async function runKbCli(argv) {
|
|
|
1505
2348
|
parsed.data
|
|
1506
2349
|
);
|
|
1507
2350
|
if (command.failsWhen?.(result)) process.exitCode = 1;
|
|
2351
|
+
if (result === "") return;
|
|
1508
2352
|
process.stdout.write(
|
|
1509
2353
|
typeof result === "string" ? result.endsWith("\n") ? result : `${result}
|
|
1510
2354
|
` : `${JSON.stringify(result, null, 2)}
|
|
@@ -1514,18 +2358,18 @@ async function runKbCli(argv) {
|
|
|
1514
2358
|
function takeBundle(argv) {
|
|
1515
2359
|
const at = argv.indexOf("--bundle");
|
|
1516
2360
|
if (at === -1) {
|
|
1517
|
-
return { bundle: (0,
|
|
2361
|
+
return { bundle: (0, import_node_path7.join)(process.cwd(), KB_DIR), rest: argv };
|
|
1518
2362
|
}
|
|
1519
2363
|
const bundle = argv[at + 1];
|
|
1520
2364
|
if (!bundle) die("--bundle requires a path");
|
|
1521
2365
|
return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };
|
|
1522
2366
|
}
|
|
1523
2367
|
function readStdin() {
|
|
1524
|
-
return new Promise((
|
|
2368
|
+
return new Promise((resolve5, reject) => {
|
|
1525
2369
|
let text = "";
|
|
1526
2370
|
process.stdin.setEncoding("utf8");
|
|
1527
2371
|
process.stdin.on("data", (chunk) => text += chunk);
|
|
1528
|
-
process.stdin.on("end", () =>
|
|
2372
|
+
process.stdin.on("end", () => resolve5(text));
|
|
1529
2373
|
process.stdin.on("error", reject);
|
|
1530
2374
|
});
|
|
1531
2375
|
}
|