@fiale-plus/pi-rogue-bundle 0.1.16 → 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.
@@ -5,6 +5,7 @@ import type {
5
5
  ContextArtifact,
6
6
  ContextArtifactInput,
7
7
  ContextArtifactKind,
8
+ ContextArtifactTier,
8
9
  ContextBrokerOptions,
9
10
  ContextBrokerStatus,
10
11
  ContextLookupQuery,
@@ -15,6 +16,7 @@ export type {
15
16
  ContextArtifact,
16
17
  ContextArtifactInput,
17
18
  ContextArtifactKind,
19
+ ContextArtifactTier,
18
20
  ContextBrokerOptions,
19
21
  ContextBrokerStatus,
20
22
  ContextLookupQuery,
@@ -25,6 +27,8 @@ const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
25
27
  const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
26
28
  const DEFAULT_SUMMARY_BYTES = 320;
27
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 };
28
32
 
29
33
  function normalizeList(values: string[] | undefined): string[] {
30
34
  return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
@@ -74,12 +78,25 @@ function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKin
74
78
  return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
75
79
  }
76
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
+
77
93
  function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery): boolean {
78
94
  if (query.id && artifact.id !== query.id) return false;
79
95
  if (query.handle && artifact.handle !== query.handle) return false;
80
96
  if (query.sessionId && artifact.sessionId !== query.sessionId) return false;
81
97
  if (query.kind && artifact.kind !== query.kind) return false;
82
98
  if (query.branch && artifact.branch !== query.branch) return false;
99
+ if (query.tier && artifact.tier !== query.tier) return false;
83
100
  if (query.tag && !artifact.tags.includes(query.tag)) return false;
84
101
  if (query.path) {
85
102
  const queryPath = query.path.replace(/\/$/, "");
@@ -98,23 +115,55 @@ function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery):
98
115
  return true;
99
116
  }
100
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
+
101
125
  export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
102
126
  const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
103
127
  const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
104
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
+ };
105
144
  const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
106
145
  const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
107
- let artifacts: Array<ContextArtifact & { sequence: number }> = [];
146
+ let artifacts: Array<ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }> = [];
108
147
  let sequence = 0;
109
148
 
110
149
  function currentStatus(): ContextBrokerStatus {
111
150
  const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
112
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");
113
156
  return {
114
157
  records: artifacts.length,
115
158
  bytes,
116
159
  pinnedRecords: pinned.length,
117
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),
118
167
  maxRecords,
119
168
  maxBytes,
120
169
  };
@@ -126,27 +175,40 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
126
175
  );
127
176
  }
128
177
 
129
- function oldestRemovable(sessionId: string, protectedIds: Set<string>): { artifact: ContextArtifact & { sequence: number }; index: number } | undefined {
178
+ function removalCandidates(sessionId: string, protectedIds: Set<string>, tier?: ContextArtifactTier): Array<{ artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }; index: number }> {
130
179
  return artifacts
131
180
  .map((artifact, index) => ({ artifact, index }))
132
- .filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id))
181
+ .filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id) && (!tier || artifact.tier === tier))
133
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
+ }
134
186
  if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
135
187
  return a.artifact.sequence - b.artifact.sequence;
136
- })[0];
188
+ });
137
189
  }
138
190
 
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;
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;
142
196
  }
143
197
 
144
198
  function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
145
199
  dropExpired(now, protectedIds);
146
200
 
147
201
  for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
148
- while (!sessionWithinCaps(sessionId)) {
149
- const candidate = oldestRemovable(sessionId, protectedIds);
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];
150
212
  if (!candidate) break;
151
213
  artifacts.splice(candidate.index, 1);
152
214
  }
@@ -169,10 +231,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
169
231
  const id = `ctx-${now.toString(36)}-${String(artifactSequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
170
232
  const session = safeName(input.sessionId || "session");
171
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;
172
237
  const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
173
- const ttlMs = input.ttlMs ?? defaultTtlMs;
238
+ const ttlMs = input.ttlMs ?? tierTtlMs[tier];
174
239
 
175
- const artifact: ContextArtifact & { sequence: number } = {
240
+ const artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier } = {
176
241
  id,
177
242
  handle,
178
243
  sessionId: input.sessionId,
@@ -183,14 +248,16 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
183
248
  sha256,
184
249
  payload,
185
250
  summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
186
- tags: normalizeList(input.tags),
251
+ tags,
187
252
  paths: normalizeList(input.paths),
188
253
  command: input.command?.trim() || undefined,
189
254
  branch: input.branch?.trim() || undefined,
255
+ tier,
190
256
  expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
191
257
  pinned: Boolean(input.pinned),
192
258
  parentIds: normalizeList(input.parentIds),
193
259
  sequence: artifactSequence,
260
+ baseTier,
194
261
  };
195
262
 
196
263
  artifacts = [artifact, ...artifacts];
@@ -203,7 +270,10 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
203
270
  const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
204
271
  return artifacts
205
272
  .filter((artifact) => artifactMatches(artifact, query))
206
- .sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.createdAt - a.createdAt || b.sequence - a.sequence)
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)
207
277
  .slice(0, limit);
208
278
  }
209
279
 
@@ -212,6 +282,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
212
282
  const artifact = artifacts.find((candidate) => candidate.id === idOrHandle || candidate.handle === idOrHandle) ?? null;
213
283
  if (!artifact) return null;
214
284
  artifact.pinned = pinned;
285
+ artifact.tier = pinned ? "hot" : artifact.baseTier;
215
286
  artifact.updatedAt = Date.now();
216
287
  prune();
217
288
  return artifacts.find((candidate) => candidate.id === artifact.id) ?? null;
@@ -219,17 +290,25 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
219
290
 
220
291
  function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
221
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");
222
301
  const lines = [
223
302
  "## Context Broker",
224
303
  `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
- }),
304
+ hot.length ? "Hot:" : "",
305
+ ...hot.map(tierLine),
306
+ warm.length ? "Warm:" : "",
307
+ ...warm.map(tierLine),
308
+ cold.length ? "Cold:" : "",
309
+ ...cold.map(tierLine),
231
310
  "Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
232
- ];
311
+ ].filter(Boolean);
233
312
 
234
313
  return truncateUtf8(lines.join("\n"), budget);
235
314
  }
@@ -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
+ });