@fiale-plus/pi-rogue-bundle 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/node_modules/@fiale-plus/pi-core/README.md +6 -5
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +0 -227
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +38 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +28 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +221 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +359 -0
- package/node_modules/@fiale-plus/{pi-core/src/context-broker.test.ts → pi-rogue-context-broker/src/index.test.ts} +1 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +245 -0
- package/package.json +9 -3
- package/src/context-broker.ts +1 -0
- package/src/extension.test.ts +63 -0
- package/src/extension.ts +14 -3
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
It stitches together (and bundles for a true single-package install):
|
|
6
6
|
|
|
7
|
+
- `@fiale-plus/pi-core` (shared contracts/helpers)
|
|
7
8
|
- `@fiale-plus/pi-rogue-advisor` (logic; direct releases paused)
|
|
9
|
+
- `@fiale-plus/pi-rogue-context-broker` (beta context-broker runtime; disabled by default)
|
|
8
10
|
- `@fiale-plus/pi-rogue-orchestration` (logic; direct releases paused)
|
|
9
11
|
|
|
10
12
|
Direct installs of the advisor/orchestration packages are paused (marked private). All users and future releases go through the bundle. See `docs/release.md` and root `AGENTS.md` / `README.md` for the release policy.
|
|
@@ -26,12 +28,16 @@ npm install
|
|
|
26
28
|
## Scope boundaries
|
|
27
29
|
|
|
28
30
|
- **Lab / internal helpers are excluded from this bundle.**
|
|
31
|
+
- The beta context-broker runtime is bundled for opt-in experiments but is not registered/enabled by default.
|
|
32
|
+
- Opt-in consumers can import the runtime through the bundle subpath: `@fiale-plus/pi-rogue-bundle/context-broker`.
|
|
33
|
+
- Set `PI_CONTEXT_BROKER_ENABLED=true` before starting Pi to register the beta `/context` command surface.
|
|
29
34
|
- `@fiale-plus/pi-rogue-bundle` is the only published surface for the logic.
|
|
30
35
|
- Internal helper packages (`@fiale-plus/pi-rogue-guardrails`, `@fiale-plus/pi-rogue-brain`, `@fiale-plus/pi-rogue-repo-arch`) are maintained separately in the lab section and not published.
|
|
31
36
|
|
|
32
37
|
## Command surface
|
|
33
38
|
|
|
34
|
-
- `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab` plus status/config/command paths (all provided via the bundle).
|
|
39
|
+
- Default: `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab` plus status/config/command paths (all provided via the bundle).
|
|
40
|
+
- Opt-in beta: `PI_CONTEXT_BROKER_ENABLED=true` adds `/context status`, `/context brief`, `/context lookup <handle|text>`, `/context pin <handle>`, and `/context prune` with autocomplete.
|
|
35
41
|
|
|
36
42
|
## Status
|
|
37
43
|
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Shared helpers for the Pi-Rogue workspace.
|
|
4
4
|
|
|
5
|
-
Includes
|
|
5
|
+
Includes shared bounded context broker contracts:
|
|
6
6
|
|
|
7
|
-
- `
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
7
|
+
- `BoundedContextBroker`
|
|
8
|
+
- `ContextArtifact` / `ContextArtifactInput`
|
|
9
|
+
- lookup, retention, and status type definitions
|
|
10
|
+
|
|
11
|
+
The executable in-memory implementation lives in `@fiale-plus/pi-rogue-context-broker`.
|
|
11
12
|
|
|
12
13
|
Install locally from this repo root: `npm install`
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { safeName } from "./text.js";
|
|
3
|
-
|
|
4
1
|
export type ContextArtifactKind =
|
|
5
2
|
| "tool_output"
|
|
6
3
|
| "diff"
|
|
@@ -82,227 +79,3 @@ export interface BoundedContextBroker {
|
|
|
82
79
|
status(): ContextBrokerStatus;
|
|
83
80
|
renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string;
|
|
84
81
|
}
|
|
85
|
-
|
|
86
|
-
const DEFAULT_MAX_RECORDS = 256;
|
|
87
|
-
const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
|
|
88
|
-
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
89
|
-
const DEFAULT_SUMMARY_BYTES = 320;
|
|
90
|
-
const DEFAULT_BRIEF_BYTES = 2_000;
|
|
91
|
-
|
|
92
|
-
function normalizeList(values: string[] | undefined): string[] {
|
|
93
|
-
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function payloadText(payload: string | Buffer): string {
|
|
97
|
-
return Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function payloadBytes(payload: string | Buffer): number {
|
|
101
|
-
return Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(String(payload ?? ""), "utf8");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function hashPayload(payload: string | Buffer): string {
|
|
105
|
-
return createHash("sha256").update(Buffer.isBuffer(payload) ? payload : String(payload)).digest("hex");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function normalizeNeedle(value: string | undefined): string {
|
|
109
|
-
return String(value ?? "").trim().toLowerCase();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function truncateUtf8(text: string, maxBytes: number): string {
|
|
113
|
-
const limit = Math.max(0, Math.floor(maxBytes));
|
|
114
|
-
if (Buffer.byteLength(text, "utf8") <= limit) return text;
|
|
115
|
-
if (limit === 0) return "";
|
|
116
|
-
|
|
117
|
-
const ellipsis = "…";
|
|
118
|
-
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8");
|
|
119
|
-
const contentLimit = Math.max(0, limit - ellipsisBytes);
|
|
120
|
-
let used = 0;
|
|
121
|
-
let result = "";
|
|
122
|
-
|
|
123
|
-
for (const char of text) {
|
|
124
|
-
const bytes = Buffer.byteLength(char, "utf8");
|
|
125
|
-
if (used + bytes > contentLimit) break;
|
|
126
|
-
result += char;
|
|
127
|
-
used += bytes;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (Buffer.byteLength(result + ellipsis, "utf8") <= limit) return result + ellipsis;
|
|
131
|
-
return result;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKind, bytes: number, sha256: string, maxBytes: number): string {
|
|
135
|
-
const cleaned = String(summary ?? "").replace(/\s+/g, " ").trim();
|
|
136
|
-
if (cleaned) return truncateUtf8(cleaned, maxBytes);
|
|
137
|
-
return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery): boolean {
|
|
141
|
-
if (query.id && artifact.id !== query.id) return false;
|
|
142
|
-
if (query.handle && artifact.handle !== query.handle) return false;
|
|
143
|
-
if (query.sessionId && artifact.sessionId !== query.sessionId) return false;
|
|
144
|
-
if (query.kind && artifact.kind !== query.kind) return false;
|
|
145
|
-
if (query.branch && artifact.branch !== query.branch) return false;
|
|
146
|
-
if (query.tag && !artifact.tags.includes(query.tag)) return false;
|
|
147
|
-
if (query.path) {
|
|
148
|
-
const queryPath = query.path.replace(/\/$/, "");
|
|
149
|
-
if (!artifact.paths.some((path) => path === query.path || path.startsWith(`${queryPath}/`))) return false;
|
|
150
|
-
}
|
|
151
|
-
if (query.commandPrefix && !artifact.command?.startsWith(query.commandPrefix)) return false;
|
|
152
|
-
|
|
153
|
-
const text = normalizeNeedle(query.text);
|
|
154
|
-
if (text) {
|
|
155
|
-
const haystack = [artifact.summary, artifact.payload, artifact.command, artifact.tags.join(" "), artifact.paths.join(" ")]
|
|
156
|
-
.join("\n")
|
|
157
|
-
.toLowerCase();
|
|
158
|
-
if (!haystack.includes(text)) return false;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
|
|
165
|
-
const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
|
|
166
|
-
const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
167
|
-
const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
|
|
168
|
-
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
169
|
-
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
170
|
-
let artifacts: Array<ContextArtifact & { sequence: number }> = [];
|
|
171
|
-
let sequence = 0;
|
|
172
|
-
|
|
173
|
-
function currentStatus(): ContextBrokerStatus {
|
|
174
|
-
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
175
|
-
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
176
|
-
return {
|
|
177
|
-
records: artifacts.length,
|
|
178
|
-
bytes,
|
|
179
|
-
pinnedRecords: pinned.length,
|
|
180
|
-
pinnedBytes: pinned.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
181
|
-
maxRecords,
|
|
182
|
-
maxBytes,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function dropExpired(now = Date.now(), protectedIds = new Set<string>()): void {
|
|
187
|
-
artifacts = artifacts.filter(
|
|
188
|
-
(artifact) => artifact.pinned || protectedIds.has(artifact.id) || !artifact.expiresAt || artifact.expiresAt > now,
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function oldestRemovable(sessionId: string, protectedIds: Set<string>): { artifact: ContextArtifact & { sequence: number }; index: number } | undefined {
|
|
193
|
-
return artifacts
|
|
194
|
-
.map((artifact, index) => ({ artifact, index }))
|
|
195
|
-
.filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id))
|
|
196
|
-
.sort((a, b) => {
|
|
197
|
-
if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
|
|
198
|
-
return a.artifact.sequence - b.artifact.sequence;
|
|
199
|
-
})[0];
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function sessionWithinCaps(sessionId: string): boolean {
|
|
203
|
-
const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId);
|
|
204
|
-
return sessionArtifacts.length <= maxRecords && sessionArtifacts.reduce((sum, artifact) => sum + artifact.bytes, 0) <= maxBytes;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
208
|
-
dropExpired(now, protectedIds);
|
|
209
|
-
|
|
210
|
-
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
211
|
-
while (!sessionWithinCaps(sessionId)) {
|
|
212
|
-
const candidate = oldestRemovable(sessionId, protectedIds);
|
|
213
|
-
if (!candidate) break;
|
|
214
|
-
artifacts.splice(candidate.index, 1);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return currentStatus();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function status(): ContextBrokerStatus {
|
|
222
|
-
dropExpired();
|
|
223
|
-
return currentStatus();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
227
|
-
const now = input.createdAt ?? Date.now();
|
|
228
|
-
const payload = payloadText(input.payload);
|
|
229
|
-
const sha256 = hashPayload(input.payload);
|
|
230
|
-
const bytes = payloadBytes(input.payload);
|
|
231
|
-
const artifactSequence = ++sequence;
|
|
232
|
-
const id = `ctx-${now.toString(36)}-${String(artifactSequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
|
|
233
|
-
const session = safeName(input.sessionId || "session");
|
|
234
|
-
const kind = input.kind;
|
|
235
|
-
const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
|
|
236
|
-
const ttlMs = input.ttlMs ?? defaultTtlMs;
|
|
237
|
-
|
|
238
|
-
const artifact: ContextArtifact & { sequence: number } = {
|
|
239
|
-
id,
|
|
240
|
-
handle,
|
|
241
|
-
sessionId: input.sessionId,
|
|
242
|
-
kind,
|
|
243
|
-
createdAt: now,
|
|
244
|
-
updatedAt: now,
|
|
245
|
-
bytes,
|
|
246
|
-
sha256,
|
|
247
|
-
payload,
|
|
248
|
-
summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
|
|
249
|
-
tags: normalizeList(input.tags),
|
|
250
|
-
paths: normalizeList(input.paths),
|
|
251
|
-
command: input.command?.trim() || undefined,
|
|
252
|
-
branch: input.branch?.trim() || undefined,
|
|
253
|
-
expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
|
|
254
|
-
pinned: Boolean(input.pinned),
|
|
255
|
-
parentIds: normalizeList(input.parentIds),
|
|
256
|
-
sequence: artifactSequence,
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
artifacts = [artifact, ...artifacts];
|
|
260
|
-
prune(now, new Set([artifact.id]));
|
|
261
|
-
return artifact;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
265
|
-
dropExpired();
|
|
266
|
-
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
267
|
-
return artifacts
|
|
268
|
-
.filter((artifact) => artifactMatches(artifact, query))
|
|
269
|
-
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.createdAt - a.createdAt || b.sequence - a.sequence)
|
|
270
|
-
.slice(0, limit);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function pin(idOrHandle: string, pinned = true): ContextArtifact | null {
|
|
274
|
-
dropExpired();
|
|
275
|
-
const artifact = artifacts.find((candidate) => candidate.id === idOrHandle || candidate.handle === idOrHandle) ?? null;
|
|
276
|
-
if (!artifact) return null;
|
|
277
|
-
artifact.pinned = pinned;
|
|
278
|
-
artifact.updatedAt = Date.now();
|
|
279
|
-
prune();
|
|
280
|
-
return artifacts.find((candidate) => candidate.id === artifact.id) ?? null;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
|
|
284
|
-
const budget = Math.max(64, Math.floor(query.budgetBytes ?? defaultBriefBytes));
|
|
285
|
-
const lines = [
|
|
286
|
-
"## Context Broker",
|
|
287
|
-
`Budget: ${budget} bytes`,
|
|
288
|
-
...lookup({ ...query, limit: query.limit ?? 8 }).map((artifact) => {
|
|
289
|
-
const pin = artifact.pinned ? " pinned" : "";
|
|
290
|
-
const path = artifact.paths.length ? ` paths=${artifact.paths.slice(0, 3).join(",")}` : "";
|
|
291
|
-
const tags = artifact.tags.length ? ` tags=${artifact.tags.slice(0, 3).join(",")}` : "";
|
|
292
|
-
return `- ${artifact.handle} kind=${artifact.kind}${pin}${path}${tags} summary="${artifact.summary}"`;
|
|
293
|
-
}),
|
|
294
|
-
"Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
|
|
295
|
-
];
|
|
296
|
-
|
|
297
|
-
return truncateUtf8(lines.join("\n"), budget);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
publish,
|
|
302
|
-
lookup,
|
|
303
|
-
pin,
|
|
304
|
-
prune,
|
|
305
|
-
status,
|
|
306
|
-
renderBrief,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Pi-Rogue Context Broker
|
|
2
|
+
|
|
3
|
+
Beta context broker runtime for Pi-Rogue.
|
|
4
|
+
|
|
5
|
+
This package contains the executable in-memory bounded broker implementation:
|
|
6
|
+
|
|
7
|
+
- `createInMemoryContextBroker()` stores artifacts behind stable `ctx://...` handles.
|
|
8
|
+
- Lookups support handle, session, kind, tag, path, command prefix, branch, and text filters.
|
|
9
|
+
- Omitted summaries become metadata-only placeholders, keeping raw payloads out of prompt briefs by default.
|
|
10
|
+
- Pruning enforces per-session record/byte caps, TTL expiry on reads, and pinned-artifact retention.
|
|
11
|
+
|
|
12
|
+
It is intentionally disabled by default in the bundle.
|
|
13
|
+
|
|
14
|
+
## Opt-in beta extension
|
|
15
|
+
|
|
16
|
+
Set `PI_CONTEXT_BROKER_ENABLED=true` before starting Pi with the bundle installed to enable the beta extension:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
PI_CONTEXT_BROKER_ENABLED=true pi
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
When enabled, the bundle registers `/context` commands:
|
|
23
|
+
|
|
24
|
+
- `/context status` — enabled state, record/byte counts, pinned counts.
|
|
25
|
+
- `/context brief` — bounded prompt-safe broker brief with handles and summaries.
|
|
26
|
+
- `/context lookup <handle|text>` — exact handle rehydration or current-session text search.
|
|
27
|
+
- `/context pin <handle>` — protect an artifact from normal TTL/cap pruning.
|
|
28
|
+
- `/context prune` — run TTL/cap pruning immediately.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
## Session behavior and limits
|
|
33
|
+
|
|
34
|
+
- On session start/reload, the beta backfills the current Pi session branch from `toolResult` and prompt-visible `bashExecution` entries.
|
|
35
|
+
- Backfill is idempotent by session entry id, skips malformed entries instead of failing the session, and honors Pi's `excludeFromContext` bash entries.
|
|
36
|
+
- The current implementation remains in-memory. Restarting Pi loses broker state until the current branch is backfilled again.
|
|
37
|
+
- Prompt integration injects only a bounded broker brief and lookup guidance. It does not yet rewrite existing raw tool-result messages out of Pi's transcript context; that deeper prompt-load reduction remains a follow-up.
|
|
38
|
+
- Rollback is immediate: unset `PI_CONTEXT_BROKER_ENABLED` and `/reload` or restart Pi.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fiale-plus/pi-rogue-context-broker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Beta context broker runtime for Pi-Rogue. In-memory bounded broker implementation behind explicit opt-in.",
|
|
5
|
+
"private": true,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"check": "tsc -p ../../tsconfig.json --noEmit",
|
|
13
|
+
"test": "cd ../.. && vitest run packages/context-broker/src/*.test.ts"
|
|
14
|
+
},
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./extension": "./src/extension.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@fiale-plus/pi-core": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"package.json"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { registerContextBrokerBeta, shouldEnableContextBrokerBeta } from "./extension.js";
|
|
3
|
+
|
|
4
|
+
function createPiMock() {
|
|
5
|
+
const handlers = new Map<string, any[]>();
|
|
6
|
+
const commands = new Map<string, any>();
|
|
7
|
+
const pi: any = {
|
|
8
|
+
on(name: string, handler: any) {
|
|
9
|
+
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
|
10
|
+
},
|
|
11
|
+
registerCommand(name: string, options: any) {
|
|
12
|
+
commands.set(name, options);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
return { pi, handlers, commands };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createCtx(entries: any[] = []) {
|
|
19
|
+
const notifications: Array<{ message: string; type?: string }> = [];
|
|
20
|
+
return {
|
|
21
|
+
ctx: {
|
|
22
|
+
cwd: "/repo",
|
|
23
|
+
ui: {
|
|
24
|
+
notify(message: string, type?: string) {
|
|
25
|
+
notifications.push({ message, type });
|
|
26
|
+
},
|
|
27
|
+
setStatus() {},
|
|
28
|
+
},
|
|
29
|
+
sessionManager: {
|
|
30
|
+
getSessionFile() {
|
|
31
|
+
return "/sessions/current.jsonl";
|
|
32
|
+
},
|
|
33
|
+
getBranch() {
|
|
34
|
+
return entries;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
} as any,
|
|
38
|
+
notifications,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runHandlers(handlers: Map<string, any[]>, name: string, event: any, ctx: any) {
|
|
43
|
+
for (const handler of handlers.get(name) ?? []) {
|
|
44
|
+
await handler(event, ctx);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("context broker beta enablement", () => {
|
|
49
|
+
const oldEnv = process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
if (oldEnv === undefined) delete process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
53
|
+
else process.env.PI_CONTEXT_BROKER_ENABLED = oldEnv;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("is disabled by default unless explicitly opted in", () => {
|
|
57
|
+
delete process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
58
|
+
expect(shouldEnableContextBrokerBeta()).toBe(false);
|
|
59
|
+
|
|
60
|
+
process.env.PI_CONTEXT_BROKER_ENABLED = "true";
|
|
61
|
+
expect(shouldEnableContextBrokerBeta()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("registers /context with command completions", () => {
|
|
65
|
+
const { pi, commands } = createPiMock();
|
|
66
|
+
registerContextBrokerBeta(pi);
|
|
67
|
+
|
|
68
|
+
const command = commands.get("context");
|
|
69
|
+
expect(command).toBeTruthy();
|
|
70
|
+
expect(command.getArgumentCompletions("")?.map((item: any) => item.value.trim())).toEqual([
|
|
71
|
+
"status",
|
|
72
|
+
"brief",
|
|
73
|
+
"lookup",
|
|
74
|
+
"pin",
|
|
75
|
+
"prune",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("backfills current branch toolResult and bashExecution entries idempotently", async () => {
|
|
80
|
+
const { pi, handlers, commands } = createPiMock();
|
|
81
|
+
registerContextBrokerBeta(pi);
|
|
82
|
+
const entries = [
|
|
83
|
+
{
|
|
84
|
+
type: "message",
|
|
85
|
+
id: "assistant-1",
|
|
86
|
+
timestamp: "2026-06-05T00:00:00.000Z",
|
|
87
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-read", name: "read", arguments: { path: "README.md" } }] },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: "message",
|
|
91
|
+
id: "tool-1",
|
|
92
|
+
timestamp: "2026-06-05T00:00:00.000Z",
|
|
93
|
+
message: { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "readme" }], isError: false },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: "message",
|
|
97
|
+
id: "bash-1",
|
|
98
|
+
timestamp: "2026-06-05T00:00:01.000Z",
|
|
99
|
+
message: { role: "bashExecution", command: "npm test", output: "passed", exitCode: 0, cancelled: false, truncated: false },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
const { ctx, notifications } = createCtx(entries);
|
|
103
|
+
|
|
104
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
105
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
106
|
+
await commands.get("context").handler("status", ctx);
|
|
107
|
+
await commands.get("context").handler("lookup README.md", ctx);
|
|
108
|
+
|
|
109
|
+
expect(notifications[0].message).toContain("Backfilled 2/2");
|
|
110
|
+
expect(notifications[1].message).toContain("Backfilled 0/2");
|
|
111
|
+
expect(notifications.at(-2)?.message).toContain("records=2");
|
|
112
|
+
expect(notifications.at(-1)?.message).toContain("README.md");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("is safe on malformed session branches", async () => {
|
|
116
|
+
const { pi, handlers } = createPiMock();
|
|
117
|
+
registerContextBrokerBeta(pi);
|
|
118
|
+
const { ctx, notifications } = createCtx([null, { type: "message", id: "broken", message: null }]);
|
|
119
|
+
|
|
120
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
121
|
+
|
|
122
|
+
expect(notifications[0].message).toContain("Backfilled 0/0");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not backfill bash entries explicitly excluded from context", async () => {
|
|
126
|
+
const { pi, handlers, commands } = createPiMock();
|
|
127
|
+
registerContextBrokerBeta(pi);
|
|
128
|
+
const { ctx, notifications } = createCtx([
|
|
129
|
+
{
|
|
130
|
+
type: "message",
|
|
131
|
+
id: "secret-bash",
|
|
132
|
+
timestamp: "2026-06-05T00:00:00.000Z",
|
|
133
|
+
message: {
|
|
134
|
+
role: "bashExecution",
|
|
135
|
+
command: "echo SECRET_TOKEN=abc123",
|
|
136
|
+
output: "SECRET_TOKEN=abc123",
|
|
137
|
+
exitCode: 0,
|
|
138
|
+
cancelled: false,
|
|
139
|
+
truncated: false,
|
|
140
|
+
excludeFromContext: true,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
146
|
+
await commands.get("context").handler("brief", ctx);
|
|
147
|
+
|
|
148
|
+
expect(notifications[0].message).toContain("Backfilled 0/0");
|
|
149
|
+
expect(notifications.at(-1)?.message).not.toContain("SECRET_TOKEN");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("exact lookup returns byte-clipped payloads and marks truncation explicitly", async () => {
|
|
153
|
+
const { pi, handlers, commands } = createPiMock();
|
|
154
|
+
registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
|
|
155
|
+
const { ctx, notifications } = createCtx();
|
|
156
|
+
|
|
157
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
158
|
+
await runHandlers(handlers, "tool_result", {
|
|
159
|
+
type: "tool_result",
|
|
160
|
+
toolCallId: "call-1",
|
|
161
|
+
toolName: "bash",
|
|
162
|
+
input: { command: "printf long" },
|
|
163
|
+
content: [{ type: "text", text: "測試".repeat(100) }],
|
|
164
|
+
isError: false,
|
|
165
|
+
}, ctx);
|
|
166
|
+
|
|
167
|
+
const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
168
|
+
expect(lookupCompletion.value).toMatch(/^lookup ctx:\/\//);
|
|
169
|
+
|
|
170
|
+
await commands.get("context").handler(lookupCompletion.value, ctx);
|
|
171
|
+
const payload = notifications.at(-1)?.message.split("payload:\n").at(-1) ?? "";
|
|
172
|
+
expect(notifications.at(-1)?.message).toContain("payload:");
|
|
173
|
+
expect(payload).toContain("[truncated: omitted");
|
|
174
|
+
expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(80);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("text search lookup returns a smaller byte-clipped excerpt", async () => {
|
|
178
|
+
const { pi, handlers, commands } = createPiMock();
|
|
179
|
+
registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
|
|
180
|
+
const { ctx, notifications } = createCtx();
|
|
181
|
+
|
|
182
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
183
|
+
await runHandlers(handlers, "tool_result", {
|
|
184
|
+
type: "tool_result",
|
|
185
|
+
toolCallId: "call-2",
|
|
186
|
+
toolName: "bash",
|
|
187
|
+
input: { command: "echo needle" },
|
|
188
|
+
content: [{ type: "text", text: "needle " + "✅".repeat(100) }],
|
|
189
|
+
isError: false,
|
|
190
|
+
}, ctx);
|
|
191
|
+
|
|
192
|
+
await commands.get("context").handler("lookup needle", ctx);
|
|
193
|
+
const payload = notifications.at(-1)?.message.split("payload:\n").at(-1) ?? "";
|
|
194
|
+
expect(notifications.at(-1)?.message).toContain("payload:");
|
|
195
|
+
expect(payload).toContain("[truncated: omitted");
|
|
196
|
+
expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(50);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("injects a bounded broker brief without raw payload text", async () => {
|
|
200
|
+
const { pi, handlers } = createPiMock();
|
|
201
|
+
registerContextBrokerBeta(pi, { briefBytes: 220 });
|
|
202
|
+
const { ctx } = createCtx();
|
|
203
|
+
|
|
204
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
205
|
+
await runHandlers(handlers, "tool_result", {
|
|
206
|
+
type: "tool_result",
|
|
207
|
+
toolCallId: "call-3",
|
|
208
|
+
toolName: "bash",
|
|
209
|
+
input: { command: "echo secret" },
|
|
210
|
+
content: [{ type: "text", text: "SECRET_TOKEN=" + "z".repeat(200) }],
|
|
211
|
+
isError: false,
|
|
212
|
+
}, ctx);
|
|
213
|
+
|
|
214
|
+
const result = await handlers.get("before_agent_start")?.[0]({ systemPrompt: "base" }, ctx);
|
|
215
|
+
|
|
216
|
+
expect(Buffer.byteLength(result.systemPrompt, "utf8")).toBeLessThanOrEqual(Buffer.byteLength("base\n\n", "utf8") + 220 + 180);
|
|
217
|
+
expect(result.systemPrompt).toContain("Context Broker");
|
|
218
|
+
expect(result.systemPrompt).toContain("ctx://");
|
|
219
|
+
expect(result.systemPrompt).not.toContain("SECRET_TOKEN");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createInMemoryContextBroker } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export interface ContextBrokerBetaOptions {
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
maxRecords?: number;
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
briefBytes?: number;
|
|
10
|
+
lookupBytes?: number;
|
|
11
|
+
searchBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type UiLike = { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus?(key: string, text: string | undefined): void };
|
|
15
|
+
type SessionContextLike = Pick<ExtensionContext, "cwd" | "sessionManager"> & { ui: UiLike };
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BRIEF_BYTES = 1_800;
|
|
18
|
+
const DEFAULT_LOOKUP_BYTES = 12_000;
|
|
19
|
+
const DEFAULT_SEARCH_BYTES = 2_000;
|
|
20
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
22
|
+
|
|
23
|
+
function isEnvEnabled(): boolean {
|
|
24
|
+
return ENABLED_VALUES.has(String(process.env.PI_CONTEXT_BROKER_ENABLED ?? "").trim().toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toText(value: unknown): string {
|
|
28
|
+
if (typeof value === "string") return value;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(value, null, 2);
|
|
31
|
+
} catch {
|
|
32
|
+
return String(value ?? "");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
37
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
38
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
39
|
+
if (totalBytes <= limit) return text;
|
|
40
|
+
if (limit === 0) return "";
|
|
41
|
+
|
|
42
|
+
let omittedBytes = totalBytes;
|
|
43
|
+
let result = "";
|
|
44
|
+
let marker = "…";
|
|
45
|
+
|
|
46
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
47
|
+
const verboseMarker = `\n[truncated: omitted ${omittedBytes} bytes]`;
|
|
48
|
+
marker = Buffer.byteLength(verboseMarker, "utf8") < limit ? verboseMarker : "…";
|
|
49
|
+
const contentLimit = Math.max(0, limit - Buffer.byteLength(marker, "utf8"));
|
|
50
|
+
let used = 0;
|
|
51
|
+
let prefix = "";
|
|
52
|
+
|
|
53
|
+
for (const char of text) {
|
|
54
|
+
const bytes = Buffer.byteLength(char, "utf8");
|
|
55
|
+
if (used + bytes > contentLimit) break;
|
|
56
|
+
prefix += char;
|
|
57
|
+
used += bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
result = prefix;
|
|
61
|
+
const nextOmittedBytes = totalBytes - used;
|
|
62
|
+
if (nextOmittedBytes === omittedBytes) break;
|
|
63
|
+
omittedBytes = nextOmittedBytes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${result}${marker}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function compact(value: string, max = 120): string {
|
|
70
|
+
return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sessionIdFor(ctx: Partial<SessionContextLike>): string {
|
|
74
|
+
const file = ctx.sessionManager?.getSessionFile?.();
|
|
75
|
+
return file || ctx.cwd || process.cwd();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function messageTimestamp(entry: any): number | undefined {
|
|
79
|
+
const value = entry?.message?.timestamp ?? entry?.timestamp;
|
|
80
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
81
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
82
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toolPayload(event: { toolName: string; input?: unknown; content?: unknown; details?: unknown; isError?: boolean }): string {
|
|
86
|
+
return [
|
|
87
|
+
`tool=${event.toolName}`,
|
|
88
|
+
`isError=${Boolean(event.isError)}`,
|
|
89
|
+
"input:",
|
|
90
|
+
toText(event.input),
|
|
91
|
+
"content:",
|
|
92
|
+
toText(event.content),
|
|
93
|
+
"details:",
|
|
94
|
+
toText(event.details),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
|
|
99
|
+
const command = event.toolName === "bash" ? event.input?.command : undefined;
|
|
100
|
+
const path = event.input?.path;
|
|
101
|
+
const target = command ? ` command=${compact(String(command), 120)}` : path ? ` path=${path}` : "";
|
|
102
|
+
return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
|
|
106
|
+
const p = pi as any;
|
|
107
|
+
if (p.__piRogueContextBrokerBetaRegistered) return;
|
|
108
|
+
p.__piRogueContextBrokerBetaRegistered = true;
|
|
109
|
+
|
|
110
|
+
const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
|
|
111
|
+
const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
|
|
112
|
+
const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
|
|
113
|
+
const broker = createInMemoryContextBroker({
|
|
114
|
+
maxRecords: options.maxRecords ?? 64,
|
|
115
|
+
maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
|
|
116
|
+
briefBytes,
|
|
117
|
+
});
|
|
118
|
+
const seenSourceIds = new Set<string>();
|
|
119
|
+
let activeSessionId = process.cwd();
|
|
120
|
+
|
|
121
|
+
function currentBrief(): string {
|
|
122
|
+
return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function publishToolArtifact(event: {
|
|
126
|
+
toolName: string;
|
|
127
|
+
input?: any;
|
|
128
|
+
content?: unknown;
|
|
129
|
+
details?: unknown;
|
|
130
|
+
isError?: boolean;
|
|
131
|
+
sourceId?: string;
|
|
132
|
+
createdAt?: number;
|
|
133
|
+
}): boolean {
|
|
134
|
+
if (event.sourceId) {
|
|
135
|
+
if (seenSourceIds.has(event.sourceId)) return false;
|
|
136
|
+
seenSourceIds.add(event.sourceId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = toolPayload(event);
|
|
140
|
+
const bytes = Buffer.byteLength(payload, "utf8");
|
|
141
|
+
broker.publish({
|
|
142
|
+
sessionId: activeSessionId,
|
|
143
|
+
kind: "tool_output",
|
|
144
|
+
payload,
|
|
145
|
+
summary: summarizeTool(event, bytes),
|
|
146
|
+
tags: [event.toolName, event.isError ? "error" : "ok", event.sourceId ? "session-backfill" : "live"],
|
|
147
|
+
command: event.toolName === "bash" && typeof event.input?.command === "string" ? event.input.command : undefined,
|
|
148
|
+
paths: typeof event.input?.path === "string" ? [event.input.path] : [],
|
|
149
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
150
|
+
parentIds: event.sourceId ? [event.sourceId] : [],
|
|
151
|
+
createdAt: event.createdAt,
|
|
152
|
+
});
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function backfillSessionArtifacts(ctx: Partial<SessionContextLike>): { added: number; scanned: number; errors: number } {
|
|
157
|
+
activeSessionId = sessionIdFor(ctx);
|
|
158
|
+
let entries: any[] = [];
|
|
159
|
+
try {
|
|
160
|
+
entries = ctx.sessionManager?.getBranch?.() ?? [];
|
|
161
|
+
} catch {
|
|
162
|
+
return { added: 0, scanned: 0, errors: 1 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const toolInputs = new Map<string, { toolName?: string; input?: unknown }>();
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const message = entry?.type === "message" ? entry.message : undefined;
|
|
168
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
169
|
+
for (const block of message.content) {
|
|
170
|
+
if (block?.type === "toolCall" && typeof block.id === "string") {
|
|
171
|
+
toolInputs.set(block.id, { toolName: typeof block.name === "string" ? block.name : undefined, input: block.arguments });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let added = 0;
|
|
177
|
+
let scanned = 0;
|
|
178
|
+
let errors = 0;
|
|
179
|
+
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
try {
|
|
182
|
+
const entryId = typeof entry?.id === "string" ? entry.id : undefined;
|
|
183
|
+
const createdAt = messageTimestamp(entry);
|
|
184
|
+
|
|
185
|
+
if (entry?.type === "message" && entry.message?.role === "toolResult") {
|
|
186
|
+
scanned += 1;
|
|
187
|
+
const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
|
|
188
|
+
const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
|
|
189
|
+
if (publishToolArtifact({
|
|
190
|
+
toolName: String(entry.message.toolName ?? toolInput?.toolName ?? "tool"),
|
|
191
|
+
input: entry.message.input ?? toolInput?.input,
|
|
192
|
+
content: entry.message.content,
|
|
193
|
+
details: entry.message.details,
|
|
194
|
+
isError: Boolean(entry.message.isError),
|
|
195
|
+
sourceId,
|
|
196
|
+
createdAt,
|
|
197
|
+
})) added += 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (entry?.type === "message" && entry.message?.role === "bashExecution") {
|
|
201
|
+
if (entry.message.excludeFromContext === true) continue;
|
|
202
|
+
scanned += 1;
|
|
203
|
+
const sourceId = entryId;
|
|
204
|
+
if (publishToolArtifact({
|
|
205
|
+
toolName: "bash",
|
|
206
|
+
input: { command: entry.message.command },
|
|
207
|
+
content: entry.message.output,
|
|
208
|
+
details: {
|
|
209
|
+
exitCode: entry.message.exitCode,
|
|
210
|
+
cancelled: entry.message.cancelled,
|
|
211
|
+
truncated: entry.message.truncated,
|
|
212
|
+
fullOutputPath: entry.message.fullOutputPath,
|
|
213
|
+
},
|
|
214
|
+
isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
|
|
215
|
+
sourceId,
|
|
216
|
+
createdAt,
|
|
217
|
+
})) added += 1;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
errors += 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { added, scanned, errors };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const contextActions: AutocompleteItem[] = [
|
|
228
|
+
{ value: "status", label: "status", description: "Show broker record, byte, and pinned counts" },
|
|
229
|
+
{ value: "brief", label: "brief", description: "Show the bounded broker brief" },
|
|
230
|
+
{ value: "lookup ", label: "lookup", description: "Lookup by ctx:// handle or current-session text" },
|
|
231
|
+
{ value: "pin ", label: "pin", description: "Pin an artifact by ctx:// handle or id" },
|
|
232
|
+
{ value: "prune", label: "prune", description: "Run TTL/cap pruning now" },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
function artifactCompletions(action: "lookup" | "pin", query: string): AutocompleteItem[] {
|
|
236
|
+
const needle = query.trim().toLowerCase();
|
|
237
|
+
return broker.lookup({ sessionId: activeSessionId, limit: 10 })
|
|
238
|
+
.filter((artifact) => {
|
|
239
|
+
if (!needle) return true;
|
|
240
|
+
return artifact.handle.toLowerCase().includes(needle)
|
|
241
|
+
|| artifact.summary.toLowerCase().includes(needle)
|
|
242
|
+
|| artifact.kind.toLowerCase().includes(needle)
|
|
243
|
+
|| artifact.tags.join(" ").toLowerCase().includes(needle)
|
|
244
|
+
|| artifact.paths.join(" ").toLowerCase().includes(needle);
|
|
245
|
+
})
|
|
246
|
+
.map((artifact) => ({
|
|
247
|
+
value: `${action} ${artifact.handle}`,
|
|
248
|
+
label: `${action} ${artifact.kind}`,
|
|
249
|
+
description: `${artifact.pinned ? "pinned; " : ""}${artifact.summary}`,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function contextArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
254
|
+
const prefix = argumentPrefix.trimStart();
|
|
255
|
+
const [action = "", ...restParts] = prefix.split(/\s+/);
|
|
256
|
+
const hasActionSeparator = /\s/.test(prefix);
|
|
257
|
+
|
|
258
|
+
if (!action || !hasActionSeparator) {
|
|
259
|
+
const items = contextActions.filter((item) => item.value.trim().startsWith(action));
|
|
260
|
+
return items.length ? items : contextActions;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (action === "lookup" || action === "pin") {
|
|
264
|
+
const items = artifactCompletions(action, restParts.join(" "));
|
|
265
|
+
return items.length ? items : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
272
|
+
const { added, scanned, errors } = backfillSessionArtifacts(ctx);
|
|
273
|
+
ctx.ui.setStatus?.("context-broker", "ctx:on beta");
|
|
274
|
+
ctx.ui.notify(
|
|
275
|
+
`Context broker beta enabled. Backfilled ${added}/${scanned} current-branch tool artifacts${errors ? ` (${errors} malformed skipped)` : ""}. Use /context status or /context brief.`,
|
|
276
|
+
errors ? "warning" : "info",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
|
|
281
|
+
activeSessionId = sessionIdFor(ctx);
|
|
282
|
+
publishToolArtifact({ ...event, sourceId: event.toolCallId });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
pi.on("before_agent_start", async (event) => {
|
|
286
|
+
const brief = currentBrief();
|
|
287
|
+
if (!brief.includes("ctx://")) return;
|
|
288
|
+
return {
|
|
289
|
+
systemPrompt: [
|
|
290
|
+
event.systemPrompt,
|
|
291
|
+
brief,
|
|
292
|
+
"Context broker beta rule: use /context lookup <handle> for exact evidence when a broker handle is relevant. Broker briefs are bounded summaries and never raw payload dumps.",
|
|
293
|
+
].join("\n\n"),
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
pi.registerCommand("context", {
|
|
298
|
+
description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
|
|
299
|
+
getArgumentCompletions: contextArgumentCompletions,
|
|
300
|
+
handler: async (args, ctx) => {
|
|
301
|
+
activeSessionId = sessionIdFor(ctx);
|
|
302
|
+
const [action = "status", ...rest] = String(args || "").trim().split(/\s+/).filter(Boolean);
|
|
303
|
+
const query = rest.join(" ");
|
|
304
|
+
|
|
305
|
+
if (action === "status") {
|
|
306
|
+
const status = broker.status();
|
|
307
|
+
ctx.ui.notify(
|
|
308
|
+
`Context broker beta: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
|
|
309
|
+
"info",
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (action === "brief") {
|
|
315
|
+
ctx.ui.notify(currentBrief(), "info");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (action === "lookup") {
|
|
320
|
+
if (!query) {
|
|
321
|
+
ctx.ui.notify("Usage: /context lookup <ctx://handle-or-text>", "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const exact = query.startsWith("ctx://");
|
|
325
|
+
const results = broker.lookup(exact ? { handle: query } : { sessionId: activeSessionId, text: query, limit: 5 });
|
|
326
|
+
ctx.ui.notify(results.length ? results.map((item) => [
|
|
327
|
+
item.handle,
|
|
328
|
+
`kind=${item.kind} bytes=${item.bytes}`,
|
|
329
|
+
`summary=${item.summary}`,
|
|
330
|
+
"payload:",
|
|
331
|
+
truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
|
|
332
|
+
].join("\n")).join("\n\n---\n\n") : "No context artifacts matched.", "info");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (action === "pin") {
|
|
337
|
+
if (!query) {
|
|
338
|
+
ctx.ui.notify("Usage: /context pin <ctx://handle-or-id>", "warning");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const pinned = broker.pin(query, true);
|
|
342
|
+
ctx.ui.notify(pinned ? `Pinned ${pinned.handle}` : "No artifact matched that handle/id.", pinned ? "info" : "warning");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (action === "prune") {
|
|
347
|
+
const status = broker.prune();
|
|
348
|
+
ctx.ui.notify(`Pruned. ${status.records} records, ${status.bytes} bytes remain.`, "info");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle> | prune", "warning");
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function shouldEnableContextBrokerBeta(options: ContextBrokerBetaOptions = {}): boolean {
|
|
358
|
+
return Boolean(options.enabled ?? isEnvEnabled());
|
|
359
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { createInMemoryContextBroker } from "./
|
|
3
|
+
import { createInMemoryContextBroker } from "./index.js";
|
|
4
4
|
|
|
5
5
|
describe("createInMemoryContextBroker", () => {
|
|
6
6
|
it("publishes stable, unique handles and looks up artifacts by handle", () => {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { safeName } from "@fiale-plus/pi-core";
|
|
3
|
+
import type {
|
|
4
|
+
BoundedContextBroker,
|
|
5
|
+
ContextArtifact,
|
|
6
|
+
ContextArtifactInput,
|
|
7
|
+
ContextArtifactKind,
|
|
8
|
+
ContextBrokerOptions,
|
|
9
|
+
ContextBrokerStatus,
|
|
10
|
+
ContextLookupQuery,
|
|
11
|
+
} from "@fiale-plus/pi-core";
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
BoundedContextBroker,
|
|
15
|
+
ContextArtifact,
|
|
16
|
+
ContextArtifactInput,
|
|
17
|
+
ContextArtifactKind,
|
|
18
|
+
ContextBrokerOptions,
|
|
19
|
+
ContextBrokerStatus,
|
|
20
|
+
ContextLookupQuery,
|
|
21
|
+
} from "@fiale-plus/pi-core";
|
|
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
|
+
|
|
29
|
+
function normalizeList(values: string[] | undefined): string[] {
|
|
30
|
+
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function payloadText(payload: string | Buffer): string {
|
|
34
|
+
return Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function payloadBytes(payload: string | Buffer): number {
|
|
38
|
+
return Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(String(payload ?? ""), "utf8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hashPayload(payload: string | Buffer): string {
|
|
42
|
+
return createHash("sha256").update(Buffer.isBuffer(payload) ? payload : String(payload)).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeNeedle(value: string | undefined): string {
|
|
46
|
+
return String(value ?? "").trim().toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
50
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
51
|
+
if (Buffer.byteLength(text, "utf8") <= limit) return text;
|
|
52
|
+
if (limit === 0) return "";
|
|
53
|
+
|
|
54
|
+
const ellipsis = "…";
|
|
55
|
+
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8");
|
|
56
|
+
const contentLimit = Math.max(0, limit - ellipsisBytes);
|
|
57
|
+
let used = 0;
|
|
58
|
+
let result = "";
|
|
59
|
+
|
|
60
|
+
for (const char of text) {
|
|
61
|
+
const bytes = Buffer.byteLength(char, "utf8");
|
|
62
|
+
if (used + bytes > contentLimit) break;
|
|
63
|
+
result += char;
|
|
64
|
+
used += bytes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Buffer.byteLength(result + ellipsis, "utf8") <= limit) return result + ellipsis;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function summarizeArtifact(summary: string | undefined, kind: ContextArtifactKind, bytes: number, sha256: string, maxBytes: number): string {
|
|
72
|
+
const cleaned = String(summary ?? "").replace(/\s+/g, " ").trim();
|
|
73
|
+
if (cleaned) return truncateUtf8(cleaned, maxBytes);
|
|
74
|
+
return truncateUtf8(`[${kind} payload stored externally; ${bytes} bytes; sha256=${sha256.slice(0, 16)}]`, maxBytes);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function artifactMatches(artifact: ContextArtifact, query: ContextLookupQuery): boolean {
|
|
78
|
+
if (query.id && artifact.id !== query.id) return false;
|
|
79
|
+
if (query.handle && artifact.handle !== query.handle) return false;
|
|
80
|
+
if (query.sessionId && artifact.sessionId !== query.sessionId) return false;
|
|
81
|
+
if (query.kind && artifact.kind !== query.kind) return false;
|
|
82
|
+
if (query.branch && artifact.branch !== query.branch) return false;
|
|
83
|
+
if (query.tag && !artifact.tags.includes(query.tag)) return false;
|
|
84
|
+
if (query.path) {
|
|
85
|
+
const queryPath = query.path.replace(/\/$/, "");
|
|
86
|
+
if (!artifact.paths.some((path) => path === query.path || path.startsWith(`${queryPath}/`))) return false;
|
|
87
|
+
}
|
|
88
|
+
if (query.commandPrefix && !artifact.command?.startsWith(query.commandPrefix)) return false;
|
|
89
|
+
|
|
90
|
+
const text = normalizeNeedle(query.text);
|
|
91
|
+
if (text) {
|
|
92
|
+
const haystack = [artifact.summary, artifact.payload, artifact.command, artifact.tags.join(" "), artifact.paths.join(" ")]
|
|
93
|
+
.join("\n")
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
if (!haystack.includes(text)) return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
|
|
102
|
+
const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
|
|
103
|
+
const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
104
|
+
const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
|
|
105
|
+
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
106
|
+
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
107
|
+
let artifacts: Array<ContextArtifact & { sequence: number }> = [];
|
|
108
|
+
let sequence = 0;
|
|
109
|
+
|
|
110
|
+
function currentStatus(): ContextBrokerStatus {
|
|
111
|
+
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
112
|
+
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
113
|
+
return {
|
|
114
|
+
records: artifacts.length,
|
|
115
|
+
bytes,
|
|
116
|
+
pinnedRecords: pinned.length,
|
|
117
|
+
pinnedBytes: pinned.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
118
|
+
maxRecords,
|
|
119
|
+
maxBytes,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function dropExpired(now = Date.now(), protectedIds = new Set<string>()): void {
|
|
124
|
+
artifacts = artifacts.filter(
|
|
125
|
+
(artifact) => artifact.pinned || protectedIds.has(artifact.id) || !artifact.expiresAt || artifact.expiresAt > now,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function oldestRemovable(sessionId: string, protectedIds: Set<string>): { artifact: ContextArtifact & { sequence: number }; index: number } | undefined {
|
|
130
|
+
return artifacts
|
|
131
|
+
.map((artifact, index) => ({ artifact, index }))
|
|
132
|
+
.filter(({ artifact }) => artifact.sessionId === sessionId && !artifact.pinned && !protectedIds.has(artifact.id))
|
|
133
|
+
.sort((a, b) => {
|
|
134
|
+
if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
|
|
135
|
+
return a.artifact.sequence - b.artifact.sequence;
|
|
136
|
+
})[0];
|
|
137
|
+
}
|
|
138
|
+
|
|
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;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
145
|
+
dropExpired(now, protectedIds);
|
|
146
|
+
|
|
147
|
+
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
148
|
+
while (!sessionWithinCaps(sessionId)) {
|
|
149
|
+
const candidate = oldestRemovable(sessionId, protectedIds);
|
|
150
|
+
if (!candidate) break;
|
|
151
|
+
artifacts.splice(candidate.index, 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return currentStatus();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function status(): ContextBrokerStatus {
|
|
159
|
+
dropExpired();
|
|
160
|
+
return currentStatus();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
164
|
+
const now = input.createdAt ?? Date.now();
|
|
165
|
+
const payload = payloadText(input.payload);
|
|
166
|
+
const sha256 = hashPayload(input.payload);
|
|
167
|
+
const bytes = payloadBytes(input.payload);
|
|
168
|
+
const artifactSequence = ++sequence;
|
|
169
|
+
const id = `ctx-${now.toString(36)}-${String(artifactSequence).padStart(4, "0")}-${sha256.slice(0, 12)}`;
|
|
170
|
+
const session = safeName(input.sessionId || "session");
|
|
171
|
+
const kind = input.kind;
|
|
172
|
+
const handle = `ctx://session/${session}/${kind}/${sha256.slice(0, 16)}/${id}`;
|
|
173
|
+
const ttlMs = input.ttlMs ?? defaultTtlMs;
|
|
174
|
+
|
|
175
|
+
const artifact: ContextArtifact & { sequence: number } = {
|
|
176
|
+
id,
|
|
177
|
+
handle,
|
|
178
|
+
sessionId: input.sessionId,
|
|
179
|
+
kind,
|
|
180
|
+
createdAt: now,
|
|
181
|
+
updatedAt: now,
|
|
182
|
+
bytes,
|
|
183
|
+
sha256,
|
|
184
|
+
payload,
|
|
185
|
+
summary: summarizeArtifact(input.summary, kind, bytes, sha256, summaryBytes),
|
|
186
|
+
tags: normalizeList(input.tags),
|
|
187
|
+
paths: normalizeList(input.paths),
|
|
188
|
+
command: input.command?.trim() || undefined,
|
|
189
|
+
branch: input.branch?.trim() || undefined,
|
|
190
|
+
expiresAt: ttlMs > 0 ? now + ttlMs : undefined,
|
|
191
|
+
pinned: Boolean(input.pinned),
|
|
192
|
+
parentIds: normalizeList(input.parentIds),
|
|
193
|
+
sequence: artifactSequence,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
artifacts = [artifact, ...artifacts];
|
|
197
|
+
prune(now, new Set([artifact.id]));
|
|
198
|
+
return artifact;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
202
|
+
dropExpired();
|
|
203
|
+
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
204
|
+
return artifacts
|
|
205
|
+
.filter((artifact) => artifactMatches(artifact, query))
|
|
206
|
+
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.createdAt - a.createdAt || b.sequence - a.sequence)
|
|
207
|
+
.slice(0, limit);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function pin(idOrHandle: string, pinned = true): ContextArtifact | null {
|
|
211
|
+
dropExpired();
|
|
212
|
+
const artifact = artifacts.find((candidate) => candidate.id === idOrHandle || candidate.handle === idOrHandle) ?? null;
|
|
213
|
+
if (!artifact) return null;
|
|
214
|
+
artifact.pinned = pinned;
|
|
215
|
+
artifact.updatedAt = Date.now();
|
|
216
|
+
prune();
|
|
217
|
+
return artifacts.find((candidate) => candidate.id === artifact.id) ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderBrief(query: ContextLookupQuery & { budgetBytes?: number } = {}): string {
|
|
221
|
+
const budget = Math.max(64, Math.floor(query.budgetBytes ?? defaultBriefBytes));
|
|
222
|
+
const lines = [
|
|
223
|
+
"## Context Broker",
|
|
224
|
+
`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
|
+
}),
|
|
231
|
+
"Lookup: use broker lookup by handle/path/tag/kind/session before replaying raw payloads.",
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
return truncateUtf8(lines.join("\n"), budget);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
publish,
|
|
239
|
+
lookup,
|
|
240
|
+
pin,
|
|
241
|
+
prune,
|
|
242
|
+
status,
|
|
243
|
+
renderBrief,
|
|
244
|
+
};
|
|
245
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiale-plus/pi-rogue-bundle",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Public Pi-Rogue bundle for advisor, orchestration, and
|
|
3
|
+
"version": "0.1.16",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
"keywords": [
|
|
12
12
|
"pi-package"
|
|
13
13
|
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "cd ../.. && vitest run packages/bundle/src/*.test.ts"
|
|
16
|
+
},
|
|
14
17
|
"main": "./src/index.ts",
|
|
15
18
|
"exports": {
|
|
16
|
-
".": "./src/index.ts"
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./context-broker": "./src/context-broker.ts"
|
|
17
21
|
},
|
|
18
22
|
"pi": {
|
|
19
23
|
"extensions": [
|
|
@@ -30,11 +34,13 @@
|
|
|
30
34
|
"dependencies": {
|
|
31
35
|
"@fiale-plus/pi-core": "^0.1.0",
|
|
32
36
|
"@fiale-plus/pi-rogue-advisor": "^0.1.0",
|
|
37
|
+
"@fiale-plus/pi-rogue-context-broker": "^0.1.0",
|
|
33
38
|
"@fiale-plus/pi-rogue-orchestration": "^0.1.0"
|
|
34
39
|
},
|
|
35
40
|
"bundledDependencies": [
|
|
36
41
|
"@fiale-plus/pi-core",
|
|
37
42
|
"@fiale-plus/pi-rogue-advisor",
|
|
43
|
+
"@fiale-plus/pi-rogue-context-broker",
|
|
38
44
|
"@fiale-plus/pi-rogue-orchestration"
|
|
39
45
|
],
|
|
40
46
|
"publishConfig": {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@fiale-plus/pi-rogue-context-broker";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createInMemoryContextBroker } from "./context-broker.js";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { registerBundle } from "./extension.js";
|
|
4
|
+
|
|
5
|
+
function createPiMock() {
|
|
6
|
+
const handlers = new Map<string, any[]>();
|
|
7
|
+
const commands = new Map<string, any>();
|
|
8
|
+
const pi: any = new Proxy({
|
|
9
|
+
on(name: string, handler: any) {
|
|
10
|
+
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
|
11
|
+
},
|
|
12
|
+
registerCommand(name: string, options: any) {
|
|
13
|
+
commands.set(name, options);
|
|
14
|
+
},
|
|
15
|
+
getFlag() {
|
|
16
|
+
return undefined;
|
|
17
|
+
},
|
|
18
|
+
}, {
|
|
19
|
+
get(target, prop) {
|
|
20
|
+
if (prop in target) return (target as any)[prop];
|
|
21
|
+
if (typeof prop === "string" && prop.startsWith("__")) return undefined;
|
|
22
|
+
return () => undefined;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return { pi, handlers, commands };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("bundle extension defaults", () => {
|
|
29
|
+
const oldEnv = process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (oldEnv === undefined) delete process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
33
|
+
else process.env.PI_CONTEXT_BROKER_ENABLED = oldEnv;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not register the beta context broker by default", async () => {
|
|
37
|
+
delete process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
38
|
+
const { pi, commands } = createPiMock();
|
|
39
|
+
|
|
40
|
+
await registerBundle(pi);
|
|
41
|
+
|
|
42
|
+
expect(commands.has("context")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("registers the beta context broker only when explicitly enabled", async () => {
|
|
46
|
+
process.env.PI_CONTEXT_BROKER_ENABLED = "true";
|
|
47
|
+
const { pi, commands } = createPiMock();
|
|
48
|
+
|
|
49
|
+
await registerBundle(pi);
|
|
50
|
+
|
|
51
|
+
expect(commands.has("context")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("bundle context-broker export", () => {
|
|
56
|
+
it("exposes the beta context broker runtime for explicit opt-in", () => {
|
|
57
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 0 });
|
|
58
|
+
const artifact = broker.publish({ sessionId: "bundle-test", kind: "memory_note", payload: "hello" });
|
|
59
|
+
|
|
60
|
+
expect(artifact.handle).toContain("ctx://session/bundle-test/memory_note/");
|
|
61
|
+
expect(broker.lookup({ handle: artifact.handle })).toEqual([artifact]);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/extension.ts
CHANGED
|
@@ -2,15 +2,26 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { registerAdvisor } from "@fiale-plus/pi-rogue-advisor";
|
|
3
3
|
import { registerOrchestration } from "@fiale-plus/pi-rogue-orchestration";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
6
|
+
|
|
7
|
+
function contextBrokerBetaEnabled(): boolean {
|
|
8
|
+
return ENABLED_VALUES.has(String(process.env.PI_CONTEXT_BROKER_ENABLED ?? "").trim().toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function registerBundle(pi: ExtensionAPI): Promise<void> {
|
|
6
12
|
const p = pi as any;
|
|
7
13
|
if (p.__piRogueBundleRegistered) return;
|
|
8
14
|
p.__piRogueBundleRegistered = true;
|
|
9
15
|
|
|
10
16
|
registerAdvisor(pi);
|
|
11
17
|
registerOrchestration(pi);
|
|
18
|
+
|
|
19
|
+
if (contextBrokerBetaEnabled()) {
|
|
20
|
+
const { registerContextBrokerBeta } = await import("@fiale-plus/pi-rogue-context-broker/extension");
|
|
21
|
+
registerContextBrokerBeta(pi);
|
|
22
|
+
}
|
|
12
23
|
}
|
|
13
24
|
|
|
14
|
-
export default function bundleExtension(pi: ExtensionAPI): void {
|
|
15
|
-
registerBundle(pi);
|
|
25
|
+
export default function bundleExtension(pi: ExtensionAPI): Promise<void> {
|
|
26
|
+
return registerBundle(pi);
|
|
16
27
|
}
|