agent-doc-system 1.4.0 → 1.5.1

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/CHANGELOG.md CHANGED
@@ -9,6 +9,36 @@ refused by the next with `AGENTDOC_COMPILER_MISMATCH` until it is recompiled.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.5.1] - 2026-11-13
13
+
14
+ ### Fixed
15
+
16
+ - Evaluation and optimization entrypoints now execute when invoked through a symbolic link, including installed paths under macOS temporary-directory aliases. Previously they could exit successfully without producing output.
17
+ - CI now exercises agent evaluation and package installation on macOS in addition to the Node 22/24/26 Linux matrix.
18
+
19
+ Recompile committed graphs after upgrading, as the compiler version is now 1.5.1.
20
+
21
+ ## [1.5.0] - 2026-11-13
22
+
23
+ ### Added
24
+
25
+ - Task-oriented `prepare`, `expand` and `refresh` JSON operations with versioned facts, evidence references, explicit coverage, snapshot-bound pagination and exact serialized token budgets for `o200k_base` and `cl100k_base`.
26
+ - Local persistent compilation reuse, shared reads and content-addressed JSON parsing. Changed files, directory probes, ignore rules and compiler inputs invalidate reuse; full compilation remains the reference.
27
+ - Session check-result imports with pinned execution evidence and conservative snapshot invalidation, plus an opt-in JavaScript TypeScript symbol-candidate index that never narrows checks.
28
+ - An agent evaluation runner with independent validators, a 30-task synthetic starter corpus, a local Codex driver, bounded campaigns and conservative candidate selection. No model-efficiency improvement is claimed from the starter corpus.
29
+ - Local context benchmarks with graph and packet equivalence checks.
30
+
31
+ ### Fixed
32
+
33
+ - Default Markdown now reports capped conflicts and checks, repository gates, typed relationships and runtime facts. Settled conflicts are distinguished from unresolved ones.
34
+ - Test fixtures no longer inherit local Git commit or tag signing requirements.
35
+
36
+ ### Upgrade
37
+
38
+ - Recompile committed graphs with `agentdoc compile` after upgrading.
39
+ - Exact token budgets use the pinned `js-tiktoken` runtime dependency. The compiler itself remains independent of model APIs. See [the task context protocol](docs/CONTEXT.md).
40
+
41
+
12
42
  ## [1.4.0] - 2026-11-10
13
43
 
14
44
  `impact` now treats a change under a Drizzle migration directory as a change
@@ -419,7 +449,9 @@ First public release.
419
449
  for project health.
420
450
  - An Agent Skill under `skills/agent-doc-system/`.
421
451
 
