@plures/praxis 1.2.12 → 1.2.41
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 +63 -0
- package/dist/browser/{chunk-VOMLVI6V.js → chunk-BBP2F7TT.js} +70 -1
- package/dist/browser/{chunk-K377RW4V.js → chunk-FCEH7WMH.js} +1 -1
- package/dist/browser/{engine-YJZV4SLD.js → engine-65QDGCAN.js} +1 -1
- package/dist/browser/index.d.ts +104 -2
- package/dist/browser/index.js +181 -5
- package/dist/browser/integrations/svelte.d.ts +2 -2
- package/dist/browser/integrations/svelte.js +2 -2
- package/dist/browser/{reactive-engine.svelte-9aS0kTa8.d.ts → reactive-engine.svelte-Cqd8Mod2.d.ts} +56 -1
- package/dist/node/{chunk-PRPQO6R5.js → chunk-32YFEEML.js} +1 -1
- package/dist/node/{chunk-VOMLVI6V.js → chunk-BBP2F7TT.js} +70 -1
- package/dist/node/{chunk-5RH7UAQC.js → chunk-PTH6MD6P.js} +1 -0
- package/dist/node/cli/index.cjs +1553 -839
- package/dist/node/cli/index.js +39 -2
- package/dist/node/cloud/index.d.cts +1 -1
- package/dist/node/cloud/index.d.ts +1 -1
- package/dist/node/components/index.d.cts +2 -2
- package/dist/node/components/index.d.ts +2 -2
- package/dist/node/conversations-KQBXTP3N.js +596 -0
- package/dist/node/{engine-2DQBKBJC.js → engine-7CXQV6RC.js} +1 -1
- package/dist/node/index.cjs +408 -3
- package/dist/node/index.d.cts +308 -7
- package/dist/node/index.d.ts +308 -7
- package/dist/node/index.js +336 -6
- package/dist/node/integrations/svelte.cjs +70 -1
- package/dist/node/integrations/svelte.d.cts +3 -3
- package/dist/node/integrations/svelte.d.ts +3 -3
- package/dist/node/integrations/svelte.js +2 -2
- package/dist/node/{protocol-Qek7ebBl.d.ts → protocol-BocKczNv.d.cts} +1 -1
- package/dist/node/{protocol-Qek7ebBl.d.cts → protocol-BocKczNv.d.ts} +1 -1
- package/dist/node/{reactive-engine.svelte-CRNqHlbv.d.ts → reactive-engine.svelte-CGe8SpVE.d.cts} +57 -2
- package/dist/node/{reactive-engine.svelte-BFIZfawz.d.cts → reactive-engine.svelte-D-xTDxT5.d.ts} +57 -2
- package/dist/node/{terminal-adapter-B-UK_Vdz.d.ts → terminal-adapter-CvIvgTo4.d.ts} +1 -1
- package/dist/node/{terminal-adapter-BQSIF5bf.d.cts → terminal-adapter-Db-snPJ3.d.cts} +1 -1
- package/dist/node/{validate-CNHUULQE.js → validate-EN3M4FUR.js} +1 -1
- package/dist/node/{verify-KLJRXVJS.js → verify-7VZRP2WS.js} +2 -2
- package/docs/BOT_UPDATE_POLICY.md +125 -0
- package/docs/DOGFOODING_CHECKLIST.md +254 -0
- package/docs/DOGFOODING_INDEX.md +169 -0
- package/docs/DOGFOODING_QUICK_START.md +140 -0
- package/docs/KNO_ENG_EXTRACTION_PLAN.md +577 -0
- package/docs/PLURES_TOOLS_INVENTORY.md +170 -0
- package/docs/README.md +12 -0
- package/docs/TESTING_BOT_WORKFLOWS.md +154 -0
- package/docs/conversations/INTEGRATION_POINTS.md +719 -0
- package/docs/conversations/README.md +168 -0
- package/docs/core/extending-praxis-core.md +604 -0
- package/docs/core/praxis-core-api.md +385 -0
- package/docs/decision-ledger/contract-index.json +2 -2
- package/docs/decision-ledger/decisions/2026-02-01-monorepo-organization.md +130 -0
- package/docs/examples/DOGFOODING_WORKFLOW_EXAMPLE.md +295 -0
- package/docs/examples/README.md +41 -0
- package/docs/workflows/pr-overlap-guard.md +50 -0
- package/package.json +7 -2
- package/src/__tests__/chronicle.test.ts +512 -0
- package/src/__tests__/conversations.test.ts +312 -0
- package/src/__tests__/edge-cases.test.ts +1 -1
- package/src/__tests__/engine-dx.test.ts +355 -0
- package/src/cli/commands/conversations.ts +252 -0
- package/src/cli/index.ts +73 -0
- package/src/conversations/README.md +230 -0
- package/src/conversations/candidate.schema.json +123 -0
- package/src/conversations/candidates.ts +114 -0
- package/src/conversations/capture.ts +56 -0
- package/src/conversations/classify.ts +110 -0
- package/src/conversations/conversation.schema.json +106 -0
- package/src/conversations/emitters/fs.ts +65 -0
- package/src/conversations/emitters/github.ts +115 -0
- package/src/conversations/gate.ts +102 -0
- package/src/conversations/index.ts +28 -0
- package/src/conversations/normalize.ts +51 -0
- package/src/conversations/redact.ts +57 -0
- package/src/conversations/types.ts +96 -0
- package/src/core/chronicle/chronicle.ts +227 -0
- package/src/core/chronicle/context.ts +80 -0
- package/src/core/chronicle/index.ts +53 -0
- package/src/core/chronicle/mcp.ts +135 -0
- package/src/core/chronicle/types.ts +61 -0
- package/src/core/engine.ts +99 -1
- package/src/core/pluresdb/index.ts +22 -0
- package/src/core/pluresdb/store.ts +162 -5
- package/src/core/rules.ts +12 -0
- package/src/dsl/index.ts +6 -0
- package/src/index.ts +18 -0
- package/src/integrations/pluresdb.ts +22 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chronicle Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - ChronicleNode creation on storeFact / appendEvent
|
|
6
|
+
* - Causal edge creation from ChronicleContext spans
|
|
7
|
+
* - trace() backward/forward traversal
|
|
8
|
+
* - range() time-bounded queries
|
|
9
|
+
* - subgraph() per-context queries
|
|
10
|
+
* - ChronosMcpTools (trace + search)
|
|
11
|
+
* - Chronicle errors never break primary operations
|
|
12
|
+
* - End-to-end: agent decision → chronicle node → trace backward to input
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
16
|
+
import {
|
|
17
|
+
createInMemoryDB,
|
|
18
|
+
InMemoryPraxisDB,
|
|
19
|
+
createPraxisDBStore,
|
|
20
|
+
PraxisDBStore,
|
|
21
|
+
createChronicle,
|
|
22
|
+
PluresDbChronicle,
|
|
23
|
+
CHRONICLE_PATHS,
|
|
24
|
+
ChronicleContext,
|
|
25
|
+
createChronosMcpTools,
|
|
26
|
+
} from '../integrations/pluresdb.js';
|
|
27
|
+
import { PraxisRegistry } from '../core/rules.js';
|
|
28
|
+
import { defineFact, defineEvent, defineRule } from '../dsl/index.js';
|
|
29
|
+
import type { Chronicle, ChronicleNode } from '../core/chronicle/index.js';
|
|
30
|
+
|
|
31
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeSetup() {
|
|
34
|
+
const db = createInMemoryDB();
|
|
35
|
+
const registry = new PraxisRegistry();
|
|
36
|
+
const chronicle = createChronicle(db);
|
|
37
|
+
const store = createPraxisDBStore(db, registry).withChronicle(chronicle);
|
|
38
|
+
return { db, registry, chronicle, store };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── PluresDbChronicle unit tests ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('PluresDbChronicle', () => {
|
|
44
|
+
let db: InMemoryPraxisDB;
|
|
45
|
+
let chronicle: PluresDbChronicle;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
db = createInMemoryDB();
|
|
49
|
+
chronicle = createChronicle(db);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should record a node and return it', async () => {
|
|
53
|
+
const node = await chronicle.record({
|
|
54
|
+
path: '/test/fact',
|
|
55
|
+
after: { tag: 'Foo', payload: {} },
|
|
56
|
+
metadata: {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(node.id).toMatch(/^chronos:\d+-\d+$/);
|
|
60
|
+
expect(node.timestamp).toBeGreaterThan(0);
|
|
61
|
+
expect(node.event.path).toBe('/test/fact');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should persist node under CHRONICLE_PATHS.NODES', async () => {
|
|
65
|
+
const node = await chronicle.record({ path: '/test', metadata: {} });
|
|
66
|
+
|
|
67
|
+
const stored = await db.get<ChronicleNode>(`${CHRONICLE_PATHS.NODES}/${node.id}`);
|
|
68
|
+
expect(stored).toBeDefined();
|
|
69
|
+
expect(stored?.id).toBe(node.id);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should update the global index', async () => {
|
|
73
|
+
const n1 = await chronicle.record({ path: '/a', metadata: {} });
|
|
74
|
+
const n2 = await chronicle.record({ path: '/b', metadata: {} });
|
|
75
|
+
|
|
76
|
+
const index = await db.get<string[]>(CHRONICLE_PATHS.INDEX);
|
|
77
|
+
expect(index).toContain(n1.id);
|
|
78
|
+
expect(index).toContain(n2.id);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should create a "causes" edge when cause is provided', async () => {
|
|
82
|
+
const n1 = await chronicle.record({ path: '/a', metadata: {} });
|
|
83
|
+
const n2 = await chronicle.record({ path: '/b', cause: n1.id, metadata: {} });
|
|
84
|
+
|
|
85
|
+
const outEdges = await db.get<Array<{ from: string; to: string; type: string }>>(
|
|
86
|
+
`${CHRONICLE_PATHS.EDGES_OUT}/${n1.id}`
|
|
87
|
+
);
|
|
88
|
+
const inEdges = await db.get<Array<{ from: string; to: string; type: string }>>(
|
|
89
|
+
`${CHRONICLE_PATHS.EDGES_IN}/${n2.id}`
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(outEdges).toHaveLength(1);
|
|
93
|
+
expect(outEdges?.[0]).toEqual({ from: n1.id, to: n2.id, type: 'causes' });
|
|
94
|
+
expect(inEdges?.[0]).toEqual({ from: n1.id, to: n2.id, type: 'causes' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should create "follows" edges between nodes in the same context', async () => {
|
|
98
|
+
const n1 = await chronicle.record({ path: '/a', context: 'ctx-1', metadata: {} });
|
|
99
|
+
const n2 = await chronicle.record({ path: '/b', context: 'ctx-1', metadata: {} });
|
|
100
|
+
const n3 = await chronicle.record({ path: '/c', context: 'ctx-1', metadata: {} });
|
|
101
|
+
|
|
102
|
+
const outEdgesN1 = await db.get<Array<{ type: string; to: string }>>(
|
|
103
|
+
`${CHRONICLE_PATHS.EDGES_OUT}/${n1.id}`
|
|
104
|
+
);
|
|
105
|
+
const outEdgesN2 = await db.get<Array<{ type: string; to: string }>>(
|
|
106
|
+
`${CHRONICLE_PATHS.EDGES_OUT}/${n2.id}`
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(outEdgesN1?.[0]).toMatchObject({ type: 'follows', to: n2.id });
|
|
110
|
+
expect(outEdgesN2?.[0]).toMatchObject({ type: 'follows', to: n3.id });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should update the context index', async () => {
|
|
114
|
+
const n1 = await chronicle.record({ path: '/a', context: 'ctx-2', metadata: {} });
|
|
115
|
+
const n2 = await chronicle.record({ path: '/b', context: 'ctx-2', metadata: {} });
|
|
116
|
+
|
|
117
|
+
const ctxIndex = await db.get<string[]>(`${CHRONICLE_PATHS.CONTEXT}/ctx-2`);
|
|
118
|
+
expect(ctxIndex).toEqual([n1.id, n2.id]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Chronicle.trace ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('Chronicle.trace', () => {
|
|
125
|
+
let chronicle: PluresDbChronicle;
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
chronicle = createChronicle(createInMemoryDB());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should trace backward through causal edges', async () => {
|
|
132
|
+
const root = await chronicle.record({ path: '/root', metadata: {} });
|
|
133
|
+
const mid = await chronicle.record({ path: '/mid', cause: root.id, metadata: {} });
|
|
134
|
+
const leaf = await chronicle.record({ path: '/leaf', cause: mid.id, metadata: {} });
|
|
135
|
+
|
|
136
|
+
const result = await chronicle.trace(leaf.id, 'backward', 10);
|
|
137
|
+
const ids = result.map((n) => n.id);
|
|
138
|
+
|
|
139
|
+
expect(ids).toContain(leaf.id);
|
|
140
|
+
expect(ids).toContain(mid.id);
|
|
141
|
+
expect(ids).toContain(root.id);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should trace forward through causal edges', async () => {
|
|
145
|
+
const root = await chronicle.record({ path: '/root', metadata: {} });
|
|
146
|
+
const mid = await chronicle.record({ path: '/mid', cause: root.id, metadata: {} });
|
|
147
|
+
await chronicle.record({ path: '/leaf', cause: mid.id, metadata: {} });
|
|
148
|
+
|
|
149
|
+
const result = await chronicle.trace(root.id, 'forward', 10);
|
|
150
|
+
const ids = result.map((n) => n.id);
|
|
151
|
+
|
|
152
|
+
expect(ids).toContain(root.id);
|
|
153
|
+
expect(ids).toContain(mid.id);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should respect maxDepth', async () => {
|
|
157
|
+
const n1 = await chronicle.record({ path: '/1', metadata: {} });
|
|
158
|
+
const n2 = await chronicle.record({ path: '/2', cause: n1.id, metadata: {} });
|
|
159
|
+
const n3 = await chronicle.record({ path: '/3', cause: n2.id, metadata: {} });
|
|
160
|
+
|
|
161
|
+
const result = await chronicle.trace(n3.id, 'backward', 1);
|
|
162
|
+
const ids = result.map((n) => n.id);
|
|
163
|
+
|
|
164
|
+
expect(ids).toContain(n3.id);
|
|
165
|
+
expect(ids).toContain(n2.id);
|
|
166
|
+
// n1 is depth 2 from n3, should be excluded with maxDepth=1
|
|
167
|
+
expect(ids).not.toContain(n1.id);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should not visit nodes twice (cycle-safe)', async () => {
|
|
171
|
+
// Build a simple two-node chain and verify tracing terminates
|
|
172
|
+
const safeDb = createInMemoryDB();
|
|
173
|
+
const safeChronicle = createChronicle(safeDb);
|
|
174
|
+
const a = await safeChronicle.record({ path: '/a', metadata: {} });
|
|
175
|
+
const b = await safeChronicle.record({ path: '/b', cause: a.id, metadata: {} });
|
|
176
|
+
|
|
177
|
+
const result = await safeChronicle.trace(b.id, 'both', 20);
|
|
178
|
+
// Should terminate without stack overflow and return at most the two nodes
|
|
179
|
+
expect(result.length).toBeLessThanOrEqual(2);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Chronicle.range ────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('Chronicle.range', () => {
|
|
186
|
+
it('should return nodes within a time range', async () => {
|
|
187
|
+
const chronicle = createChronicle(createInMemoryDB());
|
|
188
|
+
|
|
189
|
+
const before = Date.now() - 1;
|
|
190
|
+
const n1 = await chronicle.record({ path: '/a', metadata: {} });
|
|
191
|
+
const after = Date.now() + 1;
|
|
192
|
+
|
|
193
|
+
const results = await chronicle.range(before, after);
|
|
194
|
+
const ids = results.map((n) => n.id);
|
|
195
|
+
|
|
196
|
+
expect(ids).toContain(n1.id);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should exclude nodes outside the range', async () => {
|
|
200
|
+
const chronicle = createChronicle(createInMemoryDB());
|
|
201
|
+
|
|
202
|
+
const n1 = await chronicle.record({ path: '/a', metadata: {} });
|
|
203
|
+
const future = Date.now() + 10_000;
|
|
204
|
+
|
|
205
|
+
const results = await chronicle.range(future, future + 1);
|
|
206
|
+
const ids = results.map((n) => n.id);
|
|
207
|
+
|
|
208
|
+
expect(ids).not.toContain(n1.id);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ── Chronicle.subgraph ─────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
describe('Chronicle.subgraph', () => {
|
|
215
|
+
it('should return all nodes in a context', async () => {
|
|
216
|
+
const chronicle = createChronicle(createInMemoryDB());
|
|
217
|
+
|
|
218
|
+
const n1 = await chronicle.record({ path: '/a', context: 'session-X', metadata: {} });
|
|
219
|
+
const n2 = await chronicle.record({ path: '/b', context: 'session-X', metadata: {} });
|
|
220
|
+
await chronicle.record({ path: '/c', context: 'session-Y', metadata: {} });
|
|
221
|
+
|
|
222
|
+
const nodes = await chronicle.subgraph('session-X');
|
|
223
|
+
const ids = nodes.map((n) => n.id);
|
|
224
|
+
|
|
225
|
+
expect(ids).toContain(n1.id);
|
|
226
|
+
expect(ids).toContain(n2.id);
|
|
227
|
+
expect(nodes).toHaveLength(2);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── ChronicleContext ───────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe('ChronicleContext', () => {
|
|
234
|
+
it('should have no current span by default', () => {
|
|
235
|
+
expect(ChronicleContext.current).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should provide current span inside run()', () => {
|
|
239
|
+
let captured: ReturnType<typeof ChronicleContext.current>;
|
|
240
|
+
ChronicleContext.run({ spanId: 'span-1', contextId: 'ctx-1' }, () => {
|
|
241
|
+
captured = ChronicleContext.current;
|
|
242
|
+
});
|
|
243
|
+
expect(captured?.spanId).toBe('span-1');
|
|
244
|
+
expect(captured?.contextId).toBe('ctx-1');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should restore previous span after run()', () => {
|
|
248
|
+
ChronicleContext.run({ spanId: 'outer' }, () => {
|
|
249
|
+
ChronicleContext.run({ spanId: 'inner' }, () => {
|
|
250
|
+
expect(ChronicleContext.current?.spanId).toBe('inner');
|
|
251
|
+
});
|
|
252
|
+
expect(ChronicleContext.current?.spanId).toBe('outer');
|
|
253
|
+
});
|
|
254
|
+
expect(ChronicleContext.current).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should provide current span inside runAsync()', async () => {
|
|
258
|
+
let captured: ReturnType<typeof ChronicleContext.current>;
|
|
259
|
+
await ChronicleContext.runAsync({ spanId: 'async-span', contextId: 'async-ctx' }, async () => {
|
|
260
|
+
captured = ChronicleContext.current;
|
|
261
|
+
});
|
|
262
|
+
expect(captured?.spanId).toBe('async-span');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should restore span after runAsync() even if it throws', async () => {
|
|
266
|
+
await expect(
|
|
267
|
+
ChronicleContext.runAsync({ spanId: 'err-span' }, async () => {
|
|
268
|
+
throw new Error('boom');
|
|
269
|
+
})
|
|
270
|
+
).rejects.toThrow('boom');
|
|
271
|
+
|
|
272
|
+
expect(ChronicleContext.current).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should create child spans inheriting contextId', () => {
|
|
276
|
+
ChronicleContext.run({ spanId: 'parent', contextId: 'session-Z' }, () => {
|
|
277
|
+
const child = ChronicleContext.childSpan('child-span');
|
|
278
|
+
expect(child.spanId).toBe('child-span');
|
|
279
|
+
expect(child.contextId).toBe('session-Z');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── PraxisDBStore.withChronicle ────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe('PraxisDBStore.withChronicle', () => {
|
|
287
|
+
it('should record a node when storeFact is called', async () => {
|
|
288
|
+
const { store, chronicle } = makeSetup();
|
|
289
|
+
|
|
290
|
+
await store.storeFact({ tag: 'UserLoggedIn', payload: { userId: 'alice', id: 'u1' } });
|
|
291
|
+
|
|
292
|
+
const index = await (chronicle as PluresDbChronicle)['db'].get<string[]>(CHRONICLE_PATHS.INDEX);
|
|
293
|
+
expect(index).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should record nodes for each fact in storeFacts', async () => {
|
|
297
|
+
const { store, chronicle } = makeSetup();
|
|
298
|
+
|
|
299
|
+
await store.storeFacts([
|
|
300
|
+
{ tag: 'A', payload: { id: 'a1' } },
|
|
301
|
+
{ tag: 'B', payload: { id: 'b1' } },
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
const index = await (chronicle as PluresDbChronicle)['db'].get<string[]>(CHRONICLE_PATHS.INDEX);
|
|
305
|
+
expect(index).toHaveLength(2);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should record a node when appendEvent is called', async () => {
|
|
309
|
+
const { store, chronicle } = makeSetup();
|
|
310
|
+
|
|
311
|
+
await store.appendEvent({ tag: 'LOGIN', payload: { username: 'alice' } });
|
|
312
|
+
|
|
313
|
+
const index = await (chronicle as PluresDbChronicle)['db'].get<string[]>(CHRONICLE_PATHS.INDEX);
|
|
314
|
+
expect(index).toHaveLength(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should attribute recorded nodes to the current ChronicleContext span', async () => {
|
|
318
|
+
const { store, chronicle } = makeSetup();
|
|
319
|
+
|
|
320
|
+
await ChronicleContext.runAsync(
|
|
321
|
+
{ spanId: 'my-span', contextId: 'session-1' },
|
|
322
|
+
() => store.storeFact({ tag: 'Decision', payload: { route: 'fast', id: 'd1' } })
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const index = await (chronicle as PluresDbChronicle)['db'].get<string[]>(CHRONICLE_PATHS.INDEX);
|
|
326
|
+
const nodeId = index?.[0]!;
|
|
327
|
+
const node = await (chronicle as PluresDbChronicle)['db'].get<ChronicleNode>(
|
|
328
|
+
`${CHRONICLE_PATHS.NODES}/${nodeId}`
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(node?.event.cause).toBe('my-span');
|
|
332
|
+
expect(node?.event.context).toBe('session-1');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should not fail storeFact when Chronicle throws', async () => {
|
|
336
|
+
const db = createInMemoryDB();
|
|
337
|
+
const registry = new PraxisRegistry();
|
|
338
|
+
|
|
339
|
+
const brokenChronicle: Chronicle = {
|
|
340
|
+
async record() {
|
|
341
|
+
throw new Error('chronicle exploded');
|
|
342
|
+
},
|
|
343
|
+
async trace() {
|
|
344
|
+
return [];
|
|
345
|
+
},
|
|
346
|
+
async range() {
|
|
347
|
+
return [];
|
|
348
|
+
},
|
|
349
|
+
async subgraph() {
|
|
350
|
+
return [];
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const store = createPraxisDBStore(db, registry).withChronicle(brokenChronicle);
|
|
355
|
+
|
|
356
|
+
// Must not throw despite Chronicle failure
|
|
357
|
+
await expect(
|
|
358
|
+
store.storeFact({ tag: 'Safe', payload: { id: 's1' } })
|
|
359
|
+
).resolves.toBeUndefined();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should return the same store instance from withChronicle (fluent)', () => {
|
|
363
|
+
const db = createInMemoryDB();
|
|
364
|
+
const registry = new PraxisRegistry();
|
|
365
|
+
const store = createPraxisDBStore(db, registry);
|
|
366
|
+
const chronicle = createChronicle(db);
|
|
367
|
+
|
|
368
|
+
const returned = store.withChronicle(chronicle);
|
|
369
|
+
expect(returned).toBe(store);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ── ChronosMcpTools ────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
describe('ChronosMcpTools', () => {
|
|
376
|
+
it('chronos.trace should return nodes on success', async () => {
|
|
377
|
+
const { chronicle } = makeSetup();
|
|
378
|
+
const tools = createChronosMcpTools(chronicle);
|
|
379
|
+
|
|
380
|
+
const root = await chronicle.record({ path: '/root', metadata: {} });
|
|
381
|
+
const leaf = await chronicle.record({ path: '/leaf', cause: root.id, metadata: {} });
|
|
382
|
+
|
|
383
|
+
const result = await tools.trace({ nodeId: leaf.id, direction: 'backward', maxDepth: 5 });
|
|
384
|
+
|
|
385
|
+
expect(result.success).toBe(true);
|
|
386
|
+
const ids = result.data?.map((n) => n.id) ?? [];
|
|
387
|
+
expect(ids).toContain(leaf.id);
|
|
388
|
+
expect(ids).toContain(root.id);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('chronos.trace should return error on failure', async () => {
|
|
392
|
+
const brokenChronicle: Chronicle = {
|
|
393
|
+
async record() {
|
|
394
|
+
return {} as ChronicleNode;
|
|
395
|
+
},
|
|
396
|
+
async trace() {
|
|
397
|
+
throw new Error('db offline');
|
|
398
|
+
},
|
|
399
|
+
async range() {
|
|
400
|
+
return [];
|
|
401
|
+
},
|
|
402
|
+
async subgraph() {
|
|
403
|
+
return [];
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const tools = createChronosMcpTools(brokenChronicle);
|
|
408
|
+
const result = await tools.trace({ nodeId: 'x' });
|
|
409
|
+
|
|
410
|
+
expect(result.success).toBe(false);
|
|
411
|
+
expect(result.error).toBe('db offline');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('chronos.search should filter by query string', async () => {
|
|
415
|
+
const { chronicle } = makeSetup();
|
|
416
|
+
const tools = createChronosMcpTools(chronicle);
|
|
417
|
+
|
|
418
|
+
await chronicle.record({ path: '/praxis/facts/UserLoggedIn/u1', after: { userId: 'alice' }, metadata: {} });
|
|
419
|
+
await chronicle.record({ path: '/praxis/events/LOGIN', after: { username: 'bob' }, metadata: {} });
|
|
420
|
+
|
|
421
|
+
const result = await tools.search({ query: 'userloggedin' });
|
|
422
|
+
expect(result.success).toBe(true);
|
|
423
|
+
expect(result.data).toHaveLength(1);
|
|
424
|
+
expect(result.data?.[0]?.event.path).toContain('UserLoggedIn');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('chronos.search should filter by contextId', async () => {
|
|
428
|
+
const { chronicle } = makeSetup();
|
|
429
|
+
const tools = createChronosMcpTools(chronicle);
|
|
430
|
+
|
|
431
|
+
await chronicle.record({ path: '/a', context: 'ctx-search', metadata: { tag: 'alpha' } });
|
|
432
|
+
await chronicle.record({ path: '/b', context: 'ctx-other', metadata: { tag: 'beta' } });
|
|
433
|
+
|
|
434
|
+
const result = await tools.search({ query: 'alpha', contextId: 'ctx-search' });
|
|
435
|
+
expect(result.success).toBe(true);
|
|
436
|
+
expect(result.data).toHaveLength(1);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('chronos.search should respect limit', async () => {
|
|
440
|
+
const { chronicle } = makeSetup();
|
|
441
|
+
const tools = createChronosMcpTools(chronicle);
|
|
442
|
+
|
|
443
|
+
for (let i = 0; i < 5; i++) {
|
|
444
|
+
await chronicle.record({ path: `/item/${i}`, metadata: { tag: 'item' } });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const result = await tools.search({ query: 'item', limit: 2 });
|
|
448
|
+
expect(result.success).toBe(true);
|
|
449
|
+
expect(result.data).toHaveLength(2);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ── End-to-end: agent decision → chronicle node → trace backward to input ─────
|
|
454
|
+
|
|
455
|
+
describe('End-to-end: agent decision traced back to input event', () => {
|
|
456
|
+
it('should trace a RouteDecision fact back to the originating AgentInput event', async () => {
|
|
457
|
+
// Setup
|
|
458
|
+
const db = createInMemoryDB();
|
|
459
|
+
const chronicle = createChronicle(db);
|
|
460
|
+
|
|
461
|
+
interface AgentCtx {
|
|
462
|
+
sessionId: string;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const AgentInput = defineEvent<'AgentInput', { prompt: string }>('AgentInput');
|
|
466
|
+
const RouteDecision = defineFact<'RouteDecision', { route: string; id: string }>(
|
|
467
|
+
'RouteDecision'
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const routingRule = defineRule<AgentCtx>({
|
|
471
|
+
id: 'agent.route',
|
|
472
|
+
description: 'Route agent input to appropriate handler',
|
|
473
|
+
impl: (_state, events) => {
|
|
474
|
+
const input = events.find(AgentInput.is);
|
|
475
|
+
if (input) {
|
|
476
|
+
return [RouteDecision.create({ route: 'fast-path', id: 'decision-1' })];
|
|
477
|
+
}
|
|
478
|
+
return [];
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const registry = new PraxisRegistry<AgentCtx>();
|
|
483
|
+
registry.registerRule(routingRule);
|
|
484
|
+
|
|
485
|
+
const store = createPraxisDBStore(db, registry, { sessionId: 'sess-42' }).withChronicle(
|
|
486
|
+
chronicle
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// Simulate an agent request: the input event triggers routing
|
|
490
|
+
const sessionSpan = { spanId: 'request-1', contextId: 'sess-42' };
|
|
491
|
+
|
|
492
|
+
await ChronicleContext.runAsync(sessionSpan, async () => {
|
|
493
|
+
// Append input event (triggers routingRule which stores RouteDecision fact)
|
|
494
|
+
await store.appendEvent(AgentInput.create({ prompt: 'Hello, agent!' }));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Find the RouteDecision chronicle node
|
|
498
|
+
const allNodes = await chronicle.range(0, Date.now() + 1000);
|
|
499
|
+
const decisionNode = allNodes.find((n) => n.event.path.includes('RouteDecision'));
|
|
500
|
+
|
|
501
|
+
expect(decisionNode).toBeDefined();
|
|
502
|
+
|
|
503
|
+
// The RouteDecision fact should be attributed to the same context
|
|
504
|
+
expect(decisionNode?.event.context).toBe('sess-42');
|
|
505
|
+
|
|
506
|
+
// The session subgraph should contain both the input event and decision fact nodes
|
|
507
|
+
const subgraph = await chronicle.subgraph('sess-42');
|
|
508
|
+
const paths = subgraph.map((n) => n.event.path);
|
|
509
|
+
|
|
510
|
+
expect(paths.some((p) => p.includes('AgentInput'))).toBe(true);
|
|
511
|
+
});
|
|
512
|
+
});
|