@atrib/trace 0.5.21 → 1.0.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 +10 -0
- package/dist/index.d.ts +2 -79
- package/dist/index.js +7 -305
- package/dist/main.js +4 -6
- package/package.json +3 -4
- package/dist/storage.d.ts +0 -36
- package/dist/storage.js +0 -92
- package/dist/trace.d.ts +0 -106
- package/dist/trace.js +0 -274
package/README.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @atrib/trace
|
|
2
2
|
|
|
3
|
+
**Legacy home.** The read-verb implementation moved to
|
|
4
|
+
[`@atrib/recall`](../atrib-recall/README.md) per the attest/recall rename
|
|
5
|
+
([D164](../../DECISIONS.md#d164-attestrecall-verb-rename-and-primitive-surface-collapse)).
|
|
6
|
+
`trace` and `trace_forward` fold into `recall` with `shape: "walk"` and
|
|
7
|
+
`direction: "backward"` or `"forward"`. This package re-exports the same
|
|
8
|
+
surface and forwards the `atrib-trace` binary to `@atrib/recall`'s
|
|
9
|
+
handlers. Results are JSON-identical. The `trace` and `trace_forward`
|
|
10
|
+
tool names stay mounted as permanent aliases during the alias window,
|
|
11
|
+
alongside the new `recall` tool.
|
|
12
|
+
|
|
3
13
|
MCP server exposing the `trace` tool for atrib's verifiable action layer. It walks a record's `informed_by` chain backward to surface the signed relationship path that led to it.
|
|
4
14
|
|
|
5
15
|
Closes the consumer-side cognitive-loop primitive: recall returns raw records; trace returns the declared-relationship trace, so an agent asking "why did I do X?" can see "X was claimed to be informed by Y, which was claimed to be informed by Z" without manually walking `informed_by` hash-by-hash.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,79 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* informed_by chain backward to surface the reasoning chain that led to it.
|
|
4
|
-
*
|
|
5
|
-
* Closes the consumer-side cognitive-loop primitive: recall returns raw
|
|
6
|
-
* records; trace returns the declared-relationship path, so an agent asking
|
|
7
|
-
* "why did I do X?" can see "X claimed Y as input, and Y claimed Z as input"
|
|
8
|
-
* without manually walking informed_by hash-by-hash.
|
|
9
|
-
*
|
|
10
|
-
* Reads only the local mirror (~/.atrib/records/*.jsonl) for v1, same scope
|
|
11
|
-
* as recall. v2 will fall back to log.atrib.dev/v1/lookup/<hash> for hashes
|
|
12
|
-
* not in the local mirror. Forward-walk (records that reference THIS one)
|
|
13
|
-
* is also a v2 concern; v1 is backward-only.
|
|
14
|
-
*/
|
|
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
|
-
span_kind?: string;
|
|
41
|
-
span_name?: string;
|
|
42
|
-
model_name?: string;
|
|
43
|
-
prompt_version?: string;
|
|
44
|
-
topics?: string[];
|
|
45
|
-
intent?: string;
|
|
46
|
-
rationale?: string;
|
|
47
|
-
/**
|
|
48
|
-
* First 200 chars of the record's human-readable content, derived
|
|
49
|
-
* per event_type from the @atrib/mcp normative shapes (D086):
|
|
50
|
-
* observation `what`, annotation `summary`, revision `new_position`.
|
|
51
|
-
* For legacy records using non-normative names (`summary` on
|
|
52
|
-
* observation), falls back to `summary`.
|
|
53
|
-
*/
|
|
54
|
-
what?: string;
|
|
55
|
-
/** Annotation `importance` field when present. */
|
|
56
|
-
importance?: string;
|
|
57
|
-
producer?: string;
|
|
58
|
-
};
|
|
59
|
-
/** Signed structural disclosures, surfaced when present on the record. */
|
|
60
|
-
informed_by?: string[];
|
|
61
|
-
tool_name?: string;
|
|
62
|
-
args_hash?: string;
|
|
63
|
-
result_hash?: string;
|
|
64
|
-
/** D062 local mirror body, included only when include_content=true. */
|
|
65
|
-
local_content?: unknown;
|
|
66
|
-
local_producer?: string;
|
|
67
|
-
}
|
|
68
|
-
/** Pull a compact summary from the sidecar's content payload.
|
|
69
|
-
* Exported for unit testing of the per-event_type content-shape handling. */
|
|
70
|
-
export declare function summarizeSidecar(loadedRecord: {
|
|
71
|
-
local?: import('./storage.js').SidecarPayload;
|
|
72
|
-
} | undefined): CompactVisited['sidecar_summary'];
|
|
73
|
-
export declare function compactVisited(v: TraceVisited, danglingSet: Set<string>, byHash: Map<string, import('./storage.js').IndexedRecord>, includeContent: boolean): CompactVisited;
|
|
74
|
-
export declare function extractRecordHashFieldsFromMcpResult(result: unknown): string[];
|
|
75
|
-
export interface AtribTraceServer {
|
|
76
|
-
mcp: McpServer;
|
|
77
|
-
}
|
|
78
|
-
export declare function createAtribTraceServer(): Promise<AtribTraceServer>;
|
|
79
|
-
export {};
|
|
1
|
+
export { compactVisited, createAtribTraceServer, extractRecordHashFieldsFromMcpResult, registerTraceTools, runTraceWalk, summarizeSidecar, TraceInput, } from '@atrib/recall';
|
|
2
|
+
export type { AtribTraceServer, TraceInputT } from '@atrib/recall';
|
package/dist/index.js
CHANGED
|
@@ -1,306 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* without manually walking informed_by hash-by-hash.
|
|
10
|
-
*
|
|
11
|
-
* Reads only the local mirror (~/.atrib/records/*.jsonl) for v1, same scope
|
|
12
|
-
* as recall. v2 will fall back to log.atrib.dev/v1/lookup/<hash> for hashes
|
|
13
|
-
* not in the local mirror. Forward-walk (records that reference THIS one)
|
|
14
|
-
* is also a v2 concern; v1 is backward-only.
|
|
15
|
-
*/
|
|
16
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
|
-
import { z } from 'zod';
|
|
18
|
-
import { resolveEnvContextId, logReadPrimitiveCall } from '@atrib/mcp';
|
|
19
|
-
import { loadAllRecords } from './storage.js';
|
|
20
|
-
import { traceBackward, traceForward } from './trace.js';
|
|
21
|
-
const SHA256_REF_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
22
|
-
const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
|
|
23
|
-
const TraceInput = z.object({
|
|
24
|
-
record_hash: z
|
|
25
|
-
.string()
|
|
26
|
-
.regex(SHA256_REF_PATTERN)
|
|
27
|
-
.describe("The 'sha256:<64-hex>' record_hash to start from. Walks backward via " +
|
|
28
|
-
'informed_by edges from this record.'),
|
|
29
|
-
context_id: z
|
|
30
|
-
.string()
|
|
31
|
-
.regex(HEX_32_PATTERN)
|
|
32
|
-
.optional()
|
|
33
|
-
.describe('Optional 32-hex context_id scope. When set, edges crossing into a ' +
|
|
34
|
-
'different context_id surface as dangling rather than expanding the ' +
|
|
35
|
-
'walk. Defaults to process.env.ATRIB_CONTEXT_ID when valid; omit at ' +
|
|
36
|
-
'the env-var level to walk cross-context. Used by Inspect-style ' +
|
|
37
|
-
"harnesses to keep each arm's trace inside its own context per D072."),
|
|
38
|
-
depth: z
|
|
39
|
-
.number()
|
|
40
|
-
.int()
|
|
41
|
-
.min(0)
|
|
42
|
-
.max(10)
|
|
43
|
-
.optional()
|
|
44
|
-
.describe('Maximum hop count from the starting record. Use 0 to return only ' +
|
|
45
|
-
'the start record. Defaults to 3. Bounded ' +
|
|
46
|
-
'at 10 to keep responses tractable; deeper chains should be walked ' +
|
|
47
|
-
'in pieces by re-rooting at a returned upstream hash.'),
|
|
48
|
-
max_nodes: z
|
|
49
|
-
.number()
|
|
50
|
-
.int()
|
|
51
|
-
.min(1)
|
|
52
|
-
.max(500)
|
|
53
|
-
.optional()
|
|
54
|
-
.describe('Hard cap on total visited records. Defaults to 200. Prevents pathological ' +
|
|
55
|
-
'fan-out (a record with hundreds of informed_by entries) from exploding ' +
|
|
56
|
-
'the response.'),
|
|
57
|
-
compact: z
|
|
58
|
-
.boolean()
|
|
59
|
-
.optional()
|
|
60
|
-
.describe('When true (the default), per-record output omits signature/content_id/' +
|
|
61
|
-
'spec_version/chain_root to keep the response small. Set false for full ' +
|
|
62
|
-
'record bytes (useful for re-verification).'),
|
|
63
|
-
include_content: z
|
|
64
|
-
.boolean()
|
|
65
|
-
.optional()
|
|
66
|
-
.describe('When true and compact=true, include the D062 local mirror body as ' +
|
|
67
|
-
'local_content and local_producer on each visited record. Defaults false ' +
|
|
68
|
-
'so trace stays cheap for ordinary causal walks.'),
|
|
69
|
-
});
|
|
70
|
-
/** Pull a compact summary from the sidecar's content payload.
|
|
71
|
-
* Exported for unit testing of the per-event_type content-shape handling. */
|
|
72
|
-
export function summarizeSidecar(loadedRecord) {
|
|
73
|
-
if (!loadedRecord?.local)
|
|
74
|
-
return undefined;
|
|
75
|
-
const sc = loadedRecord.local;
|
|
76
|
-
const out = {};
|
|
77
|
-
if (sc.toolName)
|
|
78
|
-
out.tool_name = sc.toolName;
|
|
79
|
-
if (sc.producer)
|
|
80
|
-
out.producer = sc.producer;
|
|
81
|
-
const c = sc.content;
|
|
82
|
-
if (!out.tool_name && c && typeof c['tool_name'] === 'string') {
|
|
83
|
-
out.tool_name = c['tool_name'];
|
|
84
|
-
}
|
|
85
|
-
if (c && typeof c['span_kind'] === 'string')
|
|
86
|
-
out.span_kind = c['span_kind'];
|
|
87
|
-
if (c && typeof c['span_name'] === 'string')
|
|
88
|
-
out.span_name = c['span_name'];
|
|
89
|
-
if (c && typeof c['model_name'] === 'string')
|
|
90
|
-
out.model_name = c['model_name'];
|
|
91
|
-
if (c && typeof c['prompt_version'] === 'string')
|
|
92
|
-
out.prompt_version = c['prompt_version'];
|
|
93
|
-
if (c && typeof c['intent'] === 'string') {
|
|
94
|
-
out.intent = c['intent'].length > 200 ? c['intent'].slice(0, 197) + '...' : c['intent'];
|
|
95
|
-
}
|
|
96
|
-
if (c && typeof c['rationale'] === 'string') {
|
|
97
|
-
out.rationale =
|
|
98
|
-
c['rationale'].length > 200 ? c['rationale'].slice(0, 197) + '...' : c['rationale'];
|
|
99
|
-
}
|
|
100
|
-
if (c && Array.isArray(c['topics'])) {
|
|
101
|
-
out.topics = c['topics']
|
|
102
|
-
.filter((t) => typeof t === 'string')
|
|
103
|
-
.slice(0, 6);
|
|
104
|
-
}
|
|
105
|
-
// Per-event_type human-readable text. The `what` slot here is generic
|
|
106
|
-
// (it's the human-scannable summary regardless of event_type); the
|
|
107
|
-
// priority order pulls the normative D086 field for each shape, with
|
|
108
|
-
// an explicit final fallback to `summary` for legacy records.
|
|
109
|
-
// observation: content.what
|
|
110
|
-
// annotation: content.summary
|
|
111
|
-
// revision: content.new_position (D086-normative). Surface this
|
|
112
|
-
// so trace consumers can read the agent's stance shift
|
|
113
|
-
// without a separate recall call
|
|
114
|
-
// legacy: content.summary (catches pre-D086 extractor records)
|
|
115
|
-
if (c) {
|
|
116
|
-
let primary;
|
|
117
|
-
if (typeof c['what'] === 'string') {
|
|
118
|
-
primary = c['what'];
|
|
119
|
-
}
|
|
120
|
-
else if (typeof c['new_position'] === 'string') {
|
|
121
|
-
primary = c['new_position'];
|
|
122
|
-
}
|
|
123
|
-
else if (typeof c['summary'] === 'string') {
|
|
124
|
-
primary = c['summary'];
|
|
125
|
-
}
|
|
126
|
-
else if (typeof c['prompt'] === 'string') {
|
|
127
|
-
primary = c['prompt'];
|
|
128
|
-
}
|
|
129
|
-
else if (typeof c['output'] === 'string') {
|
|
130
|
-
primary = c['output'];
|
|
131
|
-
}
|
|
132
|
-
if (primary) {
|
|
133
|
-
out.what = primary.length > 200 ? primary.slice(0, 197) + '…' : primary;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (c && typeof c['importance'] === 'string')
|
|
137
|
-
out.importance = c['importance'];
|
|
138
|
-
return Object.keys(out).length === 0 ? undefined : out;
|
|
139
|
-
}
|
|
140
|
-
export function compactVisited(v, danglingSet, byHash, includeContent) {
|
|
141
|
-
const indexed = byHash.get(v.record_hash);
|
|
142
|
-
const record = v.record;
|
|
143
|
-
const out = {
|
|
144
|
-
depth: v.depth,
|
|
145
|
-
record_hash: v.record_hash,
|
|
146
|
-
parent_hashes: v.parent_hashes,
|
|
147
|
-
source: v.source,
|
|
148
|
-
event_type: v.record?.event_type ?? null,
|
|
149
|
-
context_id: v.record?.context_id ?? null,
|
|
150
|
-
creator_key: v.record?.creator_key ?? null,
|
|
151
|
-
timestamp: v.record?.timestamp ?? null,
|
|
152
|
-
next_informed_by: v.next_informed_by,
|
|
153
|
-
next_resolved: v.next_informed_by.filter((h) => !danglingSet.has(h)),
|
|
154
|
-
next_dangling: v.next_informed_by.filter((h) => danglingSet.has(h)),
|
|
155
|
-
...(summarizeSidecar(indexed) ? { sidecar_summary: summarizeSidecar(indexed) } : {}),
|
|
156
|
-
};
|
|
157
|
-
if (record) {
|
|
158
|
-
const informedBy = record.informed_by;
|
|
159
|
-
const toolName = record.tool_name;
|
|
160
|
-
const argsHash = record.args_hash;
|
|
161
|
-
const resultHash = record.result_hash;
|
|
162
|
-
if (Array.isArray(informedBy) && informedBy.length > 0)
|
|
163
|
-
out.informed_by = informedBy;
|
|
164
|
-
if (toolName)
|
|
165
|
-
out.tool_name = toolName;
|
|
166
|
-
if (argsHash)
|
|
167
|
-
out.args_hash = argsHash;
|
|
168
|
-
if (resultHash)
|
|
169
|
-
out.result_hash = resultHash;
|
|
170
|
-
}
|
|
171
|
-
if (includeContent && indexed?.local?.content !== undefined) {
|
|
172
|
-
out.local_content = indexed.local.content;
|
|
173
|
-
}
|
|
174
|
-
if (includeContent && indexed?.local?.producer !== undefined) {
|
|
175
|
-
out.local_producer = indexed.local.producer;
|
|
176
|
-
}
|
|
177
|
-
return out;
|
|
178
|
-
}
|
|
179
|
-
export function extractRecordHashFieldsFromMcpResult(result) {
|
|
180
|
-
const seen = new Set();
|
|
181
|
-
const pattern = /^sha256:[0-9a-f]{64}$/;
|
|
182
|
-
const content = result?.content;
|
|
183
|
-
const text = Array.isArray(content) &&
|
|
184
|
-
typeof content[0]?.text === 'string'
|
|
185
|
-
? content[0].text
|
|
186
|
-
: undefined;
|
|
187
|
-
let root = result;
|
|
188
|
-
if (text) {
|
|
189
|
-
try {
|
|
190
|
-
root = JSON.parse(text);
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
root = result;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
walk(root);
|
|
197
|
-
return Array.from(seen);
|
|
198
|
-
function walk(node) {
|
|
199
|
-
if (node === null || node === undefined)
|
|
200
|
-
return;
|
|
201
|
-
if (Array.isArray(node)) {
|
|
202
|
-
for (const item of node)
|
|
203
|
-
walk(item);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (typeof node !== 'object')
|
|
207
|
-
return;
|
|
208
|
-
const obj = node;
|
|
209
|
-
if (typeof obj.record_hash === 'string' && pattern.test(obj.record_hash)) {
|
|
210
|
-
seen.add(obj.record_hash);
|
|
211
|
-
}
|
|
212
|
-
for (const value of Object.values(obj))
|
|
213
|
-
walk(value);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
export async function createAtribTraceServer() {
|
|
217
|
-
const mcp = new McpServer({
|
|
218
|
-
name: 'atrib-trace',
|
|
219
|
-
version: '0.1.0',
|
|
220
|
-
});
|
|
221
|
-
// Shared shape: build a compact payload from a TraceResult.
|
|
222
|
-
function buildPayload(result, byHash, compact, includeContent) {
|
|
223
|
-
const danglingSet = new Set(result.dangling);
|
|
224
|
-
return compact
|
|
225
|
-
? {
|
|
226
|
-
start_hash: result.start_hash,
|
|
227
|
-
direction: result.direction,
|
|
228
|
-
depth_requested: result.depth_requested,
|
|
229
|
-
depth_reached: result.depth_reached,
|
|
230
|
-
visited: result.visited.map((v) => compactVisited(v, danglingSet, byHash, includeContent)),
|
|
231
|
-
dangling: result.dangling,
|
|
232
|
-
truncated_by_depth: result.truncated_by_depth,
|
|
233
|
-
truncated_by_cap: result.truncated_by_cap,
|
|
234
|
-
warnings: result.warnings,
|
|
235
|
-
}
|
|
236
|
-
: { ...result };
|
|
237
|
-
}
|
|
238
|
-
mcp.registerTool('trace', {
|
|
239
|
-
title: 'trace informed_by chain backward',
|
|
240
|
-
description: "Walk a record's informed_by chain backward to surface the reasoning " +
|
|
241
|
-
'chain that led to it. Reads from the local signed-record mirror ' +
|
|
242
|
-
'(~/.atrib/records/*.jsonl). Returns the records visited, parent links ' +
|
|
243
|
-
'so the caller can reconstruct the tree, and dangling hashes (records ' +
|
|
244
|
-
'referenced via informed_by but not present in the local mirror). ' +
|
|
245
|
-
'Sibling tool `trace_forward` walks the opposite direction (records ' +
|
|
246
|
-
'that cited THIS one via their informed_by).',
|
|
247
|
-
inputSchema: TraceInput.shape,
|
|
248
|
-
}, async (args) => logReadPrimitiveCall('trace', args, async () => {
|
|
249
|
-
const depth = args.depth ?? 3;
|
|
250
|
-
const maxNodes = args.max_nodes ?? 200;
|
|
251
|
-
const compact = args.compact ?? true;
|
|
252
|
-
const includeContent = args.include_content ?? false;
|
|
253
|
-
// Env-var context_id default: when the caller omitted context_id,
|
|
254
|
-
// fall back to @atrib/mcp's resolveEnvContextId (D078 + D083
|
|
255
|
-
// precedence: ATRIB_CONTEXT_ID, then a registered harness env var
|
|
256
|
-
// like CLAUDE_CODE_SESSION_ID). Explicit args.context_id always wins.
|
|
257
|
-
const contextId = args.context_id ?? resolveEnvContextId();
|
|
258
|
-
const { byHash } = loadAllRecords();
|
|
259
|
-
const result = traceBackward(args.record_hash, depth, byHash, {
|
|
260
|
-
maxNodes,
|
|
261
|
-
...(contextId ? { contextId } : {}),
|
|
262
|
-
});
|
|
263
|
-
return {
|
|
264
|
-
content: [
|
|
265
|
-
{
|
|
266
|
-
type: 'text',
|
|
267
|
-
text: JSON.stringify(buildPayload(result, byHash, compact, includeContent), null, 2),
|
|
268
|
-
},
|
|
269
|
-
],
|
|
270
|
-
};
|
|
271
|
-
}, extractRecordHashFieldsFromMcpResult));
|
|
272
|
-
// Sibling tool: forward walk (records that cited THIS one). Mirrors
|
|
273
|
-
// the trace input schema + response shape exactly so callers can use
|
|
274
|
-
// it interchangeably; only the direction of the walk differs.
|
|
275
|
-
mcp.registerTool('trace_forward', {
|
|
276
|
-
title: 'trace informed_by chain forward',
|
|
277
|
-
description: 'Walk forward from a record_hash, surfacing the records that cited ' +
|
|
278
|
-
'it via their informed_by chain. The dual of `trace` (backward). ' +
|
|
279
|
-
'Answers "I made decision X, what did I do because of it?" Reads from ' +
|
|
280
|
-
'the local signed-record mirror (~/.atrib/records/*.jsonl). Returns ' +
|
|
281
|
-
'the records visited, parent links (which upstream record at the ' +
|
|
282
|
-
'prior level cited this one), and dangling hashes (rare; only if the ' +
|
|
283
|
-
'reverse index references a record missing from the mirror).',
|
|
284
|
-
inputSchema: TraceInput.shape,
|
|
285
|
-
}, async (args) => logReadPrimitiveCall('trace_forward', args, async () => {
|
|
286
|
-
const depth = args.depth ?? 3;
|
|
287
|
-
const maxNodes = args.max_nodes ?? 200;
|
|
288
|
-
const compact = args.compact ?? true;
|
|
289
|
-
const includeContent = args.include_content ?? false;
|
|
290
|
-
const contextId = args.context_id ?? resolveEnvContextId();
|
|
291
|
-
const { byHash } = loadAllRecords();
|
|
292
|
-
const result = traceForward(args.record_hash, depth, byHash, {
|
|
293
|
-
maxNodes,
|
|
294
|
-
...(contextId ? { contextId } : {}),
|
|
295
|
-
});
|
|
296
|
-
return {
|
|
297
|
-
content: [
|
|
298
|
-
{
|
|
299
|
-
type: 'text',
|
|
300
|
-
text: JSON.stringify(buildPayload(result, byHash, compact, includeContent), null, 2),
|
|
301
|
-
},
|
|
302
|
-
],
|
|
303
|
-
};
|
|
304
|
-
}, extractRecordHashFieldsFromMcpResult));
|
|
305
|
-
return { mcp };
|
|
306
|
-
}
|
|
2
|
+
// @atrib/trace is the legacy home of the trace read primitive. The
|
|
3
|
+
// implementation lives in @atrib/recall per the attest/recall rename:
|
|
4
|
+
// trace and trace_forward fold into the `recall` verb as shape='walk'
|
|
5
|
+
// with a direction, and both legacy tool names stay mounted as permanent
|
|
6
|
+
// aliases over the same runner. This package re-exports the surface so
|
|
7
|
+
// existing imports keep working.
|
|
8
|
+
export { compactVisited, createAtribTraceServer, extractRecordHashFieldsFromMcpResult, registerTraceTools, runTraceWalk, summarizeSidecar, TraceInput, } from '@atrib/recall';
|
package/dist/main.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// atrib-trace
|
|
4
|
-
//
|
|
5
|
-
// Claude Desktop, etc.).
|
|
2
|
+
// atrib-trace standalone binary (forwarding shim). Serves the legacy
|
|
3
|
+
// atrib-trace server, which mounts `trace` + `trace_forward` plus the
|
|
4
|
+
// `recall` verb per the alias-window rule W1.
|
|
6
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
-
import { createAtribTraceServer } from '
|
|
6
|
+
import { createAtribTraceServer } from '@atrib/recall';
|
|
8
7
|
async function main() {
|
|
9
8
|
const { mcp } = await createAtribTraceServer();
|
|
10
9
|
const transport = new StdioServerTransport();
|
|
11
10
|
await mcp.connect(transport);
|
|
12
|
-
// Stays alive on the stdio transport until the host closes it.
|
|
13
11
|
}
|
|
14
12
|
main().catch((e) => {
|
|
15
13
|
console.error('atrib-trace: fatal', e instanceof Error ? e.stack ?? e.message : String(e));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atrib/trace",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Legacy home of atrib's trace read primitive. Superseded by @atrib/recall (the read verb, shape=walk with a direction); this package re-exports the same surface and forwards the atrib-trace binary.",
|
|
5
5
|
"author": "atrib <hello@atrib.dev>",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"atrib",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
27
|
-
"
|
|
28
|
-
"@atrib/mcp": "0.21.0"
|
|
27
|
+
"@atrib/recall": "1.0.0"
|
|
29
28
|
},
|
|
30
29
|
"devDependencies": {
|
|
31
30
|
"@types/node": "^25.9.3",
|
package/dist/storage.d.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { type AtribRecord, type OnRecordSidecar } from '@atrib/mcp';
|
|
2
|
-
export interface IndexedRecord {
|
|
3
|
-
record: AtribRecord;
|
|
4
|
-
/** sha256:<64-hex> form, matching informed_by entries. */
|
|
5
|
-
record_hash: string;
|
|
6
|
-
/** Source file the record was read from (for debugging). */
|
|
7
|
-
source: string;
|
|
8
|
-
/**
|
|
9
|
-
* Pre-sign payload context, when the producer wrote it to the local
|
|
10
|
-
* mirror as a `_local` sidecar on the envelope. Carries semantic content
|
|
11
|
-
* (toolName, args, result, content) the signed AtribRecord COMMITS TO
|
|
12
|
-
* via content_id / args_hash / result_hash but does not itself contain.
|
|
13
|
-
* Absent on legacy bare-record entries from older mirror writes.
|
|
14
|
-
*/
|
|
15
|
-
local?: SidecarPayload;
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Combined sidecar shape, superset of every producer's local payload
|
|
19
|
-
* (mcp-wrap writes toolName/args/result; atrib-emit writes content).
|
|
20
|
-
*/
|
|
21
|
-
export interface SidecarPayload extends OnRecordSidecar {
|
|
22
|
-
content?: unknown;
|
|
23
|
-
producer?: string;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Read every *.jsonl mirror in ATRIB_RECORDS_DIR and return an index keyed
|
|
27
|
-
* by sha256:<64-hex> record_hash. Both shapes (bare AtribRecord and emit's
|
|
28
|
-
* { record, proof, written_at } envelope) are supported.
|
|
29
|
-
*
|
|
30
|
-
* Returns the indexed map AND a flat list (newest-first by timestamp) for
|
|
31
|
-
* callers that want recall-style enumeration without by-hash lookup.
|
|
32
|
-
*/
|
|
33
|
-
export declare function loadAllRecords(dir?: string): {
|
|
34
|
-
byHash: Map<string, IndexedRecord>;
|
|
35
|
-
newestFirst: IndexedRecord[];
|
|
36
|
-
};
|
package/dist/storage.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* Local mirror reader for atrib-trace.
|
|
4
|
-
*
|
|
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).
|
|
8
|
-
*
|
|
9
|
-
* Builds an in-memory index by record_hash so trace can walk informed_by
|
|
10
|
-
* chains in O(1) per hop. The hash key is sha256:<64-hex> (the same form
|
|
11
|
-
* informed_by entries use), so no transformation needed at lookup time.
|
|
12
|
-
*/
|
|
13
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
|
-
import { basename, join } from 'node:path';
|
|
16
|
-
import { canonicalRecord, hexEncode, sha256, withDerivedLocalContent, } from '@atrib/mcp';
|
|
17
|
-
const RECORDS_FILE = process.env.ATRIB_RECORD_FILE;
|
|
18
|
-
const RECORDS_DIR = process.env.ATRIB_RECORDS_DIR ?? join(homedir(), '.atrib', 'records');
|
|
19
|
-
/**
|
|
20
|
-
* Read every *.jsonl mirror in ATRIB_RECORDS_DIR and return an index keyed
|
|
21
|
-
* by sha256:<64-hex> record_hash. Both shapes (bare AtribRecord and emit's
|
|
22
|
-
* { record, proof, written_at } envelope) are supported.
|
|
23
|
-
*
|
|
24
|
-
* Returns the indexed map AND a flat list (newest-first by timestamp) for
|
|
25
|
-
* callers that want recall-style enumeration without by-hash lookup.
|
|
26
|
-
*/
|
|
27
|
-
export function loadAllRecords(dir = RECORDS_DIR) {
|
|
28
|
-
const byHash = new Map();
|
|
29
|
-
const all = [];
|
|
30
|
-
const paths = [];
|
|
31
|
-
if (RECORDS_FILE) {
|
|
32
|
-
paths.push({ path: RECORDS_FILE, source: basename(RECORDS_FILE) });
|
|
33
|
-
}
|
|
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
|
-
}
|
|
45
|
-
}
|
|
46
|
-
for (const { path, source } of paths) {
|
|
47
|
-
try {
|
|
48
|
-
const raw = readFileSync(path, 'utf8');
|
|
49
|
-
for (const line of raw.split('\n')) {
|
|
50
|
-
const trimmed = line.trim();
|
|
51
|
-
if (!trimmed)
|
|
52
|
-
continue;
|
|
53
|
-
try {
|
|
54
|
-
const parsed = JSON.parse(trimmed);
|
|
55
|
-
// Normalize: envelope shape vs legacy bare record shape.
|
|
56
|
-
const isEnvelope = 'record' in parsed &&
|
|
57
|
-
parsed.record &&
|
|
58
|
-
parsed.record.context_id !== undefined;
|
|
59
|
-
const rec = isEnvelope
|
|
60
|
-
? parsed.record
|
|
61
|
-
: parsed;
|
|
62
|
-
if (!rec.context_id || !rec.signature || !rec.timestamp)
|
|
63
|
-
continue;
|
|
64
|
-
const hashHex = hexEncode(sha256(canonicalRecord(rec)));
|
|
65
|
-
const indexed = {
|
|
66
|
-
record: rec,
|
|
67
|
-
record_hash: `sha256:${hashHex}`,
|
|
68
|
-
source,
|
|
69
|
-
};
|
|
70
|
-
// Lift the `_local` sidecar onto the indexed record when present.
|
|
71
|
-
// Legacy bare-record entries have no sidecar; that's OK, consumers
|
|
72
|
-
// tolerate its absence.
|
|
73
|
-
if (isEnvelope) {
|
|
74
|
-
const sidecar = parsed._local;
|
|
75
|
-
if (sidecar)
|
|
76
|
-
indexed.local = withDerivedLocalContent(rec.event_type, sidecar);
|
|
77
|
-
}
|
|
78
|
-
byHash.set(indexed.record_hash, indexed);
|
|
79
|
-
all.push(indexed);
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
// Malformed line; skip.
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// File read failure; skip this file.
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
all.sort((a, b) => b.record.timestamp - a.record.timestamp);
|
|
91
|
-
return { byHash, newestFirst: all };
|
|
92
|
-
}
|
package/dist/trace.d.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounded directional walk through informed_by chains.
|
|
3
|
-
*
|
|
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.
|
|
14
|
-
*
|
|
15
|
-
* Each visited record is annotated with:
|
|
16
|
-
* - depth , hop count from the starting record (0 = the start)
|
|
17
|
-
* - parent_hashes, which records in the previous level referenced this one
|
|
18
|
-
*
|
|
19
|
-
* Records referenced via informed_by but NOT present in the local mirror
|
|
20
|
-
* are surfaced as `dangling` entries: the verifier hasn't seen the upstream
|
|
21
|
-
* locally. This is informational, not a failure, the upstream may live on
|
|
22
|
-
* the public log but not in the agent's local mirror.
|
|
23
|
-
*
|
|
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.
|
|
27
|
-
*/
|
|
28
|
-
import type { IndexedRecord } from './storage.js';
|
|
29
|
-
import type { AtribRecord } from '@atrib/mcp';
|
|
30
|
-
export type TraceDirection = 'backward' | 'forward';
|
|
31
|
-
export interface TraceVisited {
|
|
32
|
-
depth: number;
|
|
33
|
-
record_hash: string;
|
|
34
|
-
parent_hashes: string[];
|
|
35
|
-
/** Empty when source mirror lacks this record (dangling). */
|
|
36
|
-
record: AtribRecord | null;
|
|
37
|
-
/** Source file the record was read from, or null if dangling. */
|
|
38
|
-
source: string | null;
|
|
39
|
-
/** Sub-set of informed_by entries that are themselves walkable next-hop. */
|
|
40
|
-
next_informed_by: string[];
|
|
41
|
-
}
|
|
42
|
-
export interface TraceResult {
|
|
43
|
-
start_hash: string;
|
|
44
|
-
direction: TraceDirection;
|
|
45
|
-
depth_requested: number;
|
|
46
|
-
depth_reached: number;
|
|
47
|
-
visited: TraceVisited[];
|
|
48
|
-
dangling: string[];
|
|
49
|
-
truncated_by_depth: boolean;
|
|
50
|
-
truncated_by_cap: boolean;
|
|
51
|
-
warnings: string[];
|
|
52
|
-
}
|
|
53
|
-
export interface TraceOptions {
|
|
54
|
-
/** Hard cap on total visited nodes regardless of depth. Defaults to 200. */
|
|
55
|
-
maxNodes?: number;
|
|
56
|
-
/**
|
|
57
|
-
* When set, scope the walk to records that share this context_id. Edges
|
|
58
|
-
* crossing into a different context_id are treated as dangling: the
|
|
59
|
-
* upstream is real but lies outside the requested scope. Used by
|
|
60
|
-
* Inspect-style harnesses passing ATRIB_CONTEXT_ID to keep each arm's
|
|
61
|
-
* trace inside its own context.
|
|
62
|
-
*/
|
|
63
|
-
contextId?: string;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Walk informed_by edges backward from the starting record.
|
|
67
|
-
*
|
|
68
|
-
* Behaviors:
|
|
69
|
-
* - Cycle-safe: every record is visited at most once, even if multiple
|
|
70
|
-
* parents reference it.
|
|
71
|
-
* - Cap-safe: if maxNodes is hit mid-walk, returns partial result with
|
|
72
|
-
* truncated_by_cap=true. Callers can re-run with a smaller depth or
|
|
73
|
-
* start from a sub-tree if needed.
|
|
74
|
-
* - Dangling-aware: informed_by entries pointing at records not in the
|
|
75
|
-
* local mirror surface as `dangling`. Those entries do NOT advance the
|
|
76
|
-
* walk (we have nothing to walk from).
|
|
77
|
-
*/
|
|
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
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* Walk informed_by edges backward from the starting record.
|
|
4
|
-
*
|
|
5
|
-
* Behaviors:
|
|
6
|
-
* - Cycle-safe: every record is visited at most once, even if multiple
|
|
7
|
-
* parents reference it.
|
|
8
|
-
* - Cap-safe: if maxNodes is hit mid-walk, returns partial result with
|
|
9
|
-
* truncated_by_cap=true. Callers can re-run with a smaller depth or
|
|
10
|
-
* start from a sub-tree if needed.
|
|
11
|
-
* - Dangling-aware: informed_by entries pointing at records not in the
|
|
12
|
-
* local mirror surface as `dangling`. Those entries do NOT advance the
|
|
13
|
-
* walk (we have nothing to walk from).
|
|
14
|
-
*/
|
|
15
|
-
export function traceBackward(startHash, depth, index, options = {}) {
|
|
16
|
-
const maxNodes = options.maxNodes ?? 200;
|
|
17
|
-
const contextId = options.contextId;
|
|
18
|
-
const warnings = [];
|
|
19
|
-
const visited = new Map();
|
|
20
|
-
const dangling = new Set();
|
|
21
|
-
let truncatedByCap = false;
|
|
22
|
-
let truncatedByDepth = false;
|
|
23
|
-
let depthReached = 0;
|
|
24
|
-
const startIdx = index.get(startHash);
|
|
25
|
-
if (!startIdx) {
|
|
26
|
-
return {
|
|
27
|
-
start_hash: startHash,
|
|
28
|
-
direction: 'backward',
|
|
29
|
-
depth_requested: depth,
|
|
30
|
-
depth_reached: 0,
|
|
31
|
-
visited: [],
|
|
32
|
-
dangling: [startHash],
|
|
33
|
-
truncated_by_depth: false,
|
|
34
|
-
truncated_by_cap: false,
|
|
35
|
-
warnings: [`start_hash ${startHash} not in local mirror`],
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
// context_id scope check: when the caller pinned a context_id, the start
|
|
39
|
-
// record itself must already live within it. Reject explicitly rather than
|
|
40
|
-
// silently returning an empty walk; the caller likely wants to know they
|
|
41
|
-
// pointed trace at the wrong record for the requested scope.
|
|
42
|
-
if (contextId && startIdx.record.context_id !== contextId) {
|
|
43
|
-
return {
|
|
44
|
-
start_hash: startHash,
|
|
45
|
-
direction: 'backward',
|
|
46
|
-
depth_requested: depth,
|
|
47
|
-
depth_reached: 0,
|
|
48
|
-
visited: [],
|
|
49
|
-
dangling: [],
|
|
50
|
-
truncated_by_depth: false,
|
|
51
|
-
truncated_by_cap: false,
|
|
52
|
-
warnings: [
|
|
53
|
-
`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)`,
|
|
54
|
-
],
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
const frontier = [{ hash: startHash, depth: 0, parent: null }];
|
|
58
|
-
while (frontier.length > 0) {
|
|
59
|
-
if (visited.size >= maxNodes) {
|
|
60
|
-
truncatedByCap = true;
|
|
61
|
-
break;
|
|
62
|
-
}
|
|
63
|
-
const current = frontier.shift();
|
|
64
|
-
if (current.depth > depth) {
|
|
65
|
-
truncatedByDepth = true;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
depthReached = Math.max(depthReached, current.depth);
|
|
69
|
-
const idx = index.get(current.hash);
|
|
70
|
-
if (!idx) {
|
|
71
|
-
dangling.add(current.hash);
|
|
72
|
-
// Tag the parent so caller can see which walkable record had a
|
|
73
|
-
// dangling reference, but we don't visit further.
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
// context_id scope filter: an upstream record that lives in a different
|
|
77
|
-
// context_id is treated as dangling from this walk's perspective. The
|
|
78
|
-
// record itself exists, but it sits outside the requested scope. This
|
|
79
|
-
// keeps per-arm trace results clean when ATRIB_CONTEXT_ID is set per
|
|
80
|
-
// D072 (cross-arm informed_by edges, if any leaked, do not pollute
|
|
81
|
-
// the walk).
|
|
82
|
-
if (contextId && idx.record.context_id !== contextId) {
|
|
83
|
-
dangling.add(current.hash);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
let v = visited.get(current.hash);
|
|
87
|
-
if (!v) {
|
|
88
|
-
const informedByEntries = Array.isArray(idx.record.informed_by)
|
|
89
|
-
? idx.record.informed_by
|
|
90
|
-
: [];
|
|
91
|
-
v = {
|
|
92
|
-
depth: current.depth,
|
|
93
|
-
record_hash: current.hash,
|
|
94
|
-
parent_hashes: [],
|
|
95
|
-
record: idx.record,
|
|
96
|
-
source: idx.source,
|
|
97
|
-
next_informed_by: informedByEntries,
|
|
98
|
-
};
|
|
99
|
-
visited.set(current.hash, v);
|
|
100
|
-
// Schedule informed_by entries for visit at depth+1.
|
|
101
|
-
if (current.depth < depth) {
|
|
102
|
-
for (const upstream of informedByEntries) {
|
|
103
|
-
frontier.push({ hash: upstream, depth: current.depth + 1, parent: current.hash });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
else if (informedByEntries.length > 0) {
|
|
107
|
-
truncatedByDepth = true;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (current.parent && !v.parent_hashes.includes(current.parent)) {
|
|
111
|
-
v.parent_hashes.push(current.parent);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
start_hash: startHash,
|
|
116
|
-
direction: 'backward',
|
|
117
|
-
depth_requested: depth,
|
|
118
|
-
depth_reached: depthReached,
|
|
119
|
-
visited: Array.from(visited.values()),
|
|
120
|
-
dangling: Array.from(dangling),
|
|
121
|
-
truncated_by_depth: truncatedByDepth,
|
|
122
|
-
truncated_by_cap: truncatedByCap,
|
|
123
|
-
warnings,
|
|
124
|
-
};
|
|
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
|
-
}
|