@fiale-plus/pi-rogue-bundle 0.1.15 → 0.1.17
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 +8 -1
- package/node_modules/@fiale-plus/pi-core/README.md +6 -5
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +20 -227
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +26 -7
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +17 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +44 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +31 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +480 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +573 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +165 -0
- package/node_modules/@fiale-plus/{pi-core/src/context-broker.test.ts → pi-rogue-context-broker/src/index.test.ts} +61 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +324 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +78 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +500 -0
- package/package.json +11 -3
- package/src/context-broker-file.ts +1 -0
- package/src/context-broker-sqlite.ts +1 -0
- package/src/context-broker.ts +1 -0
- package/src/extension.test.ts +68 -0
- package/src/extension.ts +14 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { safeName } from "@fiale-plus/pi-core";
|
|
5
|
+
import type { BoundedContextBroker, ContextArtifact, ContextArtifactInput, ContextArtifactTier, ContextBrokerOptions, ContextBrokerStatus, ContextLookupQuery } from "@fiale-plus/pi-core";
|
|
6
|
+
import { createInMemoryContextBroker } from "./index.js";
|
|
7
|
+
|
|
8
|
+
export interface FileContextBrokerOptions extends ContextBrokerOptions {
|
|
9
|
+
dir?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STORE_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
interface StoredRecord {
|
|
15
|
+
version: number;
|
|
16
|
+
handle: string;
|
|
17
|
+
baseTier?: ContextArtifactTier;
|
|
18
|
+
input: Omit<ContextArtifactInput, "payload"> & { payloadSha256: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaultStoreDir(): string {
|
|
22
|
+
return join(homedir(), ".pi", "agent", "fiale-plus", "context-broker");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureDir(path: string): void {
|
|
26
|
+
mkdirSync(path, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function metadataFile(dir: string): string {
|
|
30
|
+
return join(dir, "metadata.jsonl");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blobFile(dir: string, sha256: string): string {
|
|
34
|
+
return join(dir, "blobs", `${sha256}.txt`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stableSource(input: ContextArtifactInput): string | undefined {
|
|
38
|
+
return input.parentIds?.find(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readStoredRecords(dir: string): StoredRecord[] {
|
|
42
|
+
const file = metadataFile(dir);
|
|
43
|
+
if (!existsSync(file)) return [];
|
|
44
|
+
const recordsByHandle = new Map<string, StoredRecord>();
|
|
45
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed) continue;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(trimmed) as StoredRecord;
|
|
50
|
+
if (parsed?.version === STORE_VERSION && parsed.handle && parsed.input?.payloadSha256) {
|
|
51
|
+
recordsByHandle.set(parsed.handle, parsed);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Ignore corrupt JSONL rows; durable storage is append-only recovery, not a startup blocker.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...recordsByHandle.values()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadPayload(dir: string, sha256: string): string | undefined {
|
|
61
|
+
const file = blobFile(dir, sha256);
|
|
62
|
+
if (!existsSync(file)) return undefined;
|
|
63
|
+
return readFileSync(file, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function artifactBaseTier(artifact: ContextArtifact, fallback?: ContextArtifactTier): ContextArtifactTier {
|
|
67
|
+
return (artifact as ContextArtifact & { baseTier?: ContextArtifactTier }).baseTier ?? fallback ?? artifact.tier;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function persistRecord(dir: string, artifact: ContextArtifact, input: ContextArtifactInput): void {
|
|
71
|
+
ensureDir(join(dir, "blobs"));
|
|
72
|
+
const blob = blobFile(dir, artifact.sha256);
|
|
73
|
+
if (!existsSync(blob)) writeFileSync(blob, artifact.payload, "utf8");
|
|
74
|
+
const record: StoredRecord = {
|
|
75
|
+
version: STORE_VERSION,
|
|
76
|
+
handle: artifact.handle,
|
|
77
|
+
baseTier: artifactBaseTier(artifact, input.tier),
|
|
78
|
+
input: {
|
|
79
|
+
sessionId: input.sessionId,
|
|
80
|
+
kind: input.kind,
|
|
81
|
+
summary: input.summary,
|
|
82
|
+
tags: input.tags,
|
|
83
|
+
paths: input.paths,
|
|
84
|
+
command: input.command,
|
|
85
|
+
branch: input.branch,
|
|
86
|
+
tier: artifactBaseTier(artifact, input.tier),
|
|
87
|
+
ttlMs: input.ttlMs,
|
|
88
|
+
pinned: artifact.pinned,
|
|
89
|
+
parentIds: input.parentIds,
|
|
90
|
+
createdAt: input.createdAt ?? artifact.createdAt,
|
|
91
|
+
payloadSha256: artifact.sha256,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
ensureDir(dirname(metadataFile(dir)));
|
|
95
|
+
appendFileSync(metadataFile(dir), `${JSON.stringify(record)}\n`, "utf8");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function persistArtifactSnapshot(dir: string, artifact: ContextArtifact): void {
|
|
99
|
+
persistRecord(dir, artifact, {
|
|
100
|
+
sessionId: artifact.sessionId,
|
|
101
|
+
kind: artifact.kind,
|
|
102
|
+
payload: artifact.payload,
|
|
103
|
+
summary: artifact.summary,
|
|
104
|
+
tags: artifact.tags,
|
|
105
|
+
paths: artifact.paths,
|
|
106
|
+
command: artifact.command,
|
|
107
|
+
branch: artifact.branch,
|
|
108
|
+
tier: artifactBaseTier(artifact),
|
|
109
|
+
ttlMs: artifact.expiresAt ? Math.max(0, artifact.expiresAt - artifact.createdAt) : 0,
|
|
110
|
+
pinned: artifact.pinned,
|
|
111
|
+
parentIds: artifact.parentIds,
|
|
112
|
+
createdAt: artifact.createdAt,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createFileContextBroker(options: FileContextBrokerOptions = {}): BoundedContextBroker {
|
|
117
|
+
const dir = options.dir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR ?? defaultStoreDir();
|
|
118
|
+
ensureDir(join(dir, "blobs"));
|
|
119
|
+
const broker = createInMemoryContextBroker(options);
|
|
120
|
+
const persistedSources = new Map<string, string>();
|
|
121
|
+
const handleAliases = new Map<string, string>();
|
|
122
|
+
|
|
123
|
+
for (const record of readStoredRecords(dir)) {
|
|
124
|
+
const payload = loadPayload(dir, record.input.payloadSha256);
|
|
125
|
+
if (payload === undefined) continue;
|
|
126
|
+
const artifact = broker.publish({ ...record.input, tier: record.baseTier ?? record.input.tier, payload });
|
|
127
|
+
handleAliases.set(record.handle, artifact.handle);
|
|
128
|
+
const source = stableSource(record.input as unknown as ContextArtifactInput);
|
|
129
|
+
if (source) persistedSources.set(source, artifact.handle);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
133
|
+
const source = stableSource(input);
|
|
134
|
+
const existingHandle = source ? persistedSources.get(source) : undefined;
|
|
135
|
+
if (existingHandle) {
|
|
136
|
+
const existing = broker.lookup({ handle: existingHandle })[0];
|
|
137
|
+
if (existing) return existing;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const artifact = broker.publish(input);
|
|
141
|
+
if (source) persistedSources.set(source, artifact.handle);
|
|
142
|
+
persistRecord(dir, artifact, input);
|
|
143
|
+
return artifact;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
publish,
|
|
148
|
+
lookup(query?: ContextLookupQuery): ContextArtifact[] {
|
|
149
|
+
const mappedHandle = query?.handle ? handleAliases.get(query.handle) : undefined;
|
|
150
|
+
return broker.lookup(mappedHandle ? { ...query, handle: mappedHandle } : query);
|
|
151
|
+
},
|
|
152
|
+
pin(idOrHandle: string, pinned?: boolean): ContextArtifact | null {
|
|
153
|
+
const artifact = broker.pin(handleAliases.get(idOrHandle) ?? idOrHandle, pinned);
|
|
154
|
+
if (artifact) persistArtifactSnapshot(dir, artifact);
|
|
155
|
+
return artifact;
|
|
156
|
+
},
|
|
157
|
+
prune(now?: number): ContextBrokerStatus { return broker.prune(now); },
|
|
158
|
+
status(): ContextBrokerStatus { return broker.status(); },
|
|
159
|
+
renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string { return broker.renderBrief(query); },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function contextBrokerStoreDirForSession(baseDir: string, sessionId: string): string {
|
|
164
|
+
return join(baseDir, safeName(sessionId));
|
|
165
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { createInMemoryContextBroker } from "./
|
|
3
|
+
import { createInMemoryContextBroker } from "./index.js";
|
|
4
4
|
|
|
5
5
|
describe("createInMemoryContextBroker", () => {
|
|
6
6
|
it("publishes stable, unique handles and looks up artifacts by handle", () => {
|
|
@@ -179,6 +179,66 @@ describe("createInMemoryContextBroker", () => {
|
|
|
179
179
|
expect(broker.lookup({ id: artifact.id })).toEqual([]);
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
it("classifies artifacts into hot, warm, and cold tiers on publish", () => {
|
|
183
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 0 });
|
|
184
|
+
const failure = broker.publish({ sessionId: "s", kind: "tool_output", payload: "failed", tags: ["error"], createdAt: 1 });
|
|
185
|
+
const command = broker.publish({ sessionId: "s", kind: "tool_output", payload: "passed", tags: ["ok"], createdAt: 2 });
|
|
186
|
+
const archive = broker.publish({ sessionId: "s", kind: "subagent_result", payload: "old", tags: ["completed"], createdAt: 3 });
|
|
187
|
+
const explicit = broker.publish({ sessionId: "s", kind: "diff", payload: "manual", tier: "cold", createdAt: 4 });
|
|
188
|
+
|
|
189
|
+
expect(failure.tier).toBe("hot");
|
|
190
|
+
expect(command.tier).toBe("warm");
|
|
191
|
+
expect(archive.tier).toBe("cold");
|
|
192
|
+
expect(explicit.tier).toBe("cold");
|
|
193
|
+
expect(broker.lookup({ sessionId: "s", tier: "cold" }).map((artifact) => artifact.id)).toEqual([explicit.id, archive.id]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("renders prompt briefs hot-first, warm-second, and excludes cold unless explicit", () => {
|
|
197
|
+
const broker = createInMemoryContextBroker({ briefBytes: 900, defaultTtlMs: 0 });
|
|
198
|
+
const cold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "cold", summary: "cold archive", tier: "cold", createdAt: 1 });
|
|
199
|
+
const warm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "warm", summary: "warm command", tier: "warm", createdAt: 2 });
|
|
200
|
+
const hot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "hot", summary: "hot failure", tier: "hot", createdAt: 3 });
|
|
201
|
+
|
|
202
|
+
const brief = broker.renderBrief({ sessionId: "s" });
|
|
203
|
+
expect(brief).toContain("Hot:");
|
|
204
|
+
expect(brief).toContain(hot.handle);
|
|
205
|
+
expect(brief).toContain("Warm:");
|
|
206
|
+
expect(brief).toContain(warm.handle);
|
|
207
|
+
expect(brief).not.toContain(cold.handle);
|
|
208
|
+
expect(brief.indexOf(hot.handle)).toBeLessThan(brief.indexOf(warm.handle));
|
|
209
|
+
|
|
210
|
+
const coldBrief = broker.renderBrief({ sessionId: "s", tier: "cold", budgetBytes: 500 });
|
|
211
|
+
expect(coldBrief).toContain("Cold:");
|
|
212
|
+
expect(coldBrief).toContain(cold.handle);
|
|
213
|
+
|
|
214
|
+
expect(broker.pin(cold.handle, true)?.tier).toBe("hot");
|
|
215
|
+
expect(broker.renderBrief({ sessionId: "s" })).toContain(cold.handle);
|
|
216
|
+
expect(broker.pin(cold.handle, false)?.tier).toBe("cold");
|
|
217
|
+
expect(broker.renderBrief({ sessionId: "s" })).not.toContain(cold.handle);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("applies tier-specific record, byte, and ttl retention", () => {
|
|
221
|
+
const broker = createInMemoryContextBroker({
|
|
222
|
+
defaultTtlMs: 0,
|
|
223
|
+
hotMaxRecords: 1,
|
|
224
|
+
warmMaxBytes: 6,
|
|
225
|
+
coldTtlMs: 10,
|
|
226
|
+
});
|
|
227
|
+
const oldHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old-hot", tier: "hot", createdAt: 1 });
|
|
228
|
+
const newHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "new-hot", tier: "hot", createdAt: 2 });
|
|
229
|
+
const oldWarm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "12345", tier: "warm", createdAt: 3 });
|
|
230
|
+
const newWarm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "abcde", tier: "warm", createdAt: 4 });
|
|
231
|
+
const cold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "cold", tier: "cold", createdAt: 5 });
|
|
232
|
+
|
|
233
|
+
expect(broker.lookup({ id: oldHot.id })).toEqual([]);
|
|
234
|
+
expect(broker.lookup({ id: newHot.id })).toEqual([newHot]);
|
|
235
|
+
expect(broker.lookup({ id: oldWarm.id })).toEqual([]);
|
|
236
|
+
expect(broker.lookup({ id: newWarm.id })).toEqual([newWarm]);
|
|
237
|
+
|
|
238
|
+
broker.prune(16);
|
|
239
|
+
expect(broker.lookup({ id: cold.id })).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
|
|
182
242
|
it("renders a bounded prompt brief with lookup instructions", () => {
|
|
183
243
|
const broker = createInMemoryContextBroker({ briefBytes: 180 });
|
|
184
244
|
broker.publish({
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { safeName } from "@fiale-plus/pi-core";
|
|
3
|
+
import type {
|
|
4
|
+
BoundedContextBroker,
|
|
5
|
+
ContextArtifact,
|
|
6
|
+
ContextArtifactInput,
|
|
7
|
+
ContextArtifactKind,
|
|
8
|
+
ContextArtifactTier,
|
|
9
|
+
ContextBrokerOptions,
|
|
10
|
+
ContextBrokerStatus,
|
|
11
|
+
ContextLookupQuery,
|
|
12
|
+
} from "@fiale-plus/pi-core";
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
BoundedContextBroker,
|
|
16
|
+
ContextArtifact,
|
|
17
|
+
ContextArtifactInput,
|
|
18
|
+
ContextArtifactKind,
|
|
19
|
+
ContextArtifactTier,
|
|
20
|
+
ContextBrokerOptions,
|
|
21
|
+
ContextBrokerStatus,
|
|
22
|
+
ContextLookupQuery,
|
|
23
|
+
} from "@fiale-plus/pi-core";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MAX_RECORDS = 256;
|
|
26
|
+
const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
|
|
27
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
28
|
+
const DEFAULT_SUMMARY_BYTES = 320;
|
|
29
|
+
const DEFAULT_BRIEF_BYTES = 2_000;
|
|
30
|
+
const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
|
|
31
|
+
const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
|
|
32
|
+
|
|
33
|
+
function normalizeList(values: string[] | undefined): string[] {
|
|
34
|
+
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function payloadText(payload: string | Buffer): string {
|
|
38
|
+
return Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function payloadBytes(payload: string | Buffer): number {
|
|
42
|
+
return Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(String(payload ?? ""), "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hashPayload(payload: string | Buffer): string {
|
|
46
|
+
return createHash("sha256").update(Buffer.isBuffer(payload) ? payload : String(payload)).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeNeedle(value: string | undefined): string {
|
|
50
|
+
return String(value ?? "").trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
54
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
55
|
+
if (Buffer.byteLength(text, "utf8") <= limit) return text;
|
|
56
|
+
if (limit === 0) return "";
|
|
57
|
+
|
|
58
|
+
const ellipsis = "…";
|
|
59
|
+
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8");
|
|
60
|
+
const contentLimit = Math.max(0, limit - ellipsisBytes);
|
|
61
|
+
let used = 0;
|
|
62
|
+
let result = "";
|
|
63
|
+
|
|
64
|
+
for (const char of text) {
|
|
65
|
+
const bytes = Buffer.byteLength(char, "utf8");
|
|
66
|
+
if (used + bytes > contentLimit) break;
|
|
67
|
+
result += char;
|
|
68
|
+
used += bytes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Buffer.byteLength(result + ellipsis, "utf8") <= limit) return result + ellipsis;
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKind, bytes: number, sha256: string, maxBytes: number): string {
|
|
76
|
+
const cleaned = String(summary ?? "").replace(/\s+/g, " ").trim();
|
|
77
|
+
if (cleaned) return truncateUtf8(cleaned, maxBytes);
|
|
78
|
+
return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function classifyBaseTier(input: ContextArtifactInput, tags: string[]): ContextArtifactTier {
|
|
82
|
+
if (input.tier) return input.tier;
|
|
83
|
+
const normalizedTags = tags.map((tag) => tag.toLowerCase());
|
|
84
|
+
if (normalizedTags.includes("hot")) return "hot";
|
|
85
|
+
if (normalizedTags.includes("warm")) return "warm";
|
|
86
|
+
if (normalizedTags.includes("cold")) return "cold";
|
|
87
|
+
if (normalizedTags.some((tag) => tag === "error" || tag === "failed" || tag === "failure")) return "hot";
|
|
88
|
+
if (normalizedTags.some((tag) => tag === "archive" || tag === "historical" || tag === "completed")) return "cold";
|
|
89
|
+
if (input.kind === "advisor_brief" || input.kind === "memory_note") return "hot";
|
|
90
|
+
return "warm";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery): boolean {
|
|
94
|
+
if (query.id && artifact.id !== query.id) return false;
|
|
95
|
+
if (query.handle && artifact.handle !== query.handle) return false;
|
|
96
|
+
if (query.sessionId && artifact.sessionId !== query.sessionId) return false;
|
|
97
|
+
if (query.kind && artifact.kind !== query.kind) return false;
|
|
98
|
+
if (query.branch && artifact.branch !== query.branch) return false;
|
|
99
|
+
if (query.tier && artifact.tier !== query.tier) return false;
|
|
100
|
+
if (query.tag && !artifact.tags.includes(query.tag)) return false;
|
|
101
|
+
if (query.path) {
|
|
102
|
+
const queryPath = query.path.replace(/\/$/, "");
|
|
103
|
+
if (!artifact.paths.some((path) => path === query.path || path.startsWith(`${queryPath}/`))) return false;
|
|
104
|
+
}
|
|
105
|
+
if (query.commandPrefix && !artifact.command?.startsWith(query.commandPrefix)) return false;
|
|
106
|
+
|
|
107
|
+
const text = normalizeNeedle(query.text);
|
|
108
|
+
if (text) {
|
|
109
|
+
const haystack = [artifact.summary, artifact.payload, artifact.command, artifact.tags.join(" "), artifact.paths.join(" ")]
|
|
110
|
+
.join("\n")
|
|
111
|
+
.toLowerCase();
|
|
112
|
+
if (!haystack.includes(text)) return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tierLine(artifact: ContextArtifact): string {
|
|
119
|
+
const pin = artifact.pinned ? " pinned" : "";
|
|
120
|
+
const path = artifact.paths.length ? ` paths=${artifact.paths.slice(0, 3).join(",")}` : "";
|
|
121
|
+
const tags = artifact.tags.length ? ` tags=${artifact.tags.slice(0, 3).join(",")}` : "";
|
|
122
|
+
return `- ${artifact.handle} tier=${artifact.tier} kind=${artifact.kind}${pin}${path}${tags} summary="${artifact.summary}"`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
|
|
126
|
+
const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
|
|
127
|
+
const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
128
|
+
const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
|
|
129
|
+
const tierTtlMs: Record<ContextArtifactTier, number> = {
|
|
130
|
+
hot: Math.max(0, Math.floor(options.hotTtlMs ?? defaultTtlMs)),
|
|
131
|
+
warm: Math.max(0, Math.floor(options.warmTtlMs ?? defaultTtlMs)),
|
|
132
|
+
cold: Math.max(0, Math.floor(options.coldTtlMs ?? defaultTtlMs)),
|
|
133
|
+
};
|
|
134
|
+
const tierMaxRecords: Record<ContextArtifactTier, number> = {
|
|
135
|
+
hot: Math.max(1, Math.floor(options.hotMaxRecords ?? maxRecords)),
|
|
136
|
+
warm: Math.max(1, Math.floor(options.warmMaxRecords ?? maxRecords)),
|
|
137
|
+
cold: Math.max(1, Math.floor(options.coldMaxRecords ?? maxRecords)),
|
|
138
|
+
};
|
|
139
|
+
const tierMaxBytes: Record<ContextArtifactTier, number> = {
|
|
140
|
+
hot: Math.max(1, Math.floor(options.hotMaxBytes ?? maxBytes)),
|
|
141
|
+
warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
|
|
142
|
+
cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
|
|
143
|
+
};
|
|
144
|
+
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
145
|
+
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
146
|
+
let artifacts: Array<ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }> = [];
|
|
147
|
+
let sequence = 0;
|
|
148
|
+
|
|
149
|
+
function currentStatus(): ContextBrokerStatus {
|
|
150
|
+
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
151
|
+
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
152
|
+
const byTier = (tier: ContextArtifactTier) => artifacts.filter((artifact) => artifact.tier === tier);
|
|
153
|
+
const hot = byTier("hot");
|
|
154
|
+
const warm = byTier("warm");
|
|
155
|
+
const cold = byTier("cold");
|
|
156
|
+
return {
|
|
157
|
+
records: artifacts.length,
|
|
158
|
+
bytes,
|
|
159
|
+
pinnedRecords: pinned.length,
|
|
160
|
+
pinnedBytes: pinned.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
161
|
+
hotRecords: hot.length,
|
|
162
|
+
hotBytes: hot.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
163
|
+
warmRecords: warm.length,
|
|
164
|
+
warmBytes: warm.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
165
|
+
coldRecords: cold.length,
|
|
166
|
+
coldBytes: cold.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
167
|
+
maxRecords,
|
|
168
|
+
maxBytes,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function dropExpired(now = Date.now(), protectedIds = new Set<string>()): void {
|
|
173
|
+
artifacts = artifacts.filter(
|
|
174
|
+
(artifact) => artifact.pinned || protectedIds.has(artifact.id) || !artifact.expiresAt || artifact.expiresAt > now,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function removalCandidates(sessionId: string, protectedIds: Set<string>, tier?: ContextArtifactTier): Array<{ artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }; index: number }> {
|
|
179
|
+
return artifacts
|
|
180
|
+
.map((artifact, index) => ({ artifact, index }))
|
|
181
|
+
.filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id) && (!tier || artifact.tier === tier))
|
|
182
|
+
.sort((a, b) => {
|
|
183
|
+
if (!tier && TIER_REMOVAL_ORDER[a.artifact.tier] !== TIER_REMOVAL_ORDER[b.artifact.tier]) {
|
|
184
|
+
return TIER_REMOVAL_ORDER[a.artifact.tier] - TIER_REMOVAL_ORDER[b.artifact.tier];
|
|
185
|
+
}
|
|
186
|
+
if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
|
|
187
|
+
return a.artifact.sequence - b.artifact.sequence;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function withinCaps(sessionId: string, tier?: ContextArtifactTier): boolean {
|
|
192
|
+
const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId && (!tier || artifact.tier === tier));
|
|
193
|
+
const recordsCap = tier ? tierMaxRecords[tier] : maxRecords;
|
|
194
|
+
const bytesCap = tier ? tierMaxBytes[tier] : maxBytes;
|
|
195
|
+
return sessionArtifacts.length <= recordsCap && sessionArtifacts.reduce((sum, artifact) => sum + artifact.bytes, 0) <= bytesCap;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
199
|
+
dropExpired(now, protectedIds);
|
|
200
|
+
|
|
201
|
+
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
202
|
+
for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
|
|
203
|
+
while (!withinCaps(sessionId, tier)) {
|
|
204
|
+
const candidate = removalCandidates(sessionId, protectedIds, tier)[0];
|
|
205
|
+
if (!candidate) break;
|
|
206
|
+
artifacts.splice(candidate.index, 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
while (!withinCaps(sessionId)) {
|
|
211
|
+
const candidate = removalCandidates(sessionId, protectedIds)[0];
|
|
212
|
+
if (!candidate) break;
|
|
213
|
+
artifacts.splice(candidate.index, 1);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return currentStatus();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function status(): ContextBrokerStatus {
|
|
221
|
+
dropExpired();
|
|
222
|
+
return currentStatus();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
226
|
+
const now = input.createdAt ?? Date.now();
|
|
227
|
+
const payload = payloadText(input.payload);
|
|
228
|
+
const sha256 = hashPayload(input.payload);
|
|
229
|
+
const bytes = payloadBytes(input.payload);
|
|
230
|
+
const artifactSequence = ++sequence;
|
|
231
|
+
const id = `ctx-${now.toString(36)}-${String(artifactSequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
|
|
232
|
+
const session = safeName(input.sessionId || "session");
|
|
233
|
+
const kind = input.kind;
|
|
234
|
+
const tags = normalizeList(input.tags);
|
|
235
|
+
const baseTier = classifyBaseTier(input, tags);
|
|
236
|
+
const tier: ContextArtifactTier = input.pinned ? "hot" : baseTier;
|
|
237
|
+
const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
|
|
238
|
+
const ttlMs = input.ttlMs ?? tierTtlMs[tier];
|
|
239
|
+
|
|
240
|
+
const artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier } = {
|
|
241
|
+
id,
|
|
242
|
+
handle,
|
|
243
|
+
sessionId: input.sessionId,
|
|
244
|
+
kind,
|
|
245
|
+
createdAt: now,
|
|
246
|
+
updatedAt: now,
|
|
247
|
+
bytes,
|
|
248
|
+
sha256,
|
|
249
|
+
payload,
|
|
250
|
+
summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
|
|
251
|
+
tags,
|
|
252
|
+
paths: normalizeList(input.paths),
|
|
253
|
+
command: input.command?.trim() || undefined,
|
|
254
|
+
branch: input.branch?.trim() || undefined,
|
|
255
|
+
tier,
|
|
256
|
+
expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
|
|
257
|
+
pinned: Boolean(input.pinned),
|
|
258
|
+
parentIds: normalizeList(input.parentIds),
|
|
259
|
+
sequence: artifactSequence,
|
|
260
|
+
baseTier,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
artifacts = [artifact, ...artifacts];
|
|
264
|
+
prune(now, new Set([artifact.id]));
|
|
265
|
+
return artifact;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
269
|
+
dropExpired();
|
|
270
|
+
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
271
|
+
return artifacts
|
|
272
|
+
.filter((artifact) => artifactMatches(artifact, query))
|
|
273
|
+
.sort((a, b) => Number(b.pinned) - Number(a.pinned)
|
|
274
|
+
|| TIER_ORDER[a.tier] - TIER_ORDER[b.tier]
|
|
275
|
+
|| b.createdAt - a.createdAt
|
|
276
|
+
|| b.sequence - a.sequence)
|
|
277
|
+
.slice(0, limit);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function pin(idOrHandle: string, pinned = true): ContextArtifact | null {
|
|
281
|
+
dropExpired();
|
|
282
|
+
const artifact = artifacts.find((candidate) => candidate.id === idOrHandle || candidate.handle === idOrHandle) ?? null;
|
|
283
|
+
if (!artifact) return null;
|
|
284
|
+
artifact.pinned = pinned;
|
|
285
|
+
artifact.tier = pinned ? "hot" : artifact.baseTier;
|
|
286
|
+
artifact.updatedAt = Date.now();
|
|
287
|
+
prune();
|
|
288
|
+
return artifacts.find((candidate) => candidate.id === artifact.id) ?? null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
|
|
292
|
+
const budget = Math.max(64, Math.floor(query.budgetBytes ?? defaultBriefBytes));
|
|
293
|
+
const explicitCold = query.tier === "cold" || Boolean(query.handle || query.id);
|
|
294
|
+
const baseQuery = { ...query };
|
|
295
|
+
delete (baseQuery as { budgetBytes?: number }).budgetBytes;
|
|
296
|
+
const candidates = lookup({ ...baseQuery, limit: query.limit ?? 32 })
|
|
297
|
+
.filter((artifact) => explicitCold || artifact.tier !== "cold");
|
|
298
|
+
const hot = candidates.filter((artifact) => artifact.tier === "hot");
|
|
299
|
+
const warm = candidates.filter((artifact) => artifact.tier === "warm");
|
|
300
|
+
const cold = candidates.filter((artifact) => artifact.tier === "cold");
|
|
301
|
+
const lines = [
|
|
302
|
+
"## Context Broker",
|
|
303
|
+
`Budget: ${budget} bytes`,
|
|
304
|
+
hot.length ? "Hot:" : "",
|
|
305
|
+
...hot.map(tierLine),
|
|
306
|
+
warm.length ? "Warm:" : "",
|
|
307
|
+
...warm.map(tierLine),
|
|
308
|
+
cold.length ? "Cold:" : "",
|
|
309
|
+
...cold.map(tierLine),
|
|
310
|
+
"Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
|
|
311
|
+
].filter(Boolean);
|
|
312
|
+
|
|
313
|
+
return truncateUtf8(lines.join("\n"), budget);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
publish,
|
|
318
|
+
lookup,
|
|
319
|
+
pin,
|
|
320
|
+
prune,
|
|
321
|
+
status,
|
|
322
|
+
renderBrief,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { createSqliteContextBroker } from "./sqlite.js";
|
|
6
|
+
|
|
7
|
+
describe("createSqliteContextBroker", () => {
|
|
8
|
+
it("persists handles, payloads, tiers, and pin state without replay reconstruction", () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
10
|
+
try {
|
|
11
|
+
const path = join(dir, "artifacts.sqlite");
|
|
12
|
+
let broker = createSqliteContextBroker({ path, defaultTtlMs: 0, briefBytes: 800 });
|
|
13
|
+
const warm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "needle payload", summary: "warm summary", createdAt: Date.now() });
|
|
14
|
+
const cold = broker.publish({ sessionId: "s", kind: "subagent_result", payload: "archived payload", summary: "cold archive", tier: "cold", createdAt: Date.now() + 1 });
|
|
15
|
+
expect(broker.pin(cold.handle, true)?.tier).toBe("hot");
|
|
16
|
+
|
|
17
|
+
broker = createSqliteContextBroker({ path, defaultTtlMs: 0, briefBytes: 800 });
|
|
18
|
+
|
|
19
|
+
expect(broker.lookup({ handle: warm.handle })[0]?.payload).toBe("needle payload");
|
|
20
|
+
const reloadedCold = broker.lookup({ handle: cold.handle })[0];
|
|
21
|
+
expect(reloadedCold?.pinned).toBe(true);
|
|
22
|
+
expect(reloadedCold?.tier).toBe("hot");
|
|
23
|
+
expect(broker.renderBrief({ sessionId: "s" })).toContain(cold.handle);
|
|
24
|
+
} finally {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("uses SQLite FTS for text lookup and enforces tier caps", () => {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
31
|
+
try {
|
|
32
|
+
const broker = createSqliteContextBroker({ path: join(dir, "artifacts.sqlite"), defaultTtlMs: 0, coldMaxRecords: 1 });
|
|
33
|
+
const firstCold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "alpha archive", tier: "cold", createdAt: Date.now() });
|
|
34
|
+
const secondCold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "needle beta archive", tier: "cold", createdAt: Date.now() + 1 });
|
|
35
|
+
|
|
36
|
+
expect(broker.lookup({ id: firstCold.id })).toEqual([]);
|
|
37
|
+
expect(broker.lookup({ text: "needle" })[0]?.handle).toBe(secondCold.handle);
|
|
38
|
+
} finally {
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("dedupes replayed source artifacts so durable handles survive caps", () => {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
45
|
+
try {
|
|
46
|
+
const path = join(dir, "artifacts.sqlite");
|
|
47
|
+
let broker = createSqliteContextBroker({ path, defaultTtlMs: 0, maxRecords: 1 });
|
|
48
|
+
const original = broker.publish({ sessionId: "s", kind: "tool_output", payload: "same replayed payload", parentIds: ["tool-call-1"], createdAt: Date.now() });
|
|
49
|
+
|
|
50
|
+
broker = createSqliteContextBroker({ path, defaultTtlMs: 0, maxRecords: 1 });
|
|
51
|
+
const replayed = broker.publish({ sessionId: "s", kind: "tool_output", payload: "same replayed payload", parentIds: ["tool-call-1"], createdAt: Date.now() + 1 });
|
|
52
|
+
|
|
53
|
+
expect(replayed.handle).toBe(original.handle);
|
|
54
|
+
expect(broker.lookup({ handle: original.handle })[0]?.payload).toBe("same replayed payload");
|
|
55
|
+
expect(broker.status().records).toBe(1);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("republishes expired replayed sources instead of returning dead handles", () => {
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
63
|
+
try {
|
|
64
|
+
const path = join(dir, "artifacts.sqlite");
|
|
65
|
+
let broker = createSqliteContextBroker({ path, defaultTtlMs: 1 });
|
|
66
|
+
const expired = broker.publish({ sessionId: "s", kind: "tool_output", payload: "expired payload", parentIds: ["tool-call-1"], createdAt: 1 });
|
|
67
|
+
expect(broker.lookup({ handle: expired.handle })).toEqual([]);
|
|
68
|
+
|
|
69
|
+
broker = createSqliteContextBroker({ path, defaultTtlMs: 1 });
|
|
70
|
+
const replayed = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh replayed payload", parentIds: ["tool-call-1"], createdAt: 1, ttlMs: Date.now() + 60_000 });
|
|
71
|
+
|
|
72
|
+
expect(replayed.handle).not.toBe(expired.handle);
|
|
73
|
+
expect(broker.lookup({ handle: replayed.handle })[0]?.payload).toBe("fresh replayed payload");
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|