422
- [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.4.0...HEAD
452
+ [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.5.1...HEAD
453
+ [1.5.1]: https://github.com/lbframe/agent-doc-system/compare/v1.5.0...v1.5.1
454
+ [1.5.0]: https://github.com/lbframe/agent-doc-system/compare/v1.4.0...v1.5.0
423
455
  [1.4.0]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...v1.4.0
424
456
  [1.3.0]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...v1.3.0
425
457
  [1.2.0]: https://github.com/lbframe/agent-doc-system/compare/v1.1.0...v1.2.0
@@ -37,7 +37,7 @@ npm install -g github:lbframe/agent-doc-system
37
37
  The command is idempotent: run it again to upgrade to the latest `main`.
38
38
  If a specific ref is required, append it: `github:lbframe/agent-doc-system#<ref>`.
39
39
 
40
- The package has zero runtime dependencies. If `npm install -g` fails on
40
+ The task context commands use a pinned tokenizer dependency. If `npm install -g` fails on
41
41
  permissions, do **not** use `sudo`; fix the npm global prefix instead
42
42
  (`npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to `PATH`),
43
43
  or report the blocker.
package/README.md CHANGED
@@ -45,6 +45,10 @@ AgentDoc gives them a map.
45
45
 
46
46
  ![AgentDoc in action — query, impact, check](docs/assets/readme/agentdoc-in-action.webp)
47
47
 
48
+ ## Task context for agents
49
+
50
+ Use `agentdoc prepare` with a JSON task request to obtain a versioned context packet with an exact token budget. `expand` continues a partial packet; `refresh` updates retained context after edits. [Protocol and examples](docs/CONTEXT.md).
51
+
48
52
  ## Three commands, one workflow
49
53
 
50
54
  ```bash
@@ -102,7 +106,7 @@ Measured, reproducible, and with no model in the loop. Method and caveats are in
102
106
  - **Seconds to map a real repository.** A fresh `init` → `scaffold` → `compile` takes 1.0–4.6 s on four public repositories, from 141 to 5,142 files (axum, pydantic-ai, full-stack-fastapi-template, create-t3-turbo). No edit was needed beyond choosing each component's placement.
103
107
  - **A briefing, not a dump.** A `query` answer is about 100–260 tokens: what the change reaches, what to read, and which checks to run.
104
108
  - **The same graph every time.** Compiles are byte-identical, and CI can verify it.
105
- - **Small and strict.** Zero runtime dependencies, a package of about 285 kB packed, 21 adapters, and 709/709 cases on the official TOML 1.0 conformance suite.
109
+ - **Small and strict.** A pinned tokenizer dependency, 21 adapters, and 709/709 cases on the official TOML 1.0 conformance suite.
106
110
 
107
111
  ## Install manually
108
112
 
package/bin/agentdoc.mjs CHANGED
@@ -188,7 +188,16 @@ try {
188
188
  process.stdout.write(commandHelp(cmd));
189
189
  process.exit(0);
190
190
  }
191
- if (cmd === "validate") {
191
+ if (["prepare", "expand", "refresh"].includes(cmd)) {
192
+ if (positional.length) throw new UsageError("context operations accept JSON via --request or stdin");
193
+ const file = opt("request");
194
+ const raw = fs.readFileSync(!file || file === "-" ? 0 : file, "utf8");
195
+ if (Buffer.byteLength(raw) > 1024 * 1024) throw new UsageError("context request exceeds 1 MiB");
196
+ const request = JSON.parse(raw);
197
+ const { prepare, expand, refresh, serializePacket } = await import("../core/context/index.mjs");
198
+ const result = ({ prepare, expand, refresh })[cmd](ROOT, request);
199
+ process.stdout.write(serializePacket(result));
200
+ } else if (cmd === "validate") {
192
201
  const res = compile(ROOT);
193
202
  printDiagnostics(res);
194
203
  if (res.errors.length) fail(res.errors, res.stats);
@@ -492,6 +501,12 @@ try {
492
501
  usage(2);
493
502
  }
494
503
  } catch (e) {
504
+ if (e instanceof UsageError || e instanceof SyntaxError && ["prepare", "expand", "refresh"].includes(cmd)) {
505
+ console.error("ERROR " + CODES.CONTEXT_REQUEST + ": " + e.message); process.exit(2);
506
+ }
507
+ if (Object.values(CODES).includes(e.code)) {
508
+ console.error("ERROR " + e.code + ": " + e.message); process.exit(1);
509
+ }
495
510
  if (e instanceof AgentDocError) {
496
511
  console.error("ERROR " + e.format());
497
512
  process.exit(1);
package/bin/commands.mjs CHANGED
@@ -13,6 +13,21 @@ const JSON_FLAG = { json: { desc: "print the machine-readable report" } };
13
13
  const JSON_ALWAYS = { json: { desc: "accepted for explicitness; the output is always JSON" } };
14
14
 
15
15
  export const COMMANDS = {
16
+ prepare: {
17
+ synopsis: "agentdoc prepare [--request file|-]",
18
+ summary: "prepare a versioned task context packet from JSON (stdin by default)",
19
+ flags: { request: { value: "file|-", desc: "JSON request file, or - for stdin" }, ...JSON_ALWAYS },
20
+ },
21
+ expand: {
22
+ synopsis: "agentdoc expand [--request file|-]",
23
+ summary: "expand a packet continuation on the same snapshot",
24
+ flags: { request: { value: "file|-", desc: "JSON continuation request" }, ...JSON_ALWAYS },
25
+ },
26
+ refresh: {
27
+ synopsis: "agentdoc refresh [--request file|-]",
28
+ summary: "refresh context with explicit retained record IDs",
29
+ flags: { request: { value: "file|-", desc: "JSON refresh request" }, ...JSON_ALWAYS },
30
+ },
16
31
  init: {
17
32
  synopsis: "agentdoc init [--force]",
18
33
  summary: "write a configuration and documentation skeleton",
package/bin/usage.txt CHANGED
@@ -4,6 +4,10 @@ agentdoc — portable documentation & software-catalog system
4
4
  agentdoc --help show this help
5
5
  agentdoc <command> --help every flag one command accepts (also: agentdoc help <command>)
6
6
 
7
+ agentdoc prepare [--request file|-] task context packet (JSON stdin/stdout)
8
+ agentdoc expand [--request file|-] continue a packet on its snapshot
9
+ agentdoc refresh [--request file|-] update retained context after changes
10
+
7
11
  agentdoc init [--force] write a configuration and documentation skeleton
8
12
  (docs skeleton goes to the configured docs.* paths;
9
13
  agentdoc/docs/ when there is no configuration yet)
@@ -0,0 +1,34 @@
1
+ // One bounded, atomic cache record per repository. Cache errors only cost work.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { sha256Hex } from "../fsx.mjs";
6
+
7
+ const LIMIT = 32 * 1024 * 1024;
8
+ const digest = (record) => sha256Hex(JSON.stringify([record.key, record.serialized, record.stats]));
9
+ export function readCompiledCache(file, key) {
10
+ try {
11
+ if (fs.statSync(file).size > LIMIT) return null;
12
+ const record = JSON.parse(fs.readFileSync(file, "utf8"));
13
+ if (record.version !== 1 || record.key !== key || typeof record.serialized !== "string") return null;
14
+ if (!record.stats || digest(record) !== record.digest) return null;
15
+ return record;
16
+ } catch { return null; }
17
+ }
18
+
19
+ export function writeCompiledCache(file, record) {
20
+ let temporary;
21
+ try {
22
+ for (const dir of [path.dirname(file), path.dirname(path.dirname(file))]) {
23
+ if (fs.existsSync(dir) && fs.lstatSync(dir).isSymbolicLink()) return false;
24
+ }
25
+ const text = JSON.stringify({ ...record, version: 1, digest: digest(record) });
26
+ if (Buffer.byteLength(text) > LIMIT) return false;
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ temporary = file + "." + randomUUID() + ".tmp";
29
+ fs.writeFileSync(temporary, text, { flag: "wx", mode: 0o600 });
30
+ fs.renameSync(temporary, file);
31
+ return true;
32
+ } catch { return false; }
33
+ finally { if (temporary) { try { fs.rmSync(temporary, { force: true }); } catch { /* cache is optional */ } } }
34
+ }
@@ -0,0 +1,33 @@
1
+ import { sha256Hex } from "../fsx.mjs";
2
+
3
+ // Bounded content-addressed JSON parsing across snapshots in one process.
4
+ // Values are cloned on both boundaries: extractors may mutate their result.
5
+ export class ParseCache {
6
+ constructor(maxBytes = 8 * 1024 * 1024) {
7
+ this.maxBytes = maxBytes;
8
+ this.bytes = 0;
9
+ this.entries = new Map();
10
+ }
11
+ get(text) {
12
+ const key = sha256Hex("json@1\0" + text);
13
+ const entry = this.entries.get(key);
14
+ if (!entry) return undefined;
15
+ this.entries.delete(key);
16
+ this.entries.set(key, entry);
17
+ return structuredClone(entry.value);
18
+ }
19
+ set(text, value) {
20
+ const bytes = Buffer.byteLength(text);
21
+ if (bytes > this.maxBytes) return;
22
+ const key = sha256Hex("json@1\0" + text);
23
+ if (this.entries.has(key)) this.bytes -= this.entries.get(key).bytes;
24
+ this.entries.delete(key);
25
+ while ((this.bytes + bytes > this.maxBytes || this.entries.size >= 1000) && this.entries.size) {
26
+ const oldest = this.entries.keys().next().value;
27
+ this.bytes -= this.entries.get(oldest).bytes;
28
+ this.entries.delete(oldest);
29
+ }
30
+ this.entries.set(key, { bytes, value: structuredClone(value) });
31
+ this.bytes += bytes;
32
+ }
33
+ }
package/core/codes.mjs CHANGED
@@ -2,6 +2,17 @@
2
2
  // produce is named here so tests, CI and the skill can match on identity
3
3
  // rather than on human-readable message text.
4
4
  export const CODES = {
5
+ // Task context, session evidence and snapshot capture.
6
+ CONTEXT_REQUEST: "AGENTDOC_CONTEXT_REQUEST",
7
+ CONTEXT_BUDGET: "AGENTDOC_CONTEXT_BUDGET",
8
+ CONTEXT_TOKENIZER: "AGENTDOC_CONTEXT_TOKENIZER",
9
+ CONTEXT_COMPILE: "AGENTDOC_CONTEXT_COMPILE",
10
+ CONTEXT_REFERENCE: "AGENTDOC_CONTEXT_REFERENCE",
11
+ CONTEXT_STALE: "AGENTDOC_CONTEXT_STALE",
12
+ SESSION_RESULT: "AGENTDOC_SESSION_RESULT",
13
+ SYMBOL_REQUEST: "AGENTDOC_SYMBOL_REQUEST",
14
+ SNAPSHOT_UNSTABLE: "AGENTDOC_SNAPSHOT_UNSTABLE",
15
+
5
16
  // source & schema
6
17
  UTF8: "AGENTDOC_UTF8",
7
18
  YAML_PARSE: "AGENTDOC_YAML_PARSE",
package/core/compile.mjs CHANGED
@@ -49,7 +49,27 @@ function earlyOut(res, extra = {}) {
49
49
  }
50
50
 
51
51
  export function compile(root, opts = {}) {
52
+ const started = performance.now();
53
+ const result = compileOnce(root, opts);
54
+ if (opts.telemetry) result.telemetry = {
55
+ durationMs: performance.now() - started,
56
+ ...result.repo?.metrics,
57
+ serializedBytes: result.serialized ? Buffer.byteLength(result.serialized) : 0,
58
+ heapUsedBytes: process.memoryUsage().heapUsed,
59
+ stages: result.phaseTimings || {},
60
+ };
61
+ return result;
62
+ }
63
+
64
+ function compileOnce(root, opts = {}) {
52
65
  const res = new CompileResult();
66
+ let stageStart = opts.telemetry ? performance.now() : 0;
67
+ const stage = (name) => {
68
+ if (!opts.telemetry) return;
69
+ const now = performance.now();
70
+ (res.phaseTimings ||= {})[name] = now - stageStart;
71
+ stageStart = now;
72
+ };
53
73
  // A peek load, only to read the generated-directory list: a project that keeps
54
74
  // build output in a directory this build does not know about must be able to
55
75
  // say so.
@@ -60,7 +80,7 @@ export function compile(root, opts = {}) {
60
80
  } catch {
61
81
  // No configuration, or an invalid one: the real load below reports it.
62
82
  }
63
- const repo = new Repo(root, { noGit: opts.noGit, generatedDirs: cfgGeneratedDirs });
83
+ const repo = new Repo(root, { noGit: opts.noGit, generatedDirs: cfgGeneratedDirs, snapshotReads: opts.snapshotReads, parseCache: opts.parseCache });
64
84
  let bundle;
65
85
  let schemaInputs;
66
86
  try {
@@ -83,6 +103,7 @@ export function compile(root, opts = {}) {
83
103
  // a coverage failure is what scaffolding exists to fix.
84
104
  res.repo = repo;
85
105
  res.cfg = cfg;
106
+ stage("configuration");
86
107
  const refs = makeRefs(cfg.namespace);
87
108
  const graphPath = cfg.output.graph;
88
109
 
@@ -145,6 +166,7 @@ export function compile(root, opts = {}) {
145
166
  throw e;
146
167
  }
147
168
  const discovery = discoverUnits(repo, cfg, adapters);
169
+ stage("sourcesAndDiscovery");
148
170
  repo.nestedUnitRoots = discovery.nestedRoots;
149
171
  // Eligibility is the compiler's own answer to "which directories are units" —
150
172
  // exposed on the result so scaffold can write descriptors for exactly these
@@ -789,6 +811,7 @@ export function compile(root, opts = {}) {
789
811
  }
790
812
  }
791
813
 
814
+ stage("extractionAndAuthority");
792
815
  const graph = assembleGraph(sources, rel, indexes, derivedMap, facts, rawConflicts, observationOut, provFinal, diagnostics);
793
816
  graph.compiler = { name: COMPILER_NAME, version: COMPILER_VERSION, adapters: adapterStamp(adapters) };
794
817
 
@@ -865,6 +888,7 @@ export function compile(root, opts = {}) {
865
888
  commit,
866
889
  dirty,
867
890
  };
891
+ stage("assemblyAndValidation");
868
892
  return res;
869
893
  }
870
894
 
@@ -0,0 +1,173 @@
1
+ import { CODES } from "../codes.mjs";
2
+ // Task context protocol. Graph authority stays in the compiler; this module
3
+ // selects and transports facts without turning an interpretation into a fact.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { getEncoding } from 'js-tiktoken';
8
+ import { compileSnapshot } from '../snapshot.mjs';
9
+ import { ContextIndex, queryContext } from '../query.mjs';
10
+ import { impact } from '../impact.mjs';
11
+ import { canonicalPath } from '../paths.mjs';
12
+ import { observationFreshness } from '../observations.mjs';
13
+ import { assertNoSecretsInText } from '../secrets.mjs';
14
+
15
+ const VERSION = 'agentdoc.dev/context/v1';
16
+ const INTENTS = ['implement', 'change-contract', 'debug-runtime', 'review'];
17
+ const ENCODERS = new Map();
18
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
19
+ function error(code, message) { throw Object.assign(new Error(message), { code }); }
20
+ function closed(value, keys, name) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) error(CODES.CONTEXT_REQUEST, name + ' must be an object');
22
+ for (const k of Object.keys(value)) if (!keys.includes(k)) error(CODES.CONTEXT_REQUEST, 'unknown ' + name + ' field: ' + k);
23
+ }
24
+ export function validateRequest(request) {
25
+ closed(request, ['task', 'paths', 'intent', 'budget', 'cache'], 'request');
26
+ if (typeof request.task !== 'string' || !request.task.trim()) error(CODES.CONTEXT_REQUEST, 'task must be a nonempty string');
27
+ if (!Array.isArray(request.paths) || !request.paths.length || request.paths.some(p => typeof p !== 'string' || !p || (!/^[a-z]+:[a-z0-9-]+\//.test(p) && canonicalPath(p) === null))) error(CODES.CONTEXT_REQUEST, 'paths must be repository paths or entity refs');
28
+ if (request.intent !== undefined && !INTENTS.includes(request.intent)) error(CODES.CONTEXT_REQUEST, 'unsupported intent');
29
+ if (request.cache !== undefined && typeof request.cache !== 'boolean') error(CODES.CONTEXT_REQUEST, 'cache must be boolean');
30
+ if (request.budget !== undefined) closed(request.budget, ['tokens', 'encoding'], 'budget');
31
+ const budget = { tokens: 4096, encoding: 'o200k_base', ...request.budget };
32
+ if (!Number.isSafeInteger(budget.tokens) || budget.tokens < 256 || budget.tokens > 1000000) error(CODES.CONTEXT_BUDGET, 'tokens must be an integer from 256 to 1000000');
33
+ if (!['o200k_base', 'cl100k_base'].includes(budget.encoding)) error(CODES.CONTEXT_TOKENIZER, 'supported pinned encodings: o200k_base, cl100k_base');
34
+ return { task: request.task.trim(), paths: [...new Set(request.paths)].sort(), intent: request.intent || 'implement', budget, cache: request.cache !== false };
35
+ }
36
+ export function countTokens(text, encoding = 'o200k_base') {
37
+ if (!ENCODERS.has(encoding)) ENCODERS.set(encoding, getEncoding(encoding));
38
+ return ENCODERS.get(encoding).encode(text, [], []).length;
39
+ }
40
+ export function serializePacket(packet) { return JSON.stringify(packet) + '\n'; }
41
+ function record(kind, subject, key, value, evidence = [], critical = false) {
42
+ const id = hash([kind, subject, key]).slice(0, 24);
43
+ return JSON.parse(JSON.stringify({ id, version: hash([value, evidence]), kind, subject, key, value, evidence: [...evidence].sort(), critical }));
44
+ }
45
+ function collect(res, request) {
46
+ const g = res.graph, idx = new ContextIndex(g), refs = new Set(), records = new Map();
47
+ const im = impact(g, request.paths);
48
+ const put = r => records.set(r.id, r);
49
+ for (const p of request.paths) { const e = idx.resolve(p); if (e) refs.add(e.ref); }
50
+ for (const e of im.affectedComponents) refs.add(e.ref);
51
+ // Unlimited selection here: the single serialized token budget controls delivery.
52
+ const unlimited = Object.fromEntries(['relations','events','externals','capabilities','verification','journeys','conflicts','assertions','provenance','contracts'].map(k => [k, Number.MAX_SAFE_INTEGER]));
53
+ for (const ref of [...refs].sort()) {
54
+ const ctx = queryContext(g, ref, { budget: unlimited, index: idx });
55
+ put(record('entity', ref, 'identity', { description: ctx.entity.description, derived: ctx.entity.derived }, ctx.entity.sourcePaths));
56
+ for (const r of ctx.relations.items) put(record('relation', ref, r.type + ':' + r.direction + ':' + r.other, r, r.provenanceIds));
57
+ for (const a of ctx.authority.assertions.items) put(record('assertion', ref, a.key + ':' + a.id, a, [], a.key.startsWith('requirement.')));
58
+ for (const c of ctx.authority.conflicts.items) put(record('conflict', ref, c.id, c, [], true));
59
+ for (const [kind, paths] of Object.entries(ctx.constraints)) for (const p of paths) {
60
+ const bytes = res.repo.readBytes(p);
61
+ const expected = res.graph.provenance.filter(x => x.path === p).map(x => x.contentHash.replace(/^sha256:/, ''));
62
+ if (!expected.includes(createHash('sha256').update(bytes).digest('hex'))) error(CODES.CONTEXT_STALE, 'document is not pinned by this snapshot: ' + p);
63
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^\uFEFF/, '');
64
+ put(record(kind === 'constraints' ? 'requirement' : 'document', ref, p,
65
+ { path: p, sha256: createHash('sha256').update(bytes).digest('hex'), text: source }, [p], kind === 'constraints'));
66
+ }
67
+ for (const c of ctx.contracts.items) put(record('contract', ref, c.id || c.ref, c, c.provenanceIds));
68
+ for (const o of ctx.authority.observations) put(record('observation', ref, o.id, o, o.evidenceBundle ? [o.evidenceBundle] : [], true));
69
+ for (const r of ctx.reviewCommitments.filter(r => !r.subject || refs.has(r.subject))) put(record('review', r.subject || 'repository', r.key || r.kind, r, [], true));
70
+ for (const j of ctx.journeys.items) put(record('journey', ref, j.id, j));
71
+ for (const e of ctx.events.items) put(record('event', ref, e.pattern, e, [], true));
72
+ }
73
+ const checks = new Map();
74
+ for (const p of [...refs]) {
75
+ const ctx = queryContext(g, p, { budget: unlimited, index: idx });
76
+ for (const v of [...ctx.verification.items, ...ctx.globalGates]) checks.set(v.id, v);
77
+ }
78
+ for (const v of im.recommendedVerification) checks.set(v.id, v);
79
+ for (const v of checks.values()) {
80
+ const original = [...g.verification, ...g.gates].find(x => x.id === v.id);
81
+ const owners = original?.componentRefs || [];
82
+ // Command text alone cannot prove a working directory; keep that unknown.
83
+ put(record('check', 'repository', v.id, { ...v, componentRefs: owners, execution: { kind: 'shell', cwd: null, status: 'requires-context' }, status: 'recommended' }, original?.provenanceIds, true));
84
+ }
85
+ for (const f of observationFreshness(g.observations)) if (f.stale) put(record('freshness', 'repository', f.id, f, [], true));
86
+ const sort = (a, b) => Number(b.critical) - Number(a.critical) || a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id);
87
+ return { records: [...records.values()].sort(sort), scope: [...refs].sort(), coverage: { unmatched: im.unmatchedFiles, limitations: ['component-level analysis; dynamic and unsupported relationships may be absent'], staleObservations: observationFreshness(g.observations).filter(f => f.stale).map(f => f.id) } };
88
+ }
89
+ function storeDir(root) { return path.join(root, '.agentdoc', 'context'); }
90
+ function save(root, id, data) {
91
+ const dir = storeDir(root); fs.mkdirSync(dir, { recursive: true });
92
+ const temp = path.join(dir, id + '.' + randomUUID() + '.tmp');
93
+ fs.writeFileSync(temp, JSON.stringify({ identity: data.identity }), { mode: 0o600 });
94
+ fs.renameSync(temp, path.join(dir, id + '.json'));
95
+ const retained = fs.readdirSync(dir).filter(n => /^[a-f0-9]{64}\.json$/.test(n)).map(n => ({ n, time: fs.statSync(path.join(dir,n)).mtimeMs })).sort((a,b) => b.time-a.time || a.n.localeCompare(b.n));
96
+ for (const old of retained.slice(64)) if (old.n !== id + '.json') fs.rmSync(path.join(dir,old.n), { force: true });
97
+ }
98
+ function load(root, id) {
99
+ if (!/^[a-f0-9]{64}$/.test(id || '')) error(CODES.CONTEXT_REFERENCE, 'invalid packet reference');
100
+ try { const x = JSON.parse(fs.readFileSync(path.join(storeDir(root), id + '.json'), 'utf8')); if (hash(x.identity) !== id) throw new Error(); return { ...x.identity, id }; }
101
+ catch { error(CODES.CONTEXT_REFERENCE, 'packet unavailable; run prepare again'); }
102
+ }
103
+ function packetPage(state, offset, limit) {
104
+ const p = { schemaVersion: VERSION, packet: state.id, snapshot: state.snapshot, task: state.request.task, intent: state.request.intent, scope: state.scope, coverage: state.coverage,
105
+ records: [], delivery: { offset, total: state.records.length, omitted: state.records.length - offset, criticalOmitted: 0, partial: true },
106
+ continuation: { packet: state.id, offset }, budget: { limit, used: 0, encoding: state.request.budget.encoding, tokenizerVersion: 'js-tiktoken@1.0.21' } };
107
+ const measure = () => {
108
+ // Digit count feeds back into encoding; converge to the exact final size.
109
+ for (let i = 0; i < 12; i++) { const n = countTokens(serializePacket(p), p.budget.encoding); if (n === p.budget.used) return n; p.budget.used = n; }
110
+ error(CODES.CONTEXT_BUDGET, 'token accounting did not converge');
111
+ };
112
+ const update = () => {
113
+ const next = offset + p.records.length;
114
+ p.delivery.omitted = state.records.length - next;
115
+ p.delivery.criticalOmitted = state.records.slice(next).filter(r => r.critical).length;
116
+ p.delivery.partial = next < state.records.length;
117
+ p.continuation = next < state.records.length ? { packet: state.id, offset: next } : null;
118
+ return measure();
119
+ };
120
+ if (update() > limit) error(CODES.CONTEXT_BUDGET, 'budget cannot hold the minimum envelope');
121
+ for (const r of state.records.slice(offset)) {
122
+ p.records.push(r);
123
+ if (update() > limit) { p.records.pop(); update(); break; }
124
+ }
125
+ if (!p.records.length && offset < state.records.length) error(CODES.CONTEXT_BUDGET, 'next record exceeds budget; expand with a larger token limit');
126
+ assertNoSecretsInText(serializePacket(p), 'context packet');
127
+ return p;
128
+ }
129
+ export function prepare(root, input, options = {}) {
130
+ const request = validateRequest(input);
131
+ const res = compileSnapshot(root, { cache: request.cache, ...options });
132
+ if (res.errors.length) error(CODES.CONTEXT_COMPILE, res.errors.map(e => e.message).join('\n'));
133
+ const selected = collect(res, request);
134
+ const snapshot = { inputHash: res.stats.inputHash, compiler: res.graph.compiler, graphHash: hash(res.graph) };
135
+ const identity = { snapshot, request, records: selected.records, logicalRecords: selected.records, scope: selected.scope, coverage: selected.coverage };
136
+ assertNoSecretsInText(JSON.stringify(identity), 'context state');
137
+ const id = hash(identity);
138
+ const state = { identity, id, snapshot, request, ...selected };
139
+ const packet = packetPage(state, 0, request.budget.tokens);
140
+ save(root, id, state);
141
+ return packet;
142
+ }
143
+ export function expand(root, request) {
144
+ closed(request, ['packet', 'offset', 'tokens'], 'expand');
145
+ const state = load(root, request.packet);
146
+ const offset = request.offset ?? 0, tokens = request.tokens ?? state.request.budget.tokens;
147
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > state.records.length || !Number.isSafeInteger(tokens) || tokens < 256 || tokens > 1000000) error(CODES.CONTEXT_REQUEST, 'invalid offset or token limit');
148
+ const res = compileSnapshot(root, { cache: state.request.cache });
149
+ if (res.errors.length || hash(res.graph) !== state.snapshot.graphHash || JSON.stringify(observationFreshness(res.graph.observations).filter(f => f.stale).map(f => f.id)) !== JSON.stringify(state.coverage.staleObservations)) error(CODES.CONTEXT_STALE, 'snapshot changed; use refresh or prepare');
150
+ return packetPage(state, offset, tokens);
151
+ }
152
+ export function refresh(root, request) {
153
+ closed(request, ['packet', 'acknowledged', 'reset'], 'refresh');
154
+ const previous = load(root, request.packet);
155
+ if (request.reset !== undefined && typeof request.reset !== 'boolean') error(CODES.CONTEXT_REQUEST, 'reset must be boolean');
156
+ if (request.acknowledged !== undefined && (!Array.isArray(request.acknowledged) || request.acknowledged.some(x => typeof x !== 'string'))) error(CODES.CONTEXT_REQUEST, 'acknowledged must be record IDs');
157
+ const known = new Set(request.reset ? [] : request.acknowledged || []);
158
+ for (const id of known) if (!(previous.logicalRecords || previous.records).some(r => r.id === id)) error(CODES.CONTEXT_REQUEST, 'acknowledged ID is not in the previous packet');
159
+ const initial = prepare(root, previous.request);
160
+ const current = load(root, initial.packet);
161
+ const old = new Map((previous.logicalRecords || previous.records).filter(r => known.has(r.id)).map(r => [r.id, r]));
162
+ const currentIds = new Set(current.records.map(r => r.id));
163
+ const records = current.records.filter(r => r.critical || !old.has(r.id) || old.get(r.id).version !== r.version);
164
+ for (const [id, r] of old) if (!currentIds.has(id)) records.push(record('retraction', r.subject, id, { id, previousVersion: r.version }, [], true));
165
+ const state = { ...current, records };
166
+ // A delta is itself addressed and can be paged; acknowledge only IDs delivered.
167
+ state.identity = { base: previous.id, snapshot: current.snapshot, request: current.request, records, logicalRecords: current.logicalRecords || current.records, scope: current.scope, coverage: current.coverage };
168
+ state.id = hash(state.identity); save(root, state.id, state);
169
+ return packetPage(state, 0, current.request.budget.tokens);
170
+ }
171
+ export function forget(root, packet) {
172
+ load(root, packet); fs.unlinkSync(path.join(storeDir(root), packet + '.json'));
173
+ }
@@ -0,0 +1,81 @@
1
+ import { CODES } from "../codes.mjs";
2
+ // On-demand lexical experiment. These are source navigation candidates, never
3
+ // complete call/dependency analysis and never grounds for removing checks.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import { canonicalPath } from "../paths.mjs";
8
+
9
+ function reject(message) { throw Object.assign(new Error(message), { code: CODES.SYMBOL_REQUEST }); }
10
+ // Preserve source offsets while hiding comments and quoted text. Interpolated
11
+ // template expressions and regex literals are deliberately unsupported.
12
+ function codeOnly(text) {
13
+ let out = "", state = "code";
14
+ for (let i = 0; i < text.length; i++) {
15
+ const c = text[i], next = text[i + 1];
16
+ if (state === "code") {
17
+ if (c === "/" && next === "/") { state = "line"; out += " "; i++; }
18
+ else if (c === "/" && next === "*") { state = "block"; out += " "; i++; }
19
+ else if (c === "'" || c === '"' || c === "`") { state = c; out += " "; }
20
+ else out += c;
21
+ } else if (state === "line") {
22
+ out += c === "\n" ? "\n" : " ";
23
+ if (c === "\n") state = "code";
24
+ } else if (state === "block") {
25
+ if (c === "*" && next === "/") { state = "code"; out += " "; i++; }
26
+ else out += c === "\n" ? "\n" : " ";
27
+ } else if (c === "\\") {
28
+ out += " ";
29
+ if (next !== undefined) { out += next === "\n" ? "\n" : " "; i++; }
30
+ } else {
31
+ out += c === "\n" ? "\n" : " ";
32
+ if (c === state) state = "code";
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function indexTypeScriptSymbols(root, files) {
39
+ if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string")) reject("files must be a nonempty list of TypeScript paths");
40
+ const base = fs.realpathSync(root);
41
+ const paths = files.map((file) => {
42
+ const rel = canonicalPath(file);
43
+ if (rel === null || !/\.(?:[cm]?ts|tsx)$/.test(rel)) reject("only repository-relative TypeScript files are supported");
44
+ return rel;
45
+ });
46
+ const sources = [...new Set(paths)].sort().map((file) => {
47
+ let absolute, source;
48
+ try {
49
+ absolute = fs.realpathSync(path.join(base, file));
50
+ if (!absolute.startsWith(base + path.sep)) reject("source escapes repository");
51
+ source = fs.readFileSync(absolute, "utf8");
52
+ } catch (e) {
53
+ if (e.code === CODES.SYMBOL_REQUEST) throw e;
54
+ reject("source unavailable: " + file);
55
+ }
56
+ const clean = codeOnly(source);
57
+ const declarations = [];
58
+ const pattern = /\b(export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(function|class|interface|type|enum|const|let|var)\s+([A-Za-z_$][\w$]*)/g;
59
+ for (const match of clean.matchAll(pattern)) {
60
+ const offset = match.index + match[0].lastIndexOf(match[3]);
61
+ const before = source.slice(0, offset);
62
+ declarations.push({ name: match[3], kind: match[2], exported: Boolean(match[1]),
63
+ line: before.split("\n").length, column: offset - before.lastIndexOf("\n") });
64
+ }
65
+ return { path: file, sha256: createHash("sha256").update(source).digest("hex"), declarations, clean };
66
+ });
67
+ const names = new Set(sources.flatMap((s) => s.declarations.map((d) => d.name)));
68
+ const indexed = sources.map(({ clean, ...file }) => {
69
+ const references = [];
70
+ for (const match of clean.matchAll(/[A-Za-z_$][\w$]*/g)) {
71
+ if (!names.has(match[0])) continue;
72
+ const before = clean.slice(0, match.index);
73
+ const line = before.split("\n").length, column = match.index - before.lastIndexOf("\n");
74
+ const declaration = file.declarations.some((d) => d.line === line && d.column === column);
75
+ if (!declaration) references.push({ name: match[0], line, column });
76
+ }
77
+ return { ...file, references };
78
+ });
79
+ return { schemaVersion: "agentdoc.dev/symbol-candidates/v1", coverage: { partial: true, checkSelection: "unchanged",
80
+ limitations: ["lexical candidates only; scope, imports and overloads are not resolved", "dynamic uses, template interpolation and regex literals are not resolved", "coverage is restricted to the explicitly requested files"] }, files: indexed };
81
+ }