@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.
@@ -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, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
18
- const testEnvironment = installExtensionTestEnvironment("pi-context-notes");
19
- test.beforeEach(() => testEnvironment.beforeEach());
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", { path: "a/b.md", content: "hello" }, ctx));
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", { path: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
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", { path: "first.md", content: "first project session" }, firstCtx);
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", { path: "second.md", content: "same project, another session" }, secondCtx);
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", { path: "third.md", content: "different project" }, thirdCtx);
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", { path: "first.md", content: "overwritten from another cwd" }, movedContext);
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", { path: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
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", { path: "first.md" }, movedContext);
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", { path: "new-from-project-b.md", content: "new note", scope: "session" }, movedContext);
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", { path: "project-note.md", content: "project home note", scope: "project" }, movedContext);
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", { path: "legacy.md" }, ctx), /requires manual migration/);
169
- await assert.rejects(() => call(captured, "notes_write", { path: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
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", { path: "invalid.md", content: "still invalid" }, ctx);
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", { path: "edit.md", content: "alpha\nbeta\nbeta\ngamma" }, ctx);
196
- const ambiguous = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }] }, ctx));
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", { path: "edit.md", edits: [{ oldText: "absent", newText: "x" }] }, ctx));
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", { path: "edit.md", edits: [{ oldText: "beta", newText: "B" }], replace_all: true }, ctx));
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", { path: "edit.md" }, ctx)).content.endsWith("alpha\nB\nB\ngamma"), true, "replace_all replaces every occurrence");
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", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
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", { path: "edit.md" }, ctx));
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", { path: "edit.md", content: "alpha\nbeta" }, ctx);
218
- const empty = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [] }, ctx));
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", { path: "missing.md", stale: true }, ctx));
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", { path: "missing.md" }, ctx));
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.path, "missing.md");
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", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "x" }] }, ctx));
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", { path: "edit.md", edits: [{ oldText: "alpha", newText: "A" }, { oldText: "beta", newText: "B" }] }, ctx));
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", { path: "edit.md" }, ctx)).content.endsWith("A\nB"), true);
233
- const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
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", { path: "one.md", content: "needle one", scope: "session" }, ctx);
274
- await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
275
- await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "human" }, ctx);
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", { scope: "human" }, ctx));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "type": "module",
5
5
  "main": "./dist/src/index.js",
6
6
  "types": "./dist/src/index.d.ts",
@@ -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: Array<{ windowId: string; customType: string; remaining: number }> = [];
49
- const notifyCommittedReminders = (ctx: ExtensionContext) => {
50
- const windowId = currentWindowId(ctx);
51
- for (const notice of pendingNotices) {
52
- if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
53
- ctx.ui.notify(notice.customType === WARNING_TYPE
54
- ? "pi-context: Context almost full; close out the current memory window."
55
- : "pi-context: Context running low; checkpoint your notes soon.", "warning");
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
- pendingNotices = drafts.map(({ windowId, customType, remaining }) => ({ windowId, customType, remaining }));
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 request can fail before Pi emits turn_end. agent_settled is the public
93
- // lifecycle point that must discard an uncommitted draft before the next prompt.
94
- // UI notices follow committed reminders. Aborted requests can retry their drafts
95
- // without showing the same low-budget notification twice.
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
- notifyCommittedReminders(ctx);
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
  }
@@ -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 list(options: NotesQuery = {}): Promise<NoteRow[]> {
352
- const stableOptions = { ...options } as NotesQuery;
353
- const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
354
- const rows: NoteRow[] = [];
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
- rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
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 home of await homesFor(context, stableOptions)) {
378
- const scope = home.scope;
379
- const root = scopeDir(scope, context, home.who);
380
- for (const path of await walkMarkdown(root)) {
381
- const address = addressFor(context, scope, path, home.who);
382
- if (matcher && !matcher.test(address)) continue;
383
- const fullPath = join(root, path);
384
- const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
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
- if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
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
+ }
@@ -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