@fiale-plus/pi-rogue-bundle 0.1.16 → 0.1.18
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 +2 -1
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +26 -0
- 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 +12 -6
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +5 -2
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +362 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +278 -28
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +191 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +74 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +112 -20
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +98 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +524 -0
- package/package.json +4 -2
- package/src/context-broker-file.ts +1 -0
- package/src/context-broker-sqlite.ts +1 -0
- package/src/extension.test.ts +5 -0
- package/src/extension.ts +3 -3
|
@@ -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({
|
|
@@ -213,4 +273,18 @@ describe("createInMemoryContextBroker", () => {
|
|
|
213
273
|
expect(Buffer.byteLength(brief, "utf8")).toBeLessThanOrEqual(170);
|
|
214
274
|
expect(brief).toContain("Context Broker");
|
|
215
275
|
});
|
|
276
|
+
|
|
277
|
+
it("purges unpinned session artifacts while retaining pinned evidence", () => {
|
|
278
|
+
const broker = createInMemoryContextBroker();
|
|
279
|
+
const unpinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "scratch", summary: "scratch" });
|
|
280
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "keep", summary: "keep", pinned: true });
|
|
281
|
+
const other = broker.publish({ sessionId: "other", kind: "tool_output", payload: "other", summary: "other" });
|
|
282
|
+
|
|
283
|
+
const status = broker.purge({ sessionId: "s", keepPinned: true });
|
|
284
|
+
|
|
285
|
+
expect(status.records).toBe(2);
|
|
286
|
+
expect(broker.lookup({ handle: unpinned.handle })).toEqual([]);
|
|
287
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("keep");
|
|
288
|
+
expect(broker.lookup({ handle: other.handle })[0]?.payload).toBe("other");
|
|
289
|
+
});
|
|
216
290
|
});
|
|
@@ -5,9 +5,11 @@ import type {
|
|
|
5
5
|
ContextArtifact,
|
|
6
6
|
ContextArtifactInput,
|
|
7
7
|
ContextArtifactKind,
|
|
8
|
+
ContextArtifactTier,
|
|
8
9
|
ContextBrokerOptions,
|
|
9
10
|
ContextBrokerStatus,
|
|
10
11
|
ContextLookupQuery,
|
|
12
|
+
ContextPurgeOptions,
|
|
11
13
|
} from "@fiale-plus/pi-core";
|
|
12
14
|
|
|
13
15
|
export type {
|
|
@@ -15,9 +17,11 @@ export type {
|
|
|
15
17
|
ContextArtifact,
|
|
16
18
|
ContextArtifactInput,
|
|
17
19
|
ContextArtifactKind,
|
|
20
|
+
ContextArtifactTier,
|
|
18
21
|
ContextBrokerOptions,
|
|
19
22
|
ContextBrokerStatus,
|
|
20
23
|
ContextLookupQuery,
|
|
24
|
+
ContextPurgeOptions,
|
|
21
25
|
} from "@fiale-plus/pi-core";
|
|
22
26
|
|
|
23
27
|
const DEFAULT_MAX_RECORDS = 256;
|
|
@@ -25,6 +29,8 @@ const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
|
|
|
25
29
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
26
30
|
const DEFAULT_SUMMARY_BYTES = 320;
|
|
27
31
|
const DEFAULT_BRIEF_BYTES = 2_000;
|
|
32
|
+
const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
|
|
33
|
+
const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
|
|
28
34
|
|
|
29
35
|
function normalizeList(values: string[] | undefined): string[] {
|
|
30
36
|
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
@@ -74,12 +80,25 @@ function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKin
|
|
|
74
80
|
return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
function classifyBaseTier(input: ContextArtifactInput, tags: string[]): ContextArtifactTier {
|
|
84
|
+
if (input.tier) return input.tier;
|
|
85
|
+
const normalizedTags = tags.map((tag) => tag.toLowerCase());
|
|
86
|
+
if (normalizedTags.includes("hot")) return "hot";
|
|
87
|
+
if (normalizedTags.includes("warm")) return "warm";
|
|
88
|
+
if (normalizedTags.includes("cold")) return "cold";
|
|
89
|
+
if (normalizedTags.some((tag) => tag === "error" || tag === "failed" || tag === "failure")) return "hot";
|
|
90
|
+
if (normalizedTags.some((tag) => tag === "archive" || tag === "historical" || tag === "completed")) return "cold";
|
|
91
|
+
if (input.kind === "advisor_brief" || input.kind === "memory_note") return "hot";
|
|
92
|
+
return "warm";
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery): boolean {
|
|
78
96
|
if (query.id && artifact.id !== query.id) return false;
|
|
79
97
|
if (query.handle && artifact.handle !== query.handle) return false;
|
|
80
98
|
if (query.sessionId && artifact.sessionId !== query.sessionId) return false;
|
|
81
99
|
if (query.kind && artifact.kind !== query.kind) return false;
|
|
82
100
|
if (query.branch && artifact.branch !== query.branch) return false;
|
|
101
|
+
if (query.tier && artifact.tier !== query.tier) return false;
|
|
83
102
|
if (query.tag && !artifact.tags.includes(query.tag)) return false;
|
|
84
103
|
if (query.path) {
|
|
85
104
|
const queryPath = query.path.replace(/\/$/, "");
|
|
@@ -98,23 +117,55 @@ function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery):
|
|
|
98
117
|
return true;
|
|
99
118
|
}
|
|
100
119
|
|
|
120
|
+
function tierLine(artifact: ContextArtifact): string {
|
|
121
|
+
const pin = artifact.pinned ? " pinned" : "";
|
|
122
|
+
const path = artifact.paths.length ? ` paths=${artifact.paths.slice(0, 3).join(",")}` : "";
|
|
123
|
+
const tags = artifact.tags.length ? ` tags=${artifact.tags.slice(0, 3).join(",")}` : "";
|
|
124
|
+
return `- ${artifact.handle} tier=${artifact.tier} kind=${artifact.kind}${pin}${path}${tags} summary="${artifact.summary}"`;
|
|
125
|
+
}
|
|
126
|
+
|
|
101
127
|
export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
|
|
102
128
|
const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
|
|
103
129
|
const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
104
130
|
const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
|
|
131
|
+
const tierTtlMs: Record<ContextArtifactTier, number> = {
|
|
132
|
+
hot: Math.max(0, Math.floor(options.hotTtlMs ?? defaultTtlMs)),
|
|
133
|
+
warm: Math.max(0, Math.floor(options.warmTtlMs ?? defaultTtlMs)),
|
|
134
|
+
cold: Math.max(0, Math.floor(options.coldTtlMs ?? defaultTtlMs)),
|
|
135
|
+
};
|
|
136
|
+
const tierMaxRecords: Record<ContextArtifactTier, number> = {
|
|
137
|
+
hot: Math.max(1, Math.floor(options.hotMaxRecords ?? maxRecords)),
|
|
138
|
+
warm: Math.max(1, Math.floor(options.warmMaxRecords ?? maxRecords)),
|
|
139
|
+
cold: Math.max(1, Math.floor(options.coldMaxRecords ?? maxRecords)),
|
|
140
|
+
};
|
|
141
|
+
const tierMaxBytes: Record<ContextArtifactTier, number> = {
|
|
142
|
+
hot: Math.max(1, Math.floor(options.hotMaxBytes ?? maxBytes)),
|
|
143
|
+
warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
|
|
144
|
+
cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
|
|
145
|
+
};
|
|
105
146
|
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
106
147
|
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
107
|
-
let artifacts: Array<ContextArtifact & { sequence: number }> = [];
|
|
148
|
+
let artifacts: Array<ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }> = [];
|
|
108
149
|
let sequence = 0;
|
|
109
150
|
|
|
110
151
|
function currentStatus(): ContextBrokerStatus {
|
|
111
152
|
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
112
153
|
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
154
|
+
const byTier = (tier: ContextArtifactTier) => artifacts.filter((artifact) => artifact.tier === tier);
|
|
155
|
+
const hot = byTier("hot");
|
|
156
|
+
const warm = byTier("warm");
|
|
157
|
+
const cold = byTier("cold");
|
|
113
158
|
return {
|
|
114
159
|
records: artifacts.length,
|
|
115
160
|
bytes,
|
|
116
161
|
pinnedRecords: pinned.length,
|
|
117
162
|
pinnedBytes: pinned.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
163
|
+
hotRecords: hot.length,
|
|
164
|
+
hotBytes: hot.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
165
|
+
warmRecords: warm.length,
|
|
166
|
+
warmBytes: warm.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
167
|
+
coldRecords: cold.length,
|
|
168
|
+
coldBytes: cold.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
118
169
|
maxRecords,
|
|
119
170
|
maxBytes,
|
|
120
171
|
};
|
|
@@ -126,27 +177,40 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
126
177
|
);
|
|
127
178
|
}
|
|
128
179
|
|
|
129
|
-
function
|
|
180
|
+
function removalCandidates(sessionId: string, protectedIds: Set<string>, tier?: ContextArtifactTier): Array<{ artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }; index: number }> {
|
|
130
181
|
return artifacts
|
|
131
182
|
.map((artifact, index) => ({ artifact, index }))
|
|
132
|
-
.filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id))
|
|
183
|
+
.filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id) && (!tier || artifact.tier === tier))
|
|
133
184
|
.sort((a, b) => {
|
|
185
|
+
if (!tier && TIER_REMOVAL_ORDER[a.artifact.tier] !== TIER_REMOVAL_ORDER[b.artifact.tier]) {
|
|
186
|
+
return TIER_REMOVAL_ORDER[a.artifact.tier] - TIER_REMOVAL_ORDER[b.artifact.tier];
|
|
187
|
+
}
|
|
134
188
|
if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
|
|
135
189
|
return a.artifact.sequence - b.artifact.sequence;
|
|
136
|
-
})
|
|
190
|
+
});
|
|
137
191
|
}
|
|
138
192
|
|
|
139
|
-
function
|
|
140
|
-
const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId);
|
|
141
|
-
|
|
193
|
+
function withinCaps(sessionId: string, tier?: ContextArtifactTier): boolean {
|
|
194
|
+
const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId && (!tier || artifact.tier === tier));
|
|
195
|
+
const recordsCap = tier ? tierMaxRecords[tier] : maxRecords;
|
|
196
|
+
const bytesCap = tier ? tierMaxBytes[tier] : maxBytes;
|
|
197
|
+
return sessionArtifacts.length <= recordsCap && sessionArtifacts.reduce((sum, artifact) => sum + artifact.bytes, 0) <= bytesCap;
|
|
142
198
|
}
|
|
143
199
|
|
|
144
200
|
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
145
201
|
dropExpired(now, protectedIds);
|
|
146
202
|
|
|
147
203
|
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
148
|
-
|
|
149
|
-
|
|
204
|
+
for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
|
|
205
|
+
while (!withinCaps(sessionId, tier)) {
|
|
206
|
+
const candidate = removalCandidates(sessionId, protectedIds, tier)[0];
|
|
207
|
+
if (!candidate) break;
|
|
208
|
+
artifacts.splice(candidate.index, 1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
while (!withinCaps(sessionId)) {
|
|
213
|
+
const candidate = removalCandidates(sessionId, protectedIds)[0];
|
|
150
214
|
if (!candidate) break;
|
|
151
215
|
artifacts.splice(candidate.index, 1);
|
|
152
216
|
}
|
|
@@ -160,6 +224,16 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
160
224
|
return currentStatus();
|
|
161
225
|
}
|
|
162
226
|
|
|
227
|
+
function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
|
|
228
|
+
dropExpired();
|
|
229
|
+
const keepPinned = options.keepPinned ?? true;
|
|
230
|
+
artifacts = artifacts.filter((artifact) => {
|
|
231
|
+
if (options.sessionId && artifact.sessionId !== options.sessionId) return true;
|
|
232
|
+
return keepPinned && artifact.pinned;
|
|
233
|
+
});
|
|
234
|
+
return currentStatus();
|
|
235
|
+
}
|
|
236
|
+
|
|
163
237
|
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
164
238
|
const now = input.createdAt ?? Date.now();
|
|
165
239
|
const payload = payloadText(input.payload);
|
|
@@ -169,10 +243,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
169
243
|
const id = `ctx-${now.toString(36)}-${String(artifactSequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
|
|
170
244
|
const session = safeName(input.sessionId || "session");
|
|
171
245
|
const kind = input.kind;
|
|
246
|
+
const tags = normalizeList(input.tags);
|
|
247
|
+
const baseTier = classifyBaseTier(input, tags);
|
|
248
|
+
const tier: ContextArtifactTier = input.pinned ? "hot" : baseTier;
|
|
172
249
|
const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
|
|
173
|
-
const ttlMs = input.ttlMs ??
|
|
250
|
+
const ttlMs = input.ttlMs ?? tierTtlMs[tier];
|
|
174
251
|
|
|
175
|
-
const artifact: ContextArtifact & { sequence: number } = {
|
|
252
|
+
const artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier } = {
|
|
176
253
|
id,
|
|
177
254
|
handle,
|
|
178
255
|
sessionId: input.sessionId,
|
|
@@ -183,14 +260,16 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
183
260
|
sha256,
|
|
184
261
|
payload,
|
|
185
262
|
summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
|
|
186
|
-
tags
|
|
263
|
+
tags,
|
|
187
264
|
paths: normalizeList(input.paths),
|
|
188
265
|
command: input.command?.trim() || undefined,
|
|
189
266
|
branch: input.branch?.trim() || undefined,
|
|
267
|
+
tier,
|
|
190
268
|
expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
|
|
191
269
|
pinned: Boolean(input.pinned),
|
|
192
270
|
parentIds: normalizeList(input.parentIds),
|
|
193
271
|
sequence: artifactSequence,
|
|
272
|
+
baseTier,
|
|
194
273
|
};
|
|
195
274
|
|
|
196
275
|
artifacts = [artifact, ...artifacts];
|
|
@@ -203,7 +282,10 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
203
282
|
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
204
283
|
return artifacts
|
|
205
284
|
.filter((artifact) => artifactMatches(artifact, query))
|
|
206
|
-
.sort((a, b) => Number(b.pinned) - Number(a.pinned)
|
|
285
|
+
.sort((a, b) => Number(b.pinned) - Number(a.pinned)
|
|
286
|
+
|| TIER_ORDER[a.tier] - TIER_ORDER[b.tier]
|
|
287
|
+
|| b.createdAt - a.createdAt
|
|
288
|
+
|| b.sequence - a.sequence)
|
|
207
289
|
.slice(0, limit);
|
|
208
290
|
}
|
|
209
291
|
|
|
@@ -212,6 +294,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
212
294
|
const artifact = artifacts.find((candidate) => candidate.id === idOrHandle || candidate.handle === idOrHandle) ?? null;
|
|
213
295
|
if (!artifact) return null;
|
|
214
296
|
artifact.pinned = pinned;
|
|
297
|
+
artifact.tier = pinned ? "hot" : artifact.baseTier;
|
|
215
298
|
artifact.updatedAt = Date.now();
|
|
216
299
|
prune();
|
|
217
300
|
return artifacts.find((candidate) => candidate.id === artifact.id) ?? null;
|
|
@@ -219,17 +302,25 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
219
302
|
|
|
220
303
|
function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
|
|
221
304
|
const budget = Math.max(64, Math.floor(query.budgetBytes ?? defaultBriefBytes));
|
|
305
|
+
const explicitCold = query.tier === "cold" || Boolean(query.handle || query.id);
|
|
306
|
+
const baseQuery = { ...query };
|
|
307
|
+
delete (baseQuery as { budgetBytes?: number }).budgetBytes;
|
|
308
|
+
const candidates = lookup({ ...baseQuery, limit: query.limit ?? 32 })
|
|
309
|
+
.filter((artifact) => explicitCold || artifact.tier !== "cold");
|
|
310
|
+
const hot = candidates.filter((artifact) => artifact.tier === "hot");
|
|
311
|
+
const warm = candidates.filter((artifact) => artifact.tier === "warm");
|
|
312
|
+
const cold = candidates.filter((artifact) => artifact.tier === "cold");
|
|
222
313
|
const lines = [
|
|
223
314
|
"## Context Broker",
|
|
224
315
|
`Budget: ${budget} bytes`,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
316
|
+
hot.length ? "Hot:" : "",
|
|
317
|
+
...hot.map(tierLine),
|
|
318
|
+
warm.length ? "Warm:" : "",
|
|
319
|
+
...warm.map(tierLine),
|
|
320
|
+
cold.length ? "Cold:" : "",
|
|
321
|
+
...cold.map(tierLine),
|
|
231
322
|
"Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
|
|
232
|
-
];
|
|
323
|
+
].filter(Boolean);
|
|
233
324
|
|
|
234
325
|
return truncateUtf8(lines.join("\n"), budget);
|
|
235
326
|
}
|
|
@@ -239,6 +330,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
239
330
|
lookup,
|
|
240
331
|
pin,
|
|
241
332
|
prune,
|
|
333
|
+
purge,
|
|
242
334
|
status,
|
|
243
335
|
renderBrief,
|
|
244
336
|
};
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
|
|
79
|
+
it("purges unpinned durable artifacts for a session", () => {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
81
|
+
try {
|
|
82
|
+
const path = join(dir, "artifacts.sqlite");
|
|
83
|
+
let broker = createSqliteContextBroker({ path, defaultTtlMs: 0 });
|
|
84
|
+
const scratch = broker.publish({ sessionId: "s", kind: "tool_output", payload: "scratch" });
|
|
85
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "keep", pinned: true });
|
|
86
|
+
const other = broker.publish({ sessionId: "other", kind: "tool_output", payload: "other" });
|
|
87
|
+
|
|
88
|
+
broker.purge({ sessionId: "s", keepPinned: true });
|
|
89
|
+
broker = createSqliteContextBroker({ path, defaultTtlMs: 0 });
|
|
90
|
+
|
|
91
|
+
expect(broker.lookup({ handle: scratch.handle })).toEqual([]);
|
|
92
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("keep");
|
|
93
|
+
expect(broker.lookup({ handle: other.handle })[0]?.payload).toBe("other");
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|