@fiale-plus/pi-rogue 0.2.2 → 0.2.4
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/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/README.md +1 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.test.ts +8 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.ts +7 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.test.ts +26 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.ts +10 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +20 -2
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +81 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +72 -10
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/README.md +3 -3
- package/node_modules/@fiale-plus/pi-rogue-orchestration/package.json +3 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/skills/orchestration/SKILL.md +3 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.test.ts +65 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +84 -4
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/loop.ts +3 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.test.ts +43 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.ts +96 -11
- package/node_modules/@fiale-plus/pi-rogue-router/README.md +46 -5
- package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.test.ts +88 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.ts +232 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +9 -1
- package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +123 -9
- package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +39 -16
- package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +145 -6
- package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +51 -11
- package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +67 -7
- package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +27 -12
- package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +87 -9
- package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +130 -6
- package/node_modules/@fiale-plus/pi-rogue-router/src/reports.test.ts +92 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/reports.ts +116 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.test.ts +223 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.ts +344 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.test.ts +126 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.ts +238 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +59 -2
- package/package.json +1 -1
|
@@ -72,6 +72,8 @@ export interface ContextBrokerStatus {
|
|
|
72
72
|
coldBytes: number;
|
|
73
73
|
maxRecords: number;
|
|
74
74
|
maxBytes: number;
|
|
75
|
+
globalMaxRecords: number;
|
|
76
|
+
globalMaxBytes: number;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export interface ContextBrokerOptions {
|
|
@@ -89,6 +91,8 @@ export interface ContextBrokerOptions {
|
|
|
89
91
|
hotMaxBytes?: number;
|
|
90
92
|
warmMaxBytes?: number;
|
|
91
93
|
coldMaxBytes?: number;
|
|
94
|
+
hotToWarmMs?: number;
|
|
95
|
+
warmToColdMs?: number;
|
|
92
96
|
summaryBytes?: number;
|
|
93
97
|
briefBytes?: number;
|
|
94
98
|
}
|
|
@@ -8,6 +8,7 @@ Strategic advisor for Pi sessions with low-overhead preflight/post-review routin
|
|
|
8
8
|
|
|
9
9
|
- SOTA-first model fallback: `gpt-5.5`/`claude-opus-4-6`/`claude-sonnet-4-6` where available.
|
|
10
10
|
- Keeps command-level behavior simple and explicit.
|
|
11
|
+
- Router/binary-gate policy escalates architecture/refactor/tradeoff/security/high-uncertainty and material stuck/no-progress work, while tiny edits, direct answers, docs/formatting cleanup, and other low-risk reactive tasks continue without advisor noise.
|
|
11
12
|
|
|
12
13
|
## Install
|
|
13
14
|
|
|
@@ -16,4 +16,12 @@ describe("binary gate feature extraction", () => {
|
|
|
16
16
|
expect(features.get("safety:production")).toBe(1);
|
|
17
17
|
expect(features.get("safety:deploy")).toBe(1);
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
it("emits stuck/no-progress cues for the binary gate", () => {
|
|
21
|
+
const features = extractBinaryGateFeatureCounts("goal loop stuck with repeated planning and no concrete progress");
|
|
22
|
+
|
|
23
|
+
expect(features.get("stuck:stuck")).toBe(1);
|
|
24
|
+
expect(features.get("stuck:repeated_planning")).toBe(1);
|
|
25
|
+
expect(features.get("stuck:no_concrete_progress")).toBe(1);
|
|
26
|
+
});
|
|
19
27
|
});
|
|
@@ -160,6 +160,13 @@ export function extractBinaryGateFeatureCounts(text: string): Map<string, number
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
const stuckWords = ["stuck", "looping", "spinning", "no progress", "no concrete progress", "same failure", "repeated failure", "repeated planning", "self talk", "forever thinking", "alternative action", "blocked"];
|
|
164
|
+
for (const stuckWord of stuckWords) {
|
|
165
|
+
if (lower.includes(stuckWord)) {
|
|
166
|
+
inc(counts, `stuck:${replaceSpaces(stuckWord)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
const contextWords = ["need more context", "missing context", "clarify", "not enough info", "unspecified", "unknown", "ambiguous"];
|
|
164
171
|
for (const contextWord of contextWords) {
|
|
165
172
|
if (lower.includes(contextWord)) {
|
|
@@ -36,6 +36,32 @@ describe("advisor router heuristics", () => {
|
|
|
36
36
|
expect(routeNote(route)).toMatch(/^\[advisor:rules: review, reason: [a-z0-9 ,.'-]+\]$/);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
it("escalates material stuck/no-progress prompts", () => {
|
|
40
|
+
const input: AdvisorRouteInput = { phase: "preflight", text: "the goal loop is stuck with repeated planning and no concrete progress after several turns" };
|
|
41
|
+
const route = heuristicRoute(input);
|
|
42
|
+
|
|
43
|
+
expect(route.label).toBe("escalate_to_advisor");
|
|
44
|
+
expect(route.escalate).toBe(true);
|
|
45
|
+
expect(route.reason).toContain("no-progress");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("escalates stuck evidence even when low-risk edit cues are present", () => {
|
|
49
|
+
const input: AdvisorRouteInput = { phase: "preflight", text: "small README edit is stuck with no concrete progress after several turns" };
|
|
50
|
+
const route = heuristicRoute(input);
|
|
51
|
+
|
|
52
|
+
expect(route.label).toBe("escalate_to_advisor");
|
|
53
|
+
expect(route.reason).toContain("no-progress");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps routine docs cleanup out of advisor escalation", () => {
|
|
57
|
+
const input: AdvisorRouteInput = { phase: "preflight", text: "routine docs and formatting cleanup in README" };
|
|
58
|
+
const route = heuristicRoute(input);
|
|
59
|
+
|
|
60
|
+
expect(route.label).toBe("continue");
|
|
61
|
+
expect(route.escalate).toBe(false);
|
|
62
|
+
expect(route.review).toBe("off");
|
|
63
|
+
});
|
|
64
|
+
|
|
39
65
|
it("flags safety-sensitive prompts", () => {
|
|
40
66
|
const input: AdvisorRouteInput = { phase: "preflight", text: "run rm -rf on prod" };
|
|
41
67
|
const route = heuristicRoute(input);
|
|
@@ -125,6 +125,7 @@ const QUICK_EDIT_RE = /\b(quick edit|small edit|tiny edit|rename|format(?:ting)?
|
|
|
125
125
|
const ROUTINE_CLEANUP_RE = /\b(routine docs?|docs? and formatting|formatting cleanup|generated changes|large diff|docs?\/formatting)\b/i;
|
|
126
126
|
const COMPLEX_RE = /\b(architecture|architectural|refactor|design|trade[- ]?off|concurrency|security|auth|migration|performance|scale|scalability|framework|system design|schema|data model|protocol|advisor routing|advisor flow|router logic|call vs skip|skip vs call|compare|recommend|benchmark|evaluate|experiment|train|strategy|choose|make sense|worth(?: it)?|kpi|kpis|how it works|where it comes from|what would you choose|what do you think|next step|pick between|buy|usage|sustained speed|available models|running model kpis)\b/i;
|
|
127
127
|
const DEBUG_RE = /\b(debug|bug|error|stack trace|traceback|fail(?:ed|ure)?|broken|investigate|why is|cannot|can't|crash|regression)\b/i;
|
|
128
|
+
const STUCK_RE = /\b(stuck|looping|spinning|no[- ]?progress|no concrete progress|same failure|repeated failure|repeated planning|self[- ]?talk|forever thinking|strategy change|alternative action|blocked)\b/i;
|
|
128
129
|
const CONTEXT_RE = /\b(need more context|missing context|clarify|not enough info|unspecified|unknown|ambiguous)\b/i;
|
|
129
130
|
const SAFETY_RE = /\b(rm\s+-rf|sudo\b|shutdown\b|reboot\b|mkfs(?:\.[\w-]+)?\b|chmod\s+-R\b|chown\b|git\s+push\b[\s\S]*--force(?:-with-lease)?|curl\b[\s\S]*\|\s*(?:sh|bash)\b|wget\b[\s\S]*\|\s*(?:sh|bash)\b|drop\s+table\b|delete\s+database\b|credential\b|password\b|secret\b)\b/i;
|
|
130
131
|
const COMPACTION_RE = /\b(compact(?:ed|ion)?|missing history|history might flip|prior constraint|resume(?:d)? after compaction)\b/i;
|
|
@@ -293,6 +294,11 @@ function hasComplexSignal(text: string): boolean {
|
|
|
293
294
|
return COMPLEX_RE.test(text) || DEBUG_RE.test(text);
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
function hasMaterialStuckSignal(text: string): boolean {
|
|
298
|
+
if (!STUCK_RE.test(text)) return false;
|
|
299
|
+
return /\b(goal|loop|autoresearch|tool|test|command|failure|failed|turns?|again|same|repeated|concrete|progress|blocked|alternative|recovery)\b/i.test(text);
|
|
300
|
+
}
|
|
301
|
+
|
|
296
302
|
function hasCompactionLowRiskSignal(text: string): boolean {
|
|
297
303
|
return COMPACTION_RE.test(text) && /\blow[- ]?risk\b/i.test(text);
|
|
298
304
|
}
|
|
@@ -342,6 +348,9 @@ function preflightSignals(input: AdvisorRouteInput): { label: PreflightLabel; co
|
|
|
342
348
|
if (isSafetySensitive(text)) {
|
|
343
349
|
return { label: "escalate_to_advisor", confidence: 0.98, reason: "Safety-sensitive keywords detected.", safety: true };
|
|
344
350
|
}
|
|
351
|
+
if (hasMaterialStuckSignal(text)) {
|
|
352
|
+
return { label: "escalate_to_advisor", confidence: 0.86, reason: "Material stuck/no-progress signal detected.", safety: false };
|
|
353
|
+
}
|
|
345
354
|
if (hasRoutineCleanupSignal(text) || (hasQuickEditSignal(text) && !hasComplexSignal(text))) {
|
|
346
355
|
return { label: "continue", confidence: 0.9, reason: "Small-edit or routine-cleanup signal detected.", safety: false };
|
|
347
356
|
}
|
|
@@ -424,7 +433,7 @@ export function buildRouterPrompt(input: AdvisorRouteInput): string {
|
|
|
424
433
|
input.failed !== undefined ? `Failed: ${String(input.failed)}` : "",
|
|
425
434
|
phase === "preflight"
|
|
426
435
|
? [
|
|
427
|
-
"Guidance: continue for tiny edits and
|
|
436
|
+
"Guidance: continue for tiny edits, direct answers, docs/formatting cleanup, and other low-risk reactive tasks; escalate_to_advisor for architecture, refactors, design, tradeoffs, security, irreversible actions, high uncertainty, or material stuck/no-progress evidence; need_more_context when underspecified; low_confidence when mixed signals. If advisor guidance conflicts with local evidence, the working model must reconcile explicitly rather than blindly follow it.",
|
|
428
437
|
].join(" ")
|
|
429
438
|
: [
|
|
430
439
|
"Guidance: on_track for clearly complete work; course_correct for partial work that needs changes; not_done when incomplete or failing; abstain when there is not enough signal.",
|
|
@@ -8,6 +8,7 @@ This package contains the executable in-memory bounded broker implementation:
|
|
|
8
8
|
- Lookups support handle, session, kind, tag, path, command prefix, branch, tier, and text filters.
|
|
9
9
|
- Omitted summaries become metadata-only placeholders, keeping raw payloads out of prompt briefs by default.
|
|
10
10
|
- Artifacts are classified as hot/warm/cold on publish; prompt briefs render hot first, warm second, and exclude cold unless explicitly queried.
|
|
11
|
+
- Aging cools unpinned artifacts from hot to warm and from warm to cold; compaction remains cleanup/removal, not a separate cooling trigger.
|
|
11
12
|
- Pruning enforces per-session record/byte caps, optional global (cross-session) record/byte caps, tier-specific caps, TTL expiry on reads, and pinned-artifact retention.
|
|
12
13
|
|
|
13
14
|
It is registered by default in the bundle, with an explicit env kill switch.
|
|
@@ -29,14 +30,30 @@ When active, the bundle registers:
|
|
|
29
30
|
- `/context export <handle>` — write full payload to a temp file without dumping it into prompt.
|
|
30
31
|
- `/context prune` — run TTL/cap pruning immediately.
|
|
31
32
|
|
|
32
|
-
The command includes autocomplete for subcommands and known artifact handles. Exact handle lookup returns clipped payload text; text search returns a smaller clipped excerpt, and truncation is marked explicitly.
|
|
33
|
+
The command includes autocomplete for subcommands and known artifact handles. Exact handle lookup returns clipped payload text; text search returns a smaller clipped excerpt, and truncation is marked explicitly. Exact-handle misses and text/filter misses use distinct messages, and `/context status` reports exact/text miss counters.
|
|
33
34
|
|
|
34
|
-
Optional durability is available with `PI_CONTEXT_BROKER_DURABLE=true` or `PI_CONTEXT_BROKER_STORE_DIR=/path/to/store`. Durable mode now defaults to SQLite (`artifacts.sqlite`) with an FTS index for text lookup, so exact handles, tier, and pin state survive restarts without replay reconstruction. Set `PI_CONTEXT_BROKER_BACKEND=jsonl` to use the legacy JSONL/blob backend.
|
|
35
|
+
Optional durability is available with `PI_CONTEXT_BROKER_DURABLE=true` or `PI_CONTEXT_BROKER_STORE_DIR=/path/to/store`. Durable mode now defaults to SQLite (`artifacts.sqlite`) with an FTS index for text lookup, so exact handles, tier, and pin state survive restarts without replay reconstruction. Set `PI_CONTEXT_BROKER_BACKEND=jsonl` to use the legacy JSONL/blob backend. Durable mode applies default global retention caps when env caps are not set: 2,048 records and 256 MiB across sessions.
|
|
35
36
|
|
|
36
37
|
- `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` controls when large `toolResult` / `bashExecution` payloads are rewritten in-context. The default is `8192` bytes, so small tool evidence remains inline while larger outputs are replaced by handles.
|
|
38
|
+
- `PI_CONTEXT_BROKER_HOT_TO_WARM_MS` controls unpinned artifact cooling from hot to warm. The default is 2 hours.
|
|
39
|
+
- `PI_CONTEXT_BROKER_WARM_TO_COLD_MS` controls unpinned artifact cooling from warm/hot to cold. The default is 12 hours.
|
|
37
40
|
|
|
38
41
|
For more aggressive prompt reduction, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES=0`. For quieter sessions, set it to a higher value to only rewrite larger outputs.
|
|
39
42
|
|
|
43
|
+
## Tier lifecycle policy
|
|
44
|
+
|
|
45
|
+
- Publish-time classification remains deterministic: explicit `tier`, `hot`/`warm`/`cold` tags, error tags, completed/archive tags, and artifact kind choose the base tier.
|
|
46
|
+
- Cooling only retiers unpinned artifacts for prompt visibility and cap pressure; it does not delete payloads.
|
|
47
|
+
- Pinned artifacts stay hot and are retained through compaction cleanup.
|
|
48
|
+
- Cold artifacts remain retrievable by exact handle/search, but are excluded from the default prompt brief unless explicitly queried.
|
|
49
|
+
- `/compact` / `session_compact` cleanup purges unpinned artifacts for the session; cooling is age-based and separate from compaction cleanup.
|
|
50
|
+
|
|
51
|
+
## Payload display policy
|
|
52
|
+
|
|
53
|
+
- Hostile/binary payloads are unsafe or control-heavy. They are stored/exportable but omitted from prompt lookup output with `/context export` guidance.
|
|
54
|
+
- Opaque payloads are printable but low-value/high-token, such as large base64-like blobs, hex dumps, minified single-line output, or compressed-looking text. They are also stored/exportable but omitted from prompt lookup output with `/context export` guidance.
|
|
55
|
+
- Normal code, logs, and test output remain visible subject to normal byte clipping.
|
|
56
|
+
|
|
40
57
|
|
|
41
58
|
## Session behavior and limits
|
|
42
59
|
|
|
@@ -51,4 +68,5 @@ For more aggressive prompt reduction, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_B
|
|
|
51
68
|
- Optional global caps can be configured via env vars:
|
|
52
69
|
- `PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS`
|
|
53
70
|
- `PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES`
|
|
71
|
+
- Durable mode defaults to global caps of 2,048 records and 256 MiB when those env vars are unset; in-memory mode remains per-session capped unless global caps are explicitly provided.
|
|
54
72
|
- Rollback is immediate: set `PI_CONTEXT_BROKER_ENABLED=false` and `/reload` or restart Pi. Disable durable writes by unsetting `PI_CONTEXT_BROKER_DURABLE` and `PI_CONTEXT_BROKER_STORE_DIR`.
|
|
@@ -287,6 +287,57 @@ lookupBytes: 80, searchBytes: 50,
|
|
|
287
287
|
expect(commandMessage).not.toContain("\u0000");
|
|
288
288
|
});
|
|
289
289
|
|
|
290
|
+
it("omits opaque printable payloads from lookup output and suggests export", async () => {
|
|
291
|
+
const { pi, handlers, commands } = createPiMock();
|
|
292
|
+
registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1 });
|
|
293
|
+
const { ctx, notifications } = createCtx();
|
|
294
|
+
const payload = "A".repeat(6000);
|
|
295
|
+
|
|
296
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
297
|
+
await runHandlers(handlers, "tool_result", {
|
|
298
|
+
type: "tool_result",
|
|
299
|
+
toolCallId: "call-opaque",
|
|
300
|
+
toolName: "bash",
|
|
301
|
+
input: { command: "printf opaque" },
|
|
302
|
+
content: [{ type: "text", text: payload }],
|
|
303
|
+
isError: false,
|
|
304
|
+
}, ctx);
|
|
305
|
+
|
|
306
|
+
const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
307
|
+
const lookupHandle = lookupCompletion?.value.replace(/^lookup /, "");
|
|
308
|
+
|
|
309
|
+
await commands.get("context").handler(`lookup ${lookupHandle}`, ctx);
|
|
310
|
+
const commandMessage = notifications.at(-1)?.message ?? "";
|
|
311
|
+
expect(commandMessage).toContain("payload omitted from prompt because it appears opaque/high-token");
|
|
312
|
+
expect(commandMessage).toContain("/context export");
|
|
313
|
+
expect(commandMessage).not.toContain(payload.slice(0, 200));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("does not classify normal multiline code as opaque", async () => {
|
|
317
|
+
const { pi, handlers, commands } = createPiMock();
|
|
318
|
+
registerContextBrokerBeta(pi, { lookupBytes: 8000, rewriteThresholdBytes: 1 });
|
|
319
|
+
const { ctx, notifications } = createCtx();
|
|
320
|
+
const payload = Array.from({ length: 240 }, (_, index) => `export function fn${index}() { return ${index}; }`).join("\n");
|
|
321
|
+
|
|
322
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
323
|
+
await runHandlers(handlers, "tool_result", {
|
|
324
|
+
type: "tool_result",
|
|
325
|
+
toolCallId: "call-code",
|
|
326
|
+
toolName: "read",
|
|
327
|
+
input: { path: "src/generated.ts" },
|
|
328
|
+
content: [{ type: "text", text: payload }],
|
|
329
|
+
isError: false,
|
|
330
|
+
}, ctx);
|
|
331
|
+
|
|
332
|
+
const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
333
|
+
const lookupHandle = lookupCompletion?.value.replace(/^lookup /, "");
|
|
334
|
+
|
|
335
|
+
await commands.get("context").handler(`lookup ${lookupHandle}`, ctx);
|
|
336
|
+
const commandMessage = notifications.at(-1)?.message ?? "";
|
|
337
|
+
expect(commandMessage).toContain("export function fn0");
|
|
338
|
+
expect(commandMessage).not.toContain("payload omitted from prompt because it appears opaque/high-token");
|
|
339
|
+
});
|
|
340
|
+
|
|
290
341
|
it("text search lookup returns a smaller byte-clipped excerpt", async () => {
|
|
291
342
|
const { pi, handlers, commands } = createPiMock();
|
|
292
343
|
registerContextBrokerBeta(pi, {
|
|
@@ -360,6 +411,27 @@ lookupBytes: 500,
|
|
|
360
411
|
expect(result.content[0].text).toContain("exact evidence payload");
|
|
361
412
|
});
|
|
362
413
|
|
|
414
|
+
it("distinguishes exact-handle and text lookup misses", async () => {
|
|
415
|
+
const { pi, handlers, commands, tools } = createPiMock();
|
|
416
|
+
registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1 });
|
|
417
|
+
const { ctx, notifications } = createCtx();
|
|
418
|
+
|
|
419
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
420
|
+
const toolExactMiss = await tools.get("context_lookup").execute("lookup-missing-handle", { handle: "ctx://missing/handle" }, undefined, undefined, ctx);
|
|
421
|
+
const toolTextMiss = await tools.get("context_lookup").execute("lookup-missing-text", { text: "definitely absent" }, undefined, undefined, ctx);
|
|
422
|
+
await commands.get("context").handler("lookup ctx://missing/handle", ctx);
|
|
423
|
+
await commands.get("context").handler("lookup definitely absent", ctx);
|
|
424
|
+
await commands.get("context").handler("status", ctx);
|
|
425
|
+
|
|
426
|
+
expect(toolExactMiss.content[0].text).toContain("exact handle");
|
|
427
|
+
expect(toolTextMiss.content[0].text).toContain("text/filter query");
|
|
428
|
+
expect(notifications.at(-4)?.message).toContain("exact handle");
|
|
429
|
+
expect(notifications.at(-3)?.message).toContain("text/filter query");
|
|
430
|
+
const telemetry = notifications.at(-1)?.message ?? "";
|
|
431
|
+
expect(telemetry).toContain("exactMisses=1");
|
|
432
|
+
expect(telemetry).toContain("textMisses=1");
|
|
433
|
+
});
|
|
434
|
+
|
|
363
435
|
it("reports routing telemetry in /context status", async () => {
|
|
364
436
|
const { pi, handlers, commands, tools } = createPiMock();
|
|
365
437
|
registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1, lookupBytes: 500, searchBytes: 500 });
|
|
@@ -390,6 +462,8 @@ lookupBytes: 500,
|
|
|
390
462
|
expect(handle).toBeTruthy();
|
|
391
463
|
expect(toolResult.content[0].text).toContain("telemetry_payload_");
|
|
392
464
|
expect(result).toBeDefined();
|
|
465
|
+
const statusMessage = notifications.at(-2)?.message ?? "";
|
|
466
|
+
expect(statusMessage).toContain("globalCaps=records:unbounded bytes:unbounded");
|
|
393
467
|
const telemetry = notifications.at(-1)?.message ?? "";
|
|
394
468
|
expect(telemetry).toContain("Context broker routing telemetry:");
|
|
395
469
|
expect(telemetry).toContain("rewriteSavings rawBytes=");
|
|
@@ -398,6 +472,8 @@ lookupBytes: 500,
|
|
|
398
472
|
expect(telemetry).toMatch(/savedBytes=[1-9]\d*/);
|
|
399
473
|
expect(telemetry).toContain("contextLookupHistoryOmitted=");
|
|
400
474
|
expect(telemetry).toContain("lookups tool(calls=");
|
|
475
|
+
expect(telemetry).toContain("exact=");
|
|
476
|
+
expect(telemetry).toContain("textMisses=");
|
|
401
477
|
expect(telemetry).toContain("lookups slash(calls=");
|
|
402
478
|
expect(telemetry).toContain("exports=");
|
|
403
479
|
expect(telemetry).toContain("pins=");
|
|
@@ -736,6 +812,7 @@ maxRecords: 1,
|
|
|
736
812
|
const secondHandle = second.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
|
|
737
813
|
await second.commands.get("context").handler(`lookup ${handle}`, secondRun.ctx);
|
|
738
814
|
await second.commands.get("context").handler("brief", secondRun.ctx);
|
|
815
|
+
await second.commands.get("context").handler("status", secondRun.ctx);
|
|
739
816
|
|
|
740
817
|
const third = createPiMock();
|
|
741
818
|
const thirdRun = createCtx();
|
|
@@ -744,9 +821,10 @@ maxRecords: 1,
|
|
|
744
821
|
await third.commands.get("context").handler(`lookup ${secondHandle}`, thirdRun.ctx);
|
|
745
822
|
await third.commands.get("context").handler("brief", thirdRun.ctx);
|
|
746
823
|
|
|
747
|
-
expect(secondRun.notifications.at(-
|
|
748
|
-
expect(secondRun.notifications.at(-
|
|
749
|
-
expect(secondRun.notifications.at(-
|
|
824
|
+
expect(secondRun.notifications.at(-4)?.message).toContain("durable payload");
|
|
825
|
+
expect(secondRun.notifications.at(-3)?.message).toContain("tier=hot");
|
|
826
|
+
expect(secondRun.notifications.at(-3)?.message).toContain("pinned");
|
|
827
|
+
expect(secondRun.notifications.at(-2)?.message).toContain("globalCaps=records:2048 bytes:268435456");
|
|
750
828
|
expect(thirdRun.notifications.at(-2)?.message).toContain("durable payload");
|
|
751
829
|
expect(thirdRun.notifications.at(-1)?.message).toContain("tier=hot");
|
|
752
830
|
expect(thirdRun.notifications.at(-1)?.message).toContain("pinned");
|
|
@@ -20,6 +20,8 @@ export interface ContextBrokerBetaOptions {
|
|
|
20
20
|
lookupBytes?: number;
|
|
21
21
|
searchBytes?: number;
|
|
22
22
|
rewriteThresholdBytes?: number;
|
|
23
|
+
hotToWarmMs?: number;
|
|
24
|
+
warmToColdMs?: number;
|
|
23
25
|
durable?: boolean;
|
|
24
26
|
storeDir?: string;
|
|
25
27
|
}
|
|
@@ -32,6 +34,10 @@ const DEFAULT_LOOKUP_BYTES = 12_000;
|
|
|
32
34
|
const DEFAULT_SEARCH_BYTES = 2_000;
|
|
33
35
|
const DEFAULT_REWRITE_THRESHOLD_BYTES = 8 * 1024;
|
|
34
36
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
const DEFAULT_HOT_TO_WARM_MS = 2 * 60 * 60 * 1000;
|
|
38
|
+
const DEFAULT_WARM_TO_COLD_MS = 12 * 60 * 60 * 1000;
|
|
39
|
+
const DEFAULT_DURABLE_GLOBAL_MAX_RECORDS = 2_048;
|
|
40
|
+
const DEFAULT_DURABLE_GLOBAL_MAX_BYTES = 256 * 1024 * 1024;
|
|
35
41
|
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
36
42
|
|
|
37
43
|
function envFlag(name: string): boolean {
|
|
@@ -89,6 +95,28 @@ function isHostilePayload(payload: string): boolean {
|
|
|
89
95
|
return hasHostileText(payload);
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
function isOpaquePayload(payload: string): boolean {
|
|
99
|
+
return hasOpaqueText(payload);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasOpaqueText(text: string): boolean {
|
|
103
|
+
const value = String(text ?? "");
|
|
104
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
105
|
+
if (bytes < 4096) return false;
|
|
106
|
+
|
|
107
|
+
const compacted = value.replace(/\s+/g, "");
|
|
108
|
+
if (compacted.length >= 4096 && /^[A-Za-z0-9+/=_-]+$/.test(compacted) && compacted.length / Math.max(1, value.length) > 0.85) return true;
|
|
109
|
+
if (/\b(?:[A-Fa-f0-9]{2}){2048,}\b/.test(value)) return true;
|
|
110
|
+
|
|
111
|
+
const lines = value.split(/\r?\n/);
|
|
112
|
+
const longestLine = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
113
|
+
const whitespace = (value.match(/\s/g) ?? []).length;
|
|
114
|
+
const whitespaceRatio = whitespace / Math.max(1, value.length);
|
|
115
|
+
if (longestLine >= 4096 && whitespaceRatio < 0.03) return true;
|
|
116
|
+
if (longestLine >= 2048 && whitespaceRatio < 0.02 && /[{};:,]/.test(value)) return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
92
120
|
function hasHostileText(text: string): boolean {
|
|
93
121
|
let suspicious = 0;
|
|
94
122
|
let scanned = 0;
|
|
@@ -117,12 +145,24 @@ function hasHostileValue(value: unknown): boolean {
|
|
|
117
145
|
return false;
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
function hasOpaqueValue(value: unknown): boolean {
|
|
149
|
+
if (typeof value === "string") return hasOpaqueText(value);
|
|
150
|
+
if (Array.isArray(value)) return value.some(hasOpaqueValue);
|
|
151
|
+
if (value && typeof value === "object") {
|
|
152
|
+
return Object.values(value as Record<string, unknown>).some((entry) => hasOpaqueValue(entry));
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
120
157
|
function renderLookupOutput(item: ContextArtifact, payloadLimit: number): string {
|
|
121
158
|
const isBinary = item.tags.includes("hostile") || item.tags.includes("binary");
|
|
122
|
-
const
|
|
159
|
+
const isOpaque = item.tags.includes("opaque");
|
|
160
|
+
const payloadLines = isBinary || isOpaque
|
|
123
161
|
? [
|
|
124
162
|
"payload:",
|
|
125
|
-
|
|
163
|
+
isOpaque && !isBinary
|
|
164
|
+
? "[payload omitted from prompt because it appears opaque/high-token; use /context export"
|
|
165
|
+
: "[payload intentionally omitted from prompt for safety; use /context export",
|
|
126
166
|
sanitizeForPrompt(item.handle),
|
|
127
167
|
"for full content]",
|
|
128
168
|
]
|
|
@@ -176,6 +216,16 @@ function compact(value: string, max = 120): string {
|
|
|
176
216
|
return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
|
|
177
217
|
}
|
|
178
218
|
|
|
219
|
+
function capText(value: number): string {
|
|
220
|
+
return Number.isFinite(value) ? String(value) : "unbounded";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function lookupMissMessage(exact: boolean): string {
|
|
224
|
+
return exact
|
|
225
|
+
? "No context artifact matched that exact handle. The artifact may be missing, expired, pruned, or from a non-durable prior session."
|
|
226
|
+
: "No context artifacts matched that text/filter query. Try an exact ctx:// handle, narrower path/tag/kind/tier filters, or a more specific search term.";
|
|
227
|
+
}
|
|
228
|
+
|
|
179
229
|
function utf8Bytes(text: string): number {
|
|
180
230
|
return Buffer.byteLength(text, "utf8");
|
|
181
231
|
}
|
|
@@ -293,14 +343,16 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
293
343
|
options.rewriteThresholdBytes
|
|
294
344
|
?? envNonNegativeInt("PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES")
|
|
295
345
|
?? DEFAULT_REWRITE_THRESHOLD_BYTES;
|
|
346
|
+
const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
|
|
296
347
|
const brokerOptions = {
|
|
297
348
|
maxRecords: options.maxRecords ?? 64,
|
|
298
349
|
maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
|
|
299
|
-
globalMaxRecords: options.globalMaxRecords ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS"),
|
|
300
|
-
globalMaxBytes: options.globalMaxBytes ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES"),
|
|
350
|
+
globalMaxRecords: options.globalMaxRecords ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS") ?? (durable ? DEFAULT_DURABLE_GLOBAL_MAX_RECORDS : undefined),
|
|
351
|
+
globalMaxBytes: options.globalMaxBytes ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES") ?? (durable ? DEFAULT_DURABLE_GLOBAL_MAX_BYTES : undefined),
|
|
352
|
+
hotToWarmMs: options.hotToWarmMs ?? envNonNegativeInt("PI_CONTEXT_BROKER_HOT_TO_WARM_MS") ?? DEFAULT_HOT_TO_WARM_MS,
|
|
353
|
+
warmToColdMs: options.warmToColdMs ?? envNonNegativeInt("PI_CONTEXT_BROKER_WARM_TO_COLD_MS") ?? DEFAULT_WARM_TO_COLD_MS,
|
|
301
354
|
briefBytes,
|
|
302
355
|
};
|
|
303
|
-
const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
|
|
304
356
|
const durableBackend = String(process.env.PI_CONTEXT_BROKER_BACKEND ?? "sqlite").trim().toLowerCase();
|
|
305
357
|
const broker = durable
|
|
306
358
|
? durableBackend === "jsonl"
|
|
@@ -331,11 +383,15 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
331
383
|
toolLookupTextCalls: 0,
|
|
332
384
|
toolLookupHits: 0,
|
|
333
385
|
toolLookupMisses: 0,
|
|
386
|
+
toolLookupExactMisses: 0,
|
|
387
|
+
toolLookupTextMisses: 0,
|
|
334
388
|
commandLookupCalls: 0,
|
|
335
389
|
commandLookupExactCalls: 0,
|
|
336
390
|
commandLookupTextCalls: 0,
|
|
337
391
|
commandLookupHits: 0,
|
|
338
392
|
commandLookupMisses: 0,
|
|
393
|
+
commandLookupExactMisses: 0,
|
|
394
|
+
commandLookupTextMisses: 0,
|
|
339
395
|
exportCalls: 0,
|
|
340
396
|
pinCalls: 0,
|
|
341
397
|
statusCalls: 0,
|
|
@@ -357,8 +413,8 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
357
413
|
`toolResults seen=${routingTelemetry.contextHookToolResults} rewritten=${routingTelemetry.contextHookToolResultRewrites} hostile=${routingTelemetry.contextHookToolResultHostile}`,
|
|
358
414
|
`bash seen=${routingTelemetry.contextHookBash} rewritten=${routingTelemetry.contextHookBashRewrites} hostile=${routingTelemetry.contextHookBashHostile}`,
|
|
359
415
|
`rewriteSavings rawBytes=${routingTelemetry.contextHookRewriteRawBytes} replacementBytes=${routingTelemetry.contextHookRewriteReplacementBytes} savedBytes=${savedBytes} savedPct=${savedPct}% contextLookupHistoryOmitted=${routingTelemetry.contextHookContextLookupHistoryOmissions}`,
|
|
360
|
-
`lookups tool(calls=${routingTelemetry.toolLookupCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses})`,
|
|
361
|
-
`lookups slash(calls=${routingTelemetry.commandLookupCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses})`,
|
|
416
|
+
`lookups tool(calls=${routingTelemetry.toolLookupCalls}, exact=${routingTelemetry.toolLookupExactCalls}, text=${routingTelemetry.toolLookupTextCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses}, exactMisses=${routingTelemetry.toolLookupExactMisses}, textMisses=${routingTelemetry.toolLookupTextMisses})`,
|
|
417
|
+
`lookups slash(calls=${routingTelemetry.commandLookupCalls}, exact=${routingTelemetry.commandLookupExactCalls}, text=${routingTelemetry.commandLookupTextCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses}, exactMisses=${routingTelemetry.commandLookupExactMisses}, textMisses=${routingTelemetry.commandLookupTextMisses})`,
|
|
362
418
|
`exports=${routingTelemetry.exportCalls}`,
|
|
363
419
|
`pins=${routingTelemetry.pinCalls}`,
|
|
364
420
|
`pruneCalls=${routingTelemetry.pruneCalls}`,
|
|
@@ -400,6 +456,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
400
456
|
const payload = toolPayload(sanitizedEvent);
|
|
401
457
|
const bytes = Buffer.byteLength(payload, "utf8");
|
|
402
458
|
const hostilePayload = isHostilePayload(payload) || hasHostileValue(sanitizedEvent);
|
|
459
|
+
const opaquePayload = !hostilePayload && (isOpaquePayload(payload) || hasOpaqueValue(sanitizedEvent));
|
|
403
460
|
const artifact = broker.publish({
|
|
404
461
|
sessionId: activeSessionId,
|
|
405
462
|
kind: "tool_output",
|
|
@@ -410,6 +467,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
410
467
|
event.isError ? "error" : "ok",
|
|
411
468
|
event.sourceId ? "session-backfill" : "live",
|
|
412
469
|
...(hostilePayload ? ["hostile", "binary"] : []),
|
|
470
|
+
...(opaquePayload ? ["opaque"] : []),
|
|
413
471
|
],
|
|
414
472
|
command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
|
|
415
473
|
paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
|
|
@@ -765,7 +823,9 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
765
823
|
});
|
|
766
824
|
if (!results.length) {
|
|
767
825
|
routingTelemetry.toolLookupMisses += 1;
|
|
768
|
-
|
|
826
|
+
if (exact) routingTelemetry.toolLookupExactMisses += 1;
|
|
827
|
+
else routingTelemetry.toolLookupTextMisses += 1;
|
|
828
|
+
return textResult(lookupMissMessage(exact));
|
|
769
829
|
}
|
|
770
830
|
routingTelemetry.toolLookupHits += 1;
|
|
771
831
|
return textResult(results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n"));
|
|
@@ -784,7 +844,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
784
844
|
routingTelemetry.statusCalls += 1;
|
|
785
845
|
const status = broker.status();
|
|
786
846
|
ctx.ui.notify(
|
|
787
|
-
`Context broker: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, tiers=hot:${status.hotRecords}/${status.hotBytes} warm:${status.warmRecords}/${status.warmBytes} cold:${status.coldRecords}/${status.coldBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
|
|
847
|
+
`Context broker: enabled, session=${activeSessionId}, records=${status.records}/${status.maxRecords}, bytes=${status.bytes}/${status.maxBytes}, globalCaps=records:${capText(status.globalMaxRecords)} bytes:${capText(status.globalMaxBytes)}, tiers=hot:${status.hotRecords}/${status.hotBytes} warm:${status.warmRecords}/${status.warmBytes} cold:${status.coldRecords}/${status.coldBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
|
|
788
848
|
"info",
|
|
789
849
|
);
|
|
790
850
|
ctx.ui.notify(formatRoutingTelemetry(), "info");
|
|
@@ -810,8 +870,10 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
810
870
|
routingTelemetry.commandLookupHits += 1;
|
|
811
871
|
} else {
|
|
812
872
|
routingTelemetry.commandLookupMisses += 1;
|
|
873
|
+
if (exact) routingTelemetry.commandLookupExactMisses += 1;
|
|
874
|
+
else routingTelemetry.commandLookupTextMisses += 1;
|
|
813
875
|
}
|
|
814
|
-
ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") :
|
|
876
|
+
ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") : lookupMissMessage(exact), "info");
|
|
815
877
|
return;
|
|
816
878
|
}
|
|
817
879
|
|
|
@@ -193,6 +193,38 @@ describe("createInMemoryContextBroker", () => {
|
|
|
193
193
|
expect(broker.lookup({ sessionId: "s", tier: "cold" }).map((artifact) => artifact.id)).toEqual([explicit.id, archive.id]);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
it("cools old artifacts across hot, warm, and cold tiers without deleting them", () => {
|
|
197
|
+
const broker = createInMemoryContextBroker({ briefBytes: 1200, defaultTtlMs: 0, hotToWarmMs: 100, warmToColdMs: 200 });
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const oldHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old-hot", summary: "old hot", tier: "hot", createdAt: now - 300 });
|
|
200
|
+
const oldWarm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old-warm", summary: "old warm", tier: "warm", createdAt: now - 300 });
|
|
201
|
+
const freshHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh-hot", summary: "fresh hot", tier: "hot", createdAt: now - 50 });
|
|
202
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "pinned", summary: "pinned hot", tier: "hot", pinned: true, createdAt: now - 300 });
|
|
203
|
+
|
|
204
|
+
broker.prune(now);
|
|
205
|
+
|
|
206
|
+
expect(broker.lookup({ handle: oldHot.handle })[0]?.tier).toBe("cold");
|
|
207
|
+
expect(broker.lookup({ handle: oldWarm.handle })[0]?.tier).toBe("cold");
|
|
208
|
+
expect(broker.lookup({ handle: freshHot.handle })[0]?.tier).toBe("hot");
|
|
209
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.tier).toBe("hot");
|
|
210
|
+
const brief = broker.renderBrief({ sessionId: "s" });
|
|
211
|
+
expect(brief).not.toContain(oldHot.handle);
|
|
212
|
+
expect(brief).not.toContain(oldWarm.handle);
|
|
213
|
+
expect(brief).toContain(freshHot.handle);
|
|
214
|
+
expect(brief).toContain(pinned.handle);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("cools protected new artifacts before enforcing tier caps", () => {
|
|
218
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 0, maxRecords: 10, hotMaxRecords: 1, hotToWarmMs: 10_000, warmToColdMs: 20_000 });
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const fresh = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh", summary: "fresh", tier: "hot", createdAt: now - 1_000 });
|
|
221
|
+
const aged = broker.publish({ sessionId: "s", kind: "tool_output", payload: "aged", summary: "aged", tier: "hot", createdAt: now - 30_000 });
|
|
222
|
+
|
|
223
|
+
expect(aged.tier).toBe("cold");
|
|
224
|
+
expect(broker.lookup({ handle: fresh.handle })[0]?.tier).toBe("hot");
|
|
225
|
+
expect(broker.lookup({ handle: aged.handle })[0]?.tier).toBe("cold");
|
|
226
|
+
});
|
|
227
|
+
|
|
196
228
|
it("renders prompt briefs hot-first, warm-second, and excludes cold unless explicit", () => {
|
|
197
229
|
const broker = createInMemoryContextBroker({ briefBytes: 900, defaultTtlMs: 0 });
|
|
198
230
|
const cold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "cold", summary: "cold archive", tier: "cold", createdAt: 1 });
|
|
@@ -32,6 +32,10 @@ const DEFAULT_BRIEF_BYTES = 2_000;
|
|
|
32
32
|
const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
|
|
33
33
|
const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
|
|
34
34
|
|
|
35
|
+
function optionMs(value: number | undefined): number {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : Number.POSITIVE_INFINITY;
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
function normalizeList(values: string[] | undefined): string[] {
|
|
36
40
|
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
37
41
|
}
|
|
@@ -149,11 +153,32 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
149
153
|
warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
|
|
150
154
|
cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
|
|
151
155
|
};
|
|
156
|
+
const hotToWarmMs = optionMs(options.hotToWarmMs);
|
|
157
|
+
const warmToColdMs = optionMs(options.warmToColdMs);
|
|
152
158
|
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
153
159
|
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
154
160
|
let artifacts: Array<ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }> = [];
|
|
155
161
|
let sequence = 0;
|
|
156
162
|
|
|
163
|
+
function cooledTier(artifact: ContextArtifact & { baseTier: ContextArtifactTier }, now = Date.now()): ContextArtifactTier {
|
|
164
|
+
if (artifact.pinned) return "hot";
|
|
165
|
+
if (artifact.baseTier === "cold") return "cold";
|
|
166
|
+
const age = Math.max(0, now - artifact.createdAt);
|
|
167
|
+
if (age >= warmToColdMs) return "cold";
|
|
168
|
+
if (artifact.baseTier === "hot" && age >= hotToWarmMs) return "warm";
|
|
169
|
+
return artifact.baseTier;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function applyCooling(now = Date.now(), _protectedIds = new Set<string>()): void {
|
|
173
|
+
for (const artifact of artifacts) {
|
|
174
|
+
const nextTier = cooledTier(artifact, now);
|
|
175
|
+
if (artifact.tier !== nextTier) {
|
|
176
|
+
artifact.tier = nextTier;
|
|
177
|
+
artifact.updatedAt = now;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
157
182
|
function currentStatus(): ContextBrokerStatus {
|
|
158
183
|
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
159
184
|
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
@@ -174,6 +199,8 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
174
199
|
coldBytes: cold.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
175
200
|
maxRecords,
|
|
176
201
|
maxBytes,
|
|
202
|
+
globalMaxRecords,
|
|
203
|
+
globalMaxBytes,
|
|
177
204
|
};
|
|
178
205
|
}
|
|
179
206
|
|
|
@@ -225,6 +252,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
225
252
|
|
|
226
253
|
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
227
254
|
dropExpired(now, protectedIds);
|
|
255
|
+
applyCooling(now, protectedIds);
|
|
228
256
|
|
|
229
257
|
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
230
258
|
for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
|
|
@@ -253,11 +281,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
253
281
|
|
|
254
282
|
function status(): ContextBrokerStatus {
|
|
255
283
|
dropExpired();
|
|
284
|
+
applyCooling();
|
|
256
285
|
return currentStatus();
|
|
257
286
|
}
|
|
258
287
|
|
|
259
288
|
function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
|
|
260
289
|
dropExpired();
|
|
290
|
+
applyCooling();
|
|
261
291
|
const keepPinned = options.keepPinned ?? true;
|
|
262
292
|
artifacts = artifacts.filter((artifact) => {
|
|
263
293
|
if (options.sessionId && artifact.sessionId !== options.sessionId) return true;
|
|
@@ -305,12 +335,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
305
335
|
};
|
|
306
336
|
|
|
307
337
|
artifacts = [artifact, ...artifacts];
|
|
308
|
-
prune(now, new Set([artifact.id]));
|
|
338
|
+
prune(Date.now(), new Set([artifact.id]));
|
|
309
339
|
return artifact;
|
|
310
340
|
}
|
|
311
341
|
|
|
312
342
|
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
313
343
|
dropExpired();
|
|
344
|
+
applyCooling();
|
|
314
345
|
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
315
346
|
return artifacts
|
|
316
347
|
.filter((artifact) => artifactMatches(artifact, query))
|