@atrib/trace 0.4.1 → 0.5.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.
package/README.md CHANGED
@@ -4,17 +4,24 @@ MCP server exposing the `trace` tool, walks a record's `informed_by` chain backw
4
4
 
5
5
  Closes the consumer-side cognitive-loop primitive: recall returns raw records; trace returns the causal chain, so an agent asking "why did I do X?" can see "X was informed by Y, which was informed by Z" without manually walking `informed_by` hash-by-hash.
6
6
 
7
- ## Tool
7
+ ## Tools
8
+
9
+ Two bidirectional walk tools share the same input + response shape; only the walk direction differs:
8
10
 
9
11
  ```
10
- mcp__atrib-trace__trace({
12
+ mcp__atrib-trace__trace({ // BACKWARD — what informed this?
11
13
  record_hash: "sha256:<64-hex>", // start
12
14
  depth?: number, // hop cap (default 3, max 10)
13
15
  max_nodes?: number, // safety cap (default 200, max 500)
14
16
  compact?: boolean // omit signature/content_id bytes (default true)
15
17
  })
18
+
19
+ mcp__atrib-trace__trace_forward({ // FORWARD — what was informed by this?
20
+ record_hash, depth?, max_nodes?, compact? // same schema as `trace`
21
+ })
22
+
16
23
  → {
17
- start_hash, direction: "backward",
24
+ start_hash, direction: "backward" | "forward",
18
25
  depth_requested, depth_reached,
19
26
  visited: [
20
27
  {
@@ -30,6 +37,10 @@ mcp__atrib-trace__trace({
30
37
  }
31
38
  ```
32
39
 
40
+ - `trace` walks `informed_by` BACKWARD (toward causal ancestors). Answers "what informed this record?"
41
+ - `trace_forward` walks `informed_by` FORWARD (records that cited this one). Answers "I made decision X, what did I do because of it?" The dual of `trace`. Same input schema, same response shape.
42
+ - For forward walks, `next_informed_by` carries the CHILDREN visited at the next hop (records citing this one) rather than this record's own informed_by — field name kept for shape-compat with `trace`.
43
+
33
44
  ## Reads
34
45
 
35
46
  Every `*.jsonl` mirror under `~/.atrib/records/` (override via `ATRIB_RECORDS_DIR`). Tolerates both producer envelope shapes:
