@hraness/wordcell 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +29 -15
  2. package/dist/agent-context.js +1 -0
  3. package/dist/agent-guide-audit.js +1 -0
  4. package/dist/attachments.js +1 -0
  5. package/dist/authoring.js +1 -0
  6. package/dist/authority-4sp9d552.js +663 -0
  7. package/dist/benchmark.js +1 -0
  8. package/dist/capture.js +1 -0
  9. package/dist/cli.js +3781 -32
  10. package/dist/clip/acquire.js +1 -0
  11. package/dist/clip/args.js +1 -0
  12. package/dist/clip/bounded-byte-buffer.js +1 -0
  13. package/dist/clip/browser-profiles.js +1 -0
  14. package/dist/clip/bundle-reader.js +1 -0
  15. package/dist/clip/cli.js +1 -0
  16. package/dist/clip/cookies.js +1 -0
  17. package/dist/clip/doctor.js +1 -0
  18. package/dist/clip/jobs.js +1 -0
  19. package/dist/clip/network-proxy.js +1 -0
  20. package/dist/clip/network.js +1 -0
  21. package/dist/clip/persist.js +1 -0
  22. package/dist/clip/refresh.js +1 -0
  23. package/dist/clip/terminal.js +1 -0
  24. package/dist/evaluation-builder.js +13 -5
  25. package/dist/evaluation-kb.js +11 -4
  26. package/dist/evaluation.js +1 -0
  27. package/dist/git.js +1 -0
  28. package/dist/graph-authority.js +29 -0
  29. package/dist/graph-percolation.js +16 -0
  30. package/dist/graph.js +1 -0
  31. package/dist/index-0k2x4nn9.js +393 -0
  32. package/dist/{index-n05s3wsb.js → index-115b07ap.js} +156 -541
  33. package/dist/index-11621h23.js +160 -0
  34. package/dist/{index-tkpxp48t.js → index-51bap0mq.js} +2 -2
  35. package/dist/{index-8a08v6x8.js → index-59ssmqqm.js} +1 -1
  36. package/dist/index-bcfn9xah.js +472 -0
  37. package/dist/index-bgfzwt4h.js +51 -0
  38. package/dist/{index-bs0yf4pp.js → index-pbr6rv47.js} +2 -2
  39. package/dist/{index-m7et4rz4.js → index-vdw5yjfa.js} +61 -9
  40. package/dist/index-z1w83f81.js +4 -0
  41. package/dist/index.js +61 -34
  42. package/dist/navigation.js +1 -0
  43. package/dist/pdf.js +1 -0
  44. package/dist/percolate.js +1 -0
  45. package/dist/portfolio.js +11 -4
  46. package/dist/query.js +1 -0
  47. package/dist/repository-memory.js +1 -0
  48. package/dist/sdk.js +10 -3
  49. package/dist/search-rules.js +1 -0
  50. package/dist/search.js +1 -0
  51. package/dist/semantic.js +3 -1
  52. package/dist/source-inbox.js +1 -0
  53. package/dist/untrusted-content.js +1 -0
  54. package/dist/url-intelligence.js +1 -0
  55. package/dist/workflow.js +1 -0
  56. package/dist/workflows/decision-context.js +11 -4
  57. package/dist/workflows/explain-change.js +1 -0
  58. package/dist/workflows/index.js +11 -4
  59. package/dist/workflows/plan-radar.js +1 -0
  60. package/package.json +24 -6
  61. package/skills/wordcell/SKILL.md +2 -2
  62. package/skills/wordcell/references/percolate.md +4 -0
  63. package/skills/wordcell/references/query.md +6 -0
  64. package/src/cli-program.ts +45 -2
  65. package/src/graph-authority-model.ts +116 -0
  66. package/src/graph-authority.ts +219 -0
  67. package/src/graph-cache-filesystem.ts +4 -0
  68. package/src/graph-cli.ts +96 -0
  69. package/src/graph-facts.ts +206 -0
  70. package/src/graph-percolation.ts +39 -0
  71. package/src/graph-query.ts +122 -0
  72. package/src/index.ts +2 -0
  73. package/src/oh/authority.ts +193 -0
  74. package/src/oh/programs.ts +99 -0
  75. package/src/oh/schema.ts +40 -0
  76. package/src/oh/snapshot.ts +114 -0
  77. package/src/oh/validation.ts +61 -0
  78. package/src/sdk.ts +51 -6
  79. package/dist/index-sy0zety7.js +0 -3630
  80. package/dist/kb-alias.js +0 -55
  81. package/src/kb-alias.ts +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/wordcell",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A knowledge base for coding agents, built from Markdown, backlinks, semantic search, and Git context.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -261,11 +261,18 @@
261
261
  "./workflows/plan-radar": {
262
262
  "types": "./src/workflows/plan-radar.ts",
263
263
  "import": "./dist/workflows/plan-radar.js"
264
+ },
265
+ "./graph-authority": {
266
+ "types": "./src/graph-authority.ts",
267
+ "import": "./dist/graph-authority.js"
268
+ },
269
+ "./graph-percolation": {
270
+ "types": "./src/graph-percolation.ts",
271
+ "import": "./dist/graph-percolation.js"
264
272
  }
265
273
  },
