@astrosheep/pi-context 0.26.0 → 0.26.1
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/dist/build-info.json +2 -2
- package/dist/extension.js +64 -56
- package/dist/src/context/budget.js +37 -18
- package/dist/src/notes/store.js +24 -40
- 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 +4 -6
- package/dist/test/budget-settings.integration.test.js +20 -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 +29 -31
- package/dist/test/notes.test.js +37 -39
- package/package.json +1 -1
- package/src/context/budget.ts +35 -18
- package/src/notes/store.ts +24 -37
- package/src/text-match.ts +13 -0
- package/src/tool-output.ts +2 -14
package/dist/build-info.json
CHANGED
package/dist/extension.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// <define:__PI_CONTEXT_BUILD__>
|
|
2
|
-
var define_PI_CONTEXT_BUILD_default = { version: "0.26.
|
|
2
|
+
var define_PI_CONTEXT_BUILD_default = { version: "0.26.1", sourceHash: "fb313022cd1aab93e422144e8f336eced42b67c4d35b89a1133be2254f066093" };
|
|
3
3
|
|
|
4
4
|
// src/index.ts
|
|
5
5
|
import { VERSION as VERSION2 } from "@earendil-works/pi-coding-agent";
|
|
@@ -8,6 +8,17 @@ import { VERSION as VERSION2 } from "@earendil-works/pi-coding-agent";
|
|
|
8
8
|
import { Type as Type2 } from "@earendil-works/pi-ai";
|
|
9
9
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
|
|
11
|
+
// src/text-match.ts
|
|
12
|
+
function earliestMatchOffsetChars(text, queries) {
|
|
13
|
+
let earliest = -1;
|
|
14
|
+
for (const query of queries) {
|
|
15
|
+
const index = text.indexOf(query);
|
|
16
|
+
if (index < 0) continue;
|
|
17
|
+
if (earliest < 0 || index < earliest) earliest = index;
|
|
18
|
+
}
|
|
19
|
+
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
// src/tool-output.ts
|
|
12
23
|
var TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
|
|
13
24
|
var DEFAULT_READ_WINDOW_CHARS = 12e3;
|
|
@@ -76,15 +87,6 @@ chars: [${window.offset_chars},${end}) of ${window.total_chars}
|
|
|
76
87
|
next_offset_chars: ${next}
|
|
77
88
|
`;
|
|
78
89
|
}
|
|
79
|
-
function earliestMatchOffsetChars(text, queries) {
|
|
80
|
-
let earliest = -1;
|
|
81
|
-
for (const query of queries) {
|
|
82
|
-
const index = text.indexOf(query);
|
|
83
|
-
if (index < 0) continue;
|
|
84
|
-
if (earliest < 0 || index < earliest) earliest = index;
|
|
85
|
-
}
|
|
86
|
-
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
87
|
-
}
|
|
88
90
|
function page(items, cursor2, key, limit, truncate) {
|
|
89
91
|
const end = Math.min(items.length, cursor2 + (limit ?? items.length));
|
|
90
92
|
const selected = [];
|
|
@@ -1003,15 +1005,6 @@ function matchLineNumbers(body, needle) {
|
|
|
1003
1005
|
}
|
|
1004
1006
|
return lines;
|
|
1005
1007
|
}
|
|
1006
|
-
function earliestMatchOffsetChars2(text, queries) {
|
|
1007
|
-
let earliest = -1;
|
|
1008
|
-
for (const query of queries) {
|
|
1009
|
-
const index = text.indexOf(query);
|
|
1010
|
-
if (index < 0) continue;
|
|
1011
|
-
if (earliest < 0 || index < earliest) earliest = index;
|
|
1012
|
-
}
|
|
1013
|
-
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
1014
|
-
}
|
|
1015
1008
|
async function atomicWrite(path, content) {
|
|
1016
1009
|
await mkdir(dirname2(path), { recursive: true });
|
|
1017
1010
|
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -1156,11 +1149,10 @@ function createNotesStore(input) {
|
|
|
1156
1149
|
return { meta, applied: operations.length, resolvedScope: scope, change };
|
|
1157
1150
|
});
|
|
1158
1151
|
}
|
|
1159
|
-
async function
|
|
1160
|
-
const
|
|
1161
|
-
const
|
|
1162
|
-
const
|
|
1163
|
-
for (const home of await homesFor(context, stableOptions)) {
|
|
1152
|
+
async function* scan(options) {
|
|
1153
|
+
const matcher = matcherFor(normalizePattern(options.pattern, context));
|
|
1154
|
+
const homes = await homesFor(context, options);
|
|
1155
|
+
for (const home of homes) {
|
|
1164
1156
|
const scope = home.scope;
|
|
1165
1157
|
const root = scopeDir(scope, context, home.who);
|
|
1166
1158
|
for (const path of await walkMarkdown(root)) {
|
|
@@ -1170,38 +1162,35 @@ function createNotesStore(input) {
|
|
|
1170
1162
|
const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
|
|
1171
1163
|
const { meta, body } = parseNote(raw);
|
|
1172
1164
|
meta.scope = scope;
|
|
1173
|
-
|
|
1165
|
+
yield { address, scope, path, meta, body };
|
|
1174
1166
|
}
|
|
1175
1167
|
}
|
|
1168
|
+
}
|
|
1169
|
+
async function list(options = {}) {
|
|
1170
|
+
const stableOptions = { ...options };
|
|
1171
|
+
const rows = [];
|
|
1172
|
+
for await (const row of scan(stableOptions)) {
|
|
1173
|
+
rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
|
|
1174
|
+
}
|
|
1176
1175
|
rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
|
|
1177
1176
|
return rows;
|
|
1178
1177
|
}
|
|
1179
1178
|
async function search(queries, options = {}) {
|
|
1180
1179
|
const stableQueries = [...queries];
|
|
1181
1180
|
const stableOptions = { ...options };
|
|
1182
|
-
const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
|
|
1183
1181
|
const rows = [];
|
|
1184
|
-
for (const
|
|
1185
|
-
const scope =
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
const { meta, body } = parseNote(raw);
|
|
1193
|
-
meta.scope = scope;
|
|
1194
|
-
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
1195
|
-
let baseChars = 0;
|
|
1196
|
-
const matches = [];
|
|
1197
|
-
for (const [index, line] of body.split("\n").entries()) {
|
|
1198
|
-
if (stableQueries.some((query) => line.includes(query))) {
|
|
1199
|
-
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars2(line, stableQueries) });
|
|
1200
|
-
}
|
|
1201
|
-
baseChars += Array.from(line).length + 1;
|
|
1182
|
+
for await (const note of scan(stableOptions)) {
|
|
1183
|
+
const { address, path, scope, meta, body } = note;
|
|
1184
|
+
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
1185
|
+
let baseChars = 0;
|
|
1186
|
+
const matches = [];
|
|
1187
|
+
for (const [index, line] of body.split("\n").entries()) {
|
|
1188
|
+
if (stableQueries.some((query) => line.includes(query))) {
|
|
1189
|
+
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
1202
1190
|
}
|
|
1203
|
-
|
|
1191
|
+
baseChars += Array.from(line).length + 1;
|
|
1204
1192
|
}
|
|
1193
|
+
if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
|
|
1205
1194
|
}
|
|
1206
1195
|
rows.sort((a, b) => a.address.localeCompare(b.address));
|
|
1207
1196
|
return rows;
|
|
@@ -1556,14 +1545,29 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1556
1545
|
};
|
|
1557
1546
|
let pendingGuidance;
|
|
1558
1547
|
let pendingWarning;
|
|
1559
|
-
let pendingNotices =
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1548
|
+
let pendingNotices = /* @__PURE__ */ new Map();
|
|
1549
|
+
const noticeKey = (sessionId, windowId) => `${sessionId}:${windowId}`;
|
|
1550
|
+
const warningCommittedInWindow = (ctx, notice) => {
|
|
1551
|
+
if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
|
|
1552
|
+
let windowId = rootWindowId(notice.sessionId);
|
|
1553
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1554
|
+
if (isWindowMarker(entry)) {
|
|
1555
|
+
windowId = entry.data.windowId;
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
|
|
1559
|
+
}
|
|
1560
|
+
return false;
|
|
1561
|
+
};
|
|
1562
|
+
const notifyCommittedWarnings = (ctx, settled = false) => {
|
|
1563
|
+
for (const [key, notice] of pendingNotices) {
|
|
1564
|
+
if (warningCommittedInWindow(ctx, notice)) {
|
|
1565
|
+
pendingNotices.delete(key);
|
|
1566
|
+
ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
|
|
1567
|
+
} else if (settled) {
|
|
1568
|
+
pendingNotices.delete(key);
|
|
1569
|
+
}
|
|
1565
1570
|
}
|
|
1566
|
-
pendingNotices = [];
|
|
1567
1571
|
};
|
|
1568
1572
|
const clearStaged = () => {
|
|
1569
1573
|
pendingGuidance = void 0;
|
|
@@ -1571,7 +1575,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1571
1575
|
};
|
|
1572
1576
|
const resetForTransition = () => {
|
|
1573
1577
|
clearStaged();
|
|
1574
|
-
pendingNotices
|
|
1578
|
+
pendingNotices.clear();
|
|
1575
1579
|
invalidateThresholds();
|
|
1576
1580
|
notifiedWarnings.clear();
|
|
1577
1581
|
};
|
|
@@ -1583,7 +1587,10 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1583
1587
|
clearStaged();
|
|
1584
1588
|
const windowId = currentWindowId(ctx);
|
|
1585
1589
|
const drafts = staged.filter((draft) => draft !== void 0 && draft.windowId === windowId);
|
|
1586
|
-
|
|
1590
|
+
if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
|
|
1591
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1592
|
+
pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
|
|
1593
|
+
}
|
|
1587
1594
|
return drafts.map((draft) => ({
|
|
1588
1595
|
type: "custom_message",
|
|
1589
1596
|
customType: draft.customType,
|
|
@@ -1598,8 +1605,9 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1598
1605
|
pi.on("session_tree", resetForTransition);
|
|
1599
1606
|
pi.on("model_select", resetForTransition);
|
|
1600
1607
|
pi.on("session_shutdown", resetForTransition);
|
|
1608
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
|
|
1601
1609
|
pi.on("agent_settled", (_event, ctx) => {
|
|
1602
|
-
|
|
1610
|
+
notifyCommittedWarnings(ctx, true);
|
|
1603
1611
|
clearStaged();
|
|
1604
1612
|
});
|
|
1605
1613
|
pi.on("context", (_event, ctx) => {
|
|
@@ -1647,7 +1655,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1647
1655
|
consumeTurnEnd,
|
|
1648
1656
|
clear: () => {
|
|
1649
1657
|
clearStaged();
|
|
1650
|
-
pendingNotices
|
|
1658
|
+
pendingNotices.clear();
|
|
1651
1659
|
}
|
|
1652
1660
|
};
|
|
1653
1661
|
}
|
|
@@ -2,7 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { GUIDANCE_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
|
|
4
4
|
import { readThresholdSettings } from "./thresholds.js";
|
|
5
|
-
import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
|
|
5
|
+
import { currentWindowId, hasWindowMessage, isWindowMarker, rootWindowId, windowUsage } from "./context-window.js";
|
|
6
6
|
import { tokenBudgetGuidance } from "./prompts.js";
|
|
7
7
|
import { output } from "../tool-output.js";
|
|
8
8
|
/** Remaining tokens in the provider's active window, or null without a usable estimate. */
|
|
@@ -42,17 +42,33 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
|
|
|
42
42
|
const invalidateThresholds = () => { cachedPolicy = undefined; };
|
|
43
43
|
let pendingGuidance;
|
|
44
44
|
let pendingWarning;
|
|
45
|
-
let pendingNotices =
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
let pendingNotices = new Map();
|
|
46
|
+
const noticeKey = (sessionId, windowId) => `${sessionId}:${windowId}`;
|
|
47
|
+
const warningCommittedInWindow = (ctx, notice) => {
|
|
48
|
+
if (ctx.sessionManager.getSessionId() !== notice.sessionId)
|
|
49
|
+
return false;
|
|
50
|
+
let windowId = rootWindowId(notice.sessionId);
|
|
51
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
52
|
+
if (isWindowMarker(entry)) {
|
|
53
|
+
windowId = entry.data.windowId;
|
|
50
54
|
continue;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
}
|
|
56
|
+
if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE)
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
};
|
|
61
|
+
const notifyCommittedWarnings = (ctx, settled = false) => {
|
|
62
|
+
for (const [key, notice] of pendingNotices) {
|
|
63
|
+
if (warningCommittedInWindow(ctx, notice)) {
|
|
64
|
+
pendingNotices.delete(key);
|
|
65
|
+
ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
|
|
66
|
+
}
|
|
67
|
+
else if (settled) {
|
|
68
|
+
// An uncommitted draft must not be matched to a later manual warning.
|
|
69
|
+
pendingNotices.delete(key);
|
|
70
|
+
}
|
|
54
71
|
}
|
|
55
|
-
pendingNotices = [];
|
|
56
72
|
};
|
|
57
73
|
const clearStaged = () => {
|
|
58
74
|
pendingGuidance = undefined;
|
|
@@ -60,7 +76,7 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
|
|
|
60
76
|
};
|
|
61
77
|
const resetForTransition = () => {
|
|
62
78
|
clearStaged();
|
|
63
|
-
pendingNotices
|
|
79
|
+
pendingNotices.clear();
|
|
64
80
|
invalidateThresholds();
|
|
65
81
|
notifiedWarnings.clear();
|
|
66
82
|
};
|
|
@@ -72,7 +88,10 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
|
|
|
72
88
|
clearStaged();
|
|
73
89
|
const windowId = currentWindowId(ctx);
|
|
74
90
|
const drafts = staged.filter((draft) => draft !== undefined && draft.windowId === windowId);
|
|
75
|
-
|
|
91
|
+
if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
|
|
92
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
93
|
+
pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
|
|
94
|
+
}
|
|
76
95
|
return drafts.map((draft) => ({
|
|
77
96
|
type: "custom_message",
|
|
78
97
|
customType: draft.customType,
|
|
@@ -84,12 +103,12 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
|
|
|
84
103
|
pi.on("session_tree", resetForTransition);
|
|
85
104
|
pi.on("model_select", resetForTransition);
|
|
86
105
|
pi.on("session_shutdown", resetForTransition);
|
|
87
|
-
// A
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
106
|
+
// A warning can be committed at turn_end, before a tool turn or a reset changes the
|
|
107
|
+
// active window. Observe the active branch at public lifecycle boundaries and match
|
|
108
|
+
// the candidate against its originating window segment, not only the current window.
|
|
109
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
|
|
91
110
|
pi.on("agent_settled", (_event, ctx) => {
|
|
92
|
-
|
|
111
|
+
notifyCommittedWarnings(ctx, true);
|
|
93
112
|
clearStaged();
|
|
94
113
|
});
|
|
95
114
|
pi.on("context", (_event, ctx) => {
|
|
@@ -149,6 +168,6 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
|
|
|
149
168
|
automaticResetEnabled,
|
|
150
169
|
hardReserveDue,
|
|
151
170
|
consumeTurnEnd,
|
|
152
|
-
clear: () => { clearStaged(); pendingNotices
|
|
171
|
+
clear: () => { clearStaged(); pendingNotices.clear(); },
|
|
153
172
|
};
|
|
154
173
|
}
|
package/dist/src/notes/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { earliestMatchOffsetChars } from "../text-match.js";
|
|
4
5
|
import { assertAddress, assertGlobPattern, addressFor, globToRegExp } from "./address.js";
|
|
5
6
|
import { snapshotNotesContext } from "./context.js";
|
|
6
7
|
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./constants.js";
|
|
@@ -149,18 +150,6 @@ function matchLineNumbers(body, needle) {
|
|
|
149
150
|
}
|
|
150
151
|
return lines;
|
|
151
152
|
}
|
|
152
|
-
/** Code-point offset of the earliest query occurrence, matching the serialized read text. */
|
|
153
|
-
function earliestMatchOffsetChars(text, queries) {
|
|
154
|
-
let earliest = -1;
|
|
155
|
-
for (const query of queries) {
|
|
156
|
-
const index = text.indexOf(query);
|
|
157
|
-
if (index < 0)
|
|
158
|
-
continue;
|
|
159
|
-
if (earliest < 0 || index < earliest)
|
|
160
|
-
earliest = index;
|
|
161
|
-
}
|
|
162
|
-
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
163
|
-
}
|
|
164
153
|
/** Every mutation uses a tmp file renamed into place in the same directory. */
|
|
165
154
|
async function atomicWrite(path, content) {
|
|
166
155
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -329,11 +318,10 @@ export function createNotesStore(input) {
|
|
|
329
318
|
return { meta, applied: operations.length, resolvedScope: scope, change };
|
|
330
319
|
});
|
|
331
320
|
}
|
|
332
|
-
async function
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
for (const home of await homesFor(context, stableOptions)) {
|
|
321
|
+
async function* scan(options) {
|
|
322
|
+
const matcher = matcherFor(normalizePattern(options.pattern, context));
|
|
323
|
+
const homes = await homesFor(context, options);
|
|
324
|
+
for (const home of homes) {
|
|
337
325
|
const scope = home.scope;
|
|
338
326
|
const root = scopeDir(scope, context, home.who);
|
|
339
327
|
for (const path of await walkMarkdown(root)) {
|
|
@@ -344,40 +332,36 @@ export function createNotesStore(input) {
|
|
|
344
332
|
const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
|
|
345
333
|
const { meta, body } = parseNote(raw);
|
|
346
334
|
meta.scope = scope;
|
|
347
|
-
|
|
335
|
+
yield { address, scope, path, meta, body };
|
|
348
336
|
}
|
|
349
337
|
}
|
|
338
|
+
}
|
|
339
|
+
async function list(options = {}) {
|
|
340
|
+
const stableOptions = { ...options };
|
|
341
|
+
const rows = [];
|
|
342
|
+
for await (const row of scan(stableOptions)) {
|
|
343
|
+
rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
|
|
344
|
+
}
|
|
350
345
|
rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
|
|
351
346
|
return rows;
|
|
352
347
|
}
|
|
353
348
|
async function search(queries, options = {}) {
|
|
354
349
|
const stableQueries = [...queries];
|
|
355
350
|
const stableOptions = { ...options };
|
|
356
|
-
const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
|
|
357
351
|
const rows = [];
|
|
358
|
-
for (const
|
|
359
|
-
const scope =
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
|
|
367
|
-
const { meta, body } = parseNote(raw);
|
|
368
|
-
meta.scope = scope;
|
|
369
|
-
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
370
|
-
let baseChars = 0;
|
|
371
|
-
const matches = [];
|
|
372
|
-
for (const [index, line] of body.split("\n").entries()) {
|
|
373
|
-
if (stableQueries.some((query) => line.includes(query))) {
|
|
374
|
-
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
375
|
-
}
|
|
376
|
-
baseChars += Array.from(line).length + 1;
|
|
352
|
+
for await (const note of scan(stableOptions)) {
|
|
353
|
+
const { address, path, scope, meta, body } = note;
|
|
354
|
+
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
355
|
+
let baseChars = 0;
|
|
356
|
+
const matches = [];
|
|
357
|
+
for (const [index, line] of body.split("\n").entries()) {
|
|
358
|
+
if (stableQueries.some((query) => line.includes(query))) {
|
|
359
|
+
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
377
360
|
}
|
|
378
|
-
|
|
379
|
-
rows.push({ address, path, scope, meta, matches });
|
|
361
|
+
baseChars += Array.from(line).length + 1;
|
|
380
362
|
}
|
|
363
|
+
if (matches.length > 0)
|
|
364
|
+
rows.push({ address, path, scope, meta, matches });
|
|
381
365
|
}
|
|
382
366
|
rows.sort((a, b) => a.address.localeCompare(b.address));
|
|
383
367
|
return rows;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
3
|
+
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
4
|
+
*/
|
|
5
|
+
export declare function earliestMatchOffsetChars(text: string, queries: string[]): number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
3
|
+
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
4
|
+
*/
|
|
5
|
+
export function earliestMatchOffsetChars(text, queries) {
|
|
6
|
+
let earliest = -1;
|
|
7
|
+
for (const query of queries) {
|
|
8
|
+
const index = text.indexOf(query);
|
|
9
|
+
if (index < 0)
|
|
10
|
+
continue;
|
|
11
|
+
if (earliest < 0 || index < earliest)
|
|
12
|
+
earliest = index;
|
|
13
|
+
}
|
|
14
|
+
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
15
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { earliestMatchOffsetChars } from "./text-match.js";
|
|
1
2
|
export declare const TOOL_OUTPUT_MAX_BYTES: number;
|
|
2
3
|
export declare const DEFAULT_READ_WINDOW_CHARS = 12000;
|
|
3
4
|
export declare const MAX_READ_WINDOW_CHARS = 50000;
|
|
@@ -55,11 +56,6 @@ export declare function readCharacterWindow<T>(text: string, offsetChars: number
|
|
|
55
56
|
* source identity fields in wire order; range and continuation semantics are shared.
|
|
56
57
|
*/
|
|
57
58
|
export declare function readWindowBlock(identity: ReadonlyArray<readonly [string, string]>, window: CharacterWindow): string;
|
|
58
|
-
/**
|
|
59
|
-
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
60
|
-
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
61
|
-
*/
|
|
62
|
-
export declare function earliestMatchOffsetChars(text: string, queries: string[]): number;
|
|
63
59
|
/** Shrink a single page item to fit; only invoked when that item alone exceeds the budget. */
|
|
64
60
|
export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
|
|
65
61
|
/**
|
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);
|