@@ -45,8 +56,8 @@ When the envelope carries an optional `_local` sidecar (per the local-mirror sid
45
56
  - **Cap-safe**: hits `max_nodes` → returns partial result with `truncated_by_cap: true`.
46
57
  - **Depth-safe**: hits `depth` → returns partial result with `truncated_by_depth: true`.
47
58
  - **Dangling-aware**: `informed_by` entries pointing at records not in the local mirror surface in `dangling` and do NOT advance the walk.
48
- - **Local-only (v1)**: reads only the local mirror. v2 will fall back to `log.atrib.dev/v1/lookup/<hash>` for hashes not in the local mirror.
49
- - **Backward-only (v1)**: walks `informed_by` upstream. Forward-walk (records that reference THIS one) is a v2 concern via the graph service.
59
+ - **Local-only**: reads only the local mirror. A future ship will fall back to `log.atrib.dev/v1/lookup/<hash>` for hashes not in the local mirror.
60
+ - **Bidirectional**: `trace` walks `informed_by` backward; `trace_forward` walks forward via a reverse index built per call from the same mirror (O(N) build, O(out-degree) per hop).
50
61
  - **Instrumented (per [D084](https://github.com/creatornader/atrib/blob/main/DECISIONS.md#d084-read-primitive-instrumentation-for-empirical-loop-closure-measurement) Surface 6)**: every call writes a per-invocation jsonl entry to `~/.atrib/state/read-primitives/calls.jsonl` for the unified loop-closure analyzer. Silent-failure per [§5.8](https://github.com/creatornader/atrib/blob/main/atrib-spec.md#58-degradation-contract); instrumentation never blocks the trace result. `ATRIB_READ_PRIMITIVES_LOG` overrides the default path for tests.
51
62
 
52
63
  ## Wire-up
package/dist/index.d.ts CHANGED
@@ -13,7 +13,50 @@
13
13
  * is also a v2 concern; v1 is backward-only.
14
14
  */
15
15
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16
+ interface CompactVisited {
17
+ depth: number;
18
+ record_hash: string;
19
+ parent_hashes: string[];
20
+ source: string | null;
21
+ event_type: string | null;
22
+ context_id: string | null;
23
+ creator_key: string | null;
24
+ timestamp: number | null;
25
+ /** informed_by entries on this record. Empty if none or dangling. */
26
+ next_informed_by: string[];
27
+ /** Sub-set of next_informed_by that were resolved (present in mirror). */
28
+ next_resolved: string[];
29
+ /** Sub-set of next_informed_by that were dangling (not in mirror). */
30
+ next_dangling: string[];
31
+ /**
32
+ * Semantic context from the local sidecar when present. Carries
33
+ * tool name + brief content summary so the agent can reason about
34
+ * the record's meaning without separately fetching it. Absent on
35
+ * legacy bare-record entries that pre-date the sidecar.
36
+ */
37
+ sidecar_summary?: {
38
+ tool_name?: string;
39
+ topics?: string[];
40
+ /**
41
+ * First 200 chars of the record's human-readable content, derived
42
+ * per event_type from the @atrib/mcp normative shapes (D086):
43
+ * observation `what`, annotation `summary`, revision `new_position`.
44
+ * For legacy records using non-normative names (`summary` on
45
+ * observation), falls back to `summary`.
46
+ */
47
+ what?: string;
48
+ /** Annotation `importance` field when present. */
49
+ importance?: string;
50
+ producer?: string;
51
+ };
52
+ }
53
+ /** Pull a compact summary from the sidecar's content payload.
54
+ * Exported for unit testing of the per-event_type content-shape handling. */
55
+ export declare function summarizeSidecar(loadedRecord: {
56
+ local?: import('./storage.js').SidecarPayload;
57
+ } | undefined): CompactVisited['sidecar_summary'];
16
58
  export interface AtribTraceServer {
17
59
  mcp: McpServer;
18
60
  }
19
61
  export declare function createAtribTraceServer(): Promise<AtribTraceServer>;
62
+ export {};
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
17
  import { z } from 'zod';
18
18
  import { resolveEnvContextId, logReadPrimitiveCall, extractRecordHashesFromMcpResult, } from '@atrib/mcp';
19
19
  import { loadAllRecords } from './storage.js';
20
- import { traceBackward } from './trace.js';
20
+ import { traceBackward, traceForward } from './trace.js';
21
21
  const SHA256_REF_PATTERN = /^sha256:[0-9a-f]{64}$/;
22
22
  const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
23
23
  const TraceInput = z.object({
@@ -38,8 +38,9 @@ const TraceInput = z.object({
38
38
  'spec_version/chain_root to keep the response small. Set false for full ' +
39
39
  'record bytes (useful for re-verification).'),
40
40
  });
41
- /** Pull a compact summary from the sidecar's content payload. */
42
- function summarizeSidecar(loadedRecord) {
41
+ /** Pull a compact summary from the sidecar's content payload.
42
+ * Exported for unit testing of the per-event_type content-shape handling. */
43
+ export function summarizeSidecar(loadedRecord) {
43
44
  if (!loadedRecord?.local)
44
45
  return undefined;
45
46
  const sc = loadedRecord.local;
@@ -52,13 +53,30 @@ function summarizeSidecar(loadedRecord) {
52
53
  if (c && Array.isArray(c['topics'])) {
53
54
  out.topics = c['topics'].filter((t) => typeof t === 'string').slice(0, 6);
54
55
  }
55
- if (c && typeof c['what'] === 'string') {
56
- const w = c['what'];
57
- out.what = w.length > 200 ? w.slice(0, 197) + '…' : w;
58
- }
59
- else if (c && typeof c['summary'] === 'string') {
60
- const s = c['summary'];
61
- out.what = s.length > 200 ? s.slice(0, 197) + '…' : s;
56
+ // Per-event_type human-readable text. The `what` slot here is generic
57
+ // (it's the human-scannable summary regardless of event_type); the
58
+ // priority order pulls the normative D086 field for each shape, with
59
+ // an explicit final fallback to `summary` for legacy records.
60
+ // observation: content.what
61
+ // annotation: content.summary
62
+ // revision: content.new_position (D086-normative) surface this
63
+ // so trace consumers can read the agent's stance shift
64
+ // without a separate recall call
65
+ // legacy: content.summary (catches pre-D086 extractor records)
66
+ if (c) {
67
+ let primary;
68
+ if (typeof c['what'] === 'string') {
69
+ primary = c['what'];
70
+ }
71
+ else if (typeof c['new_position'] === 'string') {
72
+ primary = c['new_position'];
73
+ }
74
+ else if (typeof c['summary'] === 'string') {
75
+ primary = c['summary'];
76
+ }
77
+ if (primary) {
78
+ out.what = primary.length > 200 ? primary.slice(0, 197) + '…' : primary;
79
+ }
62
80
  }
63
81
  if (c && typeof c['importance'] === 'string')
64
82
  out.importance = c['importance'];
@@ -86,13 +104,32 @@ export async function createAtribTraceServer() {
86
104
  name: 'atrib-trace',
87
105
  version: '0.1.0',
88
106
  });
107
+ // Shared shape: build a compact payload from a TraceResult.
108
+ function buildPayload(result, byHash, compact) {
109
+ const danglingSet = new Set(result.dangling);
110
+ return compact
111
+ ? {
112
+ start_hash: result.start_hash,
113
+ direction: result.direction,
114
+ depth_requested: result.depth_requested,
115
+ depth_reached: result.depth_reached,
116
+ visited: result.visited.map((v) => compactVisited(v, danglingSet, byHash)),
117
+ dangling: result.dangling,
118
+ truncated_by_depth: result.truncated_by_depth,
119
+ truncated_by_cap: result.truncated_by_cap,
120
+ warnings: result.warnings,
121
+ }
122
+ : { ...result };
123
+ }
89
124
  mcp.registerTool('trace', {
90
125
  title: 'trace informed_by chain backward',
91
126
  description: 'Walk a record\'s informed_by chain backward to surface the reasoning ' +
92
127
  'chain that led to it. Reads from the local signed-record mirror ' +
93
128
  '(~/.atrib/records/*.jsonl). Returns the records visited, parent links ' +
94
129
  'so the caller can reconstruct the tree, and dangling hashes (records ' +
95
- 'referenced via informed_by but not present in the local mirror).',
130
+ 'referenced via informed_by but not present in the local mirror). ' +
131
+ 'Sibling tool `trace_forward` walks the opposite direction (records ' +
132
+ 'that cited THIS one via their informed_by).',
96
133
  inputSchema: TraceInput.shape,
97
134
  }, async (args) => logReadPrimitiveCall('trace', args, async () => {
98
135
  const depth = args.depth ?? 3;
@@ -108,25 +145,35 @@ export async function createAtribTraceServer() {
108
145
  maxNodes,
109
146
  ...(contextId ? { contextId } : {}),
110
147
  });
111
- const danglingSet = new Set(result.dangling);
112
- const payload = compact
113
- ? {
114
- start_hash: result.start_hash,
115
- direction: result.direction,
116
- depth_requested: result.depth_requested,
117
- depth_reached: result.depth_reached,
118
- visited: result.visited.map((v) => compactVisited(v, danglingSet, byHash)),
119
- dangling: result.dangling,
120
- truncated_by_depth: result.truncated_by_depth,
121
- truncated_by_cap: result.truncated_by_cap,
122
- warnings: result.warnings,
123
- }
124
- : {
125
- ...result,
126
- // Full records included
127
- };
128
148
  return {
129
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
149
+ content: [{ type: 'text', text: JSON.stringify(buildPayload(result, byHash, compact), null, 2) }],
150
+ };
151
+ }, extractRecordHashesFromMcpResult));
152
+ // Sibling tool: forward walk (records that cited THIS one). Mirrors
153
+ // the trace input schema + response shape exactly so callers can use
154
+ // it interchangeably; only the direction of the walk differs.
155
+ mcp.registerTool('trace_forward', {
156
+ title: 'trace informed_by chain forward',
157
+ description: 'Walk forward from a record_hash, surfacing the records that cited ' +
158
+ 'it via their informed_by chain. The dual of `trace` (backward). ' +
159
+ 'Answers "I made decision X, what did I do because of it?" Reads from ' +
160
+ 'the local signed-record mirror (~/.atrib/records/*.jsonl). Returns ' +
161
+ 'the records visited, parent links (which upstream record at the ' +
162
+ 'prior level cited this one), and dangling hashes (rare; only if the ' +
163
+ 'reverse index references a record missing from the mirror).',
164
+ inputSchema: TraceInput.shape,
165
+ }, async (args) => logReadPrimitiveCall('trace_forward', args, async () => {
166
+ const depth = args.depth ?? 3;
167
+ const maxNodes = args.max_nodes ?? 200;
168
+ const compact = args.compact ?? true;
169
+ const contextId = args.context_id ?? resolveEnvContextId();
170
+ const { byHash } = loadAllRecords();
171
+ const result = traceForward(args.record_hash, depth, byHash, {
172
+ maxNodes,
173
+ ...(contextId ? { contextId } : {}),
174
+ });
175
+ return {
176
+ content: [{ type: 'text', text: JSON.stringify(buildPayload(result, byHash, compact), null, 2) }],
130
177
  };
131
178
  }, extractRecordHashesFromMcpResult));
132
179
  return { mcp };
package/dist/trace.d.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  /**
2
- * Bounded backward walk through informed_by chains.
2
+ * Bounded directional walk through informed_by chains.
3
3
  *
4
- * Given a starting record_hash and a depth bound, breadth-first walks every
5
- * informed_by edge until either (a) depth bound reached, (b) no more
6
- * unresolved hashes remain, or (c) safety cap on total visited nodes hit.
4
+ * Two directions:
5
+ * - backward (traceBackward): from a record, walk its informed_by chain
6
+ * toward causal ancestors. Answers "what informed this?"
7
+ * - forward (traceForward): from a record, walk the records that cite it
8
+ * via their informed_by. Answers "what was informed by this?" — i.e.
9
+ * what followed from this decision/observation.
10
+ *
11
+ * Each direction breadth-first walks edges until either (a) depth bound
12
+ * reached, (b) no more unresolved hashes remain, or (c) safety cap on
13
+ * total visited nodes hit.
7
14
  *
8
15
  * Each visited record is annotated with:
9
16
  * - depth , hop count from the starting record (0 = the start)
@@ -14,14 +21,13 @@
14
21
  * locally. This is informational, not a failure, the upstream may live on
15
22
  * the public log but not in the agent's local mirror.
16
23
  *
17
- * Forward-walk (records that reference THIS record via informed_by) is
18
- * out of scope for v1; it requires either a full-mirror scan per call
19
- * (acceptable but slower) or the graph service. v1 is backward-only;
20
- * forward-walk arrives in v2.
24
+ * Forward walk requires a full-mirror reverse index (built on each call
25
+ * from the same IndexedRecord map; O(N) once, then O(out-degree) per hop).
26
+ * For typical mirror sizes (~14K records) this is negligible.
21
27
  */
22
28
  import type { IndexedRecord } from './storage.js';
23
29
  import type { AtribRecord } from '@atrib/mcp';
24
- export type TraceDirection = 'backward';
30
+ export type TraceDirection = 'backward' | 'forward';
25
31
  export interface TraceVisited {
26
32
  depth: number;
27
33
  record_hash: string;
@@ -70,3 +76,31 @@ export interface TraceOptions {
70
76
  * walk (we have nothing to walk from).
71
77
  */
72
78
  export declare function traceBackward(startHash: string, depth: number, index: Map<string, IndexedRecord>, options?: TraceOptions): TraceResult;
79
+ /**
80
+ * Build a reverse informed_by index: for each record_hash R, the list of
81
+ * record_hashes that cite R in their informed_by field. O(total
82
+ * informed_by entries) build; O(1) lookup per parent during walk.
83
+ *
84
+ * Exported so callers (or tests) can pre-build the index when running
85
+ * multiple traceForward calls against the same mirror.
86
+ */
87
+ export declare function buildReverseInformedByIndex(index: Map<string, IndexedRecord>): Map<string, string[]>;
88
+ /**
89
+ * Walk informed_by edges FORWARD from the starting record.
90
+ *
91
+ * Answers: "what records cited THIS record via informed_by?" — i.e. what
92
+ * decisions/observations followed from this one in the causal substrate.
93
+ * The dual of traceBackward. Useful for "I made decision X, what did I do
94
+ * because of it?" lookups.
95
+ *
96
+ * Same behaviors as traceBackward:
97
+ * - Cycle-safe via visited-set
98
+ * - Cap-safe via maxNodes
99
+ * - context_id-scoped (if specified): edges into a different context_id
100
+ * are treated as dangling
101
+ * - Dangling: walking forward to a record present in the reverse index
102
+ * but missing from the mirror surfaces it as dangling (defensive; in
103
+ * practice the same mirror that built the reverse index has the
104
+ * record itself)
105
+ */
106
+ export declare function traceForward(startHash: string, depth: number, index: Map<string, IndexedRecord>, options?: TraceOptions): TraceResult;
package/dist/trace.js CHANGED
@@ -123,3 +123,152 @@ export function traceBackward(startHash, depth, index, options = {}) {
123
123
  warnings,
124
124
  };
125
125
  }
126
+ /**
127
+ * Build a reverse informed_by index: for each record_hash R, the list of
128
+ * record_hashes that cite R in their informed_by field. O(total
129
+ * informed_by entries) build; O(1) lookup per parent during walk.
130
+ *
131
+ * Exported so callers (or tests) can pre-build the index when running
132
+ * multiple traceForward calls against the same mirror.
133
+ */
134
+ export function buildReverseInformedByIndex(index) {
135
+ const reverse = new Map();
136
+ for (const { record, record_hash } of index.values()) {
137
+ const ib = Array.isArray(record.informed_by) ? record.informed_by : [];
138
+ for (const upstream of ib) {
139
+ if (typeof upstream !== 'string')
140
+ continue;
141
+ const existing = reverse.get(upstream);
142
+ if (existing)
143
+ existing.push(record_hash);
144
+ else
145
+ reverse.set(upstream, [record_hash]);
146
+ }
147
+ }
148
+ return reverse;
149
+ }
150
+ /**
151
+ * Walk informed_by edges FORWARD from the starting record.
152
+ *
153
+ * Answers: "what records cited THIS record via informed_by?" — i.e. what
154
+ * decisions/observations followed from this one in the causal substrate.
155
+ * The dual of traceBackward. Useful for "I made decision X, what did I do
156
+ * because of it?" lookups.
157
+ *
158
+ * Same behaviors as traceBackward:
159
+ * - Cycle-safe via visited-set
160
+ * - Cap-safe via maxNodes
161
+ * - context_id-scoped (if specified): edges into a different context_id
162
+ * are treated as dangling
163
+ * - Dangling: walking forward to a record present in the reverse index
164
+ * but missing from the mirror surfaces it as dangling (defensive; in
165
+ * practice the same mirror that built the reverse index has the
166
+ * record itself)
167
+ */
168
+ export function traceForward(startHash, depth, index, options = {}) {
169
+ const maxNodes = options.maxNodes ?? 200;
170
+ const contextId = options.contextId;
171
+ const warnings = [];
172
+ const visited = new Map();
173
+ const dangling = new Set();
174
+ let truncatedByCap = false;
175
+ let truncatedByDepth = false;
176
+ let depthReached = 0;
177
+ const startIdx = index.get(startHash);
178
+ if (!startIdx) {
179
+ return {
180
+ start_hash: startHash,
181
+ direction: 'forward',
182
+ depth_requested: depth,
183
+ depth_reached: 0,
184
+ visited: [],
185
+ dangling: [startHash],
186
+ truncated_by_depth: false,
187
+ truncated_by_cap: false,
188
+ warnings: [`start_hash ${startHash} not in local mirror`],
189
+ };
190
+ }
191
+ if (contextId && startIdx.record.context_id !== contextId) {
192
+ return {
193
+ start_hash: startHash,
194
+ direction: 'forward',
195
+ depth_requested: depth,
196
+ depth_reached: 0,
197
+ visited: [],
198
+ dangling: [],
199
+ truncated_by_depth: false,
200
+ truncated_by_cap: false,
201
+ warnings: [
202
+ `start_hash ${startHash} lives in context_id ${startIdx.record.context_id} but trace was scoped to ${contextId} (set ATRIB_CONTEXT_ID to override or omit context_id to walk cross-context)`,
203
+ ],
204
+ };
205
+ }
206
+ // Build the reverse index once per call. For Layer 1 corpus sizes
207
+ // (~14K records) the O(N) build is negligible; the saved per-hop O(1)
208
+ // child lookup is worth the linear scan.
209
+ const reverse = buildReverseInformedByIndex(index);
210
+ const frontier = [{ hash: startHash, depth: 0, parent: null }];
211
+ while (frontier.length > 0) {
212
+ if (visited.size >= maxNodes) {
213
+ truncatedByCap = true;
214
+ break;
215
+ }
216
+ const current = frontier.shift();
217
+ if (current.depth > depth) {
218
+ truncatedByDepth = true;
219
+ continue;
220
+ }
221
+ depthReached = Math.max(depthReached, current.depth);
222
+ const idx = index.get(current.hash);
223
+ if (!idx) {
224
+ // The reverse index pointed at a record not present in the mirror.
225
+ // Defensive case (the reverse index was built FROM the same mirror,
226
+ // so this branch is unreachable in practice). Treat as dangling.
227
+ dangling.add(current.hash);
228
+ continue;
229
+ }
230
+ if (contextId && idx.record.context_id !== contextId) {
231
+ dangling.add(current.hash);
232
+ continue;
233
+ }
234
+ let v = visited.get(current.hash);
235
+ if (!v) {
236
+ const children = reverse.get(current.hash) ?? [];
237
+ v = {
238
+ depth: current.depth,
239
+ record_hash: current.hash,
240
+ parent_hashes: [],
241
+ record: idx.record,
242
+ source: idx.source,
243
+ // `next_informed_by` reused here as "edges out of this node in
244
+ // the walk direction" — for forward walk that's the children
245
+ // (records citing this one), not this record's own informed_by.
246
+ // Field name kept for shape-compat with traceBackward.
247
+ next_informed_by: children,
248
+ };
249
+ visited.set(current.hash, v);
250
+ if (current.depth < depth) {
251
+ for (const child of children) {
252
+ frontier.push({ hash: child, depth: current.depth + 1, parent: current.hash });
253
+ }
254
+ }
255
+ else if (children.length > 0) {
256
+ truncatedByDepth = true;
257
+ }
258
+ }
259
+ if (current.parent && !v.parent_hashes.includes(current.parent)) {
260
+ v.parent_hashes.push(current.parent);
261
+ }
262
+ }
263
+ return {
264
+ start_hash: startHash,
265
+ direction: 'forward',
266
+ depth_requested: depth,
267
+ depth_reached: depthReached,
268
+ visited: Array.from(visited.values()),
269
+ dangling: Array.from(dangling),
270
+ truncated_by_depth: truncatedByDepth,
271
+ truncated_by_cap: truncatedByCap,
272
+ warnings,
273
+ };
274
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atrib/trace",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for atrib. Walks informed_by chains backward from a record_hash to surface the reasoning chain that produced it.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "^1.29.0",
13
13
  "zod": "^3.25.76",
14
- "@atrib/mcp": "0.11.0"
14
+ "@atrib/mcp": "0.11.1"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^25.8.0",