@astrosheep/pi-context 0.25.2 → 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/README.md +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +635 -381
- package/dist/src/context/boot.d.ts +24 -0
- package/dist/src/context/boot.js +33 -24
- package/dist/src/context/budget.d.ts +9 -0
- package/dist/src/context/budget.js +51 -28
- package/dist/src/context/context-window.d.ts +41 -0
- package/dist/src/context/context-window.js +16 -1
- package/dist/src/context/prompts.d.ts +20 -0
- package/dist/src/context/prompts.js +1 -1
- package/dist/src/context/reset-artifacts.d.ts +26 -0
- package/dist/src/context/reset-artifacts.js +18 -17
- package/dist/src/context/reset-lifecycle.d.ts +89 -0
- package/dist/src/context/reset-lifecycle.js +103 -75
- package/dist/src/context/runtime.d.ts +3 -0
- package/dist/src/context/runtime.js +53 -21
- package/dist/src/context/thresholds.d.ts +33 -0
- package/dist/src/context/thresholds.js +1 -1
- package/dist/src/dream/cli.d.ts +10 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.d.ts +2 -0
- package/dist/src/dream/doctor.js +6 -2
- package/dist/src/dream/gates.d.ts +10 -0
- package/dist/src/dream/git.d.ts +21 -0
- package/dist/src/dream/lock.d.ts +31 -0
- package/dist/src/dream/runner.d.ts +30 -0
- package/dist/src/dream/settings.d.ts +16 -0
- package/dist/src/history/history-tools.d.ts +2 -0
- package/dist/src/history/history.d.ts +57 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.js +4 -4
- package/dist/src/notes/address.d.ts +26 -0
- package/dist/src/notes/address.js +8 -14
- package/dist/src/notes/constants.d.ts +3 -0
- package/dist/src/notes/constants.js +3 -0
- package/dist/src/notes/context.d.ts +10 -0
- package/dist/src/notes/context.js +33 -0
- package/dist/src/notes/frontmatter.d.ts +46 -0
- package/dist/src/notes/frontmatter.js +10 -5
- package/dist/src/notes/index.d.ts +4 -0
- package/dist/src/notes/index.js +2 -0
- package/dist/src/notes/paths.d.ts +21 -0
- package/dist/src/notes/paths.js +72 -76
- package/dist/src/notes/store.d.ts +94 -0
- package/dist/src/notes/store.js +276 -236
- package/dist/src/pi/notes/adapter.d.ts +12 -0
- package/dist/src/pi/notes/adapter.js +39 -0
- package/dist/src/pi/notes/session-replay.d.ts +16 -0
- package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
- package/dist/src/pi/notes/snapshot.d.ts +33 -0
- package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
- package/dist/src/pi/notes/tools.d.ts +2 -0
- package/dist/src/{notes → pi/notes}/tools.js +24 -21
- package/dist/src/protocol.d.ts +41 -0
- package/dist/src/protocol.js +4 -6
- package/dist/src/session-reader.d.ts +5 -0
- package/dist/src/settings.d.ts +6 -0
- package/dist/src/text-match.d.ts +5 -0
- package/dist/src/text-match.js +15 -0
- package/dist/src/tool-output.d.ts +97 -0
- package/dist/src/tool-output.js +1 -15
- package/dist/src/tool-schema.d.ts +17 -0
- package/dist/test/agent-loop.test.d.ts +1 -0
- package/dist/test/agent-loop.test.js +387 -20
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +59 -35
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +28 -12
- package/dist/test/doctor.test.d.ts +1 -0
- package/dist/test/doctor.test.js +10 -2
- package/dist/test/dream-skill.test.d.ts +1 -0
- package/dist/test/dream-skill.test.js +69 -0
- package/dist/test/dream.test.d.ts +1 -0
- 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 +104 -0
- package/dist/test/helpers/extension.js +7 -81
- package/dist/test/helpers/notes.d.ts +6 -0
- package/dist/test/helpers/notes.js +13 -0
- package/dist/test/history.integration.test.d.ts +1 -0
- package/dist/test/history.integration.test.js +17 -19
- package/dist/test/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +128 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +51 -55
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +162 -34
- package/dist/test/reset-lifecycle.test.d.ts +1 -0
- package/dist/test/reset-lifecycle.test.js +142 -85
- package/docs/architecture.md +8 -8
- package/docs/reset-lifecycle.md +63 -79
- package/package.json +35 -2
- package/playbook.md +33 -32
- package/skills/dream/SKILL.md +12 -0
- package/src/context/boot.ts +44 -25
- package/src/context/budget.ts +52 -27
- package/src/context/context-window.ts +16 -1
- package/src/context/prompts.ts +2 -2
- package/src/context/reset-artifacts.ts +26 -24
- package/src/context/reset-lifecycle.ts +117 -111
- package/src/context/runtime.ts +50 -22
- package/src/context/thresholds.ts +1 -1
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +5 -2
- package/src/index.ts +4 -4
- package/src/notes/address.ts +9 -15
- package/src/notes/constants.ts +3 -0
- package/src/notes/context.ts +40 -0
- package/src/notes/frontmatter.ts +18 -12
- package/src/notes/index.ts +22 -0
- package/src/notes/paths.ts +64 -78
- package/src/notes/store.ts +289 -238
- package/src/pi/notes/adapter.ts +44 -0
- package/src/{notes → pi/notes}/session-replay.ts +3 -3
- package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
- package/src/{notes → pi/notes}/tools.ts +25 -23
- package/src/protocol.ts +5 -6
- package/src/text-match.ts +13 -0
- package/src/tool-output.ts +2 -14
|
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import piContext, { createPiContext } from "../../src/index.js";
|
|
7
|
-
import { loadNotesSnapshot } from "../../src/notes/
|
|
7
|
+
import { loadNotesSnapshot } from "../../src/pi/notes/snapshot.js";
|
|
8
8
|
import { renderBootBlock } from "../../src/context/prompts.js";
|
|
9
|
-
import { agentSlug, modelSlug } from "../../src/notes/
|
|
9
|
+
import { agentSlug, modelSlug } from "../../src/pi/notes/adapter.js";
|
|
10
10
|
import { rootWindowId } from "../../src/context/context-window.js";
|
|
11
11
|
import { TOOL_OUTPUT_MAX_BYTES } from "../../src/tool-output.js";
|
|
12
12
|
let defaultCwd = "/private/tmp/pi-context-test-cwd";
|
|
@@ -130,14 +130,14 @@ export function makeExtension(sessionManager, settingsManager) {
|
|
|
130
130
|
(settingsManager ? createPiContext({ settingsManager }) : piContext)(api);
|
|
131
131
|
return captured;
|
|
132
132
|
}
|
|
133
|
-
export function explicitBoot(ctx, currentWindowId, previousWindowId) {
|
|
133
|
+
export async function explicitBoot(ctx, currentWindowId, previousWindowId) {
|
|
134
134
|
return renderBootBlock({
|
|
135
135
|
agentName: agentSlug(ctx),
|
|
136
136
|
modelName: modelSlug(ctx),
|
|
137
137
|
firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
|
|
138
138
|
currentWindowId,
|
|
139
139
|
previousWindowId,
|
|
140
|
-
notes: loadNotesSnapshot(ctx),
|
|
140
|
+
notes: await loadNotesSnapshot(ctx),
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
export function context(sessionManager, compact, usage, idle = true, cwd = defaultCwd, projectTrusted = true, model) {
|
|
@@ -166,41 +166,12 @@ export function sentOf(captured, customType) {
|
|
|
166
166
|
export async function call(captured, name, params, ctx) {
|
|
167
167
|
const tool = captured.tools.get(name);
|
|
168
168
|
assert.ok(tool, `registered ${name}`);
|
|
169
|
-
// Most pre-redesign coverage names session notes by their bare address. Keep these old
|
|
170
|
-
// fixture call sites readable while routing the direct tool invocation through its new
|
|
171
|
-
// address-shaped input; contract-specific tests below pass address themselves.
|
|
172
|
-
const noteCall = name === "notes_write" || name === "notes_edit" || name === "notes_read";
|
|
173
|
-
if (noteCall && "path" in params && !("address" in params)) {
|
|
174
|
-
const { path, scope, ...rest } = params;
|
|
175
|
-
assert.equal(typeof path, "string", "legacy note fixture path is a string");
|
|
176
|
-
const address = scope === "project" ? `@project/${path}` : scope === "human" ? `@human/${path}` : path;
|
|
177
|
-
return tool.execute("call-1", { ...rest, address }, new AbortController().signal, () => { }, ctx);
|
|
178
|
-
}
|
|
179
|
-
if ((name === "notes_list" || name === "notes_search") && params.scope === "human") {
|
|
180
|
-
const { scope: _scope, pattern, ...rest } = params;
|
|
181
|
-
return tool.execute("call-1", { ...rest, pattern: `@human/${typeof pattern === "string" ? pattern : "**"}` }, new AbortController().signal, () => { }, ctx);
|
|
182
|
-
}
|
|
183
|
-
if ((name === "notes_list" || name === "notes_search") && params.scope === "session") {
|
|
184
|
-
const { scope: _scope, pattern, ...rest } = params;
|
|
185
|
-
return tool.execute("call-1", { ...rest, pattern: typeof pattern === "string" ? pattern : "*.md" }, new AbortController().signal, () => { }, ctx);
|
|
186
|
-
}
|
|
187
169
|
return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
|
|
188
170
|
}
|
|
189
171
|
export function resultJson(result) {
|
|
190
172
|
const text = result.content[0];
|
|
191
173
|
assert.ok(text && text.type === "text", "tool result carries text");
|
|
192
|
-
|
|
193
|
-
const suffix = (address) => address.startsWith("@project/") ? address.slice("@project/".length) : address.startsWith("@human/") ? address.slice("@human/".length) : address;
|
|
194
|
-
const legacyPath = (row) => {
|
|
195
|
-
if (typeof row.address === "string" && row.path === undefined)
|
|
196
|
-
Object.defineProperty(row, "path", { value: suffix(row.address), enumerable: false });
|
|
197
|
-
};
|
|
198
|
-
legacyPath(value);
|
|
199
|
-
if (Array.isArray(value.files))
|
|
200
|
-
for (const file of value.files)
|
|
201
|
-
if (file && typeof file === "object")
|
|
202
|
-
legacyPath(file);
|
|
203
|
-
return value;
|
|
174
|
+
return JSON.parse(text.text);
|
|
204
175
|
}
|
|
205
176
|
/** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
|
|
206
177
|
export function assertWithinBudget(result, message) {
|
|
@@ -223,32 +194,6 @@ export function resultRead(result) {
|
|
|
223
194
|
assert.equal(Array.from(content).length, end - offset_chars, "READ WINDOW range matches the delivered payload");
|
|
224
195
|
return { header, content, offset_chars, total_chars, next_offset_chars, details: (result.details ?? {}) };
|
|
225
196
|
}
|
|
226
|
-
/**
|
|
227
|
-
* Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
|
|
228
|
-
* and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
|
|
229
|
-
*/
|
|
230
|
-
export function assertLocalIso(value, epochMs, message) {
|
|
231
|
-
assert.equal(typeof value, "string", message);
|
|
232
|
-
assert.match(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/, message);
|
|
233
|
-
assert.equal(Date.parse(value), epochMs, `${message}: Date.parse restores the stored epoch ms`);
|
|
234
|
-
}
|
|
235
|
-
/** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
|
|
236
|
-
export function assertIsoTimestamp(text, message) {
|
|
237
|
-
const match = text.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}/);
|
|
238
|
-
assert.ok(match, message);
|
|
239
|
-
assert.equal(Number.isNaN(Date.parse(match[0])), false, `${message}: timestamp parses`);
|
|
240
|
-
return match[0];
|
|
241
|
-
}
|
|
242
|
-
/** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
|
|
243
|
-
export function assertTruncationOf(original, actual) {
|
|
244
|
-
const match = actual.match(/^([\s\S]*)…\[truncated \d+ chars\]…([\s\S]*)$/);
|
|
245
|
-
assert.ok(match, "truncated value carries the middle-truncation marker");
|
|
246
|
-
const head = match[1];
|
|
247
|
-
const tail = match[2];
|
|
248
|
-
assert.ok(original.startsWith(head), "truncation keeps the original head");
|
|
249
|
-
assert.ok(original.endsWith(tail), "truncation keeps the original tail");
|
|
250
|
-
assert.ok(head.length + tail.length < original.length, "truncation actually removes characters");
|
|
251
|
-
}
|
|
252
197
|
export async function runManualCompact(captured, ctx) {
|
|
253
198
|
const handler = captured.handlers.get("session_before_compact")?.[0];
|
|
254
199
|
assert.ok(handler, "session_before_compact handler registered");
|
|
@@ -270,32 +215,13 @@ export async function runManualCompact(captured, ctx) {
|
|
|
270
215
|
};
|
|
271
216
|
return (await handler(event, ctx));
|
|
272
217
|
}
|
|
273
|
-
export function runHandlers(captured, name, event, ctx) {
|
|
218
|
+
export async function runHandlers(captured, name, event, ctx) {
|
|
274
219
|
const isIdle = ctx.isIdle;
|
|
275
220
|
if (name === "agent_settled")
|
|
276
221
|
ctx.isIdle = () => true;
|
|
277
222
|
try {
|
|
278
223
|
for (const handler of captured.handlers.get(name) ?? [])
|
|
279
|
-
handler(event, ctx);
|
|
280
|
-
}
|
|
281
|
-
finally {
|
|
282
|
-
ctx.isIdle = isIdle;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
export async function runHandlersAsync(captured, name, event, ctx) {
|
|
286
|
-
const results = [];
|
|
287
|
-
for (const handler of captured.handlers.get(name) ?? [])
|
|
288
|
-
results.push(await handler(event, ctx));
|
|
289
|
-
return results;
|
|
290
|
-
}
|
|
291
|
-
export function completeRequestedCompaction(ctx) {
|
|
292
|
-
const requests = ctx.compactionRequests;
|
|
293
|
-
const options = requests.shift();
|
|
294
|
-
assert.ok(options?.onComplete, "a reset request has a completion callback");
|
|
295
|
-
const isIdle = ctx.isIdle;
|
|
296
|
-
ctx.isIdle = () => true;
|
|
297
|
-
try {
|
|
298
|
-
options.onComplete({});
|
|
224
|
+
await handler(event, ctx);
|
|
299
225
|
}
|
|
300
226
|
finally {
|
|
301
227
|
ctx.isIdle = isIdle;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type NoteRow, type NotesQuery, type Scope } from "../../src/notes/index.js";
|
|
3
|
+
/** Test-only conveniences for fixtures that still model notes through a live Pi context. */
|
|
4
|
+
export declare function physicalPath(scope: Scope, path: string, ctx: ExtensionContext, who?: string): string;
|
|
5
|
+
export declare function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string;
|
|
6
|
+
export declare function listNotes(ctx: ExtensionContext, query?: NotesQuery): Promise<NoteRow[]>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createNotesStore } from "../../src/notes/index.js";
|
|
2
|
+
import { notesContextFromPi } from "../../src/pi/notes/adapter.js";
|
|
3
|
+
import { physicalPath as corePhysicalPath, scopeDir as coreScopeDir } from "../../src/notes/paths.js";
|
|
4
|
+
/** Test-only conveniences for fixtures that still model notes through a live Pi context. */
|
|
5
|
+
export function physicalPath(scope, path, ctx, who) {
|
|
6
|
+
return corePhysicalPath(scope, path, notesContextFromPi(ctx), who);
|
|
7
|
+
}
|
|
8
|
+
export function scopeDir(scope, ctx, who) {
|
|
9
|
+
return coreScopeDir(scope, notesContextFromPi(ctx), who);
|
|
10
|
+
}
|
|
11
|
+
export function listNotes(ctx, query = {}) {
|
|
12
|
+
return createNotesStore(notesContextFromPi(ctx)).list(query);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -2,11 +2,9 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { historyFromSession } from "../src/index.js";
|
|
4
4
|
import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
|
|
5
|
-
import { appendText, assertWithinBudget, call, context,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
test.afterEach(() => testEnvironment.afterEach());
|
|
9
|
-
test.after(() => testEnvironment.dispose());
|
|
5
|
+
import { appendText, assertWithinBudget, call, context, makeExtension, manager, objectSchema, resultJson, resultRead, } from "./helpers/extension.js";
|
|
6
|
+
import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
|
|
7
|
+
const testEnvironment = installExtensionTestHooks("pi-context-integration");
|
|
10
8
|
test("schemas cover the History/Notes actions plus reset controls", () => {
|
|
11
9
|
const captured = makeExtension(manager());
|
|
12
10
|
for (const name of [
|
|
@@ -94,7 +92,7 @@ test("paged tool outputs stay bounded and cursors reconstruct history and notes"
|
|
|
94
92
|
}
|
|
95
93
|
assert.equal(readParts.join(""), historyText);
|
|
96
94
|
for (let index = 0; index < 100; index++) {
|
|
97
|
-
await call(captured, "notes_write", {
|
|
95
|
+
await call(captured, "notes_write", { address: `page-${"x".repeat(120)}-${index}.md`, content: Array.from({ length: 1000 }, (_, line) => `needle ${line} ${"z".repeat(30)}`).join("\n") }, ctx);
|
|
98
96
|
}
|
|
99
97
|
const listPages = [];
|
|
100
98
|
let listOffset = 0;
|
|
@@ -102,7 +100,7 @@ test("paged tool outputs stay bounded and cursors reconstruct history and notes"
|
|
|
102
100
|
while (listNext !== null) {
|
|
103
101
|
const result = resultJson(await call(captured, "notes_list", { max_results: 300, cursor: listOffset }, ctx));
|
|
104
102
|
assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
105
|
-
listPages.push(...result.files.map((file) => file.
|
|
103
|
+
listPages.push(...result.files.map((file) => file.address));
|
|
106
104
|
listNext = result.next_cursor;
|
|
107
105
|
if (listNext !== null)
|
|
108
106
|
listOffset = listNext;
|
|
@@ -127,7 +125,7 @@ test("paged tool outputs stay bounded and cursors reconstruct history and notes"
|
|
|
127
125
|
let noteOffset = 0;
|
|
128
126
|
let noteNext = 0;
|
|
129
127
|
while (noteNext !== null) {
|
|
130
|
-
const raw = await call(captured, "notes_read", {
|
|
128
|
+
const raw = await call(captured, "notes_read", { address: `page-${"x".repeat(120)}-0.md`, offset_chars: noteOffset }, ctx);
|
|
131
129
|
assertWithinBudget(raw, `notes_read page at ${noteOffset}`);
|
|
132
130
|
const result = resultRead(raw);
|
|
133
131
|
// The window is a plain prefix of the file, so the pages join by plain concatenation.
|
|
@@ -172,7 +170,7 @@ test("a page cap limits the page, not the enumerable set: cursors stay truthful
|
|
|
172
170
|
assert.equal(searchTail.next_cursor, null);
|
|
173
171
|
// notes_search: max_files caps the page, not the matched files.
|
|
174
172
|
for (let index = 0; index < 7; index++)
|
|
175
|
-
await call(captured, "notes_write", {
|
|
173
|
+
await call(captured, "notes_write", { address: `needle-${index}.md`, content: "needle" }, ctx);
|
|
176
174
|
const notes = async (params) => resultJson(await call(captured, "notes_search", params, ctx));
|
|
177
175
|
const notesFirst = await notes({ query: "needle", max_files: 3 });
|
|
178
176
|
assert.equal(notesFirst.files.length, 3);
|
|
@@ -200,18 +198,18 @@ test("multi-query search: OR semantics, dedupe, and bare-string backward compati
|
|
|
200
198
|
assert.deepEqual(await historyIds({ query: ["alpha"] }), [bothId, alphaId], "history: a one-element array searches that literal");
|
|
201
199
|
assert.deepEqual(await historyIds({ query: "alpha" }), orIds.filter((id) => id !== betaId), "history: a bare string still behaves exactly as before");
|
|
202
200
|
assert.deepEqual(await historyIds({ query: "alpha" }), await historyIds({ query: ["alpha"] }), "history: bare string equals the single-element list");
|
|
203
|
-
await call(captured, "notes_write", {
|
|
204
|
-
await call(captured, "notes_write", {
|
|
205
|
-
await call(captured, "notes_write", {
|
|
206
|
-
await call(captured, "notes_write", {
|
|
201
|
+
await call(captured, "notes_write", { address: "both.md", content: "alpha beta\nunrelated" }, ctx);
|
|
202
|
+
await call(captured, "notes_write", { address: "alpha.md", content: "alpha only" }, ctx);
|
|
203
|
+
await call(captured, "notes_write", { address: "beta.md", content: "beta only" }, ctx);
|
|
204
|
+
await call(captured, "notes_write", { address: "gamma.md", content: "gamma only" }, ctx);
|
|
207
205
|
const notesSearch = async (params) => resultJson(await call(captured, "notes_search", params, ctx)).files;
|
|
208
206
|
const orFiles = await notesSearch({ query: ["alpha", "beta"] });
|
|
209
|
-
assert.deepEqual(orFiles.map((file) => file.
|
|
210
|
-
assert.equal(orFiles.find((file) => file.
|
|
211
|
-
assert.deepEqual((await notesSearch({ query: ["alpha"] })).map((file) => file.
|
|
212
|
-
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.
|
|
213
|
-
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.
|
|
214
|
-
assert.deepEqual((await notesSearch({ query: ["gamma"] })).map((file) => file.
|
|
207
|
+
assert.deepEqual(orFiles.map((file) => file.address), ["alpha.md", "beta.md", "both.md"], "notes: a file matching any query is returned once, address-ordered");
|
|
208
|
+
assert.equal(orFiles.find((file) => file.address === "both.md")?.matches.length, 1, "notes: one line containing both queries is reported once");
|
|
209
|
+
assert.deepEqual((await notesSearch({ query: ["alpha"] })).map((file) => file.address), ["alpha.md", "both.md"], "notes: a one-element array searches that literal");
|
|
210
|
+
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.address), ["alpha.md", "both.md"], "notes: a bare string still behaves exactly as before");
|
|
211
|
+
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.address), (await notesSearch({ query: ["alpha"] })).map((file) => file.address), "notes: bare string equals the single-element list");
|
|
212
|
+
assert.deepEqual((await notesSearch({ query: ["gamma"] })).map((file) => file.address), ["gamma.md"]);
|
|
215
213
|
// An empty array is an argument error, not a silently empty result set.
|
|
216
214
|
await assert.rejects(() => call(captured, "history_search", { query: [] }, ctx), /non-empty array of strings/, "history: empty query array is refused");
|
|
217
215
|
await assert.rejects(() => call(captured, "notes_search", { query: [] }, ctx), /non-empty array of strings/, "notes: empty query array is refused");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createNotesStore, NoteError } from "../src/notes/index.js";
|
|
7
|
+
function fixture(t) {
|
|
8
|
+
const home = mkdtempSync(join(tmpdir(), "notes-library-"));
|
|
9
|
+
t.after(() => rmSync(home, { recursive: true, force: true }));
|
|
10
|
+
const context = { home, sessionId: "session-a", projectKey: "project-12345678", agent: "root", model: "test-model" };
|
|
11
|
+
return { home, context, notes: createNotesStore(context) };
|
|
12
|
+
}
|
|
13
|
+
test("standalone notes API persists camelCase metadata, edits, lists and searches full results", async (t) => {
|
|
14
|
+
const { home, notes } = fixture(t);
|
|
15
|
+
const first = await notes.write("checkpoint", "alpha\nneedle 😀", { origin: "user" });
|
|
16
|
+
assert.equal(first.meta.project, "project-12345678");
|
|
17
|
+
const read = (await notes.read("checkpoint.md"));
|
|
18
|
+
assert.equal(read.body, "alpha\nneedle 😀");
|
|
19
|
+
assert.equal(read.meta.origin, "user");
|
|
20
|
+
assert.equal(read.meta.accessCount, 1);
|
|
21
|
+
assert.equal(await notes.read("missing.md"), undefined);
|
|
22
|
+
const edited = await notes.edit("checkpoint", [{ oldText: "alpha", newText: "beta" }]);
|
|
23
|
+
assert.deepEqual(edited.change, { kind: "body", before: "alpha\nneedle 😀", after: "beta\nneedle 😀" });
|
|
24
|
+
assert.equal(edited.resolvedScope, read.resolvedScope);
|
|
25
|
+
assert.equal(edited.applied, 1);
|
|
26
|
+
const metadataEdit = await notes.edit("checkpoint", undefined, { stale: true });
|
|
27
|
+
assert.equal(metadataEdit.change.kind, "metadata");
|
|
28
|
+
assert.match(metadataEdit.change.before, /\nstale: false\n/);
|
|
29
|
+
assert.match(metadataEdit.change.after, /\nstale: true\n/);
|
|
30
|
+
assert.equal(metadataEdit.change.after.includes("needle"), false, "metadata-only diff excludes body");
|
|
31
|
+
assert.equal((await notes.list())[0]?.meta.stale, true);
|
|
32
|
+
const matches = await notes.search(["needle"]);
|
|
33
|
+
assert.equal(matches[0]?.matches[0]?.line, 2);
|
|
34
|
+
const text = (await notes.read("checkpoint")).text;
|
|
35
|
+
assert.equal(Array.from(text).slice(matches[0].matches[0].offsetChars).join(""), "needle 😀");
|
|
36
|
+
// An existing hand-written extra field and original ownership survive a new caller.
|
|
37
|
+
const path = join(home, "pi/session/session-a/checkpoint.md");
|
|
38
|
+
writeFileSync(path, readFileSync(path, "utf8").replace("\n---\n\n", "\nsourceWindow: pcw:test\nrecurrenceCount: 2\nrecurrenceWindows:\n - pcw:one\n - pcw:two\n__proto__: {\"sentinel\":true}\ncustom: preserved\n---\n\n"));
|
|
39
|
+
const moved = createNotesStore({ home, sessionId: "session-a", projectKey: "other-12345678", agent: "root", model: "test-model" });
|
|
40
|
+
const rewritten = await moved.write("checkpoint", "new body");
|
|
41
|
+
assert.equal(rewritten.meta.createdAt, first.meta.createdAt);
|
|
42
|
+
assert.equal(rewritten.meta.project, "project-12345678");
|
|
43
|
+
assert.equal(rewritten.meta.custom, "preserved");
|
|
44
|
+
assert.deepEqual(rewritten.meta["__proto__"], { sentinel: true });
|
|
45
|
+
assert.deepEqual((await moved.read("checkpoint"))?.meta["__proto__"], { sentinel: true }, "unknown __proto__ metadata survives persistence and reread");
|
|
46
|
+
assert.equal(rewritten.meta.sourceWindow, "pcw:test");
|
|
47
|
+
assert.equal(rewritten.meta.recurrenceCount, 2);
|
|
48
|
+
assert.deepEqual(rewritten.meta.recurrenceWindows, ["pcw:one", "pcw:two"]);
|
|
49
|
+
assert.equal(rewritten.meta.stale, false);
|
|
50
|
+
const combined = await moved.edit("checkpoint", [{ oldText: "new", newText: "final" }], { stale: true });
|
|
51
|
+
assert.equal(combined.change.kind, "file");
|
|
52
|
+
assert.match(combined.change.after, /\nstale: true\n/);
|
|
53
|
+
assert.match(combined.change.after, /final body$/);
|
|
54
|
+
assert.deepEqual((await moved.edit("checkpoint", undefined, { stale: true })).change, { kind: "none", before: "", after: "" });
|
|
55
|
+
});
|
|
56
|
+
test("same-file read/modify/write operations serialize across stores and markdown aliases", async (t) => {
|
|
57
|
+
const { home, context, notes } = fixture(t);
|
|
58
|
+
const other = createNotesStore(context);
|
|
59
|
+
await notes.write("shared", "alpha\nbeta");
|
|
60
|
+
const firstEdit = notes.edit("shared", [{ oldText: "alpha", newText: "A" }]);
|
|
61
|
+
const secondEdit = other.edit("shared.md", [{ oldText: "beta", newText: "B" }]);
|
|
62
|
+
await Promise.all([firstEdit, secondEdit]);
|
|
63
|
+
assert.equal((await notes.read("shared"))?.body, "A\nB", "edits through address aliases retain both changes");
|
|
64
|
+
const before = (await notes.read("shared")).meta.accessCount;
|
|
65
|
+
const [readA, readB] = await Promise.all([notes.read("shared.md"), other.read("shared")]);
|
|
66
|
+
assert.ok(readA && readB);
|
|
67
|
+
assert.equal((await notes.list({ scope: "session" }))[0]?.meta.accessCount, before + 2, "parallel reads do not lose access-counter updates");
|
|
68
|
+
const file = join(home, "pi/session/session-a/shared.md");
|
|
69
|
+
const stableEdits = [{ oldText: "A", newText: "first" }];
|
|
70
|
+
const operation = notes.edit("shared", stableEdits);
|
|
71
|
+
stableEdits[0].newText = "mutated after call";
|
|
72
|
+
await operation;
|
|
73
|
+
assert.equal((await notes.read("shared"))?.body, "first\nB", "edit arguments are snapshotted at method entry");
|
|
74
|
+
assert.equal(readFileSync(file, "utf8").includes("mutated after call"), false);
|
|
75
|
+
});
|
|
76
|
+
test("stores snapshot explicit identity and do not leak homes across instances", async (t) => {
|
|
77
|
+
const { home, context } = fixture(t);
|
|
78
|
+
const mutable = { ...context };
|
|
79
|
+
const notes = createNotesStore(mutable);
|
|
80
|
+
const other = createNotesStore({ ...context, home: join(home, "other"), agent: "other-agent", model: "other-model" });
|
|
81
|
+
assert.equal(existsSync(join(home, "other")), false, "construction does not create the supplied home");
|
|
82
|
+
mutable.agent = "changed-after-construction";
|
|
83
|
+
await notes.write("@self/private", "first");
|
|
84
|
+
await other.write("@self/private", "second");
|
|
85
|
+
assert.equal((await notes.read("@self/private"))?.body, "first");
|
|
86
|
+
assert.equal((await other.read("@self/private"))?.body, "second");
|
|
87
|
+
assert.equal((await notes.list())[0]?.address, "@agents/root/private.md");
|
|
88
|
+
assert.equal((await other.list())[0]?.address, "@agents/other-agent/private.md");
|
|
89
|
+
mkdirSync(join(home, "agents/visitor"), { recursive: true });
|
|
90
|
+
writeFileSync(join(home, "agents/visitor/hello.md"), "visiting");
|
|
91
|
+
assert.equal((await notes.read("@agents/visitor/hello"))?.body, "visiting");
|
|
92
|
+
await assert.rejects(() => notes.write("@agents/visitor/hello", "overwrite"), (error) => error instanceof NoteError && error.code === "invalid_scope");
|
|
93
|
+
});
|
|
94
|
+
test("list and search share scoped pattern scans and search offsets count Unicode code points", async (t) => {
|
|
95
|
+
const { notes } = fixture(t);
|
|
96
|
+
await notes.write("shared.md", "😀 needle in session");
|
|
97
|
+
await notes.write("@project/shared.md", "needle in project");
|
|
98
|
+
await notes.write("@human/shared.md", "needle in human");
|
|
99
|
+
await notes.write("@project/elsewhere.md", "unrelated");
|
|
100
|
+
const pattern = "**/shared.md";
|
|
101
|
+
const listed = await notes.list({ pattern });
|
|
102
|
+
const searched = await notes.search(["needle"], { pattern });
|
|
103
|
+
const listedAddresses = listed.map((row) => row.address).sort();
|
|
104
|
+
const searchedAddresses = searched.map((row) => row.address).sort();
|
|
105
|
+
assert.deepEqual(searchedAddresses, ["@human/shared.md", "@project/shared.md", "shared.md"]);
|
|
106
|
+
assert.deepEqual(searchedAddresses, listedAddresses, "list and search traverse the same filtered homes and files");
|
|
107
|
+
const sessionMatch = searched.find((row) => row.address === "shared.md").matches[0];
|
|
108
|
+
const serialized = (await notes.read("shared.md")).text;
|
|
109
|
+
assert.ok(Array.from(serialized).slice(sessionMatch.offsetChars).join("").startsWith("needle"), "the absolute offset counts the emoji as one code point");
|
|
110
|
+
});
|
|
111
|
+
test("invalid addressing and failed edits leave stored bytes untouched without poisoning the queue", async (t) => {
|
|
112
|
+
const { home, context, notes } = fixture(t);
|
|
113
|
+
assert.throws(() => createNotesStore({ ...context, sessionId: "../escape" }));
|
|
114
|
+
await assert.rejects(() => notes.write("@project/../escape", "bad"));
|
|
115
|
+
await assert.rejects(() => notes.list({ scope: "agent", who: "../escape" }));
|
|
116
|
+
// The typed API disallows this; JavaScript callers must still receive a refusal.
|
|
117
|
+
// @ts-expect-error who cannot accompany project scope
|
|
118
|
+
await assert.rejects(() => notes.list({ scope: "project", who: "root" }), (error) => error instanceof NoteError && error.code === "invalid_scope");
|
|
119
|
+
await notes.write("edit", "alpha\nbeta\nbeta");
|
|
120
|
+
const path = join(home, "pi/session/session-a/edit.md");
|
|
121
|
+
const before = readFileSync(path, "utf8");
|
|
122
|
+
await assert.rejects(() => notes.edit("edit", [{ oldText: "beta", newText: "B" }]), (error) => error instanceof NoteError && error.code === "ambiguous_edit" && error.lineNumbers?.join(",") === "2,3");
|
|
123
|
+
await assert.rejects(() => notes.edit("edit", [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "X" }]), (error) => error instanceof NoteError && error.code === "no_match" && error.editIndex === 1);
|
|
124
|
+
assert.equal(readFileSync(path, "utf8"), before);
|
|
125
|
+
await assert.rejects(() => notes.edit("absent", undefined, { stale: true }), (error) => error instanceof NoteError && error.code === "not_found");
|
|
126
|
+
await notes.edit("edit", [{ oldText: "alpha", newText: "A" }]);
|
|
127
|
+
assert.equal((await notes.read("edit"))?.body, "A\nbeta\nbeta", "failed operations do not poison later queue work");
|
|
128
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|