@saasontools/strauss-kb 0.1.3 → 0.1.5
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 +37 -2
- package/dist/{chunk-HYNAEAPM.js → chunk-FZIMFPGR.js} +88 -23
- package/dist/chunk-FZIMFPGR.js.map +1 -0
- package/dist/{chunk-QLTB77W4.js → chunk-KQMGKSPZ.js} +2 -2
- package/dist/{chunk-FSI4Q2FD.js → chunk-VOJ6D6OX.js} +2 -2
- package/dist/cli-main.cjs +87 -22
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +87 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -3
- package/dist/index.d.ts +28 -3
- package/dist/index.js +3 -3
- package/dist/mcp-main.cjs +87 -22
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-HYNAEAPM.js.map +0 -1
- /package/dist/{chunk-QLTB77W4.js.map → chunk-KQMGKSPZ.js.map} +0 -0
- /package/dist/{chunk-FSI4Q2FD.js.map → chunk-VOJ6D6OX.js.map} +0 -0
package/README.md
CHANGED
|
@@ -151,7 +151,31 @@ before publishing, which narrows the lost-update window rather than closing it.
|
|
|
151
151
|
[ARCHITECTURE.md](./ARCHITECTURE.md) says why a lock was rejected.
|
|
152
152
|
|
|
153
153
|
`supersede` writes both directions, so a backlink cannot drift in normal use and
|
|
154
|
-
`validate` drops to catching hand-edits.
|
|
154
|
+
`validate` drops to catching hand-edits. A `write` (or `write-decision`) that
|
|
155
|
+
carries `supersedes` does the same: the new record publishes first, then each
|
|
156
|
+
prior record it names is marked superseded in turn — a crash between the two
|
|
157
|
+
leaves an old record with no backlink, which `validate` already reports as
|
|
158
|
+
"is not marked superseded", never a silent drift. A `supersedes` id naming a
|
|
159
|
+
record that does not exist yet is legal and does not fail the write; `validate`
|
|
160
|
+
is what reports a target that never resolves. A `supersedes` id naming the
|
|
161
|
+
record's own concept id is a no-op rather than an error, duplicate ids mark
|
|
162
|
+
once, and the array is capped at 32 entries.
|
|
163
|
+
|
|
164
|
+
A concurrent writer marking the same target races the compare-and-swap check;
|
|
165
|
+
that's retried a few times before giving up, and giving up is reported the
|
|
166
|
+
same way as a target that doesn't exist yet — left out of `supersededIds` for
|
|
167
|
+
`validate` to catch, not thrown, since the calling record is already
|
|
168
|
+
published by that point. If two different records both name the same target
|
|
169
|
+
in `supersedes`, the target's backlink points at whichever wrote last;
|
|
170
|
+
`validate` doesn't see this as a problem because the target genuinely is
|
|
171
|
+
superseded, but `kb_query`/`kb_load`'s adjudication surfaces the resulting
|
|
172
|
+
fork as a warning at read time.
|
|
173
|
+
|
|
174
|
+
`kb_write` and `kb_write_decision` return
|
|
175
|
+
`{ conceptId, action: "created" | "superseded-prior", supersededIds }` —
|
|
176
|
+
`supersededIds` is only the ids actually marked, not every id the input named.
|
|
177
|
+
A 409 from a concept-id collision carries `action: "refused"` in its `details`,
|
|
178
|
+
alongside the `conceptId`.
|
|
155
179
|
|
|
156
180
|
Records are never deleted. Superseding keeps the earlier reasoning inspectable,
|
|
157
181
|
which is what a later `trace` reads.
|
|
@@ -172,7 +196,7 @@ strauss-kb [--bundle PATH] <command> [args]
|
|
|
172
196
|
status <concept-id> <status> Move a record's status, compare-and-swap.
|
|
173
197
|
supersede <concept-id> <replacement-id> Mark a record superseded, linking both directions.
|
|
174
198
|
answer <concept-id> <answer...> Resolve an open question and append the answer.
|
|
175
|
-
load [type] [--budget N]
|
|
199
|
+
load [type] [--budget N | --all] Hand over the whole base, each record with its standing.
|
|
176
200
|
query <text...> Search; every match arrives flagged with its standing.
|
|
177
201
|
trace <concept-id> [edges...] How a position was arrived at, as a timeline.
|
|
178
202
|
list [type] Every record, optionally narrowed to one type.
|
|
@@ -299,6 +323,17 @@ by default). Superseded records come back as name, replacement and date only —
|
|
|
299
323
|
their bodies no longer hold, and a body read later in a long session outlives
|
|
300
324
|
the qualifier that said so. `trace` still reaches them by id.
|
|
301
325
|
|
|
326
|
+
`--all` (`all: true` over MCP) is the escape hatch: it bypasses the refusal
|
|
327
|
+
outright and hands back the entire bundle whatever its size. A loaded result
|
|
328
|
+
carries `tokensLoaded`, the same estimate the budget is held against, and
|
|
329
|
+
`budgetTokens: null` marks that no ceiling was applied; `--all` is mutually
|
|
330
|
+
exclusive with `--budget`. That refusal is the guardrail an agent needs so a
|
|
331
|
+
wide base does not silently consume its whole context; `--all` is for a
|
|
332
|
+
deliberate operator who has decided the size is worth the tokens, not a
|
|
333
|
+
setting to reach for by default. A reader that does not actually need every
|
|
334
|
+
record is better served by a narrower `type` filter or a `query` than by
|
|
335
|
+
turning the guardrail off.
|
|
336
|
+
|
|
302
337
|
**Flag, never filter.** `query` returns every hit with its standing, because a
|
|
303
338
|
filtered result set is invisible — the caller cannot tell it missed anything.
|
|
304
339
|
The single exception is narrow: a superseded record is dropped only when its
|
|
@@ -180,7 +180,7 @@ var composeInputSchema = z2.object({
|
|
|
180
180
|
/** Concept ids this record relates to; rendered as body links. */
|
|
181
181
|
relatedConceptIds: z2.array(kbConceptIdSchema).optional(),
|
|
182
182
|
/** Concept ids this record replaces. The store settles the backlinks. */
|
|
183
|
-
supersedes: z2.array(kbConceptIdSchema).optional(),
|
|
183
|
+
supersedes: z2.array(kbConceptIdSchema).max(32).optional(),
|
|
184
184
|
materiality: z2.enum(KB_MATERIALITIES).optional(),
|
|
185
185
|
confidence: z2.enum(KB_CONFIDENCES).optional(),
|
|
186
186
|
owner: z2.string().min(1).optional()
|
|
@@ -1198,25 +1198,32 @@ import { z as z11 } from "zod";
|
|
|
1198
1198
|
var loadCommand = define({
|
|
1199
1199
|
name: "load",
|
|
1200
1200
|
tool: "kb_load",
|
|
1201
|
-
usage: "load [type] [--budget N]",
|
|
1202
|
-
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.",
|
|
1201
|
+
usage: "load [type] [--budget N | --all]",
|
|
1202
|
+
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.\n\nThat refusal is the default guardrail, meant for an agent that would otherwise burn its whole context on one call. `all` bypasses it and loads everything regardless of size: a deliberate operator with the budget to spend, not something to reach for automatically. It is mutually exclusive with `budgetTokens`. When the reader does not need everything, kb_query or a narrower `type` filter is the better fit than either.",
|
|
1203
1203
|
input: z11.object({
|
|
1204
1204
|
bundlePath,
|
|
1205
1205
|
type: z11.enum(KB_RECORD_TYPES).optional(),
|
|
1206
|
-
budgetTokens: z11.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
|
|
1206
|
+
budgetTokens: z11.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
|
|
1207
|
+
all: z11.boolean().optional().describe(
|
|
1208
|
+
"Load the entire base regardless of size. The deliberate-operator escape hatch; mutually exclusive with budgetTokens."
|
|
1209
|
+
)
|
|
1210
|
+
}).refine((value) => !(value.all && value.budgetTokens !== void 0), {
|
|
1211
|
+
message: "all and budgetTokens are mutually exclusive: pass a ceiling or none, not both."
|
|
1207
1212
|
}),
|
|
1208
1213
|
fromArgv: (argv, path) => {
|
|
1209
1214
|
const budget = argvFlag(argv, "--budget");
|
|
1210
1215
|
return {
|
|
1211
1216
|
bundlePath: path,
|
|
1212
|
-
...argv[1] && argv[1]
|
|
1213
|
-
...budget ? { budgetTokens: Number(budget) } : {}
|
|
1217
|
+
...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {},
|
|
1218
|
+
...budget ? { budgetTokens: Number(budget) } : {},
|
|
1219
|
+
...argv.includes("--all") ? { all: true } : {}
|
|
1214
1220
|
};
|
|
1215
1221
|
},
|
|
1216
|
-
run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
|
|
1222
|
+
run: async ({ store }, { bundlePath: path, type, budgetTokens, all }) => {
|
|
1217
1223
|
const result = await store.load(path, {
|
|
1218
1224
|
...type ? { type } : {},
|
|
1219
|
-
...budgetTokens ? { budgetTokens } : {}
|
|
1225
|
+
...budgetTokens ? { budgetTokens } : {},
|
|
1226
|
+
...all ? { all } : {}
|
|
1220
1227
|
});
|
|
1221
1228
|
if (!result.loaded) return result;
|
|
1222
1229
|
return {
|
|
@@ -1561,7 +1568,11 @@ var writeCommand = define({
|
|
|
1561
1568
|
composeRecord(type, input, actor, now()),
|
|
1562
1569
|
actor
|
|
1563
1570
|
);
|
|
1564
|
-
return {
|
|
1571
|
+
return {
|
|
1572
|
+
conceptId: record.conceptId,
|
|
1573
|
+
action: record.action,
|
|
1574
|
+
supersededIds: record.supersededIds
|
|
1575
|
+
};
|
|
1565
1576
|
}
|
|
1566
1577
|
});
|
|
1567
1578
|
|
|
@@ -1591,7 +1602,11 @@ var writeDecisionCommand = define({
|
|
|
1591
1602
|
composeDecisionRecord(input, actor, now()),
|
|
1592
1603
|
actor
|
|
1593
1604
|
);
|
|
1594
|
-
return {
|
|
1605
|
+
return {
|
|
1606
|
+
conceptId: record.conceptId,
|
|
1607
|
+
action: record.action,
|
|
1608
|
+
supersededIds: record.supersededIds
|
|
1609
|
+
};
|
|
1595
1610
|
}
|
|
1596
1611
|
});
|
|
1597
1612
|
|
|
@@ -1690,7 +1705,7 @@ var KbRecordAlreadyExistsError = class extends BaseError {
|
|
|
1690
1705
|
fault: "User" /* User */,
|
|
1691
1706
|
retriable: false,
|
|
1692
1707
|
reportToUser: true,
|
|
1693
|
-
details: { conceptId: conceptId2 }
|
|
1708
|
+
details: { conceptId: conceptId2, action: "refused" }
|
|
1694
1709
|
});
|
|
1695
1710
|
this.conceptId = conceptId2;
|
|
1696
1711
|
}
|
|
@@ -1882,13 +1897,27 @@ var KbStore = class {
|
|
|
1882
1897
|
conceptId: conceptId2,
|
|
1883
1898
|
by: actor
|
|
1884
1899
|
});
|
|
1900
|
+
const targets = new Set(frontmatter.strauss_supersedes ?? []);
|
|
1901
|
+
targets.delete(conceptId2);
|
|
1902
|
+
const supersededIds = [];
|
|
1903
|
+
for (const old of targets) {
|
|
1904
|
+
if (await this.markSupersededRetrying(bundlePath2, old, conceptId2, actor)) {
|
|
1905
|
+
supersededIds.push(old);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1885
1908
|
this.logger.info?.({
|
|
1886
1909
|
operation: "kb.write",
|
|
1887
1910
|
bundlePath: root,
|
|
1888
1911
|
conceptId: conceptId2,
|
|
1889
1912
|
anchors: frontmatter.strauss_anchors?.length ?? 0
|
|
1890
1913
|
});
|
|
1891
|
-
return {
|
|
1914
|
+
return {
|
|
1915
|
+
conceptId: conceptId2,
|
|
1916
|
+
frontmatter,
|
|
1917
|
+
body: input.body,
|
|
1918
|
+
action: supersededIds.length ? "superseded-prior" : "created",
|
|
1919
|
+
supersededIds
|
|
1920
|
+
};
|
|
1892
1921
|
}
|
|
1893
1922
|
/** One record by concept id, or null when it does not exist. */
|
|
1894
1923
|
async read(bundlePath2, conceptId2) {
|
|
@@ -1952,15 +1981,11 @@ var KbStore = class {
|
|
|
1952
1981
|
async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
|
|
1953
1982
|
const replacement = await this.read(bundlePath2, replacementId);
|
|
1954
1983
|
if (!replacement) throw new KbRecordNotFoundError(replacementId);
|
|
1955
|
-
const superseded = await this.
|
|
1984
|
+
const superseded = await this.markSuperseded(
|
|
1956
1985
|
bundlePath2,
|
|
1957
1986
|
conceptId2,
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
strauss_status: "superseded",
|
|
1961
|
-
strauss_superseded_by: replacementId
|
|
1962
|
-
}),
|
|
1963
|
-
{ operation: "supersede", by: actor, target: replacementId }
|
|
1987
|
+
replacementId,
|
|
1988
|
+
actor
|
|
1964
1989
|
);
|
|
1965
1990
|
await this.mutate(
|
|
1966
1991
|
bundlePath2,
|
|
@@ -2049,6 +2074,10 @@ ${answer}
|
|
|
2049
2074
|
* Refuses rather than truncates when the base is too large. A truncated base
|
|
2050
2075
|
* is indistinguishable from a complete one, so a caller would answer "that
|
|
2051
2076
|
* was never decided" from a slice it did not know was a slice.
|
|
2077
|
+
*
|
|
2078
|
+
* That refusal is the default guardrail. `all` bypasses it outright and
|
|
2079
|
+
* always hands back the whole bundle: an explicit, never-accidental escape
|
|
2080
|
+
* hatch for an operator who has the budget to spend, not a wider default.
|
|
2052
2081
|
*/
|
|
2053
2082
|
async load(bundlePath2, options = {}) {
|
|
2054
2083
|
const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
|
|
@@ -2058,7 +2087,7 @@ ${answer}
|
|
|
2058
2087
|
const records = adjudicated.filter((hit) => hit.standing !== "superseded");
|
|
2059
2088
|
const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
|
|
2060
2089
|
const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
|
|
2061
|
-
if (approxTokens2 > budgetTokens) {
|
|
2090
|
+
if (!options.all && approxTokens2 > budgetTokens) {
|
|
2062
2091
|
return {
|
|
2063
2092
|
loaded: false,
|
|
2064
2093
|
recordCount: wanted.length,
|
|
@@ -2069,8 +2098,8 @@ ${answer}
|
|
|
2069
2098
|
return {
|
|
2070
2099
|
loaded: true,
|
|
2071
2100
|
recordCount: wanted.length,
|
|
2072
|
-
|
|
2073
|
-
budgetTokens,
|
|
2101
|
+
tokensLoaded: approxTokens2,
|
|
2102
|
+
budgetTokens: options.all ? null : budgetTokens,
|
|
2074
2103
|
records,
|
|
2075
2104
|
superseded
|
|
2076
2105
|
};
|
|
@@ -2124,6 +2153,42 @@ ${answer}
|
|
|
2124
2153
|
}
|
|
2125
2154
|
return result;
|
|
2126
2155
|
}
|
|
2156
|
+
/**
|
|
2157
|
+
* `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
|
|
2158
|
+
* a missing target (a broken link, legal per compose.ts) or a CAS conflict
|
|
2159
|
+
* from a concurrent writer touching the same target. A conflict is retried
|
|
2160
|
+
* a bounded number of times — each attempt re-reads the target fresh — and
|
|
2161
|
+
* on the last, `false` reports "not marked" rather than throwing: the
|
|
2162
|
+
* caller's own record is already published, so failing here would leave
|
|
2163
|
+
* that publish unreported instead of undone. kb_validate's existing
|
|
2164
|
+
* "not marked superseded" check is what surfaces the residue.
|
|
2165
|
+
*/
|
|
2166
|
+
async markSupersededRetrying(bundlePath2, conceptId2, replacementId, actor, retries = 3) {
|
|
2167
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
2168
|
+
try {
|
|
2169
|
+
await this.markSuperseded(bundlePath2, conceptId2, replacementId, actor);
|
|
2170
|
+
return true;
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
if (error instanceof KbRecordNotFoundError) return false;
|
|
2173
|
+
if (!(error instanceof KbWriteConflictError)) throw error;
|
|
2174
|
+
if (attempt === retries) return false;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
/** The one-directional half of `supersede`: marks `conceptId` superseded. */
|
|
2180
|
+
async markSuperseded(bundlePath2, conceptId2, replacementId, actor) {
|
|
2181
|
+
return this.mutate(
|
|
2182
|
+
bundlePath2,
|
|
2183
|
+
conceptId2,
|
|
2184
|
+
(frontmatter) => ({
|
|
2185
|
+
...frontmatter,
|
|
2186
|
+
strauss_status: "superseded",
|
|
2187
|
+
strauss_superseded_by: replacementId
|
|
2188
|
+
}),
|
|
2189
|
+
{ operation: "supersede", by: actor, target: replacementId }
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2127
2192
|
async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
|
|
2128
2193
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
2129
2194
|
const before = await readFile3(target, "utf8").catch(() => null);
|
|
@@ -2320,4 +2385,4 @@ export {
|
|
|
2320
2385
|
KB_DIR,
|
|
2321
2386
|
KbStore
|
|
2322
2387
|
};
|
|
2323
|
-
//# sourceMappingURL=chunk-
|
|
2388
|
+
//# sourceMappingURL=chunk-FZIMFPGR.js.map
|