@saasontools/strauss-kb 0.1.1 → 0.1.3
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/ARCHITECTURE.md +85 -0
- package/README.md +106 -6
- package/dist/{chunk-WFHYWZX5.js → chunk-FSI4Q2FD.js} +3 -2
- package/dist/chunk-FSI4Q2FD.js.map +1 -0
- package/dist/chunk-HYNAEAPM.js +2323 -0
- package/dist/chunk-HYNAEAPM.js.map +1 -0
- package/dist/{chunk-KGM34MYU.js → chunk-QLTB77W4.js} +3 -2
- package/dist/chunk-QLTB77W4.js.map +1 -0
- package/dist/cli-main.cjs +1357 -567
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +1121 -298
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +278 -12
- package/dist/index.d.ts +278 -12
- package/dist/index.js +45 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +1352 -562
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +2 -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/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The README says what the format is and how to use it. This says why it is
|
|
4
|
+
shaped that way, and which alternatives were tried and dropped — the decisions
|
|
5
|
+
a later reader would otherwise reopen.
|
|
6
|
+
|
|
7
|
+
## One record per file
|
|
8
|
+
|
|
9
|
+
The filename is the identity, so two writers never merge; they only choose
|
|
10
|
+
distinct names. Publication uses `link`, which fails when the name is taken, and
|
|
11
|
+
a collision surfaces as a 409 the caller has to answer rather than a silent
|
|
12
|
+
last-write-wins.
|
|
13
|
+
|
|
14
|
+
Read-modify-write (`setStatus`, `answer`) checks a content digest immediately
|
|
15
|
+
before publishing. That narrows the lost-update window to two adjacent syscalls
|
|
16
|
+
rather than closing it. A lock would close it and add a stale-hold failure worse
|
|
17
|
+
than the residue: a crashed holder blocks every later writer, where a lost
|
|
18
|
+
update costs one retry.
|
|
19
|
+
|
|
20
|
+
## Read for a question, not for a session
|
|
21
|
+
|
|
22
|
+
A base loaded at the start of a long conversation is summarised away by the end
|
|
23
|
+
of it, and nothing keeps it alive. So no consumer loads it that way:
|
|
24
|
+
|
|
25
|
+
| Consumer | How it reads |
|
|
26
|
+
| ------------------------------- | ---------------------------------------------------------------------------- |
|
|
27
|
+
| Diff annotation | `matchToDiff` — deterministic, no context involved |
|
|
28
|
+
| "Has this been decided?" | a fresh short-lived reader, given the base and the question, discarded after |
|
|
29
|
+
| An implementor writing a record | a point query at the moment of writing, not a load an hour earlier |
|
|
30
|
+
|
|
31
|
+
Each has clean context by construction. Where a base genuinely must stay
|
|
32
|
+
resident it will drift, and there is no defence — the mitigation is that
|
|
33
|
+
reloading costs about three thousand tokens, so read it again at the point of
|
|
34
|
+
use rather than trying to keep it.
|
|
35
|
+
|
|
36
|
+
## What happens when a base outgrows a context
|
|
37
|
+
|
|
38
|
+
Loading stops working somewhere above a few hundred records. What replaces it is
|
|
39
|
+
not a different answer to the same question — it is the same reader, given
|
|
40
|
+
candidates instead of everything:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
vector recall → top ~20 candidates, with their scores
|
|
44
|
+
↓
|
|
45
|
+
reader judges → picks what answers, or says nothing does
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The reader stays the judge in both regimes, which is what preserves the two
|
|
49
|
+
structural wins the README reports: it can say no record answers the question,
|
|
50
|
+
and it picks the record that answers rather than the one nearest the topic.
|
|
51
|
+
Neither survives if a ranker's top hit is taken as the answer.
|
|
52
|
+
|
|
53
|
+
**A score threshold is not the growth path.** The wrong `audit trail` hit scored
|
|
54
|
+
0.318 against the correct `race condition` hit at 0.295; any cut that drops the
|
|
55
|
+
first drops the second. A threshold excludes the absurd — an unrelated query
|
|
56
|
+
scored 0.091 — and nothing else.
|
|
57
|
+
|
|
58
|
+
**Tags are not the growth path either.** The field exists, is written, and is
|
|
59
|
+
read by nothing but an index line, deliberately. Free-text tags drift the way
|
|
60
|
+
`auth` / `authentication` / `authn` drift, which is the failure this format was
|
|
61
|
+
rewritten to remove; enforcing a vocabulary would make them a closed enum, which
|
|
62
|
+
`type` already is. The labels that matter here are already verifiable —
|
|
63
|
+
`strauss_anchors` names a file and a symbol, which either match the repository
|
|
64
|
+
or do not, where a tag can be wrong forever. If narrowing ever matters, measure
|
|
65
|
+
tag narrowing against vector recall on a real base rather than adding both.
|
|
66
|
+
|
|
67
|
+
## Rejected: a format that needs a parser
|
|
68
|
+
|
|
69
|
+
This was broken twice. A hand-rolled frontmatter reader could not express nested
|
|
70
|
+
maps, so it misread every OKF `generated`, `sources[]`, and `verified[]`. Its
|
|
71
|
+
replacement's first log format was `·`-delimited, with a splitter to read it
|
|
72
|
+
back. Both are gone: the log is JSONL and the schema is emitted from Zod, so
|
|
73
|
+
`strauss-kb schema` is the contract rather than a description of one.
|
|
74
|
+
|
|
75
|
+
## Rejected for now: a base registry
|
|
76
|
+
|
|
77
|
+
Cross-base questions are unaskable by construction — supersession, traces, and
|
|
78
|
+
search stop at the directory boundary. That is the price of a base that can be
|
|
79
|
+
copied, deleted, or handed over whole, and it is what keeps the search index
|
|
80
|
+
disposable.
|
|
81
|
+
|
|
82
|
+
If cross-base ever becomes the common case, the cheap escape is a registry: a
|
|
83
|
+
list of paths a caller may name explicitly, queried one at a time and merged
|
|
84
|
+
only for display. It is deliberately unbuilt. Adding it early would drag back
|
|
85
|
+
the cross-scope machinery this model exists to avoid.
|
package/README.md
CHANGED
|
@@ -146,12 +146,21 @@ the name is taken — two writers choosing one concept id is a 409 the caller mu
|
|
|
146
146
|
answer, by picking a more specific slug or by saying it meant to replace.
|
|
147
147
|
`rename` is used only when the caller passes `overwrite`.
|
|
148
148
|
|
|
149
|
+
Read-modify-write (`setStatus`, `answer`) checks a content digest immediately
|
|
150
|
+
before publishing, which narrows the lost-update window rather than closing it.
|
|
151
|
+
[ARCHITECTURE.md](./ARCHITECTURE.md) says why a lock was rejected.
|
|
152
|
+
|
|
149
153
|
`supersede` writes both directions, so a backlink cannot drift in normal use and
|
|
150
154
|
`validate` drops to catching hand-edits.
|
|
151
155
|
|
|
152
156
|
Records are never deleted. Superseding keeps the earlier reasoning inspectable,
|
|
153
157
|
which is what a later `trace` reads.
|
|
154
158
|
|
|
159
|
+
`no-decision` records the explicit claim that a piece of work had nothing to
|
|
160
|
+
decide (an idempotent `decision.none` record). It exists for workflow gates:
|
|
161
|
+
"did you write a decision?" rewards writing a junk one, "did you answer?"
|
|
162
|
+
does not — so silence has to be expressible.
|
|
163
|
+
|
|
155
164
|
## CLI
|
|
156
165
|
|
|
157
166
|
```
|
|
@@ -172,6 +181,11 @@ strauss-kb [--bundle PATH] <command> [args]
|
|
|
172
181
|
validate Cross-record checks. Exits 1 when it reports a problem.
|
|
173
182
|
schema JSON Schema for the format.
|
|
174
183
|
types The twelve types, their sections and initial status.
|
|
184
|
+
pin [bundle-path] [flags] Pin a base. --mode, --profiles, --frozen; --local/--user pick the layer.
|
|
185
|
+
unpin [bundle-path] Remove a base from every manifest layer that holds it.
|
|
186
|
+
pins Every pinned base, with whether it resolves to records.
|
|
187
|
+
context [--profile NAME] [--budget N] The pinned-base index block, for injection at context birth.
|
|
188
|
+
sync-instructions <file> Plant the context block between sentinels in an instruction file.
|
|
175
189
|
|
|
176
190
|
--bundle PATH defaults to ./.strauss/kb
|
|
177
191
|
STRAUSS_KB_ACTOR names the writer in the log
|
|
@@ -202,9 +216,12 @@ strauss-kb validate || echo "problems above"
|
|
|
202
216
|
`strauss-kb-mcp` speaks stdio and takes no API key and no required environment.
|
|
203
217
|
Every CLI verb is a tool: `kb_write`, `kb_write_decision`, `kb_no_decision`,
|
|
204
218
|
`kb_status`, `kb_supersede`, `kb_answer`, `kb_load`, `kb_query`, `kb_trace`,
|
|
205
|
-
`kb_list`, `kb_index`, `kb_log`, `kb_validate`, `kb_schema`, `kb_types
|
|
206
|
-
|
|
207
|
-
format rather than any one base
|
|
219
|
+
`kb_list`, `kb_index`, `kb_log`, `kb_validate`, `kb_schema`, `kb_types`,
|
|
220
|
+
`kb_pin`, `kb_unpin`, `kb_pins`, `kb_context`. Most tools take a `bundlePath`;
|
|
221
|
+
`kb_schema` and `kb_types` describe the format rather than any one base, and
|
|
222
|
+
`kb_pins` and `kb_context` read the workspace pin manifests instead. The one
|
|
223
|
+
CLI verb with no tool is `sync-instructions` — file plumbing for hooks, not an
|
|
224
|
+
agent capability; the capability is `kb_context`.
|
|
208
225
|
|
|
209
226
|
```json
|
|
210
227
|
{
|
|
@@ -265,12 +282,20 @@ base answered eight; embedding search over the same records answered four. Two
|
|
|
265
282
|
of those differences are structural rather than matters of degree: a reader can
|
|
266
283
|
say no record answers the question, where vector search returns its nearest
|
|
267
284
|
neighbour whatever the distance; and a reader picks the record that answers the
|
|
268
|
-
question rather than the one nearest the topic.
|
|
285
|
+
question rather than the one nearest the topic. The mechanism is simple:
|
|
286
|
+
retrieval makes similarity the gatekeeper, and a match the ranker misses never
|
|
287
|
+
reaches the model. A full read lets the model do the matching itself —
|
|
288
|
+
synonymy, implication across records, aggregation — which no ranker does.
|
|
289
|
+
|
|
290
|
+
Read for a question, not for a session: a base loaded at the start of a long
|
|
291
|
+
conversation is summarised away by the end of it, and reloading costs about
|
|
292
|
+
three thousand tokens. Read it again at the point of use.
|
|
269
293
|
|
|
270
294
|
`load` refuses rather than truncating when a base exceeds its budget (25,000
|
|
271
295
|
tokens by default). A truncated base is indistinguishable from a complete one,
|
|
272
296
|
so a caller would answer "that was never decided" from a slice it did not know
|
|
273
|
-
was a slice.
|
|
297
|
+
was a slice. `context` refuses the same way at its own, tighter budget (4,000
|
|
298
|
+
by default). Superseded records come back as name, replacement and date only —
|
|
274
299
|
their bodies no longer hold, and a body read later in a long session outlives
|
|
275
300
|
the qualifier that said so. `trace` still reaches them by id.
|
|
276
301
|
|
|
@@ -294,6 +319,80 @@ fact; a missing replacement is `broken-chain` with no head — the case that nee
|
|
|
294
319
|
the most care, because returning the stale record unmarked looks exactly like
|
|
295
320
|
success.
|
|
296
321
|
|
|
322
|
+
## Living in an agent session
|
|
323
|
+
|
|
324
|
+
Long sessions lose a knowledge base twice over: attention decays, and
|
|
325
|
+
compaction summarises away both the records loaded early and the instruction
|
|
326
|
+
that said to consult them. The fix is two-tier: a small index is re-injected
|
|
327
|
+
at every context birth, and record bodies are fetched by tool when a question
|
|
328
|
+
actually needs them.
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
strauss-kb pin docs/kb # mark a base every session should see
|
|
332
|
+
strauss-kb context # emit the pinned index block
|
|
333
|
+
strauss-kb sync-instructions AGENTS.md # or keep it in an instruction file
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Pins live in `.strauss/kb-pins.json`, committed with the repo. Two more
|
|
337
|
+
layers exist: `.strauss/kb-pins.local.json` (personal, gitignore it) and
|
|
338
|
+
`~/.strauss/kb-pins.json` (every workspace). Nearest layer wins per base,
|
|
339
|
+
`--local`/`--user` write the other layers, and `unpin` removes from all
|
|
340
|
+
three. A malformed layer is skipped on read and refused on write.
|
|
341
|
+
|
|
342
|
+
Per pin:
|
|
343
|
+
|
|
344
|
+
- `--mode full` — inject the records themselves, not just the index. For
|
|
345
|
+
small or critical bases (ADRs). Falls back to a labelled index when it
|
|
346
|
+
cannot fit the block budget.
|
|
347
|
+
- `--mode index` — never inject bodies.
|
|
348
|
+
- `--profiles a,b` — only inject in the named profiles.
|
|
349
|
+
- `--frozen` — the base is concluded; write commands refuse until `--unfreeze`.
|
|
350
|
+
|
|
351
|
+
Budgets are named profiles — `session-start`, `compact`, `turn` — with
|
|
352
|
+
per-repo overrides in the manifest, so hook commands never carry numbers:
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
{
|
|
356
|
+
"pins": [{ "path": "docs/adr", "mode": "full" }],
|
|
357
|
+
"context": { "compact": { "budgetTokens": 1500 } }
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Flags beat the manifest, the manifest beats the built-ins, and invalid values
|
|
362
|
+
fall back to defaults instead of silencing the index. Past its budget,
|
|
363
|
+
`context` refuses like `load` does — never truncates — and its refusal says
|
|
364
|
+
what to load directly and how to shrink the block.
|
|
365
|
+
|
|
366
|
+
`sync-instructions <file>` keeps the same block between
|
|
367
|
+
`<!-- strauss-kb:begin/end -->` sentinels in AGENTS.md or CLAUDE.md, touching
|
|
368
|
+
nothing outside them. Re-run it when pins change; it is idempotent. This is
|
|
369
|
+
the mechanism for runtimes without a reliable post-compaction hook, since
|
|
370
|
+
instruction files are re-read where conversation history is not.
|
|
371
|
+
|
|
372
|
+
What each runtime gets (configs in the
|
|
373
|
+
[plugin's adapters](../../plugins/strauss-kb/adapters/)):
|
|
374
|
+
|
|
375
|
+
| Layer | Claude Code | Codex CLI | Antigravity CLI |
|
|
376
|
+
| ------------------------- | ------------------ | ------------------------------------------- | -------------------------- |
|
|
377
|
+
| MCP tool descriptions | ✓ | ✓ | ✓ |
|
|
378
|
+
| Session-start injection | SessionStart hook | SessionStart hook | PreInvocation, per turn |
|
|
379
|
+
| Post-compact re-injection | ✓ `compact` source | ✓ client-side; instruction-only when hosted | moot — injected every turn |
|
|
380
|
+
| File-read blocking | opt-in PreToolUse | ✗ (shell is the side door) | opt-in PreToolUse, JSON |
|
|
381
|
+
| Instruction file | CLAUDE.md | AGENTS.md | AGENTS.md + rules/ |
|
|
382
|
+
|
|
383
|
+
One more thing agents add: file tools. A raw read of a record file bypasses
|
|
384
|
+
standing entirely — a superseded record reads exactly like a current one — so
|
|
385
|
+
bases are read through the tools, and a workspace can enforce that with deny
|
|
386
|
+
rules or the plugin's opt-in PreToolUse script:
|
|
387
|
+
|
|
388
|
+
```json
|
|
389
|
+
{
|
|
390
|
+
"permissions": {
|
|
391
|
+
"deny": ["Read(.strauss/kb/**)", "Read(**/.strauss/kb/**)"]
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
297
396
|
## Optional search tier
|
|
298
397
|
|
|
299
398
|
`@tobilu/qmd` is an **optional peer dependency** providing BM25 (`searchLex`,
|
|
@@ -333,7 +432,8 @@ hold is the exception — no invariant, deterministic path.
|
|
|
333
432
|
the directory boundary. "Was this settled somewhere else?" is answered by a
|
|
334
433
|
person choosing which base to open. That is the price of a base that can be
|
|
335
434
|
copied, deleted, or handed over whole, and it is what keeps the search index
|
|
336
|
-
disposable.
|
|
435
|
+
disposable. [ARCHITECTURE.md](./ARCHITECTURE.md) covers the registry that would
|
|
436
|
+
lift it, and why it is unbuilt.
|
|
337
437
|
|
|
338
438
|
## License
|
|
339
439
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
KB_COMMANDS,
|
|
3
3
|
KbStore
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-HYNAEAPM.js";
|
|
5
5
|
|
|
6
6
|
// src/mcp.ts
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -18,6 +18,7 @@ function createKbMcpServer() {
|
|
|
18
18
|
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
19
19
|
};
|
|
20
20
|
for (const command of KB_COMMANDS) {
|
|
21
|
+
if (!command.tool) continue;
|
|
21
22
|
server.registerTool(
|
|
22
23
|
command.tool,
|
|
23
24
|
{ description: command.description, inputSchema: command.input.shape },
|
|
@@ -44,4 +45,4 @@ export {
|
|
|
44
45
|
createKbMcpServer,
|
|
45
46
|
runKbMcpServer
|
|
46
47
|
};
|
|
47
|
-
//# sourceMappingURL=chunk-
|
|
48
|
+
//# sourceMappingURL=chunk-FSI4Q2FD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/mcp.ts"],"sourcesContent":["import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { KB_COMMANDS } from \"./commands/index.js\";\nimport { KbStore } from \"./kb-store.js\";\n\n/**\n * A knowledge base's own MCP server, over stdio.\n *\n * Standalone because a base is self-contained: a directory of markdown that\n * needs no database, no HTTP surface, and no running service to read. Folding\n * these tools into a larger server would make every consumer start that server\n * to open files it could open itself.\n *\n * Every tool is a projection of `KB_COMMANDS`, which the CLI also projects, so\n * the two cannot drift.\n */\nexport function createKbMcpServer(): McpServer {\n const server = new McpServer({ name: \"strauss-kb\", version: \"0.1.0\" });\n const store = new KbStore({\n warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}\\n`),\n });\n const ctx = {\n store,\n actor: process.env.STRAUSS_KB_ACTOR ?? \"mcp\",\n now: () => new Date().toISOString(),\n };\n\n for (const command of KB_COMMANDS) {\n // CLI-only plumbing (sync-instructions) edits files for hooks and\n // instruction blocks; the agent capability it serves is kb_context.\n if (!command.tool) continue;\n server.registerTool(\n command.tool,\n { description: command.description, inputSchema: command.input.shape },\n async (args: unknown) => {\n const result = await command.run(ctx, command.input.parse(args));\n return {\n content: [\n {\n type: \"text\" as const,\n text:\n typeof result === \"string\"\n ? result\n : JSON.stringify(result, null, 2),\n },\n ],\n };\n },\n );\n }\n\n return server;\n}\n\nexport async function runKbMcpServer(): Promise<void> {\n await createKbMcpServer().connect(new StdioServerTransport());\n}\n"],"mappings":";;;;;;AAAA,SAAS,iBAAiB;AAC1B,SAAS,4BAA4B;AAe9B,SAAS,oBAA+B;AAC7C,QAAM,SAAS,IAAI,UAAU,EAAE,MAAM,cAAc,SAAS,QAAQ,CAAC;AACrE,QAAM,QAAQ,IAAI,QAAQ;AAAA,IACxB,MAAM,CAAC,UAAU,QAAQ,OAAO,MAAM,GAAG,KAAK,UAAU,KAAK,CAAC;AAAA,CAAI;AAAA,EACpE,CAAC;AACD,QAAM,MAAM;AAAA,IACV;AAAA,IACA,OAAO,QAAQ,IAAI,oBAAoB;AAAA,IACvC,KAAK,OAAM,oBAAI,KAAK,GAAE,YAAY;AAAA,EACpC;AAEA,aAAW,WAAW,aAAa;AAGjC,QAAI,CAAC,QAAQ,KAAM;AACnB,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,EAAE,aAAa,QAAQ,aAAa,aAAa,QAAQ,MAAM,MAAM;AAAA,MACrE,OAAO,SAAkB;AACvB,cAAM,SAAS,MAAM,QAAQ,IAAI,KAAK,QAAQ,MAAM,MAAM,IAAI,CAAC;AAC/D,eAAO;AAAA,UACL,SAAS;AAAA,YACP;AAAA,cACE,MAAM;AAAA,cACN,MACE,OAAO,WAAW,WACd,SACA,KAAK,UAAU,QAAQ,MAAM,CAAC;AAAA,YACtC;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAEA,eAAsB,iBAAgC;AACpD,QAAM,kBAAkB,EAAE,QAAQ,IAAI,qBAAqB,CAAC;AAC9D;","names":[]}
|