@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 +16 -5
- package/dist/index.d.ts +43 -0
- package/dist/index.js +76 -29
- package/dist/trace.d.ts +43 -9
- package/dist/trace.js +149 -0
- package/package.json +2 -2
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
|
-
##
|
|
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
|
|
49
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
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
|
|
2
|
+
* Bounded directional walk through informed_by chains.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
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.
|
|
14
|
+
"@atrib/mcp": "0.11.1"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"@types/node": "^25.8.0",
|