@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.
@@ -0,0 +1,500 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { safeName } from "@fiale-plus/pi-core";
7
+ import type {
8
+ BoundedContextBroker,
9
+ ContextArtifact,
10
+ ContextArtifactInput,
11
+ ContextArtifactKind,
12
+ ContextArtifactTier,
13
+ ContextBrokerOptions,
14
+ ContextBrokerStatus,
15
+ ContextLookupQuery,
16
+ } from "@fiale-plus/pi-core";
17
+
18
+ export interface SqliteContextBrokerOptions extends ContextBrokerOptions {
19
+ path?: string;
20
+ dir?: string;
21
+ }
22
+
23
+ const DEFAULT_MAX_RECORDS = 256;
24
+ const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
25
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
26
+ const DEFAULT_SUMMARY_BYTES = 320;
27
+ const DEFAULT_BRIEF_BYTES = 2_000;
28
+ const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
29
+ const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
30
+
31
+ function defaultStoreDir(): string {
32
+ return join(homedir(), ".pi", "agent", "fiale-plus", "context-broker");
33
+ }
34
+
35
+ function defaultSqlitePath(options: SqliteContextBrokerOptions): string {
36
+ return options.path ?? join(options.dir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR ?? defaultStoreDir(), "artifacts.sqlite");
37
+ }
38
+
39
+ function ensureParent(path: string): void {
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ }
42
+
43
+ function normalizeList(values: string[] | undefined): string[] {
44
+ return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
45
+ }
46
+
47
+ function payloadText(payload: string | Buffer): string {
48
+ return Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
49
+ }
50
+
51
+ function payloadBytes(payload: string | Buffer): number {
52
+ return Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(String(payload ?? ""), "utf8");
53
+ }
54
+
55
+ function hashPayload(payload: string | Buffer): string {
56
+ return createHash("sha256").update(Buffer.isBuffer(payload) ? payload : String(payload)).digest("hex");
57
+ }
58
+
59
+ function truncateUtf8(text: string, maxBytes: number): string {
60
+ const limit = Math.max(0, Math.floor(maxBytes));
61
+ if (Buffer.byteLength(text, "utf8") <= limit) return text;
62
+ if (limit === 0) return "";
63
+
64
+ const ellipsis = "…";
65
+ const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8");
66
+ const contentLimit = Math.max(0, limit - ellipsisBytes);
67
+ let used = 0;
68
+ let result = "";
69
+
70
+ for (const char of text) {
71
+ const bytes = Buffer.byteLength(char, "utf8");
72
+ if (used + bytes > contentLimit) break;
73
+ result += char;
74
+ used += bytes;
75
+ }
76
+
77
+ if (Buffer.byteLength(result + ellipsis, "utf8") <= limit) return result + ellipsis;
78
+ return result;
79
+ }
80
+
81
+ function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKind, bytes: number, sha256: string, maxBytes: number): string {
82
+ const cleaned = String(summary ?? "").replace(/\s+/g, " ").trim();
83
+ if (cleaned) return truncateUtf8(cleaned, maxBytes);
84
+ return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
85
+ }
86
+
87
+ function classifyBaseTier(input: ContextArtifactInput, tags: string[]): ContextArtifactTier {
88
+ if (input.tier) return input.tier;
89
+ const normalizedTags = tags.map((tag) => tag.toLowerCase());
90
+ if (normalizedTags.includes("hot")) return "hot";
91
+ if (normalizedTags.includes("warm")) return "warm";
92
+ if (normalizedTags.includes("cold")) return "cold";
93
+ if (normalizedTags.some((tag) => tag === "error" || tag === "failed" || tag === "failure")) return "hot";
94
+ if (normalizedTags.some((tag) => tag === "archive" || tag === "historical" || tag === "completed")) return "cold";
95
+ if (input.kind === "advisor_brief" || input.kind === "memory_note") return "hot";
96
+ return "warm";
97
+ }
98
+
99
+ function jsonList(value: string | null | undefined): string[] {
100
+ if (!value) return [];
101
+ try {
102
+ const parsed = JSON.parse(value);
103
+ return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : [];
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ function asNumber(value: unknown): number | undefined {
110
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
111
+ }
112
+
113
+ function rowToArtifact(row: Record<string, unknown>): ContextArtifact & { baseTier: ContextArtifactTier } {
114
+ return {
115
+ id: String(row.id),
116
+ handle: String(row.handle),
117
+ sessionId: String(row.sessionId),
118
+ kind: String(row.kind) as ContextArtifactKind,
119
+ createdAt: Number(row.createdAt),
120
+ updatedAt: Number(row.updatedAt),
121
+ bytes: Number(row.bytes),
122
+ sha256: String(row.sha256),
123
+ payload: String(row.payload ?? ""),
124
+ summary: String(row.summary ?? ""),
125
+ tags: jsonList(row.tagsJson as string | undefined),
126
+ paths: jsonList(row.pathsJson as string | undefined),
127
+ command: row.command == null ? undefined : String(row.command),
128
+ branch: row.branch == null ? undefined : String(row.branch),
129
+ tier: String(row.tier) as ContextArtifactTier,
130
+ expiresAt: row.expiresAt == null ? undefined : Number(row.expiresAt),
131
+ pinned: Boolean(row.pinned),
132
+ parentIds: jsonList(row.parentIdsJson as string | undefined),
133
+ baseTier: String(row.baseTier ?? row.tier) as ContextArtifactTier,
134
+ };
135
+ }
136
+
137
+ function tierLine(artifact: ContextArtifact): string {
138
+ const pin = artifact.pinned ? " pinned" : "";
139
+ const path = artifact.paths.length ? ` paths=${artifact.paths.slice(0, 3).join(",")}` : "";
140
+ const tags = artifact.tags.length ? ` tags=${artifact.tags.slice(0, 3).join(",")}` : "";
141
+ return `- ${artifact.handle} tier=${artifact.tier} kind=${artifact.kind}${pin}${path}${tags} summary="${artifact.summary}"`;
142
+ }
143
+
144
+ function escapeFtsTerm(term: string): string {
145
+ return `"${term.replace(/"/g, '""')}"`;
146
+ }
147
+
148
+ function ftsQuery(text: string): string {
149
+ return text.split(/\s+/).map((term) => term.trim()).filter(Boolean).map(escapeFtsTerm).join(" AND ");
150
+ }
151
+
152
+ function likePattern(text: string): string {
153
+ return `%${text.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
154
+ }
155
+
156
+ function stableSource(input: ContextArtifactInput): string | undefined {
157
+ return input.parentIds?.find(Boolean);
158
+ }
159
+
160
+ function initialize(db: DatabaseSync): void {
161
+ db.exec(`
162
+ PRAGMA journal_mode = WAL;
163
+ PRAGMA synchronous = NORMAL;
164
+ CREATE TABLE IF NOT EXISTS meta (
165
+ key TEXT PRIMARY KEY,
166
+ value TEXT NOT NULL
167
+ );
168
+ CREATE TABLE IF NOT EXISTS artifacts (
169
+ id TEXT PRIMARY KEY,
170
+ handle TEXT NOT NULL UNIQUE,
171
+ sessionId TEXT NOT NULL,
172
+ kind TEXT NOT NULL,
173
+ createdAt INTEGER NOT NULL,
174
+ updatedAt INTEGER NOT NULL,
175
+ bytes INTEGER NOT NULL,
176
+ sha256 TEXT NOT NULL,
177
+ payload TEXT NOT NULL,
178
+ summary TEXT NOT NULL,
179
+ tagsJson TEXT NOT NULL,
180
+ pathsJson TEXT NOT NULL,
181
+ command TEXT,
182
+ branch TEXT,
183
+ tier TEXT NOT NULL,
184
+ baseTier TEXT NOT NULL,
185
+ expiresAt INTEGER,
186
+ pinned INTEGER NOT NULL DEFAULT 0,
187
+ parentIdsJson TEXT NOT NULL
188
+ );
189
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(sessionId);
190
+ CREATE INDEX IF NOT EXISTS idx_artifacts_handle ON artifacts(handle);
191
+ CREATE INDEX IF NOT EXISTS idx_artifacts_kind ON artifacts(kind);
192
+ CREATE INDEX IF NOT EXISTS idx_artifacts_tier ON artifacts(tier);
193
+ CREATE INDEX IF NOT EXISTS idx_artifacts_created ON artifacts(createdAt);
194
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_fts USING fts5(id UNINDEXED, summary, payload, command, tags, paths);
195
+ `);
196
+ }
197
+
198
+ export function createSqliteContextBroker(options: SqliteContextBrokerOptions = {}): BoundedContextBroker {
199
+ const dbPath = defaultSqlitePath(options);
200
+ if (dbPath !== ":memory:" && !existsSync(dirname(dbPath))) ensureParent(dbPath);
201
+ const db = new DatabaseSync(dbPath);
202
+ initialize(db);
203
+
204
+ const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
205
+ const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
206
+ const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
207
+ const tierTtlMs: Record<ContextArtifactTier, number> = {
208
+ hot: Math.max(0, Math.floor(options.hotTtlMs ?? defaultTtlMs)),
209
+ warm: Math.max(0, Math.floor(options.warmTtlMs ?? defaultTtlMs)),
210
+ cold: Math.max(0, Math.floor(options.coldTtlMs ?? defaultTtlMs)),
211
+ };
212
+ const tierMaxRecords: Record<ContextArtifactTier, number> = {
213
+ hot: Math.max(1, Math.floor(options.hotMaxRecords ?? maxRecords)),
214
+ warm: Math.max(1, Math.floor(options.warmMaxRecords ?? maxRecords)),
215
+ cold: Math.max(1, Math.floor(options.coldMaxRecords ?? maxRecords)),
216
+ };
217
+ const tierMaxBytes: Record<ContextArtifactTier, number> = {
218
+ hot: Math.max(1, Math.floor(options.hotMaxBytes ?? maxBytes)),
219
+ warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
220
+ cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
221
+ };
222
+ const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
223
+ const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
224
+
225
+ function nextSequence(): number {
226
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'sequence'").get();
227
+ const next = Number(row?.value ?? 0) + 1;
228
+ db.prepare("INSERT INTO meta(key, value) VALUES('sequence', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(next));
229
+ return next;
230
+ }
231
+
232
+ function deleteArtifact(id: string): void {
233
+ db.prepare("DELETE FROM artifact_fts WHERE id = ?").run(id);
234
+ db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
235
+ }
236
+
237
+ function currentStatus(): ContextBrokerStatus {
238
+ const row = db.prepare(`
239
+ SELECT
240
+ COUNT(*) AS records,
241
+ COALESCE(SUM(bytes), 0) AS bytes,
242
+ COALESCE(SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END), 0) AS pinnedRecords,
243
+ COALESCE(SUM(CASE WHEN pinned = 1 THEN bytes ELSE 0 END), 0) AS pinnedBytes,
244
+ COALESCE(SUM(CASE WHEN tier = 'hot' THEN 1 ELSE 0 END), 0) AS hotRecords,
245
+ COALESCE(SUM(CASE WHEN tier = 'hot' THEN bytes ELSE 0 END), 0) AS hotBytes,
246
+ COALESCE(SUM(CASE WHEN tier = 'warm' THEN 1 ELSE 0 END), 0) AS warmRecords,
247
+ COALESCE(SUM(CASE WHEN tier = 'warm' THEN bytes ELSE 0 END), 0) AS warmBytes,
248
+ COALESCE(SUM(CASE WHEN tier = 'cold' THEN 1 ELSE 0 END), 0) AS coldRecords,
249
+ COALESCE(SUM(CASE WHEN tier = 'cold' THEN bytes ELSE 0 END), 0) AS coldBytes
250
+ FROM artifacts
251
+ `).get() ?? {};
252
+ return {
253
+ records: Number(row.records ?? 0),
254
+ bytes: Number(row.bytes ?? 0),
255
+ pinnedRecords: Number(row.pinnedRecords ?? 0),
256
+ pinnedBytes: Number(row.pinnedBytes ?? 0),
257
+ hotRecords: Number(row.hotRecords ?? 0),
258
+ hotBytes: Number(row.hotBytes ?? 0),
259
+ warmRecords: Number(row.warmRecords ?? 0),
260
+ warmBytes: Number(row.warmBytes ?? 0),
261
+ coldRecords: Number(row.coldRecords ?? 0),
262
+ coldBytes: Number(row.coldBytes ?? 0),
263
+ maxRecords,
264
+ maxBytes,
265
+ };
266
+ }
267
+
268
+ function dropExpired(now = Date.now(), protectedIds = new Set<string>()): void {
269
+ const rows = db.prepare("SELECT id FROM artifacts WHERE pinned = 0 AND expiresAt IS NOT NULL AND expiresAt <= ?").all(now);
270
+ for (const row of rows) {
271
+ const id = String(row.id);
272
+ if (!protectedIds.has(id)) deleteArtifact(id);
273
+ }
274
+ }
275
+
276
+ function capStats(sessionId: string, tier?: ContextArtifactTier): { records: number; bytes: number } {
277
+ const row = tier
278
+ ? db.prepare("SELECT COUNT(*) AS records, COALESCE(SUM(bytes), 0) AS bytes FROM artifacts WHERE sessionId = ? AND tier = ?").get(sessionId, tier)
279
+ : db.prepare("SELECT COUNT(*) AS records, COALESCE(SUM(bytes), 0) AS bytes FROM artifacts WHERE sessionId = ?").get(sessionId);
280
+ return { records: Number(row?.records ?? 0), bytes: Number(row?.bytes ?? 0) };
281
+ }
282
+
283
+ function withinCaps(sessionId: string, tier?: ContextArtifactTier): boolean {
284
+ const stats = capStats(sessionId, tier);
285
+ return stats.records <= (tier ? tierMaxRecords[tier] : maxRecords) && stats.bytes <= (tier ? tierMaxBytes[tier] : maxBytes);
286
+ }
287
+
288
+ function removalCandidate(sessionId: string, protectedIds: Set<string>, tier?: ContextArtifactTier): string | undefined {
289
+ const protectedList = [...protectedIds];
290
+ const protectedClause = protectedList.length ? `AND id NOT IN (${protectedList.map(() => "?").join(",")})` : "";
291
+ const tierClause = tier ? "AND tier = ?" : "";
292
+ const order = tier ? "createdAt ASC, rowid ASC" : "CASE tier WHEN 'cold' THEN 0 WHEN 'warm' THEN 1 ELSE 2 END ASC, createdAt ASC, rowid ASC";
293
+ const params = tier ? [sessionId, tier, ...protectedList] : [sessionId, ...protectedList];
294
+ const row = db.prepare(`SELECT id FROM artifacts WHERE sessionId = ? AND pinned = 0 ${tierClause} ${protectedClause} ORDER BY ${order} LIMIT 1`).get(...params);
295
+ return row?.id == null ? undefined : String(row.id);
296
+ }
297
+
298
+ function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
299
+ dropExpired(now, protectedIds);
300
+ const sessions = db.prepare("SELECT DISTINCT sessionId FROM artifacts").all().map((row) => String(row.sessionId));
301
+ for (const sessionId of sessions) {
302
+ for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
303
+ while (!withinCaps(sessionId, tier)) {
304
+ const id = removalCandidate(sessionId, protectedIds, tier);
305
+ if (!id) break;
306
+ deleteArtifact(id);
307
+ }
308
+ }
309
+
310
+ while (!withinCaps(sessionId)) {
311
+ const id = removalCandidate(sessionId, protectedIds);
312
+ if (!id) break;
313
+ deleteArtifact(id);
314
+ }
315
+ }
316
+ return currentStatus();
317
+ }
318
+
319
+ function status(): ContextBrokerStatus {
320
+ dropExpired();
321
+ return currentStatus();
322
+ }
323
+
324
+ function publish(input: ContextArtifactInput): ContextArtifact {
325
+ dropExpired();
326
+ const source = stableSource(input);
327
+ if (source) {
328
+ const existing = db.prepare("SELECT * FROM artifacts WHERE sessionId = ? AND parentIdsJson LIKE ? ESCAPE '\\' ORDER BY createdAt DESC, rowid DESC LIMIT 1")
329
+ .get(input.sessionId, likePattern(`"${source}"`));
330
+ if (existing) return rowToArtifact(existing);
331
+ }
332
+
333
+ const now = input.createdAt ?? Date.now();
334
+ const payload = payloadText(input.payload);
335
+ const sha256 = hashPayload(input.payload);
336
+ const bytes = payloadBytes(input.payload);
337
+ const tags = normalizeList(input.tags);
338
+ const paths = normalizeList(input.paths);
339
+ const parentIds = normalizeList(input.parentIds);
340
+ const baseTier = classifyBaseTier(input, tags);
341
+ const tier: ContextArtifactTier = input.pinned ? "hot" : baseTier;
342
+ const ttlMs = input.ttlMs ?? tierTtlMs[tier];
343
+ const sequence = nextSequence();
344
+ const id = `ctx-${now.toString(36)}-${String(sequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
345
+ const session = safeName(input.sessionId || "session");
346
+ const kind = input.kind;
347
+ const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
348
+ const artifact = {
349
+ id,
350
+ handle,
351
+ sessionId: input.sessionId,
352
+ kind,
353
+ createdAt: now,
354
+ updatedAt: now,
355
+ bytes,
356
+ sha256,
357
+ payload,
358
+ summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
359
+ tags,
360
+ paths,
361
+ command: input.command?.trim() || undefined,
362
+ branch: input.branch?.trim() || undefined,
363
+ tier,
364
+ baseTier,
365
+ expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
366
+ pinned: Boolean(input.pinned),
367
+ parentIds,
368
+ } satisfies ContextArtifact & { baseTier: ContextArtifactTier };
369
+
370
+ db.exec("BEGIN IMMEDIATE");
371
+ try {
372
+ db.prepare(`
373
+ INSERT INTO artifacts(id, handle, sessionId, kind, createdAt, updatedAt, bytes, sha256, payload, summary, tagsJson, pathsJson, command, branch, tier, baseTier, expiresAt, pinned, parentIdsJson)
374
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
375
+ `).run(
376
+ artifact.id,
377
+ artifact.handle,
378
+ artifact.sessionId,
379
+ artifact.kind,
380
+ artifact.createdAt,
381
+ artifact.updatedAt,
382
+ artifact.bytes,
383
+ artifact.sha256,
384
+ artifact.payload,
385
+ artifact.summary,
386
+ JSON.stringify(artifact.tags),
387
+ JSON.stringify(artifact.paths),
388
+ artifact.command ?? null,
389
+ artifact.branch ?? null,
390
+ artifact.tier,
391
+ artifact.baseTier,
392
+ artifact.expiresAt ?? null,
393
+ artifact.pinned ? 1 : 0,
394
+ JSON.stringify(artifact.parentIds),
395
+ );
396
+ db.prepare("INSERT INTO artifact_fts(id, summary, payload, command, tags, paths) VALUES (?, ?, ?, ?, ?, ?)").run(
397
+ artifact.id,
398
+ artifact.summary,
399
+ artifact.payload,
400
+ artifact.command ?? "",
401
+ artifact.tags.join(" "),
402
+ artifact.paths.join(" "),
403
+ );
404
+ db.exec("COMMIT");
405
+ } catch (error) {
406
+ db.exec("ROLLBACK");
407
+ throw error;
408
+ }
409
+
410
+ prune(now, new Set([artifact.id]));
411
+ return artifact;
412
+ }
413
+
414
+ function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
415
+ dropExpired();
416
+ const storedCount = Number(db.prepare("SELECT COUNT(*) AS count FROM artifacts").get()?.count ?? 1) || 1;
417
+ const limit = Math.max(1, Math.floor(query.limit ?? storedCount));
418
+ const clauses: string[] = [];
419
+ const params: Array<string | number> = [];
420
+ let joinFts = false;
421
+
422
+ if (query.id) { clauses.push("a.id = ?"); params.push(query.id); }
423
+ if (query.handle) { clauses.push("a.handle = ?"); params.push(query.handle); }
424
+ if (query.sessionId) { clauses.push("a.sessionId = ?"); params.push(query.sessionId); }
425
+ if (query.kind) { clauses.push("a.kind = ?"); params.push(query.kind); }
426
+ if (query.branch) { clauses.push("a.branch = ?"); params.push(query.branch); }
427
+ if (query.tier) { clauses.push("a.tier = ?"); params.push(query.tier); }
428
+ if (query.tag) { clauses.push("a.tagsJson LIKE ? ESCAPE '\\'"); params.push(likePattern(`"${query.tag}"`)); }
429
+ if (query.path) { clauses.push("(a.pathsJson LIKE ? ESCAPE '\\' OR a.pathsJson LIKE ? ESCAPE '\\')"); params.push(likePattern(`"${query.path}"`), likePattern(`"${query.path.replace(/\/$/, "")}/`)); }
430
+ if (query.commandPrefix) { clauses.push("a.command LIKE ? ESCAPE '\\'"); params.push(`${query.commandPrefix.replace(/[\\%_]/g, (char) => `\\${char}`)}%`); }
431
+ if (query.text?.trim()) {
432
+ joinFts = true;
433
+ clauses.push("artifact_fts MATCH ?");
434
+ params.push(ftsQuery(query.text.trim()));
435
+ }
436
+
437
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
438
+ const sql = `
439
+ SELECT a.* FROM artifacts a
440
+ ${joinFts ? "JOIN artifact_fts ON artifact_fts.id = a.id" : ""}
441
+ ${where}
442
+ ORDER BY a.pinned DESC,
443
+ CASE a.tier WHEN 'hot' THEN ${TIER_ORDER.hot} WHEN 'warm' THEN ${TIER_ORDER.warm} ELSE ${TIER_ORDER.cold} END ASC,
444
+ a.createdAt DESC,
445
+ a.rowid DESC
446
+ LIMIT ?
447
+ `;
448
+
449
+ try {
450
+ return db.prepare(sql).all(...params, limit).map(rowToArtifact);
451
+ } catch {
452
+ if (!query.text?.trim()) return [];
453
+ const fallbackQuery = { ...query, text: undefined };
454
+ const text = query.text.toLowerCase();
455
+ return lookup(fallbackQuery).filter((artifact) => [artifact.summary, artifact.payload, artifact.command, artifact.tags.join(" "), artifact.paths.join(" ")].join("\n").toLowerCase().includes(text)).slice(0, limit);
456
+ }
457
+ }
458
+
459
+ function pin(idOrHandle: string, pinned = true): ContextArtifact | null {
460
+ dropExpired();
461
+ const artifact = lookup(idOrHandle.startsWith("ctx://") ? { handle: idOrHandle } : { id: idOrHandle })[0] as (ContextArtifact & { baseTier?: ContextArtifactTier }) | undefined;
462
+ if (!artifact) return null;
463
+ const nextTier: ContextArtifactTier = pinned ? "hot" : artifact.baseTier ?? artifact.tier;
464
+ const updatedAt = Date.now();
465
+ db.prepare("UPDATE artifacts SET pinned = ?, tier = ?, updatedAt = ? WHERE id = ?").run(pinned ? 1 : 0, nextTier, updatedAt, artifact.id);
466
+ prune();
467
+ return lookup({ id: artifact.id })[0] ?? null;
468
+ }
469
+
470
+ function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
471
+ const budget = Math.max(64, Math.floor(query.budgetBytes ?? defaultBriefBytes));
472
+ const explicitCold = query.tier === "cold" || Boolean(query.handle || query.id);
473
+ const baseQuery = { ...query };
474
+ delete (baseQuery as { budgetBytes?: number }).budgetBytes;
475
+ const candidates = lookup({ ...baseQuery, limit: query.limit ?? 32 })
476
+ .filter((artifact) => explicitCold || artifact.tier !== "cold");
477
+ const hot = candidates.filter((artifact) => artifact.tier === "hot");
478
+ const warm = candidates.filter((artifact) => artifact.tier === "warm");
479
+ const cold = candidates.filter((artifact) => artifact.tier === "cold");
480
+ const lines = [
481
+ "## Context Broker",
482
+ `Budget: ${budget} bytes`,
483
+ hot.length ? "Hot:" : "",
484
+ ...hot.map(tierLine),
485
+ warm.length ? "Warm:" : "",
486
+ ...warm.map(tierLine),
487
+ cold.length ? "Cold:" : "",
488
+ ...cold.map(tierLine),
489
+ "Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
490
+ ].filter(Boolean);
491
+
492
+ return truncateUtf8(lines.join("\n"), budget);
493
+ }
494
+
495
+ return { publish, lookup, pin, prune, status, renderBrief };
496
+ }
497
+
498
+ export function contextBrokerSqlitePathForSession(baseDir: string, sessionId: string): string {
499
+ return join(baseDir, safeName(sessionId), "artifacts.sqlite");
500
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiale-plus/pi-rogue-bundle",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Public Pi-Rogue bundle for advisor, orchestration, and beta context broker. Single consolidated artefact (leaf releases paused; private packages are bundled here).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,7 +17,9 @@
17
17
  "main": "./src/index.ts",
18
18
  "exports": {
19
19
  ".": "./src/index.ts",
20
- "./context-broker": "./src/context-broker.ts"
20
+ "./context-broker": "./src/context-broker.ts",
21
+ "./context-broker/file": "./src/context-broker-file.ts",
22
+ "./context-broker/sqlite": "./src/context-broker-sqlite.ts"
21
23
  },
22
24
  "pi": {
23
25
  "extensions": [
@@ -0,0 +1 @@
1
+ export * from "@fiale-plus/pi-rogue-context-broker/file";
@@ -0,0 +1 @@
1
+ export * from "@fiale-plus/pi-rogue-context-broker/sqlite";
@@ -60,4 +60,9 @@ describe("bundle context-broker export", () => {
60
60
  expect(artifact.handle).toContain("ctx://session/bundle-test/memory_note/");
61
61
  expect(broker.lookup({ handle: artifact.handle })).toEqual([artifact]);
62
62
  });
63
+
64
+ it("exposes the durable sqlite backend through a bundle subpath", async () => {
65
+ const sqlite = await import("./context-broker-sqlite.js");
66
+ expect(sqlite.createSqliteContextBroker).toBeTypeOf("function");
67
+ });
63
68
  });
package/src/extension.ts CHANGED
@@ -13,13 +13,13 @@ export async function registerBundle(pi: ExtensionAPI): Promise<void> {
13
13
  if (p.__piRogueBundleRegistered) return;
14
14
  p.__piRogueBundleRegistered = true;
15
15
 
16
- registerAdvisor(pi);
17
- registerOrchestration(pi);
18
-
19
16
  if (contextBrokerBetaEnabled()) {
20
17
  const { registerContextBrokerBeta } = await import("@fiale-plus/pi-rogue-context-broker/extension");
21
18
  registerContextBrokerBeta(pi);
22
19
  }
20
+
21
+ registerAdvisor(pi);
22
+ registerOrchestration(pi);
23
23
  }
24
24
 
25
25
  export default function bundleExtension(pi: ExtensionAPI): Promise<void> {