266
274
  "bin": {
267
275
  "wordcell": "./dist/cli.js",
268
- "kb": "./dist/kb-alias.js",
269
276
  "wordcell-evaluation-builder": "./dist/evaluation-builder.js"
270
277
  },
271
278
  "main": "./dist/index.js",
@@ -285,7 +292,6 @@
285
292
  "src/url-intelligence.ts",
286
293
  "src/cli.ts",
287
294
  "src/cli-program.ts",
288
- "src/kb-alias.ts",
289
295
  "src/clip/acquire.ts",
290
296
  "src/clip/archive-today.ts",
291
297
  "src/clip/args.ts",
@@ -378,14 +384,26 @@
378
384
  "src/workflows/plan-radar.ts",
379
385
  "skills/wordcell",
380
386
  "README.md",
381
- "LICENSE"
387
+ "LICENSE",
388
+ "src/graph-authority.ts",
389
+ "src/graph-authority-model.ts",
390
+ "src/graph-cache-filesystem.ts",
391
+ "src/graph-facts.ts",
392
+ "src/graph-query.ts",
393
+ "src/graph-cli.ts",
394
+ "src/graph-percolation.ts",
395
+ "src/oh/authority.ts",
396
+ "src/oh/programs.ts",
397
+ "src/oh/schema.ts",
398
+ "src/oh/snapshot.ts",
399
+ "src/oh/validation.ts"
382
400
  ],
383
401
  "scripts": {
384
402
  "kb:refresh": "bunx --bun github:hraness/kb#v0.15.1 refresh --root kb",
385
403
  "kb:check:lane": "bunx --bun github:hraness/kb#v0.15.1 check --root kb --no-catalog",
386
404
  "kb:check": "bunx --bun github:hraness/kb#v0.15.1 check --root kb && bunx --bun github:hraness/kb#v0.15.1 agents check --root kb --repo .",
387
405
  "kb:catalog": "bunx --bun github:hraness/kb#v0.15.1 catalog --root kb",
388
- "build": "bun -e 'await (await import(\"node:fs/promises\")).rm(\"./dist\", { recursive: true, force: true })' && bun build ./src/index.ts ./src/agent-context.ts ./src/agent-guide-audit.ts ./src/attachments.ts ./src/authoring.ts ./src/benchmark.ts ./src/clip/browser-profiles.ts ./src/capture.ts ./src/cli.ts ./src/kb-alias.ts ./src/clip/acquire.ts ./src/clip/args.ts ./src/clip/bounded-byte-buffer.ts ./src/clip/bundle-reader.ts ./src/clip/cli.ts ./src/clip/cookies.ts ./src/clip/doctor.ts ./src/clip/jobs.ts ./src/clip/network.ts ./src/clip/network-proxy.ts ./src/clip/persist.ts ./src/clip/refresh.ts ./src/clip/terminal.ts ./src/evaluation.ts ./src/evaluation-builder.ts ./src/evaluation-kb.ts ./src/git.ts ./src/graph.ts ./src/navigation.ts ./src/pdf.ts ./src/percolate.ts ./src/portfolio.ts ./src/query.ts ./src/repository-memory.ts ./src/sdk.ts ./src/search.ts ./src/search-rules.ts ./src/semantic.ts ./src/source-inbox.ts ./src/untrusted-content.ts ./src/url-intelligence.ts ./src/workflow.ts ./src/workflows/index.ts ./src/workflows/decision-context.ts ./src/workflows/explain-change.ts ./src/workflows/plan-radar.ts ./src/clip/defuddle-worker.ts --outdir ./dist --root ./src --target bun --format esm --splitting --packages external",
406
+ "build": "bun -e 'await (await import(\"node:fs/promises\")).rm(\"./dist\", { recursive: true, force: true })' && bun build ./src/index.ts ./src/agent-context.ts ./src/agent-guide-audit.ts ./src/attachments.ts ./src/authoring.ts ./src/benchmark.ts ./src/clip/browser-profiles.ts ./src/capture.ts ./src/cli.ts ./src/clip/acquire.ts ./src/clip/args.ts ./src/clip/bounded-byte-buffer.ts ./src/clip/bundle-reader.ts ./src/clip/cli.ts ./src/clip/cookies.ts ./src/clip/doctor.ts ./src/clip/jobs.ts ./src/clip/network.ts ./src/clip/network-proxy.ts ./src/clip/persist.ts ./src/clip/refresh.ts ./src/clip/terminal.ts ./src/evaluation.ts ./src/evaluation-builder.ts ./src/evaluation-kb.ts ./src/git.ts ./src/graph.ts ./src/graph-percolation.ts ./src/graph-authority.ts ./src/navigation.ts ./src/pdf.ts ./src/percolate.ts ./src/portfolio.ts ./src/query.ts ./src/repository-memory.ts ./src/sdk.ts ./src/search.ts ./src/search-rules.ts ./src/semantic.ts ./src/source-inbox.ts ./src/untrusted-content.ts ./src/url-intelligence.ts ./src/workflow.ts ./src/workflows/index.ts ./src/workflows/decision-context.ts ./src/workflows/explain-change.ts ./src/workflows/plan-radar.ts ./src/clip/defuddle-worker.ts --outdir ./dist --root ./src --target bun --format esm --splitting --packages external",
389
407
  "check:workflows": "bun ./scripts/check-workflow-yaml.ts",
390
408
  "check:installed-command-docs": "bun ./scripts/check-installed-command-docs.ts",
391
409
  "check:portfolio-inventory": "bun ./scripts/check-portfolio-inventory.ts",
@@ -400,7 +418,7 @@
400
418
  "check:effect": "bun run scripts/check-effect-policy.ts && bun test ./scripts/check-effect-architecture.test.ts"
401
419
  },
