@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 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] Hand over the whole base, each record with its standing.
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] !== "--budget" ? { type: 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 { conceptId: record.conceptId };
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 { conceptId: record.conceptId };
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 { conceptId: conceptId2, frontmatter, body: input.body };
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.mutate(
1984
+ const superseded = await this.markSuperseded(
1956
1985
  bundlePath2,
1957
1986
  conceptId2,
1958
- (frontmatter) => ({
1959
- ...frontmatter,
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
- approxTokens: approxTokens2,
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-HYNAEAPM.js.map
2388
+ //# sourceMappingURL=chunk-FZIMFPGR.js.map