@atrib/trace 0.4.1 → 0.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/README.md +25 -11
- package/dist/index.d.ts +54 -0
- package/dist/index.js +145 -34
- package/dist/storage.js +21 -13
- package/dist/trace.d.ts +43 -9
- package/dist/trace.js +149 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -4,24 +4,34 @@ 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
|
-
depth?: number, // hop cap (default 3, max 10)
|
|
14
|
+
depth?: number, // hop cap (0=start only, default 3, max 10)
|
|
13
15
|
max_nodes?: number, // safety cap (default 200, max 500)
|
|
14
|
-
compact?: boolean
|
|
16
|
+
compact?: boolean, // omit signature/content_id bytes (default true)
|
|
17
|
+
include_content?: boolean // include D062 local_content (default false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
mcp__atrib-trace__trace_forward({ // FORWARD — what was informed by this?
|
|
21
|
+
record_hash, depth?, max_nodes?, compact?, include_content? // same schema
|
|
15
22
|
})
|
|
23
|
+
|
|
16
24
|
→ {
|
|
17
|
-
start_hash, direction: "backward",
|
|
25
|
+
start_hash, direction: "backward" | "forward",
|
|
18
26
|
depth_requested, depth_reached,
|
|
19
27
|
visited: [
|
|
20
28
|
{
|
|
21
29
|
depth, record_hash, parent_hashes, source,
|
|
22
30
|
event_type, context_id, creator_key, timestamp,
|
|
23
31
|
next_informed_by, next_resolved, next_dangling,
|
|
24
|
-
|
|
32
|
+
informed_by?, tool_name?, args_hash?, result_hash?,
|
|
33
|
+
sidecar_summary?: { tool_name?, topics?, what?, importance?, producer? },
|
|
34
|
+
local_content?, local_producer? // only when include_content=true
|
|
25
35
|
}
|
|
26
36
|
],
|
|
27
37
|
dangling: string[],
|
|
@@ -30,14 +40,18 @@ mcp__atrib-trace__trace({
|
|
|
30
40
|
}
|
|
31
41
|
```
|
|
32
42
|
|
|
43
|
+
- `trace` walks `informed_by` BACKWARD (toward causal ancestors). Answers "what informed this record?"
|
|
44
|
+
- `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.
|
|
45
|
+
- 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`.
|
|
46
|
+
|
|
33
47
|
## Reads
|
|
34
48
|
|
|
35
|
-
Every `*.jsonl` mirror under `~/.atrib/records/` (override via `ATRIB_RECORDS_DIR`)
|
|
49
|
+
Every `*.jsonl` mirror under `~/.atrib/records/` (override via `ATRIB_RECORDS_DIR`) or one explicit mirror file via `ATRIB_RECORD_FILE`. Tolerates both producer envelope shapes:
|
|
36
50
|
|
|
37
51
|
- Bare `AtribRecord` per line (legacy / wrapper convention pre-sidecar)
|
|
38
52
|
- `{ record, _local?, written_at }` envelope (current shape)
|
|
39
53
|
|
|
40
|
-
When the envelope carries an optional `_local` sidecar (per the local-mirror sidecar pattern shipped in `@atrib/mcp` v0.2.x), trace surfaces a compact `sidecar_summary` per record: `tool_name`, `topics`, first ~200 chars of `what`/`summary`, and `importance` for annotations. Without the sidecar (legacy entries), the per-record output still includes the cryptographic evidence (event_type, hashes, creator_key, timestamp), just without
|
|
54
|
+
When the envelope carries an optional `_local` sidecar (per the local-mirror sidecar pattern shipped in `@atrib/mcp` v0.2.x), trace surfaces a compact `sidecar_summary` per record: `tool_name`, `topics`, first ~200 chars of `what`/`summary`, and `importance` for annotations. When `include_content=true`, trace also returns the full [D062](../../DECISIONS.md#d062-local-mirror-sidecar--two-tier-private-local--public-canonical-persistence) local mirror body as `local_content` plus `local_producer`; this is intended for harness-mediated causal replay where the agent needs rich expected/actual outcome context. Without the sidecar (legacy entries), the per-record output still includes the cryptographic evidence (event_type, hashes, creator_key, timestamp), just without semantic context.
|
|
41
55
|
|
|
42
56
|
## Behaviors
|
|
43
57
|
|
|
@@ -45,9 +59,9 @@ When the envelope carries an optional `_local` sidecar (per the local-mirror sid
|
|
|
45
59
|
- **Cap-safe**: hits `max_nodes` → returns partial result with `truncated_by_cap: true`.
|
|
46
60
|
- **Depth-safe**: hits `depth` → returns partial result with `truncated_by_depth: true`.
|
|
47
61
|
- **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
|
-
- **
|
|
50
|
-
- **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.
|
|
62
|
+
- **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.
|
|
63
|
+
- **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).
|
|
64
|
+
- **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. Instrumentation samples only `record_hash` fields, not `args_hash`, `result_hash`, or hashes embedded in `local_content`. 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
65
|
|
|
52
66
|
## Wire-up
|
|
53
67
|
|
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,61 @@
|
|
|
13
13
|
* is also a v2 concern; v1 is backward-only.
|
|
14
14
|
*/
|
|
15
15
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
|
+
import { type TraceVisited } from './trace.js';
|
|
17
|
+
interface CompactVisited {
|
|
18
|
+
depth: number;
|
|
19
|
+
record_hash: string;
|
|
20
|
+
parent_hashes: string[];
|
|
21
|
+
source: string | null;
|
|
22
|
+
event_type: string | null;
|
|
23
|
+
context_id: string | null;
|
|
24
|
+
creator_key: string | null;
|
|
25
|
+
timestamp: number | null;
|
|
26
|
+
/** informed_by entries on this record. Empty if none or dangling. */
|
|
27
|
+
next_informed_by: string[];
|
|
28
|
+
/** Sub-set of next_informed_by that were resolved (present in mirror). */
|
|
29
|
+
next_resolved: string[];
|
|
30
|
+
/** Sub-set of next_informed_by that were dangling (not in mirror). */
|
|
31
|
+
next_dangling: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Semantic context from the local sidecar when present. Carries
|
|
34
|
+
* tool name + brief content summary so the agent can reason about
|
|
35
|
+
* the record's meaning without separately fetching it. Absent on
|
|
36
|
+
* legacy bare-record entries that pre-date the sidecar.
|
|
37
|
+
*/
|
|
38
|
+
sidecar_summary?: {
|
|
39
|
+
tool_name?: string;
|
|
40
|
+
topics?: string[];
|
|
41
|
+
/**
|
|
42
|
+
* First 200 chars of the record's human-readable content, derived
|
|
43
|
+
* per event_type from the @atrib/mcp normative shapes (D086):
|
|
44
|
+
* observation `what`, annotation `summary`, revision `new_position`.
|
|
45
|
+
* For legacy records using non-normative names (`summary` on
|
|
46
|
+
* observation), falls back to `summary`.
|
|
47
|
+
*/
|
|
48
|
+
what?: string;
|
|
49
|
+
/** Annotation `importance` field when present. */
|
|
50
|
+
importance?: string;
|
|
51
|
+
producer?: string;
|
|
52
|
+
};
|
|
53
|
+
/** Signed structural disclosures, surfaced when present on the record. */
|
|
54
|
+
informed_by?: string[];
|
|
55
|
+
tool_name?: string;
|
|
56
|
+
args_hash?: string;
|
|
57
|
+
result_hash?: string;
|
|
58
|
+
/** D062 local mirror body, included only when include_content=true. */
|
|
59
|
+
local_content?: unknown;
|
|
60
|
+
local_producer?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Pull a compact summary from the sidecar's content payload.
|
|
63
|
+
* Exported for unit testing of the per-event_type content-shape handling. */
|
|
64
|
+
export declare function summarizeSidecar(loadedRecord: {
|
|
65
|
+
local?: import('./storage.js').SidecarPayload;
|
|
66
|
+
} | undefined): CompactVisited['sidecar_summary'];
|
|
67
|
+
export declare function compactVisited(v: TraceVisited, danglingSet: Set<string>, byHash: Map<string, import('./storage.js').IndexedRecord>, includeContent: boolean): CompactVisited;
|
|
68
|
+
export declare function extractRecordHashFieldsFromMcpResult(result: unknown): string[];
|
|
16
69
|
export interface AtribTraceServer {
|
|
17
70
|
mcp: McpServer;
|
|
18
71
|
}
|
|
19
72
|
export declare function createAtribTraceServer(): Promise<AtribTraceServer>;
|
|
73
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
17
|
import { z } from 'zod';
|
|
18
|
-
import { resolveEnvContextId, logReadPrimitiveCall,
|
|
18
|
+
import { resolveEnvContextId, logReadPrimitiveCall, } 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({
|
|
@@ -28,7 +28,8 @@ const TraceInput = z.object({
|
|
|
28
28
|
'walk. Defaults to process.env.ATRIB_CONTEXT_ID when valid; omit at ' +
|
|
29
29
|
'the env-var level to walk cross-context. Used by Inspect-style ' +
|
|
30
30
|
'harnesses to keep each arm\'s trace inside its own context per D072.'),
|
|
31
|
-
depth: z.number().int().min(
|
|
31
|
+
depth: z.number().int().min(0).max(10).optional().describe('Maximum hop count from the starting record. Use 0 to return only ' +
|
|
32
|
+
'the start record. Defaults to 3. Bounded ' +
|
|
32
33
|
'at 10 to keep responses tractable; deeper chains should be walked ' +
|
|
33
34
|
'in pieces by re-rooting at a returned upstream hash.'),
|
|
34
35
|
max_nodes: z.number().int().min(1).max(500).optional().describe('Hard cap on total visited records. Defaults to 200. Prevents pathological ' +
|
|
@@ -37,9 +38,13 @@ const TraceInput = z.object({
|
|
|
37
38
|
compact: z.boolean().optional().describe('When true (the default), per-record output omits signature/content_id/' +
|
|
38
39
|
'spec_version/chain_root to keep the response small. Set false for full ' +
|
|
39
40
|
'record bytes (useful for re-verification).'),
|
|
41
|
+
include_content: z.boolean().optional().describe('When true and compact=true, include the D062 local mirror body as ' +
|
|
42
|
+
'local_content and local_producer on each visited record. Defaults false ' +
|
|
43
|
+
'so trace stays cheap for ordinary causal walks.'),
|
|
40
44
|
});
|
|
41
|
-
/** Pull a compact summary from the sidecar's content payload.
|
|
42
|
-
|
|
45
|
+
/** Pull a compact summary from the sidecar's content payload.
|
|
46
|
+
* Exported for unit testing of the per-event_type content-shape handling. */
|
|
47
|
+
export function summarizeSidecar(loadedRecord) {
|
|
43
48
|
if (!loadedRecord?.local)
|
|
44
49
|
return undefined;
|
|
45
50
|
const sc = loadedRecord.local;
|
|
@@ -52,21 +57,39 @@ function summarizeSidecar(loadedRecord) {
|
|
|
52
57
|
if (c && Array.isArray(c['topics'])) {
|
|
53
58
|
out.topics = c['topics'].filter((t) => typeof t === 'string').slice(0, 6);
|
|
54
59
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Per-event_type human-readable text. The `what` slot here is generic
|
|
61
|
+
// (it's the human-scannable summary regardless of event_type); the
|
|
62
|
+
// priority order pulls the normative D086 field for each shape, with
|
|
63
|
+
// an explicit final fallback to `summary` for legacy records.
|
|
64
|
+
// observation: content.what
|
|
65
|
+
// annotation: content.summary
|
|
66
|
+
// revision: content.new_position (D086-normative) — surface this
|
|
67
|
+
// so trace consumers can read the agent's stance shift
|
|
68
|
+
// without a separate recall call
|
|
69
|
+
// legacy: content.summary (catches pre-D086 extractor records)
|
|
70
|
+
if (c) {
|
|
71
|
+
let primary;
|
|
72
|
+
if (typeof c['what'] === 'string') {
|
|
73
|
+
primary = c['what'];
|
|
74
|
+
}
|
|
75
|
+
else if (typeof c['new_position'] === 'string') {
|
|
76
|
+
primary = c['new_position'];
|
|
77
|
+
}
|
|
78
|
+
else if (typeof c['summary'] === 'string') {
|
|
79
|
+
primary = c['summary'];
|
|
80
|
+
}
|
|
81
|
+
if (primary) {
|
|
82
|
+
out.what = primary.length > 200 ? primary.slice(0, 197) + '…' : primary;
|
|
83
|
+
}
|
|
62
84
|
}
|
|
63
85
|
if (c && typeof c['importance'] === 'string')
|
|
64
86
|
out.importance = c['importance'];
|
|
65
87
|
return Object.keys(out).length === 0 ? undefined : out;
|
|
66
88
|
}
|
|
67
|
-
function compactVisited(v, danglingSet, byHash) {
|
|
89
|
+
export function compactVisited(v, danglingSet, byHash, includeContent) {
|
|
68
90
|
const indexed = byHash.get(v.record_hash);
|
|
69
|
-
|
|
91
|
+
const record = v.record;
|
|
92
|
+
const out = {
|
|
70
93
|
depth: v.depth,
|
|
71
94
|
record_hash: v.record_hash,
|
|
72
95
|
parent_hashes: v.parent_hashes,
|
|
@@ -80,24 +103,101 @@ function compactVisited(v, danglingSet, byHash) {
|
|
|
80
103
|
next_dangling: v.next_informed_by.filter((h) => danglingSet.has(h)),
|
|
81
104
|
...(summarizeSidecar(indexed) ? { sidecar_summary: summarizeSidecar(indexed) } : {}),
|
|
82
105
|
};
|
|
106
|
+
if (record) {
|
|
107
|
+
const informedBy = record.informed_by;
|
|
108
|
+
const toolName = record.tool_name;
|
|
109
|
+
const argsHash = record.args_hash;
|
|
110
|
+
const resultHash = record.result_hash;
|
|
111
|
+
if (Array.isArray(informedBy) && informedBy.length > 0)
|
|
112
|
+
out.informed_by = informedBy;
|
|
113
|
+
if (toolName)
|
|
114
|
+
out.tool_name = toolName;
|
|
115
|
+
if (argsHash)
|
|
116
|
+
out.args_hash = argsHash;
|
|
117
|
+
if (resultHash)
|
|
118
|
+
out.result_hash = resultHash;
|
|
119
|
+
}
|
|
120
|
+
if (includeContent && indexed?.local?.content !== undefined) {
|
|
121
|
+
out.local_content = indexed.local.content;
|
|
122
|
+
}
|
|
123
|
+
if (includeContent && indexed?.local?.producer !== undefined) {
|
|
124
|
+
out.local_producer = indexed.local.producer;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
export function extractRecordHashFieldsFromMcpResult(result) {
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const pattern = /^sha256:[0-9a-f]{64}$/;
|
|
131
|
+
const content = result?.content;
|
|
132
|
+
const text = Array.isArray(content) && typeof content[0]?.text === 'string'
|
|
133
|
+
? (content[0].text)
|
|
134
|
+
: undefined;
|
|
135
|
+
let root = result;
|
|
136
|
+
if (text) {
|
|
137
|
+
try {
|
|
138
|
+
root = JSON.parse(text);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
root = result;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
walk(root);
|
|
145
|
+
return Array.from(seen);
|
|
146
|
+
function walk(node) {
|
|
147
|
+
if (node === null || node === undefined)
|
|
148
|
+
return;
|
|
149
|
+
if (Array.isArray(node)) {
|
|
150
|
+
for (const item of node)
|
|
151
|
+
walk(item);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (typeof node !== 'object')
|
|
155
|
+
return;
|
|
156
|
+
const obj = node;
|
|
157
|
+
if (typeof obj.record_hash === 'string' && pattern.test(obj.record_hash)) {
|
|
158
|
+
seen.add(obj.record_hash);
|
|
159
|
+
}
|
|
160
|
+
for (const value of Object.values(obj))
|
|
161
|
+
walk(value);
|
|
162
|
+
}
|
|
83
163
|
}
|
|
84
164
|
export async function createAtribTraceServer() {
|
|
85
165
|
const mcp = new McpServer({
|
|
86
166
|
name: 'atrib-trace',
|
|
87
167
|
version: '0.1.0',
|
|
88
168
|
});
|
|
169
|
+
// Shared shape: build a compact payload from a TraceResult.
|
|
170
|
+
function buildPayload(result, byHash, compact, includeContent) {
|
|
171
|
+
const danglingSet = new Set(result.dangling);
|
|
172
|
+
return compact
|
|
173
|
+
? {
|
|
174
|
+
start_hash: result.start_hash,
|
|
175
|
+
direction: result.direction,
|
|
176
|
+
depth_requested: result.depth_requested,
|
|
177
|
+
depth_reached: result.depth_reached,
|
|
178
|
+
visited: result.visited.map((v) => compactVisited(v, danglingSet, byHash, includeContent)),
|
|
179
|
+
dangling: result.dangling,
|
|
180
|
+
truncated_by_depth: result.truncated_by_depth,
|
|
181
|
+
truncated_by_cap: result.truncated_by_cap,
|
|
182
|
+
warnings: result.warnings,
|
|
183
|
+
}
|
|
184
|
+
: { ...result };
|
|
185
|
+
}
|
|
89
186
|
mcp.registerTool('trace', {
|
|
90
187
|
title: 'trace informed_by chain backward',
|
|
91
188
|
description: 'Walk a record\'s informed_by chain backward to surface the reasoning ' +
|
|
92
189
|
'chain that led to it. Reads from the local signed-record mirror ' +
|
|
93
190
|
'(~/.atrib/records/*.jsonl). Returns the records visited, parent links ' +
|
|
94
191
|
'so the caller can reconstruct the tree, and dangling hashes (records ' +
|
|
95
|
-
'referenced via informed_by but not present in the local mirror).'
|
|
192
|
+
'referenced via informed_by but not present in the local mirror). ' +
|
|
193
|
+
'Sibling tool `trace_forward` walks the opposite direction (records ' +
|
|
194
|
+
'that cited THIS one via their informed_by).',
|
|
96
195
|
inputSchema: TraceInput.shape,
|
|
97
196
|
}, async (args) => logReadPrimitiveCall('trace', args, async () => {
|
|
98
197
|
const depth = args.depth ?? 3;
|
|
99
198
|
const maxNodes = args.max_nodes ?? 200;
|
|
100
199
|
const compact = args.compact ?? true;
|
|
200
|
+
const includeContent = args.include_content ?? false;
|
|
101
201
|
// Env-var context_id default: when the caller omitted context_id,
|
|
102
202
|
// fall back to @atrib/mcp's resolveEnvContextId (D078 + D083
|
|
103
203
|
// precedence: ATRIB_CONTEXT_ID, then a registered harness env var
|
|
@@ -108,26 +208,37 @@ export async function createAtribTraceServer() {
|
|
|
108
208
|
maxNodes,
|
|
109
209
|
...(contextId ? { contextId } : {}),
|
|
110
210
|
});
|
|
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
211
|
return {
|
|
129
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
212
|
+
content: [{ type: 'text', text: JSON.stringify(buildPayload(result, byHash, compact, includeContent), null, 2) }],
|
|
213
|
+
};
|
|
214
|
+
}, extractRecordHashFieldsFromMcpResult));
|
|
215
|
+
// Sibling tool: forward walk (records that cited THIS one). Mirrors
|
|
216
|
+
// the trace input schema + response shape exactly so callers can use
|
|
217
|
+
// it interchangeably; only the direction of the walk differs.
|
|
218
|
+
mcp.registerTool('trace_forward', {
|
|
219
|
+
title: 'trace informed_by chain forward',
|
|
220
|
+
description: 'Walk forward from a record_hash, surfacing the records that cited ' +
|
|
221
|
+
'it via their informed_by chain. The dual of `trace` (backward). ' +
|
|
222
|
+
'Answers "I made decision X, what did I do because of it?" Reads from ' +
|
|
223
|
+
'the local signed-record mirror (~/.atrib/records/*.jsonl). Returns ' +
|
|
224
|
+
'the records visited, parent links (which upstream record at the ' +
|
|
225
|
+
'prior level cited this one), and dangling hashes (rare; only if the ' +
|
|
226
|
+
'reverse index references a record missing from the mirror).',
|
|
227
|
+
inputSchema: TraceInput.shape,
|
|
228
|
+
}, async (args) => logReadPrimitiveCall('trace_forward', args, async () => {
|
|
229
|
+
const depth = args.depth ?? 3;
|
|
230
|
+
const maxNodes = args.max_nodes ?? 200;
|
|
231
|
+
const compact = args.compact ?? true;
|
|
232
|
+
const includeContent = args.include_content ?? false;
|
|
233
|
+
const contextId = args.context_id ?? resolveEnvContextId();
|
|
234
|
+
const { byHash } = loadAllRecords();
|
|
235
|
+
const result = traceForward(args.record_hash, depth, byHash, {
|
|
236
|
+
maxNodes,
|
|
237
|
+
...(contextId ? { contextId } : {}),
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: 'text', text: JSON.stringify(buildPayload(result, byHash, compact, includeContent), null, 2) }],
|
|
130
241
|
};
|
|
131
|
-
},
|
|
242
|
+
}, extractRecordHashFieldsFromMcpResult));
|
|
132
243
|
return { mcp };
|
|
133
244
|
}
|
package/dist/storage.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Local mirror reader for atrib-trace.
|
|
4
4
|
*
|
|
5
|
-
* Reads every *.jsonl mirror under
|
|
6
|
-
*
|
|
5
|
+
* Reads either one ATRIB_RECORD_FILE mirror or every *.jsonl mirror under
|
|
6
|
+
* ~/.atrib/records/ and normalizes to bare AtribRecord shape regardless of
|
|
7
|
+
* producer (wrapper-signed or emit envelope).
|
|
7
8
|
*
|
|
8
9
|
* Builds an in-memory index by record_hash so trace can walk informed_by
|
|
9
10
|
* chains in O(1) per hop. The hash key is sha256:<64-hex> (the same form
|
|
@@ -11,8 +12,9 @@
|
|
|
11
12
|
*/
|
|
12
13
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
14
|
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
+
import { basename, join } from 'node:path';
|
|
15
16
|
import { canonicalRecord, hexEncode, sha256 } from '@atrib/mcp';
|
|
17
|
+
const RECORDS_FILE = process.env.ATRIB_RECORD_FILE;
|
|
16
18
|
const RECORDS_DIR = process.env.ATRIB_RECORDS_DIR ?? join(homedir(), '.atrib', 'records');
|
|
17
19
|
/**
|
|
18
20
|
* Read every *.jsonl mirror in ATRIB_RECORDS_DIR and return an index keyed
|
|
@@ -25,17 +27,23 @@ const RECORDS_DIR = process.env.ATRIB_RECORDS_DIR ?? join(homedir(), '.atrib', '
|
|
|
25
27
|
export function loadAllRecords(dir = RECORDS_DIR) {
|
|
26
28
|
const byHash = new Map();
|
|
27
29
|
const all = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
30
|
+
const paths = [];
|
|
31
|
+
if (RECORDS_FILE) {
|
|
32
|
+
paths.push({ path: RECORDS_FILE, source: basename(RECORDS_FILE) });
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
else {
|
|
35
|
+
if (!existsSync(dir))
|
|
36
|
+
return { byHash, newestFirst: [] };
|
|
37
|
+
try {
|
|
38
|
+
for (const fname of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
|
|
39
|
+
paths.push({ path: join(dir, fname), source: fname });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { byHash, newestFirst: [] };
|
|
44
|
+
}
|
|
36
45
|
}
|
|
37
|
-
for (const
|
|
38
|
-
const path = join(dir, fname);
|
|
46
|
+
for (const { path, source } of paths) {
|
|
39
47
|
try {
|
|
40
48
|
const raw = readFileSync(path, 'utf8');
|
|
41
49
|
for (const line of raw.split('\n')) {
|
|
@@ -57,7 +65,7 @@ export function loadAllRecords(dir = RECORDS_DIR) {
|
|
|
57
65
|
const indexed = {
|
|
58
66
|
record: rec,
|
|
59
67
|
record_hash: `sha256:${hashHex}`,
|
|
60
|
-
source
|
|
68
|
+
source,
|
|
61
69
|
};
|
|
62
70
|
// Lift the `_local` sidecar onto the indexed record when present.
|
|
63
71
|
// Legacy bare-record entries have no sidecar; that's OK, consumers
|
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.1",
|
|
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,13 +11,13 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
13
13
|
"zod": "^3.25.76",
|
|
14
|
-
"@atrib/mcp": "0.
|
|
14
|
+
"@atrib/mcp": "0.12.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@types/node": "^25.
|
|
18
|
-
"tsx": "^4.22.
|
|
17
|
+
"@types/node": "^25.9.1",
|
|
18
|
+
"tsx": "^4.22.3",
|
|
19
19
|
"typescript": "^6.0.3",
|
|
20
|
-
"vitest": "^4.1.
|
|
20
|
+
"vitest": "^4.1.7"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist"
|