@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/README.md
CHANGED
|
@@ -109,9 +109,9 @@ const matches = await notes.search(["library"]);
|
|
|
109
109
|
|
|
110
110
|
`list` and `search` share the `NotesQuery` type. A merged query uses `{ pattern? }`; a single-home query adds `scope`. Only `scope: "agent" | "model"` accepts `who`. TypeScript rejects combinations such as `{ scope: "project", who: "root" }`, and JavaScript callers receive a runtime refusal.
|
|
111
111
|
|
|
112
|
-
The library returns full data, not tool envelopes or paginated/truncated output. `NoteError` exposes the existing named store refusals through `code`, with `lineNumbers` for ambiguous edits and `editIndex` for a failed edit. Runtime API fields and known persisted note metadata use camelCase; Pi tool wire fields such as `updated_at`, `offset_chars`, and `replace_all` retain their established names.
|
|
112
|
+
The library returns full data, not tool envelopes or paginated/truncated output. `NoteError` exposes the existing named store refusals through `code`, with `lineNumbers` for ambiguous edits and `editIndex` for a failed edit. Runtime API fields and known persisted note metadata use camelCase; Pi tool wire fields such as `updated_at`, `offset_chars`, and `replace_all` retain their established names. Unrecognized frontmatter keys, including old snake_case metadata, are preserved as ordinary extras; they are not interpreted as current camelCase fields or migrated automatically. Invalid addresses/identities and filesystem failures reject; only a missing `read` returns `undefined`. Notes remain markdown files with the existing size limits and same-directory atomic rename. Same-file read/modify/write work is serialized by absolute physical filename across all store instances in this process (including `.md` address aliases); symlink/case aliases and cross-process locking are not guaranteed. `list` and `search` asynchronously traverse homes and serialize each discovered file read against pending mutations, but are not global snapshots and may not discover a file created after traversal. Foreign named homes can be read (including the access-metadata update), but their bodies cannot be written or edited through the store. These are cooperative address rules, not an OS security sandbox.
|
|
113
113
|
|
|
114
|
-
Addresses use bare paths, `@project/`, `@human/`, `@self/`, `@model/`, or explicit `@agents/<slug>/` and `@models/<slug>/`. Relative self/model addresses resolve to the supplied identity; listing renders their concrete names. The disk layout remains `pi/session/<sessionId>/`, `project/<projectKey>/`, `human/`, `agents/<agent>/`, and `models/<model>/`. No data migration happens on library import or construction. Notes already using camelCase metadata retain their metadata;
|
|
114
|
+
Addresses use bare paths, `@project/`, `@human/`, `@self/`, `@model/`, or explicit `@agents/<slug>/` and `@models/<slug>/`. Relative self/model addresses resolve to the supplied identity; listing renders their concrete names. The disk layout remains `pi/session/<sessionId>/`, `project/<projectKey>/`, `human/`, `agents/<agent>/`, and `models/<model>/`. No data migration happens on library import or construction. Notes already using camelCase metadata retain their metadata; old snake_case keys are preserved as unrecognized extras, not interpreted or migrated. New session notes record the supplied project key.
|
|
115
115
|
|
|
116
116
|
### Library and plugin boundary
|
|
117
117
|
|
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.2", sourceHash: "b35cc5826ee3ac651b5a18c57833aab912a9ee999af2c2dfc7f8672b74e31f81" };
|
|
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 = [];
|
|
@@ -247,10 +249,10 @@ var WARNING_TYPE = "pi-context/warning";
|
|
|
247
249
|
var RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
248
250
|
var CONTINUATION_TYPE = "pi-context/continuation";
|
|
249
251
|
var POCKET_SESSION_LIMIT = 5;
|
|
250
|
-
var POCKET_PROJECT_LIMIT =
|
|
251
|
-
var POCKET_HUMAN_LIMIT =
|
|
252
|
-
var POCKET_AGENT_LIMIT =
|
|
253
|
-
var POCKET_MODEL_LIMIT =
|
|
252
|
+
var POCKET_PROJECT_LIMIT = 5;
|
|
253
|
+
var POCKET_HUMAN_LIMIT = 5;
|
|
254
|
+
var POCKET_AGENT_LIMIT = 5;
|
|
255
|
+
var POCKET_MODEL_LIMIT = 3;
|
|
254
256
|
var CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
255
257
|
var CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
256
258
|
var CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
|
@@ -592,7 +594,6 @@ var SCOPES = ["session", "project", "human", "agent", "model"];
|
|
|
592
594
|
var ORIGINS = ["user", "self", "external"];
|
|
593
595
|
var STATUSES = ["active", "superseded", "pending", "archived"];
|
|
594
596
|
var TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
|
|
595
|
-
var LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
|
|
596
597
|
var KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
|
|
597
598
|
var pad2 = (value) => String(value).padStart(2, "0");
|
|
598
599
|
function localIso(epochMs) {
|
|
@@ -666,8 +667,6 @@ function parseFrontmatter(raw) {
|
|
|
666
667
|
}
|
|
667
668
|
function parseNote(raw, now = Date.now()) {
|
|
668
669
|
const { fields, body } = parseFrontmatter(raw);
|
|
669
|
-
const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
|
|
670
|
-
if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
|
|
671
670
|
const meta = { ...fields };
|
|
672
671
|
meta.scope = isScope(meta.scope) ? meta.scope : "session";
|
|
673
672
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
@@ -1003,15 +1002,6 @@ function matchLineNumbers(body, needle) {
|
|
|
1003
1002
|
}
|
|
1004
1003
|
return lines;
|
|
1005
1004
|
}
|
|
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
1005
|
async function atomicWrite(path, content) {
|
|
1016
1006
|
await mkdir(dirname2(path), { recursive: true });
|
|
1017
1007
|
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -1156,11 +1146,10 @@ function createNotesStore(input) {
|
|
|
1156
1146
|
return { meta, applied: operations.length, resolvedScope: scope, change };
|
|
1157
1147
|
});
|
|
1158
1148
|
}
|
|
1159
|
-
async function
|
|
1160
|
-
const
|
|
1161
|
-
const
|
|
1162
|
-
const
|
|
1163
|
-
for (const home of await homesFor(context, stableOptions)) {
|
|
1149
|
+
async function* scan(options) {
|
|
1150
|
+
const matcher = matcherFor(normalizePattern(options.pattern, context));
|
|
1151
|
+
const homes = await homesFor(context, options);
|
|
1152
|
+
for (const home of homes) {
|
|
1164
1153
|
const scope = home.scope;
|
|
1165
1154
|
const root = scopeDir(scope, context, home.who);
|
|
1166
1155
|
for (const path of await walkMarkdown(root)) {
|
|
@@ -1170,38 +1159,35 @@ function createNotesStore(input) {
|
|
|
1170
1159
|
const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
|
|
1171
1160
|
const { meta, body } = parseNote(raw);
|
|
1172
1161
|
meta.scope = scope;
|
|
1173
|
-
|
|
1162
|
+
yield { address, scope, path, meta, body };
|
|
1174
1163
|
}
|
|
1175
1164
|
}
|
|
1165
|
+
}
|
|
1166
|
+
async function list(options = {}) {
|
|
1167
|
+
const stableOptions = { ...options };
|
|
1168
|
+
const rows = [];
|
|
1169
|
+
for await (const row of scan(stableOptions)) {
|
|
1170
|
+
rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
|
|
1171
|
+
}
|
|
1176
1172
|
rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
|
|
1177
1173
|
return rows;
|
|
1178
1174
|
}
|
|
1179
1175
|
async function search(queries, options = {}) {
|
|
1180
1176
|
const stableQueries = [...queries];
|
|
1181
1177
|
const stableOptions = { ...options };
|
|
1182
|
-
const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
|
|
1183
1178
|
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;
|
|
1179
|
+
for await (const note of scan(stableOptions)) {
|
|
1180
|
+
const { address, path, scope, meta, body } = note;
|
|
1181
|
+
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
1182
|
+
let baseChars = 0;
|
|
1183
|
+
const matches = [];
|
|
1184
|
+
for (const [index, line] of body.split("\n").entries()) {
|
|
1185
|
+
if (stableQueries.some((query) => line.includes(query))) {
|
|
1186
|
+
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
1202
1187
|
}
|
|
1203
|
-
|
|
1188
|
+
baseChars += Array.from(line).length + 1;
|
|
1204
1189
|
}
|
|
1190
|
+
if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
|
|
1205
1191
|
}
|
|
1206
1192
|
rows.sort((a, b) => a.address.localeCompare(b.address));
|
|
1207
1193
|
return rows;
|
|
@@ -1494,7 +1480,7 @@ function notesIndex(snapshot) {
|
|
|
1494
1480
|
if (recentNotes.length > 0) {
|
|
1495
1481
|
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
|
|
1496
1482
|
for (const row of recentNotes) {
|
|
1497
|
-
lines.push(`- ${row.address}
|
|
1483
|
+
lines.push(`- ${row.address} \xB7 ${Array.from(row.body).length} chars \xB7 ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
|
|
1498
1484
|
}
|
|
1499
1485
|
sections.push(lines.join("\n"));
|
|
1500
1486
|
}
|
|
@@ -1556,14 +1542,29 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1556
1542
|
};
|
|
1557
1543
|
let pendingGuidance;
|
|
1558
1544
|
let pendingWarning;
|
|
1559
|
-
let pendingNotices =
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1545
|
+
let pendingNotices = /* @__PURE__ */ new Map();
|
|
1546
|
+
const noticeKey = (sessionId, windowId) => `${sessionId}:${windowId}`;
|
|
1547
|
+
const warningCommittedInWindow = (ctx, notice) => {
|
|
1548
|
+
if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
|
|
1549
|
+
let windowId = rootWindowId(notice.sessionId);
|
|
1550
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1551
|
+
if (isWindowMarker(entry)) {
|
|
1552
|
+
windowId = entry.data.windowId;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
|
|
1556
|
+
}
|
|
1557
|
+
return false;
|
|
1558
|
+
};
|
|
1559
|
+
const notifyCommittedWarnings = (ctx, settled = false) => {
|
|
1560
|
+
for (const [key, notice] of pendingNotices) {
|
|
1561
|
+
if (warningCommittedInWindow(ctx, notice)) {
|
|
1562
|
+
pendingNotices.delete(key);
|
|
1563
|
+
ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
|
|
1564
|
+
} else if (settled) {
|
|
1565
|
+
pendingNotices.delete(key);
|
|
1566
|
+
}
|
|
1565
1567
|
}
|
|
1566
|
-
pendingNotices = [];
|
|
1567
1568
|
};
|
|
1568
1569
|
const clearStaged = () => {
|
|
1569
1570
|
pendingGuidance = void 0;
|
|
@@ -1571,7 +1572,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1571
1572
|
};
|
|
1572
1573
|
const resetForTransition = () => {
|
|
1573
1574
|
clearStaged();
|
|
1574
|
-
pendingNotices
|
|
1575
|
+
pendingNotices.clear();
|
|
1575
1576
|
invalidateThresholds();
|
|
1576
1577
|
notifiedWarnings.clear();
|
|
1577
1578
|
};
|
|
@@ -1583,7 +1584,10 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1583
1584
|
clearStaged();
|
|
1584
1585
|
const windowId = currentWindowId(ctx);
|
|
1585
1586
|
const drafts = staged.filter((draft) => draft !== void 0 && draft.windowId === windowId);
|
|
1586
|
-
|
|
1587
|
+
if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
|
|
1588
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1589
|
+
pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
|
|
1590
|
+
}
|
|
1587
1591
|
return drafts.map((draft) => ({
|
|
1588
1592
|
type: "custom_message",
|
|
1589
1593
|
customType: draft.customType,
|
|
@@ -1598,8 +1602,9 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1598
1602
|
pi.on("session_tree", resetForTransition);
|
|
1599
1603
|
pi.on("model_select", resetForTransition);
|
|
1600
1604
|
pi.on("session_shutdown", resetForTransition);
|
|
1605
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
|
|
1601
1606
|
pi.on("agent_settled", (_event, ctx) => {
|
|
1602
|
-
|
|
1607
|
+
notifyCommittedWarnings(ctx, true);
|
|
1603
1608
|
clearStaged();
|
|
1604
1609
|
});
|
|
1605
1610
|
pi.on("context", (_event, ctx) => {
|
|
@@ -1647,7 +1652,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
|
|
|
1647
1652
|
consumeTurnEnd,
|
|
1648
1653
|
clear: () => {
|
|
1649
1654
|
clearStaged();
|
|
1650
|
-
pendingNotices
|
|
1655
|
+
pendingNotices.clear();
|
|
1651
1656
|
}
|
|
1652
1657
|
};
|
|
1653
1658
|
}
|
|
@@ -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
|
}
|
|
@@ -32,8 +32,8 @@ function notesUnavailableNotice(snapshot) {
|
|
|
32
32
|
* per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
|
|
33
33
|
* pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
|
|
34
34
|
* POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
|
|
35
|
-
* most-recently-updated first within each home, one metadata line each: address,
|
|
36
|
-
*
|
|
35
|
+
* most-recently-updated first within each home, one metadata line each: address, body character
|
|
36
|
+
* count, relative update time at window open. Bodies never render
|
|
37
37
|
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
38
38
|
*/
|
|
39
39
|
function notesIndex(snapshot) {
|
|
@@ -59,7 +59,7 @@ function notesIndex(snapshot) {
|
|
|
59
59
|
if (recentNotes.length > 0) {
|
|
60
60
|
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
|
|
61
61
|
for (const row of recentNotes) {
|
|
62
|
-
lines.push(`- ${row.address}
|
|
62
|
+
lines.push(`- ${row.address} · ${Array.from(row.body).length} chars · ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
|
|
63
63
|
}
|
|
64
64
|
sections.push(lines.join("\n"));
|
|
65
65
|
}
|
package/dist/src/dream/doctor.js
CHANGED
|
@@ -41,10 +41,6 @@ export function doctor(home) {
|
|
|
41
41
|
if (!valid.test(fields.get(key) ?? ""))
|
|
42
42
|
report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
43
43
|
}
|
|
44
|
-
for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
|
|
45
|
-
if (fields.has(legacy))
|
|
46
|
-
report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
|
|
47
|
-
}
|
|
48
44
|
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
49
45
|
const value = fields.get(key);
|
|
50
46
|
if (!value || !Number.isFinite(Date.parse(value)))
|
|
@@ -33,8 +33,7 @@ export declare function isScope(value: unknown): value is Scope;
|
|
|
33
33
|
export declare function isOrigin(value: unknown): value is Origin;
|
|
34
34
|
/**
|
|
35
35
|
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
36
|
-
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
37
|
-
* snake_case metadata is refused because it requires the explicit manual migration.
|
|
36
|
+
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
38
37
|
*/
|
|
39
38
|
export declare function parseNote(raw: string, now?: number): {
|
|
40
39
|
meta: NoteMeta;
|
|
@@ -2,7 +2,6 @@ const SCOPES = ["session", "project", "human", "agent", "model"];
|
|
|
2
2
|
const ORIGINS = ["user", "self", "external"];
|
|
3
3
|
const STATUSES = ["active", "superseded", "pending", "archived"];
|
|
4
4
|
const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
|
|
5
|
-
const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
|
|
6
5
|
/** Emission order, exactly the Design's key list. */
|
|
7
6
|
const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
|
|
8
7
|
const pad2 = (value) => String(value).padStart(2, "0");
|
|
@@ -98,14 +97,10 @@ function parseFrontmatter(raw) {
|
|
|
98
97
|
}
|
|
99
98
|
/**
|
|
100
99
|
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
101
|
-
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
102
|
-
* snake_case metadata is refused because it requires the explicit manual migration.
|
|
100
|
+
* accessCount 0, timestamps now); unknown keys are carried through untouched.
|
|
103
101
|
*/
|
|
104
102
|
export function parseNote(raw, now = Date.now()) {
|
|
105
103
|
const { fields, body } = parseFrontmatter(raw);
|
|
106
|
-
const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
|
|
107
|
-
if (legacyKeys.length > 0)
|
|
108
|
-
throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
|
|
109
104
|
const meta = { ...fields };
|
|
110
105
|
// scope is a legacy on-disk field: store callers derive it from the file's home and
|
|
111
106
|
// overwrite it after parsing, so an absent or outdated value just falls back.
|
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;
|
package/dist/src/protocol.d.ts
CHANGED
|
@@ -6,10 +6,10 @@ export declare const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
|
6
6
|
export declare const CONTINUATION_TYPE = "pi-context/continuation";
|
|
7
7
|
export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
|
|
8
8
|
export declare const POCKET_SESSION_LIMIT = 5;
|
|
9
|
-
export declare const POCKET_PROJECT_LIMIT =
|
|
10
|
-
export declare const POCKET_HUMAN_LIMIT =
|
|
11
|
-
export declare const POCKET_AGENT_LIMIT =
|
|
12
|
-
export declare const POCKET_MODEL_LIMIT =
|
|
9
|
+
export declare const POCKET_PROJECT_LIMIT = 5;
|
|
10
|
+
export declare const POCKET_HUMAN_LIMIT = 5;
|
|
11
|
+
export declare const POCKET_AGENT_LIMIT = 5;
|
|
12
|
+
export declare const POCKET_MODEL_LIMIT = 3;
|
|
13
13
|
export declare const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
14
14
|
export declare const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
15
15
|
export declare const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
package/dist/src/protocol.js
CHANGED
|
@@ -6,10 +6,10 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
|
|
|
6
6
|
export const CONTINUATION_TYPE = "pi-context/continuation";
|
|
7
7
|
export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
|
|
8
8
|
export const POCKET_SESSION_LIMIT = 5;
|
|
9
|
-
export const POCKET_PROJECT_LIMIT =
|
|
10
|
-
export const POCKET_HUMAN_LIMIT =
|
|
11
|
-
export const POCKET_AGENT_LIMIT =
|
|
12
|
-
export const POCKET_MODEL_LIMIT =
|
|
9
|
+
export const POCKET_PROJECT_LIMIT = 5;
|
|
10
|
+
export const POCKET_HUMAN_LIMIT = 5;
|
|
11
|
+
export const POCKET_AGENT_LIMIT = 5;
|
|
12
|
+
export const POCKET_MODEL_LIMIT = 3;
|
|
13
13
|
export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
|
|
14
14
|
export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
|
|
15
15
|
export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
|
|
@@ -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
|
/**
|