@astrosheep/pi-context 0.26.0 → 0.26.2
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 +2 -2
- package/dist/build-info.json +2 -2
- package/dist/extension.js +69 -64
- package/dist/src/context/budget.js +37 -18
- package/dist/src/context/prompts.js +3 -3
- package/dist/src/dream/doctor.js +0 -4
- package/dist/src/notes/frontmatter.d.ts +1 -2
- package/dist/src/notes/frontmatter.js +1 -6
- package/dist/src/notes/store.js +24 -40
- package/dist/src/protocol.d.ts +4 -4
- package/dist/src/protocol.js +4 -4
- package/dist/src/text-match.d.ts +5 -0
- package/dist/src/text-match.js +15 -0
- package/dist/src/tool-output.d.ts +1 -5
- package/dist/src/tool-output.js +1 -15
- package/dist/test/agent-loop.test.js +78 -10
- package/dist/test/boot.integration.test.js +5 -7
- package/dist/test/budget-settings.integration.test.js +20 -5
- package/dist/test/doctor.test.js +4 -5
- package/dist/test/helpers/extension-test-environment.d.ts +1 -0
- package/dist/test/helpers/extension-test-environment.js +9 -0
- package/dist/test/helpers/extension.d.ts +0 -11
- package/dist/test/helpers/extension.js +1 -75
- package/dist/test/history.integration.test.js +17 -19
- package/dist/test/notes-library.test.js +17 -0
- package/dist/test/notes.integration.test.js +47 -31
- package/dist/test/notes.test.js +51 -44
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/src/context/budget.ts +35 -18
- package/src/context/prompts.ts +3 -3
- package/src/dream/doctor.ts +0 -3
- package/src/notes/frontmatter.ts +1 -5
- package/src/notes/store.ts +24 -37
- package/src/protocol.ts +4 -4
- package/src/text-match.ts +13 -0
- package/src/tool-output.ts +2 -14
package/dist/src/tool-output.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { earliestMatchOffsetChars } from "./text-match.js";
|
|
1
2
|
export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
|
|
2
3
|
export const DEFAULT_READ_WINDOW_CHARS = 12000;
|
|
3
4
|
export const MAX_READ_WINDOW_CHARS = 50000;
|
|
@@ -111,21 +112,6 @@ export function readWindowBlock(identity, window) {
|
|
|
111
112
|
const fields = identity.map(([name, value]) => `${name}: ${value}`).join("\n");
|
|
112
113
|
return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
|
|
113
114
|
}
|
|
114
|
-
/**
|
|
115
|
-
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
116
|
-
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
117
|
-
*/
|
|
118
|
-
export function earliestMatchOffsetChars(text, queries) {
|
|
119
|
-
let earliest = -1;
|
|
120
|
-
for (const query of queries) {
|
|
121
|
-
const index = text.indexOf(query);
|
|
122
|
-
if (index < 0)
|
|
123
|
-
continue;
|
|
124
|
-
if (earliest < 0 || index < earliest)
|
|
125
|
-
earliest = index;
|
|
126
|
-
}
|
|
127
|
-
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
128
|
-
}
|
|
129
115
|
/**
|
|
130
116
|
* Build a page without ever adding an item that would exceed the wire budget.
|
|
131
117
|
*
|
|
@@ -127,7 +127,7 @@ async function openFixture(options) {
|
|
|
127
127
|
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE && entry.data?.windowId === windowId), "notification follows the reset marker commit");
|
|
128
128
|
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId), "notification follows the reset boot commit");
|
|
129
129
|
}
|
|
130
|
-
if (type === "warning" &&
|
|
130
|
+
if (type === "warning" && /^pi-context: Context (?:almost full|running low)/.test(message))
|
|
131
131
|
budgetNotices++;
|
|
132
132
|
notices.push(message);
|
|
133
133
|
},
|
|
@@ -213,30 +213,98 @@ function assertFreshRequest(fixture, requestIndex, oldSentinel) {
|
|
|
213
213
|
assert.ok(body.includes(CONTEXT_WINDOW_OPEN_TAG), "the new provider request includes the fresh context-window boot");
|
|
214
214
|
assert.equal(body.split(CONTINUATION).length - 1, 1, "the fresh window carries exactly one reset message");
|
|
215
215
|
}
|
|
216
|
-
test("real AgentSession: aborted
|
|
216
|
+
test("real AgentSession: aborted early guidance retries silently with automatic reset disabled", async () => {
|
|
217
217
|
let fixture;
|
|
218
218
|
fixture = await openFixture({
|
|
219
219
|
compactionEnabled: false,
|
|
220
220
|
script: (request) => assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 2 ? "aborted" : "stop", { usage: usage(50_000) }),
|
|
221
221
|
});
|
|
222
222
|
try {
|
|
223
|
-
const
|
|
223
|
+
const guidance = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
|
|
224
224
|
const notices = () => fixture.budgetNotices();
|
|
225
225
|
await fixture.session.prompt("Establish usage below the reminder line.");
|
|
226
226
|
await fixture.session.waitForIdle();
|
|
227
|
-
assert.equal(
|
|
227
|
+
assert.equal(guidance().length, 0);
|
|
228
228
|
await fixture.session.prompt("Abort this low-budget request.");
|
|
229
229
|
await fixture.session.waitForIdle();
|
|
230
|
-
assert.equal(
|
|
231
|
-
assert.equal(notices(), 0, "
|
|
230
|
+
assert.equal(guidance().length, 0, "an aborted turn does not commit early guidance");
|
|
231
|
+
assert.equal(notices(), 0, "early guidance never notifies");
|
|
232
232
|
await fixture.session.prompt("Retry successfully.");
|
|
233
233
|
await fixture.session.waitForIdle();
|
|
234
|
-
assert.equal(
|
|
235
|
-
assert.equal(notices(),
|
|
234
|
+
assert.equal(guidance().length, 1, "the successful retry commits the model guidance");
|
|
235
|
+
assert.equal(notices(), 0, "committed early guidance stays UI-silent");
|
|
236
236
|
await fixture.session.prompt("Continue in the same window.");
|
|
237
237
|
await fixture.session.waitForIdle();
|
|
238
|
-
assert.equal(
|
|
239
|
-
assert.equal(notices(),
|
|
238
|
+
assert.equal(guidance().length, 1);
|
|
239
|
+
assert.equal(notices(), 0, "later turns cannot toast the early reminder");
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
fixture.close();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
test("real AgentSession: an aborted final warning stays silent until a successful retry commits it", async () => {
|
|
246
|
+
let fixture;
|
|
247
|
+
fixture = await openFixture({
|
|
248
|
+
compactionEnabled: true,
|
|
249
|
+
script: (request, context) => {
|
|
250
|
+
if (request === 1)
|
|
251
|
+
return assistant(fixture, [{ type: "toolCall", id: "abort-budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
|
|
252
|
+
if (request === 2)
|
|
253
|
+
return assistant(fixture, [{ type: "toolCall", id: "abort-budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
|
|
254
|
+
assert.ok(text(context).includes(WARNING_PROMPT), "the final warning reaches the model on both attempts");
|
|
255
|
+
return assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 3 ? "aborted" : "stop", { usage: usage(60_000) });
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
const warnings = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE);
|
|
260
|
+
const notices = () => fixture.budgetNotices();
|
|
261
|
+
await fixture.session.prompt("Establish usage and reach the final runway.");
|
|
262
|
+
await fixture.session.waitForIdle();
|
|
263
|
+
assert.equal(fixture.requests.length, 3, "budget probes reach one final-warning attempt");
|
|
264
|
+
assert.equal(warnings().length, 0, "the aborted attempt leaves no durable warning");
|
|
265
|
+
assert.equal(notices(), 0, "an uncommitted warning never notifies");
|
|
266
|
+
await fixture.session.prompt("Retry successfully.");
|
|
267
|
+
await fixture.session.waitForIdle();
|
|
268
|
+
assert.equal(warnings().length, 1, "the successful retry commits one hidden warning");
|
|
269
|
+
assert.equal(resetMarkers(fixture).length, 1, "normal close-out resets after the committed warning");
|
|
270
|
+
assert.equal(notices(), 1, "the committed warning notifies once, including after reset");
|
|
271
|
+
assert.equal(fixture.notices.some((notice) => notice.includes("Context running low")), false, "early guidance remains UI-silent");
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
fixture.close();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
test("real AgentSession: final warning survives its reset boundary and notifies once", { timeout: 20000 }, async () => {
|
|
278
|
+
let fixture;
|
|
279
|
+
fixture = await openFixture({
|
|
280
|
+
compactionEnabled: true,
|
|
281
|
+
script: (request, context) => {
|
|
282
|
+
if (request === 1)
|
|
283
|
+
return assistant(fixture, [{ type: "toolCall", id: "final-budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
|
|
284
|
+
if (request === 2)
|
|
285
|
+
return assistant(fixture, [{ type: "toolCall", id: "final-budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
|
|
286
|
+
if (request === 3) {
|
|
287
|
+
assert.ok(text(context).includes(WARNING_PROMPT), "the final warning reaches the model");
|
|
288
|
+
return assistant(fixture, [{ type: "toolCall", id: "final-budget-wipe", name: "wipe_memory", arguments: {} }], "toolUse", { usage: usage(60_000) });
|
|
289
|
+
}
|
|
290
|
+
return assistant(fixture, [{ type: "text", text: "fresh window ready" }]);
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
try {
|
|
294
|
+
assert.equal(fixture.notices.length, 0, "boot is silent");
|
|
295
|
+
await fixture.session.prompt("FINAL_WARNING_RESET_SENTINEL");
|
|
296
|
+
await fixture.session.waitForIdle();
|
|
297
|
+
assert.equal(fixture.requests.length, 4, "two budget probes, close-out tool, and fresh-window request run");
|
|
298
|
+
assertFreshRequest(fixture, 3, "FINAL_WARNING_RESET_SENTINEL");
|
|
299
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1);
|
|
300
|
+
assert.equal(resetMarkers(fixture).length, 1, "the reset commits in the same boundary as the final warning");
|
|
301
|
+
assert.equal(fixture.budgetNotices(), 1, "the old-window committed warning reaches the UI after reset");
|
|
302
|
+
assert.equal(fixture.notices.filter((notice) => notice.startsWith("pi-context: memory cleared · ")).length, 1, "reset notification remains intact");
|
|
303
|
+
assert.equal(fixture.notices.some((notice) => notice.includes("Context running low")), false, "early guidance stays silent");
|
|
304
|
+
await fixture.session.prompt("Continue after reset.");
|
|
305
|
+
await fixture.session.waitForIdle();
|
|
306
|
+
assert.equal(fixture.budgetNotices(), 1, "later turns cannot repeat the consumed final warning");
|
|
307
|
+
assert.equal(fixture.notices.filter((notice) => notice.startsWith("pi-context: memory cleared · ")).length, 1, "the prior reset notification is also deduplicated");
|
|
240
308
|
}
|
|
241
309
|
finally {
|
|
242
310
|
fixture.close();
|
|
@@ -4,11 +4,9 @@ import test from "node:test";
|
|
|
4
4
|
import { historyFromSession, internal } from "../src/index.js";
|
|
5
5
|
import { listNotes } from "./helpers/notes.js";
|
|
6
6
|
import { CONTINUATION_TYPE, WARNING_TYPE } from "../src/protocol.js";
|
|
7
|
-
import { appendText, call, commitTurnEndBoundary, context,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
11
|
-
test.after(() => testEnvironment.dispose());
|
|
7
|
+
import { appendText, call, commitTurnEndBoundary, context, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
|
|
8
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
9
|
+
const testEnvironment = installExtensionTestHooks("pi-context-integration");
|
|
12
10
|
test("custom reset marker removes old provider context while history remains searchable", async () => {
|
|
13
11
|
const sessionManager = manager();
|
|
14
12
|
const captured = makeExtension(sessionManager);
|
|
@@ -50,7 +48,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
|
|
|
50
48
|
const ctx = context(sessionManager);
|
|
51
49
|
appendText(sessionManager, "user", "task before reset");
|
|
52
50
|
appendText(sessionManager, "assistant", "working");
|
|
53
|
-
await call(captured, "notes_write", {
|
|
51
|
+
await call(captured, "notes_write", { address: "decisions.md", content: "use terra" }, ctx);
|
|
54
52
|
// Root window: session_start persists the boot block without triggering a turn.
|
|
55
53
|
await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
|
|
56
54
|
assert.equal(captured.sent.length, 1);
|
|
@@ -71,7 +69,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
|
|
|
71
69
|
assert.ok(rootText.includes("decisions.md"));
|
|
72
70
|
const decisionsMeta = (await listNotes(ctx, { scope: "session" })).find((row) => row.path === "decisions.md")?.meta;
|
|
73
71
|
assert.ok(decisionsMeta);
|
|
74
|
-
assert.match(rootText,
|
|
72
|
+
assert.match(rootText, /· \d+s ago/, "boot note metadata carries a relative update time");
|
|
75
73
|
assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
|
|
76
74
|
// Reset: the marker and boot are committed together at the turn boundary.
|
|
77
75
|
await call(captured, "wipe_memory", {}, ctx);
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { internal } from "../src/index.js";
|
|
4
|
-
import { appendText, call, commitTurnEndBoundary, context,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
8
|
-
test.after(() => testEnvironment.dispose());
|
|
4
|
+
import { appendText, call, commitTurnEndBoundary, context, makeExtension, manager, noticesOf, resultJson, runContextHook, runHandlers, sentOf, settingsFixture, } from "./helpers/extension.js";
|
|
5
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
6
|
+
const testEnvironment = installExtensionTestHooks("pi-context-integration");
|
|
9
7
|
test("low-budget guidance and warning persist at turn_end, once per active window", async () => {
|
|
10
8
|
const sessionManager = manager();
|
|
11
9
|
const captured = makeExtension(sessionManager);
|
|
@@ -24,6 +22,23 @@ test("low-budget guidance and warning persist at turn_end, once per active windo
|
|
|
24
22
|
assert.equal(warningBoundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.WARNING_TYPE).length, 1);
|
|
25
23
|
assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.WARNING_TYPE).length, 1);
|
|
26
24
|
});
|
|
25
|
+
test("early guidance stays silent and the committed final warning notifies once", async () => {
|
|
26
|
+
const sm = manager();
|
|
27
|
+
const captured = makeExtension(sm);
|
|
28
|
+
const early = context(sm, undefined, { tokens: 165_000, percent: 82.5, contextWindow: 200_000 });
|
|
29
|
+
assert.equal(await runContextHook(captured, early), undefined, "early guidance is persisted at turn_end, not injected immediately");
|
|
30
|
+
await commitTurnEndBoundary(captured, sm, early);
|
|
31
|
+
await runHandlers(captured, "agent_settled", {}, early);
|
|
32
|
+
assert.equal(noticesOf(early).filter((notice) => notice.message.includes("Context running low") || notice.message.includes("Context almost full")).length, 0, "early model guidance has no UI toast");
|
|
33
|
+
assert.equal(sm.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.GUIDANCE_TYPE).length, 1, "early guidance remains durable for the model");
|
|
34
|
+
const final = context(sm, undefined, { tokens: 181_000, percent: 90.5, contextWindow: 200_000 });
|
|
35
|
+
const warning = await runContextHook(captured, final);
|
|
36
|
+
assert.equal(warning?.messages[0]?.customType, internal.WARNING_TYPE);
|
|
37
|
+
await commitTurnEndBoundary(captured, sm, final);
|
|
38
|
+
await runHandlers(captured, "turn_start", {}, final);
|
|
39
|
+
await runHandlers(captured, "agent_settled", {}, final);
|
|
40
|
+
assert.equal(noticesOf(final).filter((notice) => notice.message === "pi-context: Context almost full; close out the current memory window.").length, 1, "only the committed final warning notifies, exactly once");
|
|
41
|
+
});
|
|
27
42
|
test("the visible countdown ends at the warning line, clamps at zero, and preserves unknown usage", async () => {
|
|
28
43
|
const fixture = settingsFixture({
|
|
29
44
|
reserveTokens: 16_384,
|
package/dist/test/doctor.test.js
CHANGED
|
@@ -6,13 +6,12 @@ import test from "node:test";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
const note = "---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00Z\nupdatedAt: 2026-01-01T00:00:00Z\nlastAccessed: 2026-01-01T00:00:00Z\naccessCount: 0\n---\n\n";
|
|
9
|
-
test("doctor
|
|
10
|
-
const root = mkdtempSync(join(tmpdir(), "dream-doctor-
|
|
9
|
+
test("doctor ignores unknown frontmatter keys while validating canonical metadata", (t) => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "dream-doctor-extra-"));
|
|
11
11
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
12
12
|
mkdirSync(join(root, "human"));
|
|
13
|
-
writeFileSync(join(root, "human/
|
|
14
|
-
|
|
15
|
-
assert.ok(issues.some((issue) => issue.includes("legacy metadata key created_at; manually migrate to createdAt")));
|
|
13
|
+
writeFileSync(join(root, "human/extra.md"), note.replace("accessCount: 0", "accessCount: 0\ncreated_at: 2025-01-01\nupdated_at: 2025-01-01\nlast_accessed: 2025-01-01\naccess_count: 7\nsource_window: old\nrecurrence_count: 2\nrecurrence_windows: old"));
|
|
14
|
+
assert.deepEqual(doctor(root), [], "unknown fields have no special diagnostics");
|
|
16
15
|
});
|
|
17
16
|
test("doctor validates without repairing files or running the dreamer", async () => {
|
|
18
17
|
const root = mkdtempSync(join(tmpdir(), "dream-doctor-test-"));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function installExtensionTestHooks(prefix: string): import("./extension.js").ExtensionTestEnvironment;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import { installExtensionTestEnvironment } from "./extension.js";
|
|
3
|
+
export function installExtensionTestHooks(prefix) {
|
|
4
|
+
const environment = installExtensionTestEnvironment(prefix);
|
|
5
|
+
test.beforeEach(() => environment.beforeEach());
|
|
6
|
+
test.afterEach(() => environment.afterEach());
|
|
7
|
+
test.after(() => environment.dispose());
|
|
8
|
+
return environment;
|
|
9
|
+
}
|
|
@@ -88,19 +88,8 @@ export type ReadWindow = {
|
|
|
88
88
|
};
|
|
89
89
|
/** Decode either raw read without including its shared metadata block in the payload. */
|
|
90
90
|
export declare function resultRead(result: AgentToolResult<unknown>): ReadWindow;
|
|
91
|
-
/**
|
|
92
|
-
* Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
|
|
93
|
-
* and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
|
|
94
|
-
*/
|
|
95
|
-
export declare function assertLocalIso(value: unknown, epochMs: number, message: string): void;
|
|
96
|
-
/** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
|
|
97
|
-
export declare function assertIsoTimestamp(text: string, message: string): string;
|
|
98
|
-
/** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
|
|
99
|
-
export declare function assertTruncationOf(original: string, actual: string): void;
|
|
100
91
|
export declare function runManualCompact(captured: Captured, ctx: ExtensionContext): Promise<CompactionHookResult>;
|
|
101
92
|
export declare function runHandlers(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<void>;
|
|
102
|
-
export declare function runHandlersAsync(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<unknown[]>;
|
|
103
|
-
export declare function completeRequestedCompaction(ctx: ExtensionContext): void;
|
|
104
93
|
export declare function runCommand(captured: Captured, name: string, args: string, ctx: ExtensionContext): Promise<Notice[]>;
|
|
105
94
|
export type ContextHookResult = {
|
|
106
95
|
messages: unknown[];
|
|
@@ -166,41 +166,12 @@ export function sentOf(captured, customType) {
|
|
|
166
166
|
export async function call(captured, name, params, ctx) {
|
|
167
167
|
const tool = captured.tools.get(name);
|
|
168
168
|
assert.ok(tool, `registered ${name}`);
|
|
169
|
-
// Most pre-redesign coverage names session notes by their bare address. Keep these old
|
|
170
|
-
// fixture call sites readable while routing the direct tool invocation through its new
|
|
171
|
-
// address-shaped input; contract-specific tests below pass address themselves.
|
|
172
|
-
const noteCall = name === "notes_write" || name === "notes_edit" || name === "notes_read";
|
|
173
|
-
if (noteCall && "path" in params && !("address" in params)) {
|
|
174
|
-
const { path, scope, ...rest } = params;
|
|
175
|
-
assert.equal(typeof path, "string", "legacy note fixture path is a string");
|
|
176
|
-
const address = scope === "project" ? `@project/${path}` : scope === "human" ? `@human/${path}` : path;
|
|
177
|
-
return tool.execute("call-1", { ...rest, address }, new AbortController().signal, () => { }, ctx);
|
|
178
|
-
}
|
|
179
|
-
if ((name === "notes_list" || name === "notes_search") && params.scope === "human") {
|
|
180
|
-
const { scope: _scope, pattern, ...rest } = params;
|
|
181
|
-
return tool.execute("call-1", { ...rest, pattern: `@human/${typeof pattern === "string" ? pattern : "**"}` }, new AbortController().signal, () => { }, ctx);
|
|
182
|
-
}
|
|
183
|
-
if ((name === "notes_list" || name === "notes_search") && params.scope === "session") {
|
|
184
|
-
const { scope: _scope, pattern, ...rest } = params;
|
|
185
|
-
return tool.execute("call-1", { ...rest, pattern: typeof pattern === "string" ? pattern : "*.md" }, new AbortController().signal, () => { }, ctx);
|
|
186
|
-
}
|
|
187
169
|
return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
|
|
188
170
|
}
|
|
189
171
|
export function resultJson(result) {
|
|
190
172
|
const text = result.content[0];
|
|
191
173
|
assert.ok(text && text.type === "text", "tool result carries text");
|
|
192
|
-
|
|
193
|
-
const suffix = (address) => address.startsWith("@project/") ? address.slice("@project/".length) : address.startsWith("@human/") ? address.slice("@human/".length) : address;
|
|
194
|
-
const legacyPath = (row) => {
|
|
195
|
-
if (typeof row.address === "string" && row.path === undefined)
|
|
196
|
-
Object.defineProperty(row, "path", { value: suffix(row.address), enumerable: false });
|
|
197
|
-
};
|
|
198
|
-
legacyPath(value);
|
|
199
|
-
if (Array.isArray(value.files))
|
|
200
|
-
for (const file of value.files)
|
|
201
|
-
if (file && typeof file === "object")
|
|
202
|
-
legacyPath(file);
|
|
203
|
-
return value;
|
|
174
|
+
return JSON.parse(text.text);
|
|
204
175
|
}
|
|
205
176
|
/** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
|
|
206
177
|
export function assertWithinBudget(result, message) {
|
|
@@ -223,32 +194,6 @@ export function resultRead(result) {
|
|
|
223
194
|
assert.equal(Array.from(content).length, end - offset_chars, "READ WINDOW range matches the delivered payload");
|
|
224
195
|
return { header, content, offset_chars, total_chars, next_offset_chars, details: (result.details ?? {}) };
|
|
225
196
|
}
|
|
226
|
-
/**
|
|
227
|
-
* Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
|
|
228
|
-
* and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
|
|
229
|
-
*/
|
|
230
|
-
export function assertLocalIso(value, epochMs, message) {
|
|
231
|
-
assert.equal(typeof value, "string", message);
|
|
232
|
-
assert.match(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/, message);
|
|
233
|
-
assert.equal(Date.parse(value), epochMs, `${message}: Date.parse restores the stored epoch ms`);
|
|
234
|
-
}
|
|
235
|
-
/** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
|
|
236
|
-
export function assertIsoTimestamp(text, message) {
|
|
237
|
-
const match = text.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}/);
|
|
238
|
-
assert.ok(match, message);
|
|
239
|
-
assert.equal(Number.isNaN(Date.parse(match[0])), false, `${message}: timestamp parses`);
|
|
240
|
-
return match[0];
|
|
241
|
-
}
|
|
242
|
-
/** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
|
|
243
|
-
export function assertTruncationOf(original, actual) {
|
|
244
|
-
const match = actual.match(/^([\s\S]*)…\[truncated \d+ chars\]…([\s\S]*)$/);
|
|
245
|
-
assert.ok(match, "truncated value carries the middle-truncation marker");
|
|
246
|
-
const head = match[1];
|
|
247
|
-
const tail = match[2];
|
|
248
|
-
assert.ok(original.startsWith(head), "truncation keeps the original head");
|
|
249
|
-
assert.ok(original.endsWith(tail), "truncation keeps the original tail");
|
|
250
|
-
assert.ok(head.length + tail.length < original.length, "truncation actually removes characters");
|
|
251
|
-
}
|
|
252
197
|
export async function runManualCompact(captured, ctx) {
|
|
253
198
|
const handler = captured.handlers.get("session_before_compact")?.[0];
|
|
254
199
|
assert.ok(handler, "session_before_compact handler registered");
|
|
@@ -282,25 +227,6 @@ export async function runHandlers(captured, name, event, ctx) {
|
|
|
282
227
|
ctx.isIdle = isIdle;
|
|
283
228
|
}
|
|
284
229
|
}
|
|
285
|
-
export async function runHandlersAsync(captured, name, event, ctx) {
|
|
286
|
-
const results = [];
|
|
287
|
-
for (const handler of captured.handlers.get(name) ?? [])
|
|
288
|
-
results.push(await handler(event, ctx));
|
|
289
|
-
return results;
|
|
290
|
-
}
|
|
291
|
-
export function completeRequestedCompaction(ctx) {
|
|
292
|
-
const requests = ctx.compactionRequests;
|
|
293
|
-
const options = requests.shift();
|
|
294
|
-
assert.ok(options?.onComplete, "a reset request has a completion callback");
|
|
295
|
-
const isIdle = ctx.isIdle;
|
|
296
|
-
ctx.isIdle = () => true;
|
|
297
|
-
try {
|
|
298
|
-
options.onComplete({});
|
|
299
|
-
}
|
|
300
|
-
finally {
|
|
301
|
-
ctx.isIdle = isIdle;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
230
|
export async function runCommand(captured, name, args, ctx) {
|
|
305
231
|
const command = captured.commands.get(name);
|
|
306
232
|
assert.ok(command, `${name} command registered`);
|
|
@@ -2,11 +2,9 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { historyFromSession } from "../src/index.js";
|
|
4
4
|
import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
|
|
5
|
-
import { appendText, assertWithinBudget, call, context,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
9
|
-
test.after(() => testEnvironment.dispose());
|
|
5
|
+
import { appendText, assertWithinBudget, call, context, makeExtension, manager, objectSchema, resultJson, resultRead, } from "./helpers/extension.js";
|
|
6
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
7
|
+
const testEnvironment = installExtensionTestHooks("pi-context-integration");
|
|
10
8
|
test("schemas cover the History/Notes actions plus reset controls", () => {
|
|
11
9
|
const captured = makeExtension(manager());
|
|
12
10
|
for (const name of [
|
|
@@ -94,7 +92,7 @@ test("paged tool outputs stay bounded and cursors reconstruct history and notes"
|
|
|
94
92
|
}
|
|
95
93
|
assert.equal(readParts.join(""), historyText);
|
|
96
94
|
for (let index = 0; index < 100; index++) {
|
|
97
|
-
await call(captured, "notes_write", {
|
|
95
|
+
await call(captured, "notes_write", { address: `page-${"x".repeat(120)}-${index}.md`, content: Array.from({ length: 1000 }, (_, line) => `needle ${line} ${"z".repeat(30)}`).join("\n") }, ctx);
|
|
98
96
|
}
|
|
99
97
|
const listPages = [];
|
|
100
98
|
let listOffset = 0;
|
|
@@ -102,7 +100,7 @@ test("paged tool outputs stay bounded and cursors reconstruct history and notes"
|
|
|
102
100
|
while (listNext !== null) {
|
|
103
101
|
const result = resultJson(await call(captured, "notes_list", { max_results: 300, cursor: listOffset }, ctx));
|
|
104
102
|
assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
105
|
-
listPages.push(...result.files.map((file) => file.
|
|
103
|
+
listPages.push(...result.files.map((file) => file.address));
|
|
106
104
|
listNext = result.next_cursor;
|
|
107
105
|
if (listNext !== null)
|
|
108
106
|
listOffset = listNext;
|
|
@@ -127,7 +125,7 @@ test("paged tool outputs stay bounded and cursors reconstruct history and notes"
|
|
|
127
125
|
let noteOffset = 0;
|
|
128
126
|
let noteNext = 0;
|
|
129
127
|
while (noteNext !== null) {
|
|
130
|
-
const raw = await call(captured, "notes_read", {
|
|
128
|
+
const raw = await call(captured, "notes_read", { address: `page-${"x".repeat(120)}-0.md`, offset_chars: noteOffset }, ctx);
|
|
131
129
|
assertWithinBudget(raw, `notes_read page at ${noteOffset}`);
|
|
132
130
|
const result = resultRead(raw);
|
|
133
131
|
// The window is a plain prefix of the file, so the pages join by plain concatenation.
|
|
@@ -172,7 +170,7 @@ test("a page cap limits the page, not the enumerable set: cursors stay truthful
|
|
|
172
170
|
assert.equal(searchTail.next_cursor, null);
|
|
173
171
|
// notes_search: max_files caps the page, not the matched files.
|
|
174
172
|
for (let index = 0; index < 7; index++)
|
|
175
|
-
await call(captured, "notes_write", {
|
|
173
|
+
await call(captured, "notes_write", { address: `needle-${index}.md`, content: "needle" }, ctx);
|
|
176
174
|
const notes = async (params) => resultJson(await call(captured, "notes_search", params, ctx));
|
|
177
175
|
const notesFirst = await notes({ query: "needle", max_files: 3 });
|
|
178
176
|
assert.equal(notesFirst.files.length, 3);
|
|
@@ -200,18 +198,18 @@ test("multi-query search: OR semantics, dedupe, and bare-string backward compati
|
|
|
200
198
|
assert.deepEqual(await historyIds({ query: ["alpha"] }), [bothId, alphaId], "history: a one-element array searches that literal");
|
|
201
199
|
assert.deepEqual(await historyIds({ query: "alpha" }), orIds.filter((id) => id !== betaId), "history: a bare string still behaves exactly as before");
|
|
202
200
|
assert.deepEqual(await historyIds({ query: "alpha" }), await historyIds({ query: ["alpha"] }), "history: bare string equals the single-element list");
|
|
203
|
-
await call(captured, "notes_write", {
|
|
204
|
-
await call(captured, "notes_write", {
|
|
205
|
-
await call(captured, "notes_write", {
|
|
206
|
-
await call(captured, "notes_write", {
|
|
201
|
+
await call(captured, "notes_write", { address: "both.md", content: "alpha beta\nunrelated" }, ctx);
|
|
202
|
+
await call(captured, "notes_write", { address: "alpha.md", content: "alpha only" }, ctx);
|
|
203
|
+
await call(captured, "notes_write", { address: "beta.md", content: "beta only" }, ctx);
|
|
204
|
+
await call(captured, "notes_write", { address: "gamma.md", content: "gamma only" }, ctx);
|
|
207
205
|
const notesSearch = async (params) => resultJson(await call(captured, "notes_search", params, ctx)).files;
|
|
208
206
|
const orFiles = await notesSearch({ query: ["alpha", "beta"] });
|
|
209
|
-
assert.deepEqual(orFiles.map((file) => file.
|
|
210
|
-
assert.equal(orFiles.find((file) => file.
|
|
211
|
-
assert.deepEqual((await notesSearch({ query: ["alpha"] })).map((file) => file.
|
|
212
|
-
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.
|
|
213
|
-
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.
|
|
214
|
-
assert.deepEqual((await notesSearch({ query: ["gamma"] })).map((file) => file.
|
|
207
|
+
assert.deepEqual(orFiles.map((file) => file.address), ["alpha.md", "beta.md", "both.md"], "notes: a file matching any query is returned once, address-ordered");
|
|
208
|
+
assert.equal(orFiles.find((file) => file.address === "both.md")?.matches.length, 1, "notes: one line containing both queries is reported once");
|
|
209
|
+
assert.deepEqual((await notesSearch({ query: ["alpha"] })).map((file) => file.address), ["alpha.md", "both.md"], "notes: a one-element array searches that literal");
|
|
210
|
+
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.address), ["alpha.md", "both.md"], "notes: a bare string still behaves exactly as before");
|
|
211
|
+
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.address), (await notesSearch({ query: ["alpha"] })).map((file) => file.address), "notes: bare string equals the single-element list");
|
|
212
|
+
assert.deepEqual((await notesSearch({ query: ["gamma"] })).map((file) => file.address), ["gamma.md"]);
|
|
215
213
|
// An empty array is an argument error, not a silently empty result set.
|
|
216
214
|
await assert.rejects(() => call(captured, "history_search", { query: [] }, ctx), /non-empty array of strings/, "history: empty query array is refused");
|
|
217
215
|
await assert.rejects(() => call(captured, "notes_search", { query: [] }, ctx), /non-empty array of strings/, "notes: empty query array is refused");
|
|
@@ -91,6 +91,23 @@ test("stores snapshot explicit identity and do not leak homes across instances",
|
|
|
91
91
|
assert.equal((await notes.read("@agents/visitor/hello"))?.body, "visiting");
|
|
92
92
|
await assert.rejects(() => notes.write("@agents/visitor/hello", "overwrite"), (error) => error instanceof NoteError && error.code === "invalid_scope");
|
|
93
93
|
});
|
|
94
|
+
test("list and search share scoped pattern scans and search offsets count Unicode code points", async (t) => {
|
|
95
|
+
const { notes } = fixture(t);
|
|
96
|
+
await notes.write("shared.md", "😀 needle in session");
|
|
97
|
+
await notes.write("@project/shared.md", "needle in project");
|
|
98
|
+
await notes.write("@human/shared.md", "needle in human");
|
|
99
|
+
await notes.write("@project/elsewhere.md", "unrelated");
|
|
100
|
+
const pattern = "**/shared.md";
|
|
101
|
+
const listed = await notes.list({ pattern });
|
|
102
|
+
const searched = await notes.search(["needle"], { pattern });
|
|
103
|
+
const listedAddresses = listed.map((row) => row.address).sort();
|
|
104
|
+
const searchedAddresses = searched.map((row) => row.address).sort();
|
|
105
|
+
assert.deepEqual(searchedAddresses, ["@human/shared.md", "@project/shared.md", "shared.md"]);
|
|
106
|
+
assert.deepEqual(searchedAddresses, listedAddresses, "list and search traverse the same filtered homes and files");
|
|
107
|
+
const sessionMatch = searched.find((row) => row.address === "shared.md").matches[0];
|
|
108
|
+
const serialized = (await notes.read("shared.md")).text;
|
|
109
|
+
assert.ok(Array.from(serialized).slice(sessionMatch.offsetChars).join("").startsWith("needle"), "the absolute offset counts the emoji as one code point");
|
|
110
|
+
});
|
|
94
111
|
test("invalid addressing and failed edits leave stored bytes untouched without poisoning the queue", async (t) => {
|
|
95
112
|
const { home, context, notes } = fixture(t);
|
|
96
113
|
assert.throws(() => createNotesStore({ ...context, sessionId: "../escape" }));
|