402
420
  "dependencies": {
403
- "@hraness/oh": "github:hraness/oh#v0.2.0",
421
+ "@hraness/oh": "https://github.com/hraness/oh/releases/download/v0.4.3/hraness-oh-0.4.3.tgz",
404
422
  "@steipete/sweet-cookie": "0.4.3",
405
423
  "@tobilu/qmd": "git+https://github.com/hraness/qmd.git#aa993dceb3ef8cfb71d470554ca437570f5a2b3c",
406
424
  "agent-browser": "0.32.3",
@@ -53,9 +53,9 @@ rules, then repeat command discovery. Install Wordcell only while `wordcell` rem
53
53
  missing:
54
54
 
55
55
  ```sh
56
- command -v kb >/dev/null 2>&1 || {
56
+ command -v wordcell >/dev/null 2>&1 || {
57
57
  command -v bun >/dev/null 2>&1 || exit 1
58
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.20.0/hraness-wordcell-0.20.0.tgz
58
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.21.0/hraness-wordcell-0.21.0.tgz
59
59
  }
60
60
  wordcell --help
61
61
  ```
@@ -174,3 +174,7 @@ authored-catalog vault, refresh and check leave the front door untouched, while
174
174
  `wordcell catalog --root "$KB_ROOT"` renders an exhaustive disposable inventory.
175
175
  Resolve same-note Git conflicts from the prose and evidence; do not accept one
176
176
  side's frontmatter mechanically.
177
+
178
+ ## Inspect positive graph proofs
179
+
180
+ Use `wordcell percolate <note> --proofs --root "$KB_ROOT" --json` when you need shared-tag and shared-concept proof trees. This explicit envelope keeps existing V2 candidates under `suggestions` and separate bounded query results under `positiveSupport`. Absence and counts remain Wordcell decisions; choosing a predicate and writing a relationship remain author decisions. Inspect all truncation markers and read the cited Markdown before editing.
@@ -250,3 +250,9 @@ then leave it untouched. `wordcell catalog --root "$KB_ROOT"` renders the exhaus
250
250
  inventory on demand. A managed vault keeps the original generated-catalog
251
251
  behavior. Neither mode changes scanning, graph analysis, semantic indexing, or
252
252
  attachment validation.
253
+
254
+ ## Query named graph programs
255
+
256
+ Use `wordcell graph query --program backlinks --note <exact-id> --root "$KB_ROOT" --json` for positive source proofs. Programs also include bounded `reachability`, predicate-specific `relation-closure`, exact `scope-route`, `shared-tags`, and `shared-concepts`. Default queries write nothing. `graph rebuild` writes only the disposable `.wordcell/oh.sqlite` cache; `graph verify` and queries with `--persisted` reject missing or stale state without repairing it. After a damaged cache, `graph rebuild --fresh` builds a replacement from Markdown.
257
+
258
+ Inspect the source revision, row/proof truncation and source record digests. Reopen SDK sessions after Markdown changes. Proofs establish derivation from source records, not the truth of an authored assertion. `wordcell graph --json` keeps its existing structural report.
@@ -68,6 +68,13 @@ import {
68
68
  type RelationIssue,
69
69
  type VaultAnalysis,
70
70
  } from "./graph.js";
71
+ import {
72
+ executeGraphCommand,
73
+ parseGraphCommand,
74
+ type GraphCliCommand,
75
+ type GraphCliDependencies,
76
+ } from "./graph-cli.js";
77
+ import { percolateWithGraph } from "./graph-percolation.js";
71
78
  import { initVault, type InitVaultResult } from "./init.js";
72
79
  import type {
73
80
  GitHistoryForNotesResult,
@@ -223,13 +230,16 @@ Usage:
223
230
  wordcell check [--root <directory>] [--index <path>] [--no-catalog] [--json]
224
231
  wordcell catalog [--root <directory>] [--index <path>] [--json]
225
232
  wordcell graph [--root <directory>] [--index <path>] [--json]
233
+ wordcell graph rebuild [--fresh] [--root <directory>] [--index <path>] [--json]
234
+ wordcell graph verify [--root <directory>] [--index <path>] [--json]
235
+ wordcell graph query --program <backlinks|reachability|scope-route|relation-closure|shared-tags|shared-concepts> [--note <id> | --scope <path>] [--predicate <predicate>] [--depth <count>] [--limit <count>] [--persisted] [--root <directory>] [--index <path>] [--json]
226
236
  wordcell backlinks <note> [--root <directory>] [--index <path>] [--json]
227
237
  wordcell links <note> [--root <directory>] [--direction <in|out|both>] [--depth <count>] [--limit <count>] [--json]
228
238
  wordcell note create <id> --title <title> [--type <type>] [--tag <tag>] [--body <markdown> | --body-file <path>] [--root <directory>] [--json]
229
239
  wordcell relation add <source> <predicate> <target> [--root <directory>] [--expected-revision <sha256:...>] [--json]
230
240
  wordcell relation remove <source> <predicate> <target> [--root <directory>] [--expected-revision <sha256:...>] [--json]
231
241
  wordcell relation list <note> [--root <directory>] [--json]
232
- wordcell percolate [note] [--root <directory>] [--min-support <count>] [--limit <count>] [--json]
242
+ wordcell percolate [note] [--proofs] [--root <directory>] [--min-support <count>] [--limit <count>] [--json]
233
243
  wordcell list [--root <directory>] [--where <path=value>] [--has <path>] [--tag <tag>] [--scope <repository-path>] [--sort <field>] [--order <asc|desc>] [--limit <count>] [--json]
234
244
  wordcell index [--root <directory>] [--database <path>] [--force] [--json]
235
245
  wordcell search <query> [--root <directory>] [--repo <repository>] [--database <path>] [--mode <hybrid|exact|keyword|semantic>] [--rules <file>] [--priority] [--where <path=value>] [--has <path>] [--tag <tag>] [--scope <repository-path>] [--related <note>] [--graph-depth <1|2>] [--no-graph] [--history | --no-history | --require-history] [--limit <count>] [--candidate-limit <count>] [--min-score <score>] [--json]
@@ -252,6 +262,7 @@ Run \`wordcell clip --help\` for web capture options or \`wordcell pdf --help\`
252
262
  type VaultCommand = "refresh" | "check" | "graph" | "backlinks" | "links";
253
263
 
254
264
  type ParsedCommand =
265
+ | GraphCliCommand
255
266
  | { readonly kind: "help" }
256
267
  | { readonly kind: "clip"; readonly arguments: readonly string[] }
257
268
  | {
@@ -422,6 +433,7 @@ type ParsedCommand =
422
433
  }
423
434
  | {
424
435
  readonly kind: "percolate";
436
+ readonly proofs?: true;
425
437
  readonly root: string;
426
438
  readonly note?: string;
427
439
  readonly minSupport: number;
@@ -433,7 +445,7 @@ type ParseResult =
433
445
  | { readonly ok: true; readonly value: ParsedCommand }
434
446
  | { readonly ok: false; readonly message: string };
435
447
 
436
- type CliDependencies = {
448
+ type CliDependencies = GraphCliDependencies & {
437
449
  readonly runClipCommand?: typeof runClipCommand;
438
450
  readonly readCaptureBundle?: typeof readCaptureBundle;
439
451
  readonly verifyCaptureBundle?: typeof verifyCaptureBundle;
@@ -455,6 +467,7 @@ type CliDependencies = {
455
467
  readonly addNoteRelation?: typeof addNoteRelation;
456
468
  readonly removeNoteRelation?: typeof removeNoteRelation;
457
469
  readonly percolateVault?: typeof percolateVault;
470
+ readonly percolateWithGraph?: typeof percolateWithGraph;
458
471
  readonly inspectAgentContextRepository?: typeof inspectAgentContextRepository;
459
472
  readonly buildRepositoryMemoryContext?: typeof buildRepositoryMemoryContext;
460
473
  readonly auditAgentGuideRepository?: typeof auditAgentGuideRepository;
@@ -1678,10 +1691,16 @@ function parsePercolateCommand(arguments_: readonly string[]): ParseResult {
1678
1691
  let minSupport = 2;
1679
1692
  let limit = 25;
1680
1693
  let json = false;
1694
+ let proofs = false;
1681
1695
  const positional: string[] = [];
1682
1696
  for (let cursor = 0; cursor < arguments_.length; cursor += 1) {
1683
1697
  const argument = arguments_[cursor];
1684
1698
  if (argument === undefined) continue;
1699
+ if (argument === "--proofs") {
1700
+ if (proofs) return { ok: false, message: "duplicate percolation proofs option" };
1701
+ proofs = true;
1702
+ continue;
1703
+ }
1685
1704
  if (argument === "--json") {
1686
1705
  json = true;
1687
1706
  continue;
@@ -1714,10 +1733,12 @@ function parsePercolateCommand(arguments_: readonly string[]): ParseResult {
1714
1733
  if (positional.length > 1) {
1715
1734
  return { ok: false, message: "percolate accepts at most one note ID" };
1716
1735
  }
1736
+ if (proofs && note === undefined) return { ok: false, message: "percolate --proofs requires one note" };
1717
1737
  return {
1718
1738
  ok: true,
1719
1739
  value: {
1720
1740
  kind: "percolate",
1741
+ ...(proofs ? { proofs: true as const } : {}),
1721
1742
  root,
1722
1743
  ...(note === undefined ? {} : { note }),
1723
1744
  minSupport,
@@ -1888,6 +1909,9 @@ export function parseArguments(arguments_: readonly string[]): ParseResult {
1888
1909
  if (positional[0] !== undefined) directory = positional[0];
1889
1910
  return { ok: true, value: { kind: "init", directory, json } };
1890
1911
  }
1912
+ if (command === "graph" && ["rebuild", "verify", "query"].includes(arguments_[1] ?? "")) {
1913
+ return parseGraphCommand(arguments_.slice(1));
1914
+ }
1891
1915
  if (command === "refresh" || command === "check" || command === "graph" || command === "backlinks" || command === "links") {
1892
1916
  return parseVaultCommand(command, arguments_.slice(1));
1893
1917
  }
@@ -2745,6 +2769,20 @@ async function runPercolate(
2745
2769
  ...(command.note === undefined ? {} : { mentionScope: command.note }),
2746
2770
  },
2747
2771
  );
2772
+ if (command.proofs === true) {
2773
+ if (command.note === undefined) throw new Error("percolate --proofs requires one note");
2774
+ const result = await (dependencies.percolateWithGraph ?? percolateWithGraph)(snapshot, {
2775
+ note: command.note, minSupport: command.minSupport, limit: command.limit,
2776
+ });
2777
+ const support = [result.positiveSupport.sharedTags, result.positiveSupport.sharedConcepts];
2778
+ const partial = support.some((item) => item.truncated || item.proofsTruncated);
2779
+ const text = `${renderPercolation(result.suggestions, command.note)}\nSnapshot: ${result.revision}\n`
2780
+ + "Positive shared-tag and shared-concept proofs are included in --json output.\n"
2781
+ + "Absence checks, support counts, and predicate choices remain Wordcell authoring decisions.\n"
2782
+ + (partial ? "Partial proof evidence: a graph query reached its bounds.\n" : "");
2783
+ output.stdout(command.json ? terminalSafeJson(result) : sanitizeTerminalText(redactSensitiveText(text)));
2784
+ return partial ? 4 : 0;
2785
+ }
2748
2786
  const result = (dependencies.percolateVault ?? percolateVault)(
2749
2787
  snapshot.notes,
2750
2788
  snapshot.analysis,
@@ -3527,6 +3565,11 @@ export async function main(
3527
3565
  if (command.kind === "agents") return await runAgents(command, output, dependencies);
3528
3566
  if (command.kind === "note-create") return await runNoteCreate(command, output, dependencies);
3529
3567
  if (command.kind === "relation") return await runRelation(command, output, dependencies);
3568
+ if (command.kind === "graph-rebuild" || command.kind === "graph-verify" || command.kind === "graph-query") {
3569
+ const result = await executeGraphCommand(command, dependencies);
3570
+ output.stdout(command.json ? terminalSafeJson(result.value) : sanitizeTerminalText(redactSensitiveText(result.text)));
3571
+ return result.exitCode;
3572
+ }
3530
3573
  if (command.kind === "percolate") return await runPercolate(command, output, dependencies);
3531
3574
  if (command.kind === "list") return await runList(command, output, dependencies);
3532
3575
  if (command.kind === "inbox") return await runInbox(command, output, dependencies);
@@ -0,0 +1,116 @@
1
+ /** Wordcell-owned graph protocol. Vendor record and program types stay in the adapter. */
2
+ export type GraphAtom = string | number | boolean | null;
3
+ export type GraphFactRelation = "wordcell.note" | "wordcell.link" | "wordcell.relation"
4
+ | "wordcell.external-relation" | "wordcell.tag" | "wordcell.scope" | "wordcell.concept";
5
+
6
+ export type GraphFact = Readonly<{ relation: GraphFactRelation; tuple: readonly GraphAtom[] }>;
7
+ export type GraphSourceRecord = Readonly<{
8
+ key: string;
9
+ id: string;
10
+ path: string;
11
+ documentId: string | null;
12
+ contentSha256: string;
13
+ facts: readonly GraphFact[];
14
+ }>;
15
+ export type GraphSnapshot = Readonly<{
16
+ schemaVersion: 1;
17
+ vaultIdentity: string;
18
+ revision: string;
19
+ catalogNoteId: string;
20
+ sourceDigests: readonly Readonly<{ path: string; contentSha256: string }>[];
21
+ records: readonly GraphSourceRecord[];
22
+ factCount: number;
23
+ }>;
24
+
25
+ export const GRAPH_LIMITS = Object.freeze({
26
+ notes: 4_000,
27
+ facts: 100_000,
28
+ sourceBytes: 64 * 1024 * 1024,
29
+ atomBytes: 16 * 1024,
30
+ depth: 8,
31
+ rows: 1_000,
32
+ workUnits: 8_000_000,
33
+ derivedTuples: 100_000,
34
+ rounds: 64,
35
+ proofDepth: 64,
36
+ proofNodes: 4_096,
37
+ totalProofNodes: 32_768,
38
+ resultBytes: 8 * 1024 * 1024,
39
+ });
40
+
41
+ export type GraphQueryLimits = Readonly<{
42
+ rows?: number;
43
+ workUnits?: number;
44
+ derivedTuples?: number;
45
+ rounds?: number;
46
+ proofDepth?: number;
47
+ proofNodes?: number;
48
+ totalProofNodes?: number;
49
+ resultBytes?: number;
50
+ }>;
51
+
52
+ export type GraphProgramRequest =
53
+ | Readonly<{ program: "backlinks"; note: string }>
54
+ | Readonly<{ program: "reachability"; note: string; depth?: number }>
55
+ | Readonly<{ program: "scope-route"; scope: string }>
56
+ | Readonly<{ program: "relation-closure"; note: string; predicate: string; depth?: number }>
57
+ | Readonly<{ program: "shared-tags"; note: string }>
58
+ | Readonly<{ program: "shared-concepts"; note: string }>;
59
+ export type GraphQueryRequest = GraphProgramRequest & Readonly<{ limits?: GraphQueryLimits }>;
60
+
61
+ export type GraphProofSource = Readonly<{
62
+ key: string;
63
+ recordSha256: string;
64
+ noteId: string;
65
+ path: string;
66
+ contentSha256: string;
67
+ }>;
68
+ export type GraphProof =
69
+ | Readonly<{ kind: "fact"; relation: string; tuple: readonly GraphAtom[]; sources: readonly GraphProofSource[] }>
70
+ | Readonly<{ kind: "derived"; relation: string; tuple: readonly GraphAtom[]; ruleId: string;
71
+ ruleSha256: string; premises: readonly GraphProof[]; premisesTruncated: boolean }>
72
+ | Readonly<{ kind: "truncated"; relation: string; tuple: readonly GraphAtom[]; reason: "cycle" | "depth" | "nodes" }>;
73
+
74
+ export type GraphQueryResult = Readonly<{
75
+ schemaVersion: 1;
76
+ authority: "derived";
77
+ vaultIdentity: string;
78
+ revision: string;
79
+ projectionSha256: string;
80
+ request: GraphQueryRequest;
81
+ columns: readonly string[];
82
+ rows: readonly Readonly<{ values: readonly GraphAtom[]; proofs: readonly GraphProof[];
83
+ proofsTruncated: boolean; supportCount: number }>[];
84
+ limits: Required<GraphQueryLimits>;
85
+ truncated: boolean;
86
+ proofsTruncated: boolean;
87
+ truncationReasons: readonly string[];
88
+ stats: Readonly<{ baseFacts: number; derivedFacts: number; workUnits: number;
89
+ rounds: number; proofNodes: number; queryMatches: number }>;
90
+ }>;
91
+
92
+ export type GraphVerification = Readonly<{
93
+ schemaVersion: 1;
94
+ status: "verified";
95
+ vaultIdentity: string;
96
+ revision: string;
97
+ records: number;
98
+ facts: number;
99
+ }>;
100
+
101
+ export interface GraphAuthority {
102
+ query(request: GraphQueryRequest): Promise<GraphQueryResult>;
103
+ /** Re-evaluate a bounded result against this authority; foreign or stale evidence is rejected. */
104
+ verifyResult(value: unknown): Promise<boolean>;
105
+ verify(): Promise<GraphVerification>;
106
+ close(): Promise<void>;
107
+ }
108
+
109
+ export class GraphAuthorityError extends Error {
110
+ readonly code: "invalid-input" | "budget" | "stale" | "unsafe-cache" | "corrupt-cache" | "closed";
111
+ constructor(code: GraphAuthorityError["code"], message: string) {
112
+ super(message);
113
+ this.name = "GraphAuthorityError";
114
+ this.code = code;
115
+ }
116
+ }
@@ -0,0 +1,219 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+
6
+ import { createGraphSnapshot } from "./graph-facts.js";
7
+ import { graphCacheFileSystem as fs } from "./graph-cache-filesystem.js";
8
+ import { GraphAuthorityError, GRAPH_LIMITS } from "./graph-authority-model.js";
9
+ import type { GraphAuthority, GraphQueryRequest, GraphQueryResult, GraphVerification } from "./graph-authority-model.js";
10
+ import { acquireFileLease } from "./note-lock.js";
11
+ import type { openOhGraphAdapter as OhAdapterFactory } from "./oh/authority.js";
12
+
13
+ // Keep Node-compatible package discovery independent of the Bun-only graph runtime.
14
+ const openOhGraphAdapter: typeof OhAdapterFactory = async (...args) =>
15
+ await (await import("./oh/authority.js")).openOhGraphAdapter(...args);
16
+ import { validateGraphQueryRequest } from "./graph-query.js";
17
+ import { scanVault } from "./vault.js";
18
+ import type { VaultSnapshot } from "./vault.js";
19
+
20
+ export * from "./graph-authority-model.js";
21
+ export { createGraphSnapshot, validateGraphQueryRequest };
22
+ export type GraphOptions = Readonly<{ index?: string }>;
23
+ export type GraphRebuildOptions = GraphOptions & Readonly<{ fresh?: boolean }>;
24
+ export type GraphReadOptions = GraphOptions & Readonly<{ persisted?: boolean }>;
25
+
26
+ const MAX_CACHE_BYTES = 64 * 1024 * 1024;
27
+ const ignoredCache = "# Disposable Wordcell graph state. Rebuild from Markdown.\n*\n";
28
+ const hash = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
29
+ function errno(value: unknown, code: string): boolean {
30
+ return value !== null && typeof value === "object" && "code" in value && value.code === code;
31
+ }
32
+ async function rootPath(root: string): Promise<string> {
33
+ if (typeof root !== "string" || root.trim() === "" || root.includes("\0")) {
34
+ throw new GraphAuthorityError("invalid-input", "A graph operation requires a vault root.");
35
+ }
36
+ return await realpath(resolve(root));
37
+ }
38
+ async function scan(root: string, options: GraphOptions): Promise<VaultSnapshot> {
39
+ return await scanVault(root, { ...options, mentionScope: false,
40
+ maxNotes: GRAPH_LIMITS.notes, maxTotalBytes: GRAPH_LIMITS.sourceBytes,
41
+ maxConnectionObservations: GRAPH_LIMITS.facts });
42
+ }
43
+ async function directoryIdentity(path: string) {
44
+ const info = await fs.lstat(path, { bigint: true });
45
+ if (!info.isDirectory() || info.isSymbolicLink() || await realpath(path) !== path) {
46
+ throw new GraphAuthorityError("unsafe-cache", "Wordcell graph state requires a real directory inside the vault.");
47
+ }
48
+ return { dev: info.dev, ino: info.ino };
49
+ }
50
+ async function sameDirectory(path: string, expected: Awaited<ReturnType<typeof directoryIdentity>>): Promise<void> {
51
+ const actual = await directoryIdentity(path);
52
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino) {
53
+ throw new GraphAuthorityError("unsafe-cache", "The graph cache directory changed during the operation.");
54
+ }
55
+ }
56
+ /** Read one bounded, private regular file through its observed identity. Never follow a cache symlink. */
57
+ async function readCache(path: string, maximumBytes = MAX_CACHE_BYTES): Promise<Buffer | null> {
58
+ let observed;
59
+ try { observed = await fs.lstat(path, { bigint: true }); }
60
+ catch (error) { if (errno(error, "ENOENT")) return null; throw error; }
61
+ if (!observed.isFile() || observed.isSymbolicLink() || observed.nlink !== 1n) {
62
+ throw new GraphAuthorityError("unsafe-cache", "Graph cache files must be regular files with one link.");
63
+ }
64
+ if (observed.size > BigInt(maximumBytes)) throw new GraphAuthorityError("budget", "Graph cache exceeds its byte limit.");
65
+ const handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
66
+ try {
67
+ const before = await handle.stat({ bigint: true });
68
+ if (!before.isFile() || before.nlink !== 1n || before.dev !== observed.dev || before.ino !== observed.ino) {
69
+ throw new GraphAuthorityError("unsafe-cache", "The graph cache changed before it could be read.");
70
+ }
71
+ if (before.size > BigInt(maximumBytes)) {
72
+ throw new GraphAuthorityError("budget", "Graph cache exceeds its byte limit.");
73
+ }
74
+ if (before.size !== observed.size || before.mtimeNs !== observed.mtimeNs || before.ctimeNs !== observed.ctimeNs) {
75
+ throw new GraphAuthorityError("stale", "The graph cache changed before it could be read.");
76
+ }
77
+ const bytes = Buffer.alloc(Number(before.size));
78
+ let offset = 0;
79
+ while (offset < bytes.length) {
80
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
81
+ if (bytesRead === 0) throw new GraphAuthorityError("stale", "The graph cache changed during its read.");
82
+ offset += bytesRead;
83
+ }
84
+ const after = await handle.stat({ bigint: true });
85
+ const current = await fs.lstat(path, { bigint: true });
86
+ if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
87
+ || current.dev !== before.dev || current.ino !== before.ino || current.nlink !== 1n) {
88
+ throw new GraphAuthorityError("stale", "The graph cache changed during its read.");
89
+ }
90
+ return bytes;
91
+ } finally { await handle.close(); }
92
+ }
93
+ async function requireSingleFileCache(path: string): Promise<void> {
94
+ for (const suffix of ["-wal", "-shm", "-journal"]) {
95
+ try { await fs.lstat(path + suffix); }
96
+ catch (error) { if (errno(error, "ENOENT")) continue; throw error; }
97
+ throw new GraphAuthorityError("unsafe-cache", "Graph cache has an active or foreign SQLite sidecar; close its writer before rebuilding.");
98
+ }
99
+ }
100
+ async function assertFresh(root: string, options: GraphOptions, revision: string): Promise<void> {
101
+ if (createGraphSnapshot(await scan(root, options)).revision !== revision) {
102
+ throw new GraphAuthorityError("stale", "Markdown changed during the graph operation; retry from a fresh snapshot.");
103
+ }
104
+ }
105
+
106
+ /** A read-only, in-memory authority sharing the caller's exact Markdown snapshot. */
107
+ export async function openGraphAuthority(snapshot: VaultSnapshot): Promise<GraphAuthority> {
108
+ return await openOhGraphAdapter(createGraphSnapshot(snapshot));
109
+ }
110
+
111
+ async function withGraph<T>(rootInput: string, options: GraphReadOptions,
112
+ work: (authority: GraphAuthority) => Promise<T>): Promise<T> {
113
+ const root = await rootPath(rootInput);
114
+ const snapshot = createGraphSnapshot(await scan(root, options));
115
+ let authority: GraphAuthority;
116
+ if (options.persisted) {
117
+ const cache = join(root, ".wordcell");
118
+ let identity;
119
+ try { identity = await directoryIdentity(cache); }
120
+ catch (error) {
121
+ if (errno(error, "ENOENT")) throw new GraphAuthorityError("stale", "No graph cache exists; run wordcell graph rebuild first.");
122
+ throw error;
123
+ }
124
+ const path = join(cache, "oh.sqlite");
125
+ await requireSingleFileCache(path);
126
+ const bytes = await readCache(path);
127
+ if (bytes === null) throw new GraphAuthorityError("stale", "No graph cache exists; run wordcell graph rebuild first.");
128
+ await sameDirectory(cache, identity);
129
+ authority = await openOhGraphAdapter(snapshot, { databaseBytes: bytes });
130
+ } else authority = await openOhGraphAdapter(snapshot);
131
+ try {
132
+ const result = await work(authority);
133
+ await assertFresh(root, options, snapshot.revision);
134
+ return result;
135
+ } finally { await authority.close(); }
136
+ }
137
+
138
+ /** Verify persisted state without opening any vault file for writing. */
139
+ export async function verifyGraph(root: string, options: GraphOptions = {}): Promise<GraphVerification> {
140
+ return await withGraph(root, { ...options, persisted: true }, async (authority) => await authority.verify());
141
+ }
142
+
143
+ export async function queryGraph(root: string, request: GraphQueryRequest, options: GraphReadOptions = {}): Promise<GraphQueryResult> {
144
+ const checked = validateGraphQueryRequest(request);
145
+ return await withGraph(root, options, async (authority) => await authority.query(checked));
146
+ }
147
+
148
+ /** Reconcile in a private staging database and install only after the source and old cache still match. */
149
+ export async function rebuildGraph(rootInput: string, options: GraphRebuildOptions = {}): Promise<GraphVerification> {
150
+ const root = await rootPath(rootInput);
151
+ const rootIdentity = await directoryIdentity(root);
152
+ const snapshot = createGraphSnapshot(await scan(root, options));
153
+ const cache = join(root, ".wordcell");
154
+ try { await mkdir(cache, { mode: 0o700 }); }
155
+ catch (error) { if (!errno(error, "EEXIST")) throw error; }
156
+ const cacheIdentity = await directoryIdentity(cache);
157
+ const lease = await acquireFileLease(join(cache, ".rebuild.lock"));
158
+ let staging: string | undefined;
159
+ let stagingIdentity: Awaited<ReturnType<typeof directoryIdentity>> | undefined;
160
+ try {
161
+ await sameDirectory(root, rootIdentity);
162
+ await sameDirectory(cache, cacheIdentity);
163
+ const ignore = join(cache, ".gitignore");
164
+ const oldIgnore = await readCache(ignore, 4096);
165
+ if (oldIgnore !== null && oldIgnore.toString("utf8") !== ignoredCache) {
166
+ throw new GraphAuthorityError("unsafe-cache", "The graph cache has an unrecognized .gitignore; preserve it and use a separate vault root.");
167
+ }
168
+ if (oldIgnore === null) {
169
+ const handle = await fs.open(ignore, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
170
+ try { await handle.writeFile(ignoredCache); await handle.sync(); } finally { await handle.close(); }
171
+ }
172
+ const path = join(cache, "oh.sqlite");
173
+ await requireSingleFileCache(path);
174
+ const previous = await readCache(path);
175
+ staging = await mkdtemp(join(cache, ".build-"));
176
+ stagingIdentity = await directoryIdentity(staging);
177
+ const stagedPath = join(staging, "oh.sqlite");
178
+ if (previous !== null && !options.fresh) {
179
+ const handle = await fs.open(stagedPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
180
+ try { await handle.writeFile(previous); } finally { await handle.close(); }
181
+ }
182
+ const authority = await openOhGraphAdapter(snapshot, { databasePath: stagedPath, writable: true });
183
+ let result;
184
+ try { result = await authority.verify(); } finally { await authority.close(); }
185
+ const ready = await readCache(stagedPath);
186
+ if (ready === null) throw new GraphAuthorityError("corrupt-cache", "The graph rebuild did not produce a database.");
187
+ await requireSingleFileCache(stagedPath);
188
+ await assertFresh(root, options, snapshot.revision);
189
+ await lease.assertOwned();
190
+ await sameDirectory(root, rootIdentity);
191
+ await sameDirectory(cache, cacheIdentity);
192
+ await requireSingleFileCache(path);
193
+ const current = await readCache(path);
194
+ if ((previous === null) !== (current === null) || (previous !== null && current !== null && hash(previous) !== hash(current))) {
195
+ throw new GraphAuthorityError("stale", "The graph cache changed during rebuild; retry from its current state.");
196
+ }
197
+ const handle = await fs.open(stagedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
198
+ try { await handle.sync(); } finally { await handle.close(); }
199
+ await rename(stagedPath, path);
200
+ const directory = await fs.open(cache, constants.O_RDONLY);
201
+ try { await directory.sync(); } finally { await directory.close(); }
202
+ return result;
203
+ } finally {
204
+ try {
205
+ if (staging !== undefined && stagingIdentity !== undefined) {
206
+ // Never recursively remove a replacement directory after losing the original parent.
207
+ let stillOwned = false;
208
+ try {
209
+ await sameDirectory(root, rootIdentity);
210
+ await sameDirectory(cache, cacheIdentity);
211
+ await sameDirectory(staging, stagingIdentity);
212
+ stillOwned = true;
213
+ } catch { /* Preserve uncertain paths for the owner to inspect. */ }
214
+ if (stillOwned) await rm(staging, { recursive: true, force: true });
215
+ }
216
+ }
217
+ finally { await lease.release(); }
218
+ }
219
+ }
@@ -0,0 +1,4 @@
1
+ import { lstat, open } from "node:fs/promises";
2
+
3
+ /** Internal filesystem boundary, kept explicit so race tests observe the real I/O sequence. */
4
+ export const graphCacheFileSystem = { lstat, open };