@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/test/notes.test.js
CHANGED
|
@@ -14,11 +14,9 @@ import test from "node:test";
|
|
|
14
14
|
import { parseNote } from "../src/notes/frontmatter.js";
|
|
15
15
|
import { projectKey } from "../src/notes/paths.js";
|
|
16
16
|
import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
|
|
17
|
-
import { call, context, explicitBoot,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
21
|
-
test.after(() => testEnvironment.dispose());
|
|
17
|
+
import { call, context, explicitBoot, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
|
|
18
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
19
|
+
const testEnvironment = installExtensionTestHooks("pi-context-notes");
|
|
22
20
|
function freshRoot() {
|
|
23
21
|
return testEnvironment.newNotesRoot();
|
|
24
22
|
}
|
|
@@ -59,7 +57,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
59
57
|
const captured = makeExtension(session);
|
|
60
58
|
const ctx = context(session);
|
|
61
59
|
const sessionId = session.getSessionId();
|
|
62
|
-
const result = resultJson(await call(captured, "notes_write", {
|
|
60
|
+
const result = resultJson(await call(captured, "notes_write", { address: "a/b.md", content: "hello" }, ctx));
|
|
63
61
|
assert.deepEqual(Object.keys(result).sort(), ["address", "written"]);
|
|
64
62
|
const file = physicalPath("session", "a/b.md", ctx);
|
|
65
63
|
assert.equal(file, join(root, "pi", "session", sessionId, "a", "b.md"));
|
|
@@ -79,7 +77,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
|
|
|
79
77
|
assert.equal(result.written, true);
|
|
80
78
|
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
|
|
81
79
|
// A leading YAML block in user content is stripped from the body.
|
|
82
|
-
await call(captured, "notes_write", {
|
|
80
|
+
await call(captured, "notes_write", { address: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
|
|
83
81
|
const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
|
|
84
82
|
assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
|
|
85
83
|
assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
|
|
@@ -96,33 +94,33 @@ test("session-note project ownership is per note, persistent across sessions, an
|
|
|
96
94
|
const firstSession = manager();
|
|
97
95
|
const firstCaptured = makeExtension(firstSession);
|
|
98
96
|
const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
|
|
99
|
-
await call(firstCaptured, "notes_write", {
|
|
97
|
+
await call(firstCaptured, "notes_write", { address: "first.md", content: "first project session" }, firstCtx);
|
|
100
98
|
const firstFile = physicalPath("session", "first.md", firstCtx);
|
|
101
99
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
|
|
102
100
|
const secondSession = manager();
|
|
103
101
|
const secondCaptured = makeExtension(secondSession);
|
|
104
102
|
const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
|
|
105
|
-
await call(secondCaptured, "notes_write", {
|
|
103
|
+
await call(secondCaptured, "notes_write", { address: "second.md", content: "same project, another session" }, secondCtx);
|
|
106
104
|
const secondFile = physicalPath("session", "second.md", secondCtx);
|
|
107
105
|
assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
|
|
108
106
|
const thirdSession = manager();
|
|
109
107
|
const thirdCaptured = makeExtension(thirdSession);
|
|
110
108
|
const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
|
|
111
|
-
await call(thirdCaptured, "notes_write", {
|
|
109
|
+
await call(thirdCaptured, "notes_write", { address: "third.md", content: "different project" }, thirdCtx);
|
|
112
110
|
const thirdFile = physicalPath("session", "third.md", thirdCtx);
|
|
113
111
|
assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
|
|
114
112
|
const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
|
|
115
113
|
assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
|
|
116
114
|
const movedContext = context(firstSession, undefined, undefined, true, cwdB);
|
|
117
|
-
await call(firstCaptured, "notes_write", {
|
|
115
|
+
await call(firstCaptured, "notes_write", { address: "first.md", content: "overwritten from another cwd" }, movedContext);
|
|
118
116
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
|
|
119
|
-
await call(firstCaptured, "notes_edit", {
|
|
117
|
+
await call(firstCaptured, "notes_edit", { address: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
|
|
120
118
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
|
|
121
|
-
await call(firstCaptured, "notes_read", {
|
|
119
|
+
await call(firstCaptured, "notes_read", { address: "first.md" }, movedContext);
|
|
122
120
|
assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
|
|
123
|
-
await call(firstCaptured, "notes_write", {
|
|
121
|
+
await call(firstCaptured, "notes_write", { address: "new-from-project-b.md", content: "new note" }, movedContext);
|
|
124
122
|
assert.equal(parseNote(readFileSync(physicalPath("session", "new-from-project-b.md", movedContext), "utf8")).meta.project, projectB, "only a newly-created session note uses the current project key");
|
|
125
|
-
await call(firstCaptured, "notes_write", {
|
|
123
|
+
await call(firstCaptured, "notes_write", { address: "@project/project-note.md", content: "project home note" }, movedContext);
|
|
126
124
|
assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
|
|
127
125
|
assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
|
|
128
126
|
});
|
|
@@ -165,8 +163,8 @@ legacy body`);
|
|
|
165
163
|
assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
|
|
166
164
|
const legacyBytes = readFileSync(legacyFile, "utf8");
|
|
167
165
|
await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
|
|
168
|
-
await assert.rejects(() => call(captured, "notes_read", {
|
|
169
|
-
await assert.rejects(() => call(captured, "notes_write", {
|
|
166
|
+
await assert.rejects(() => call(captured, "notes_read", { address: "legacy.md" }, ctx), /requires manual migration/);
|
|
167
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
|
|
170
168
|
assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
|
|
171
169
|
const invalidFile = physicalPath("session", "invalid.md", ctx);
|
|
172
170
|
writeFileSync(invalidFile, `---
|
|
@@ -183,7 +181,7 @@ project: 17
|
|
|
183
181
|
invalid owner`);
|
|
184
182
|
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
|
|
185
183
|
assert.notEqual(parseNote(readFileSync(invalidFile, "utf8")).meta.project, projectKey(ctx.cwd), "invalid ownership does not match the current project key");
|
|
186
|
-
await call(captured, "notes_write", {
|
|
184
|
+
await call(captured, "notes_write", { address: "invalid.md", content: "still invalid" }, ctx);
|
|
187
185
|
assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
|
|
188
186
|
assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
|
|
189
187
|
});
|
|
@@ -192,19 +190,19 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
|
|
|
192
190
|
const session = manager();
|
|
193
191
|
const captured = makeExtension(session);
|
|
194
192
|
const ctx = context(session);
|
|
195
|
-
await call(captured, "notes_write", {
|
|
196
|
-
const ambiguous = resultJson(await call(captured, "notes_edit", {
|
|
193
|
+
await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
|
|
194
|
+
const ambiguous = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
|
|
197
195
|
assert.match(ambiguous.error, /occurs 2 times/);
|
|
198
196
|
assert.deepEqual(ambiguous.line_numbers, [2, 3], "the multi-match error carries every match line number");
|
|
199
|
-
const missing = resultJson(await call(captured, "notes_edit", {
|
|
197
|
+
const missing = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
|
|
200
198
|
assert.equal(missing.edit_index, 0, "a zero-match anchor names the failing edit index");
|
|
201
|
-
const all = resultJson(await call(captured, "notes_edit", {
|
|
199
|
+
const all = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
|
|
202
200
|
assert.equal(all.applied, 1);
|
|
203
201
|
assert.equal(all.address, "edit.md");
|
|
204
202
|
assertNoPublicScope(all, "notes_edit");
|
|
205
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
203
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
|
|
206
204
|
// An anchor that occurs only in frontmatter is not matched: edits are body-only.
|
|
207
|
-
const frontmatterOnly = resultJson(await call(captured, "notes_edit", {
|
|
205
|
+
const frontmatterOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
|
|
208
206
|
assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
|
|
209
207
|
});
|
|
210
208
|
test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
|
|
@@ -212,25 +210,25 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
|
|
|
212
210
|
const session = manager();
|
|
213
211
|
const captured = makeExtension(session);
|
|
214
212
|
const ctx = context(session);
|
|
215
|
-
const nameOnly = resultJson(await call(captured, "notes_edit", {
|
|
213
|
+
const nameOnly = resultJson(await call(captured, "notes_edit", { address: "edit.md" }, ctx));
|
|
216
214
|
assert.match(nameOnly.error, /nothing to do/, "neither edits nor setters is a named error");
|
|
217
|
-
await call(captured, "notes_write", {
|
|
218
|
-
const empty = resultJson(await call(captured, "notes_edit", {
|
|
215
|
+
await call(captured, "notes_write", { address: "edit.md", content: "alpha\nbeta" }, ctx);
|
|
216
|
+
const empty = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [] }, ctx));
|
|
219
217
|
assert.match(empty.error, /nothing to do/, "an empty edits list with no setters is also nothing to do");
|
|
220
|
-
const editMissing = resultJson(await call(captured, "notes_edit", {
|
|
218
|
+
const editMissing = resultJson(await call(captured, "notes_edit", { address: "missing.md", stale: true }, ctx));
|
|
221
219
|
assert.equal(editMissing.error, "note not found");
|
|
222
|
-
const readMissing = resultJson(await call(captured, "notes_read", {
|
|
220
|
+
const readMissing = resultJson(await call(captured, "notes_read", { address: "missing.md" }, ctx));
|
|
223
221
|
assert.equal(readMissing.error, "note not found");
|
|
224
|
-
assert.equal(readMissing.
|
|
222
|
+
assert.equal(readMissing.address, "missing.md");
|
|
225
223
|
const file = physicalPath("session", "edit.md", ctx);
|
|
226
224
|
const before = readFileSync(file, "utf8");
|
|
227
|
-
const failed = resultJson(await call(captured, "notes_edit", {
|
|
225
|
+
const failed = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
|
|
228
226
|
assert.equal(failed.edit_index, 1, "the failing edit is named");
|
|
229
227
|
assert.equal(readFileSync(file, "utf8"), before, "a failing batch leaves the file byte-identical, frontmatter included");
|
|
230
|
-
const applied = resultJson(await call(captured, "notes_edit", {
|
|
228
|
+
const applied = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
|
|
231
229
|
assert.equal(applied.applied, 2);
|
|
232
|
-
assert.equal(resultRead(await call(captured, "notes_read", {
|
|
233
|
-
const zero = resultJson(await call(captured, "notes_edit", {
|
|
230
|
+
assert.equal(resultRead(await call(captured, "notes_read", { address: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
|
|
231
|
+
const zero = resultJson(await call(captured, "notes_edit", { address: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
|
|
234
232
|
assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
|
|
235
233
|
});
|
|
236
234
|
test("all notes tool results use address as the only home identity", async () => {
|
|
@@ -270,16 +268,16 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
270
268
|
const session = manager();
|
|
271
269
|
const captured = makeExtension(session);
|
|
272
270
|
const ctx = context(session);
|
|
273
|
-
await call(captured, "notes_write", {
|
|
274
|
-
await call(captured, "notes_write", {
|
|
275
|
-
await call(captured, "notes_write", {
|
|
271
|
+
await call(captured, "notes_write", { address: "one.md", content: "needle one" }, ctx);
|
|
272
|
+
await call(captured, "notes_write", { address: "@project/two.md", content: "needle two" }, ctx);
|
|
273
|
+
await call(captured, "notes_write", { address: "@human/three.md", content: "needle three" }, ctx);
|
|
276
274
|
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
277
275
|
assert.deepEqual([...listed.files].map((file) => file.address).sort(), ["@human/three.md", "@project/two.md", "one.md"], "every merged row carries its full address");
|
|
278
276
|
for (const row of listed.files) {
|
|
279
277
|
assert.deepEqual(Object.keys(row).sort(), ["address", "stale", "updated_at"]);
|
|
280
278
|
assert.equal(row.stale, false);
|
|
281
279
|
}
|
|
282
|
-
const scoped = resultJson(await call(captured, "notes_list", {
|
|
280
|
+
const scoped = resultJson(await call(captured, "notes_list", { pattern: "@human/**" }, ctx));
|
|
283
281
|
assert.deepEqual(scoped.files.map((file) => file.address), ["@human/three.md"], "an address-pattern filter narrows the set");
|
|
284
282
|
const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
|
|
285
283
|
assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
|
|
@@ -295,7 +293,7 @@ test("list and search merge scopes and carry addresses; the path jail rejects es
|
|
|
295
293
|
const escaped = ["../evil", "/abs", "a\\b"];
|
|
296
294
|
for (const tool of ["notes_write", "notes_edit", "notes_read"]) {
|
|
297
295
|
for (const path of escaped) {
|
|
298
|
-
await assert.rejects(() => call(captured, tool, { path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
296
|
+
await assert.rejects(() => call(captured, tool, { address: path, content: "x", edits: [{ oldText: "a", newText: "b" }] }, ctx), `${tool} rejects ${path}`);
|
|
299
297
|
}
|
|
300
298
|
}
|
|
301
299
|
await assert.rejects(() => call(captured, "notes_list", { pattern: "bad\\glob" }, ctx), /backslash/);
|
package/package.json
CHANGED
package/src/context/budget.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { defineTool, type ExtensionAPI, type ExtensionContext, type SessionBoundaryDraft, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { GUIDANCE_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
|
|
4
4
|
import { readThresholdSettings, type ResolvedThresholds, type ThresholdSettingsResolution } 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
|
|
|
@@ -45,16 +45,30 @@ export function registerBudget(
|
|
|
45
45
|
const invalidateThresholds = () => { cachedPolicy = undefined; };
|
|
46
46
|
let pendingGuidance: { windowId: string; content: string; remaining: number } | undefined;
|
|
47
47
|
let pendingWarning: { windowId: string; content: string; remaining: number } | undefined;
|
|
48
|
-
let pendingNotices
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
let pendingNotices = new Map<string, { sessionId: string; windowId: string }>();
|
|
49
|
+
const noticeKey = (sessionId: string, windowId: string) => `${sessionId}:${windowId}`;
|
|
50
|
+
const warningCommittedInWindow = (ctx: ExtensionContext, notice: { sessionId: string; windowId: string }): boolean => {
|
|
51
|
+
if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
|
|
52
|
+
let windowId = rootWindowId(notice.sessionId);
|
|
53
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
54
|
+
if (isWindowMarker(entry)) {
|
|
55
|
+
windowId = entry.data.windowId;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
const notifyCommittedWarnings = (ctx: ExtensionContext, settled = false) => {
|
|
63
|
+
for (const [key, notice] of pendingNotices) {
|
|
64
|
+
if (warningCommittedInWindow(ctx, notice)) {
|
|
65
|
+
pendingNotices.delete(key);
|
|
66
|
+
ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
|
|
67
|
+
} else if (settled) {
|
|
68
|
+
// An uncommitted draft must not be matched to a later manual warning.
|
|
69
|
+
pendingNotices.delete(key);
|
|
70
|
+
}
|
|
56
71
|
}
|
|
57
|
-
pendingNotices = [];
|
|
58
72
|
};
|
|
59
73
|
|
|
60
74
|
const clearStaged = () => {
|
|
@@ -63,7 +77,7 @@ export function registerBudget(
|
|
|
63
77
|
};
|
|
64
78
|
const resetForTransition = () => {
|
|
65
79
|
clearStaged();
|
|
66
|
-
pendingNotices
|
|
80
|
+
pendingNotices.clear();
|
|
67
81
|
invalidateThresholds();
|
|
68
82
|
notifiedWarnings.clear();
|
|
69
83
|
};
|
|
@@ -76,7 +90,10 @@ export function registerBudget(
|
|
|
76
90
|
clearStaged();
|
|
77
91
|
const windowId = currentWindowId(ctx);
|
|
78
92
|
const drafts = staged.filter((draft): draft is NonNullable<typeof draft> => draft !== undefined && draft.windowId === windowId);
|
|
79
|
-
|
|
93
|
+
if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
|
|
94
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
95
|
+
pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
|
|
96
|
+
}
|
|
80
97
|
return drafts.map((draft) => ({
|
|
81
98
|
type: "custom_message" as const,
|
|
82
99
|
customType: draft.customType,
|
|
@@ -89,12 +106,12 @@ export function registerBudget(
|
|
|
89
106
|
pi.on("session_tree", resetForTransition);
|
|
90
107
|
pi.on("model_select", resetForTransition);
|
|
91
108
|
pi.on("session_shutdown", resetForTransition);
|
|
92
|
-
// A
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
109
|
+
// A warning can be committed at turn_end, before a tool turn or a reset changes the
|
|
110
|
+
// active window. Observe the active branch at public lifecycle boundaries and match
|
|
111
|
+
// the candidate against its originating window segment, not only the current window.
|
|
112
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
|
|
96
113
|
pi.on("agent_settled", (_event, ctx) => {
|
|
97
|
-
|
|
114
|
+
notifyCommittedWarnings(ctx, true);
|
|
98
115
|
clearStaged();
|
|
99
116
|
});
|
|
100
117
|
pi.on("context", (_event, ctx) => {
|
|
@@ -151,6 +168,6 @@ export function registerBudget(
|
|
|
151
168
|
automaticResetEnabled,
|
|
152
169
|
hardReserveDue,
|
|
153
170
|
consumeTurnEnd,
|
|
154
|
-
clear: () => { clearStaged(); pendingNotices
|
|
171
|
+
clear: () => { clearStaged(); pendingNotices.clear(); },
|
|
155
172
|
};
|
|
156
173
|
}
|
package/src/notes/store.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { Dirent } from "node:fs";
|
|
3
3
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { earliestMatchOffsetChars } from "../text-match.js";
|
|
5
6
|
import { assertAddress, assertGlobPattern, addressFor, globToRegExp } from "./address.js";
|
|
6
7
|
import { snapshotNotesContext, type NotesContext } from "./context.js";
|
|
7
8
|
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./constants.js";
|
|
@@ -179,17 +180,6 @@ function matchLineNumbers(body: string, needle: string): number[] {
|
|
|
179
180
|
return lines;
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
/** Code-point offset of the earliest query occurrence, matching the serialized read text. */
|
|
183
|
-
function earliestMatchOffsetChars(text: string, queries: string[]): number {
|
|
184
|
-
let earliest = -1;
|
|
185
|
-
for (const query of queries) {
|
|
186
|
-
const index = text.indexOf(query);
|
|
187
|
-
if (index < 0) continue;
|
|
188
|
-
if (earliest < 0 || index < earliest) earliest = index;
|
|
189
|
-
}
|
|
190
|
-
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
183
|
/** Every mutation uses a tmp file renamed into place in the same directory. */
|
|
194
184
|
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
195
185
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -348,11 +338,10 @@ export function createNotesStore(input: NotesContext): NotesStore {
|
|
|
348
338
|
});
|
|
349
339
|
}
|
|
350
340
|
|
|
351
|
-
async function
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
for (const home of await homesFor(context, stableOptions)) {
|
|
341
|
+
async function* scan(options: NotesQuery): AsyncGenerator<Omit<NoteRow, "sizeBytes">> {
|
|
342
|
+
const matcher = matcherFor(normalizePattern(options.pattern, context));
|
|
343
|
+
const homes = await homesFor(context, options);
|
|
344
|
+
for (const home of homes) {
|
|
356
345
|
const scope = home.scope;
|
|
357
346
|
const root = scopeDir(scope, context, home.who);
|
|
358
347
|
for (const path of await walkMarkdown(root)) {
|
|
@@ -362,9 +351,17 @@ export function createNotesStore(input: NotesContext): NotesStore {
|
|
|
362
351
|
const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
|
|
363
352
|
const { meta, body } = parseNote(raw);
|
|
364
353
|
meta.scope = scope;
|
|
365
|
-
|
|
354
|
+
yield { address, scope, path, meta, body };
|
|
366
355
|
}
|
|
367
356
|
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function list(options: NotesQuery = {}): Promise<NoteRow[]> {
|
|
360
|
+
const stableOptions = { ...options } as NotesQuery;
|
|
361
|
+
const rows: NoteRow[] = [];
|
|
362
|
+
for await (const row of scan(stableOptions)) {
|
|
363
|
+
rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
|
|
364
|
+
}
|
|
368
365
|
rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
|
|
369
366
|
return rows;
|
|
370
367
|
}
|
|
@@ -372,29 +369,19 @@ export function createNotesStore(input: NotesContext): NotesStore {
|
|
|
372
369
|
async function search(queries: string[], options: NotesQuery = {}): Promise<NoteSearchRow[]> {
|
|
373
370
|
const stableQueries = [...queries];
|
|
374
371
|
const stableOptions = { ...options } as NotesQuery;
|
|
375
|
-
const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
|
|
376
372
|
const rows: NoteSearchRow[] = [];
|
|
377
|
-
for (const
|
|
378
|
-
const scope =
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const { meta, body } = parseNote(raw);
|
|
386
|
-
meta.scope = scope;
|
|
387
|
-
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
388
|
-
let baseChars = 0;
|
|
389
|
-
const matches: NoteMatch[] = [];
|
|
390
|
-
for (const [index, line] of body.split("\n").entries()) {
|
|
391
|
-
if (stableQueries.some((query) => line.includes(query))) {
|
|
392
|
-
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
393
|
-
}
|
|
394
|
-
baseChars += Array.from(line).length + 1;
|
|
373
|
+
for await (const note of scan(stableOptions)) {
|
|
374
|
+
const { address, path, scope, meta, body } = note;
|
|
375
|
+
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
376
|
+
let baseChars = 0;
|
|
377
|
+
const matches: NoteMatch[] = [];
|
|
378
|
+
for (const [index, line] of body.split("\n").entries()) {
|
|
379
|
+
if (stableQueries.some((query) => line.includes(query))) {
|
|
380
|
+
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
395
381
|
}
|
|
396
|
-
|
|
382
|
+
baseChars += Array.from(line).length + 1;
|
|
397
383
|
}
|
|
384
|
+
if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
|
|
398
385
|
}
|
|
399
386
|
rows.sort((a, b) => a.address.localeCompare(b.address));
|
|
400
387
|
return rows;
|
|
@@ -0,0 +1,13 @@
|
|
|
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: string, queries: string[]): number {
|
|
6
|
+
let earliest = -1;
|
|
7
|
+
for (const query of queries) {
|
|
8
|
+
const index = text.indexOf(query);
|
|
9
|
+
if (index < 0) continue;
|
|
10
|
+
if (earliest < 0 || index < earliest) earliest = index;
|
|
11
|
+
}
|
|
12
|
+
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
13
|
+
}
|
package/src/tool-output.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { earliestMatchOffsetChars } from "./text-match.js";
|
|
2
|
+
|
|
1
3
|
export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
|
|
2
4
|
export const DEFAULT_READ_WINDOW_CHARS = 12000;
|
|
3
5
|
export const MAX_READ_WINDOW_CHARS = 50000;
|
|
@@ -125,20 +127,6 @@ export function readWindowBlock(identity: ReadonlyArray<readonly [string, string
|
|
|
125
127
|
return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
/**
|
|
129
|
-
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
130
|
-
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
131
|
-
*/
|
|
132
|
-
export function earliestMatchOffsetChars(text: string, queries: string[]): number {
|
|
133
|
-
let earliest = -1;
|
|
134
|
-
for (const query of queries) {
|
|
135
|
-
const index = text.indexOf(query);
|
|
136
|
-
if (index < 0) continue;
|
|
137
|
-
if (earliest < 0 || index < earliest) earliest = index;
|
|
138
|
-
}
|
|
139
|
-
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
130
|
/** Shrink a single page item to fit; only invoked when that item alone exceeds the budget. */
|
|
143
131
|
export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
|
|
144
132
|
|