@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.
@@ -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 oldestRemovable(sessionId: string, protectedIds: Set<string>): { artifact: ContextArtifact & { sequence: number }; index: number } | undefined {
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
- })[0];
190
+ });
137
191
  }
138
192
 
139
- function sessionWithinCaps(sessionId: string): boolean {
140
- const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId);
141
- return sessionArtifacts.length <= maxRecords && sessionArtifacts.reduce((sum, artifact) => sum + artifact.bytes, 0) <= maxBytes;
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
- while (!sessionWithinCaps(sessionId)) {
149
- const candidate = oldestRemovable(sessionId, protectedIds);
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 ?? defaultTtlMs;
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: normalizeList(input.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) || b.createdAt - a.createdAt || b.sequence - a.sequence)
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
- ...lookup({ ...query, limit: query.limit ?? 8 }).map((artifact) => {
226
- const pin = artifact.pinned ? " pinned" : "";
227
- const path = artifact.paths.length ? ` paths=${artifact.paths.slice(0, 3).join(",")}` : "";
228
- const tags = artifact.tags.length ? ` tags=${artifact.tags.slice(0, 3).join(",")}` : "";
229
- return `- ${artifact.handle} kind=${artifact.kind}${pin}${path}${tags} summary="${artifact.summary}"`;
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
+ });