@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
package/dist/src/notes/store.js
CHANGED
|
@@ -1,55 +1,71 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { earliestMatchOffsetChars } from "../text-match.js";
|
|
5
|
+
import { assertAddress, assertGlobPattern, addressFor, globToRegExp } from "./address.js";
|
|
6
|
+
import { snapshotNotesContext } from "./context.js";
|
|
7
|
+
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./constants.js";
|
|
6
8
|
import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
import { earliestMatchOffsetChars } from "../tool-output.js";
|
|
10
|
-
/** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
|
|
9
|
+
import { namespaceSlugs, physicalPath, scopeDir, SLUG_PATTERN } from "./paths.js";
|
|
10
|
+
/** Typed store refusal. Edit locations are exposed in camelCase. */
|
|
11
11
|
export class NoteError extends Error {
|
|
12
12
|
code;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
lineNumbers;
|
|
14
|
+
editIndex;
|
|
15
15
|
constructor(code, message, extra = {}) {
|
|
16
16
|
super(message);
|
|
17
17
|
this.name = "NoteError";
|
|
18
18
|
this.code = code;
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
19
|
+
this.lineNumbers = extra.lineNumbers;
|
|
20
|
+
this.editIndex = extra.editIndex;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
const SCOPE_ORDER = ["session", "project", "human", "agent", "model"];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/** Mutations and read-modify-write reads serialize by physical file across all store instances. */
|
|
25
|
+
const pathQueues = new Map();
|
|
26
|
+
function withPathQueue(path, operation) {
|
|
27
|
+
const key = resolve(path);
|
|
28
|
+
const previous = pathQueues.get(key) ?? Promise.resolve();
|
|
29
|
+
const result = previous.then(operation);
|
|
30
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
31
|
+
pathQueues.set(key, tail);
|
|
32
|
+
void tail.then(() => {
|
|
33
|
+
if (pathQueues.get(key) === tail)
|
|
34
|
+
pathQueues.delete(key);
|
|
35
|
+
});
|
|
36
|
+
return result;
|
|
28
37
|
}
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
function errno(error) {
|
|
39
|
+
return typeof error === "object" && error !== null ? error.code : undefined;
|
|
40
|
+
}
|
|
41
|
+
async function readFileIfExists(path) {
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(path, "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (errno(error) === "ENOENT")
|
|
47
|
+
return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
33
50
|
}
|
|
34
51
|
/** Recursively list `.md` files under `dir` as forward-slash virtual paths relative to `base`. */
|
|
35
|
-
function walkMarkdown(dir, base = dir) {
|
|
52
|
+
async function walkMarkdown(dir, base = dir) {
|
|
36
53
|
let entries;
|
|
37
54
|
try {
|
|
38
|
-
entries =
|
|
55
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
39
56
|
}
|
|
40
57
|
catch (error) {
|
|
41
|
-
// A home that has never been created is normal.
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
if (typeof error === "object" && error !== null && error.code === "ENOENT")
|
|
58
|
+
// A home that has never been created is normal. Other failures must reach the
|
|
59
|
+
// boot snapshot boundary instead of masquerading as an empty home.
|
|
60
|
+
if (errno(error) === "ENOENT")
|
|
45
61
|
return [];
|
|
46
62
|
throw error;
|
|
47
63
|
}
|
|
48
64
|
const paths = [];
|
|
49
65
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
50
|
-
const child =
|
|
66
|
+
const child = join(dir, entry.name);
|
|
51
67
|
if (entry.isDirectory())
|
|
52
|
-
paths.push(...walkMarkdown(child, base));
|
|
68
|
+
paths.push(...await walkMarkdown(child, base));
|
|
53
69
|
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
54
70
|
paths.push(child.slice(base.length + 1).split("\\").join("/"));
|
|
55
71
|
}
|
|
@@ -59,49 +75,7 @@ function matcherFor(pattern) {
|
|
|
59
75
|
const normalized = assertGlobPattern(pattern);
|
|
60
76
|
return normalized === undefined ? undefined : globToRegExp(normalized);
|
|
61
77
|
}
|
|
62
|
-
|
|
63
|
-
* Every mutation lands through a tmp file renamed into place in the same directory, so a crash
|
|
64
|
-
* never leaves a torn note. No cross-process locking: out of scope by decision.
|
|
65
|
-
*/
|
|
66
|
-
function atomicWrite(path, content) {
|
|
67
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
68
|
-
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
69
|
-
try {
|
|
70
|
-
writeFileSync(tmp, content);
|
|
71
|
-
renameSync(tmp, path);
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
rmSync(tmp, { force: true });
|
|
75
|
-
throw error;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/** Write-time vpath guard: the byte cap is a tool-boundary rule, never a jail rule. */
|
|
79
|
-
function assertWritablePath(vpath) {
|
|
80
|
-
const bytes = Buffer.byteLength(vpath, "utf8");
|
|
81
|
-
if (bytes > MAX_NOTE_PATH_BYTES)
|
|
82
|
-
throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
|
|
83
|
-
}
|
|
84
|
-
/** Serialized-size guard applied after the frontmatter is merged, before any bytes are written. */
|
|
85
|
-
function assertSerializedSize(content) {
|
|
86
|
-
const bytes = Buffer.byteLength(content, "utf8");
|
|
87
|
-
if (bytes > MAX_NOTE_BYTES)
|
|
88
|
-
throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
|
|
89
|
-
}
|
|
90
|
-
/** Frontmatter block only (the body separator stripped), for the metadata-only diff. */
|
|
91
|
-
function frontmatterOf(meta) {
|
|
92
|
-
return serializeNote(meta, "").slice(0, -2);
|
|
93
|
-
}
|
|
94
|
-
/** Named agent/model homes are read-only to whoever is not running there. */
|
|
95
|
-
function assertWritableHome(scope, who, ctx) {
|
|
96
|
-
if (who === undefined)
|
|
97
|
-
return;
|
|
98
|
-
const current = scope === "agent" ? agentSlug(ctx) : modelSlug(ctx);
|
|
99
|
-
if (who === current)
|
|
100
|
-
return;
|
|
101
|
-
const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
|
|
102
|
-
throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
|
|
103
|
-
}
|
|
104
|
-
function homesForPattern(pattern) {
|
|
78
|
+
async function homesForPattern(pattern, context) {
|
|
105
79
|
if (!pattern || !pattern.startsWith("@"))
|
|
106
80
|
return undefined;
|
|
107
81
|
const head = /^@([^/]+)\//.exec(pattern)?.[1];
|
|
@@ -117,25 +91,51 @@ function homesForPattern(pattern) {
|
|
|
117
91
|
const scope = head === "agents" ? "agent" : "model";
|
|
118
92
|
const name = pattern.slice(head.length + 2).split("/")[0] ?? "";
|
|
119
93
|
if (name.length > 0 && !/[*?]/.test(name))
|
|
120
|
-
return [{ scope, who: name }];
|
|
121
|
-
return namespaceSlugs(head).map((who) => ({ scope, who }));
|
|
94
|
+
return [{ scope, who: assertWho(name) }];
|
|
95
|
+
return (await namespaceSlugs(head, context.home)).map((who) => ({ scope, who }));
|
|
122
96
|
}
|
|
123
97
|
return [];
|
|
124
98
|
}
|
|
125
99
|
/** Relative pattern heads resolve to canonical names, so they match rendered addresses. */
|
|
126
|
-
function normalizePattern(pattern,
|
|
100
|
+
function normalizePattern(pattern, context) {
|
|
127
101
|
if (!pattern)
|
|
128
102
|
return pattern;
|
|
129
103
|
if (pattern.startsWith("@self/"))
|
|
130
|
-
return `@agents/${
|
|
104
|
+
return `@agents/${context.agent}/${pattern.slice("@self/".length)}`;
|
|
131
105
|
if (pattern.startsWith("@model/"))
|
|
132
|
-
return `@models/${
|
|
106
|
+
return `@models/${context.model}/${pattern.slice("@model/".length)}`;
|
|
133
107
|
return pattern;
|
|
134
108
|
}
|
|
135
|
-
function
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
return
|
|
109
|
+
function assertScope(value) {
|
|
110
|
+
if (!isScope(value))
|
|
111
|
+
throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function assertOrigin(value) {
|
|
115
|
+
if (!isOrigin(value))
|
|
116
|
+
throw new NoteError("invalid_origin", `origin must be one of user, self, external (got ${JSON.stringify(value)})`);
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
function assertWho(value) {
|
|
120
|
+
if (typeof value !== "string" || !SLUG_PATTERN.test(value)) {
|
|
121
|
+
throw new NoteError("invalid_scope", "who must be a canonical lowercase slug");
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
async function homesFor(context, opts) {
|
|
126
|
+
if (opts.scope !== undefined) {
|
|
127
|
+
const scope = assertScope(opts.scope);
|
|
128
|
+
if (opts.who !== undefined) {
|
|
129
|
+
const who = assertWho(opts.who);
|
|
130
|
+
if (scope !== "agent" && scope !== "model")
|
|
131
|
+
throw new NoteError("invalid_scope", "who is only valid with agent or model scope");
|
|
132
|
+
return [{ scope, who }];
|
|
133
|
+
}
|
|
134
|
+
return [{ scope }];
|
|
135
|
+
}
|
|
136
|
+
if (opts.who !== undefined)
|
|
137
|
+
throw new NoteError("invalid_scope", "who requires agent or model scope");
|
|
138
|
+
return await homesForPattern(normalizePattern(opts.pattern, context), context) ?? SCOPE_ORDER.map((scope) => ({ scope }));
|
|
139
139
|
}
|
|
140
140
|
/** Line numbers (1-based) of every occurrence of `needle` in `body`. */
|
|
141
141
|
function matchLineNumbers(body, needle) {
|
|
@@ -150,181 +150,221 @@ function matchLineNumbers(body, needle) {
|
|
|
150
150
|
}
|
|
151
151
|
return lines;
|
|
152
152
|
}
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const path = physicalPath(scope, vpath, ctx, opts.who);
|
|
161
|
-
const now = Date.now();
|
|
162
|
-
const cleanBody = stripLeadingFrontmatter(body);
|
|
163
|
-
const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
|
|
164
|
-
const meta = existing ?? {
|
|
165
|
-
scope,
|
|
166
|
-
origin,
|
|
167
|
-
status: "active",
|
|
168
|
-
stale: false,
|
|
169
|
-
created_at: now,
|
|
170
|
-
updated_at: now,
|
|
171
|
-
last_accessed: now,
|
|
172
|
-
access_count: 0,
|
|
173
|
-
};
|
|
174
|
-
meta.scope = scope;
|
|
175
|
-
meta.origin = origin;
|
|
176
|
-
meta.status = "active";
|
|
177
|
-
meta.stale = opts.stale ?? false;
|
|
178
|
-
meta.updated_at = now;
|
|
179
|
-
const serialized = serializeNote(meta, cleanBody);
|
|
180
|
-
assertSerializedSize(serialized);
|
|
181
|
-
atomicWrite(path, serialized);
|
|
182
|
-
return { meta };
|
|
183
|
-
}
|
|
184
|
-
/** Dream harness mutation: metadata changes still use the store's atomic writer. */
|
|
185
|
-
export function updateNoteMeta(ctx, vpath, scope, mutate, who) {
|
|
186
|
-
assertVirtualPath(vpath);
|
|
187
|
-
const path = physicalPath(scope, vpath, ctx, who);
|
|
188
|
-
if (!existsSync(path))
|
|
189
|
-
throw new NoteError("not_found", `note not found: ${vpath}`);
|
|
190
|
-
const parsed = parseNote(readFileSync(path, "utf8"));
|
|
191
|
-
const meta = { ...parsed.meta, scope };
|
|
192
|
-
mutate(meta);
|
|
193
|
-
meta.updated_at = Date.now();
|
|
194
|
-
const serialized = serializeNote(meta, parsed.body);
|
|
195
|
-
assertSerializedSize(serialized);
|
|
196
|
-
atomicWrite(path, serialized);
|
|
197
|
-
return { meta, body: parsed.body };
|
|
198
|
-
}
|
|
199
|
-
/** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
|
|
200
|
-
export function editNote(ctx, vpath, scope, edits, opts = {}, who) {
|
|
201
|
-
assertVirtualPath(vpath);
|
|
202
|
-
assertWritablePath(vpath);
|
|
203
|
-
assertWritableHome(scope, who, ctx);
|
|
204
|
-
const operations = edits ?? [];
|
|
205
|
-
if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
|
|
206
|
-
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
|
|
153
|
+
/** Every mutation uses a tmp file renamed into place in the same directory. */
|
|
154
|
+
async function atomicWrite(path, content) {
|
|
155
|
+
await mkdir(dirname(path), { recursive: true });
|
|
156
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
157
|
+
try {
|
|
158
|
+
await writeFile(tmp, content);
|
|
159
|
+
await rename(tmp, path);
|
|
207
160
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const raw = readFileSync(path, "utf8");
|
|
212
|
-
const { meta, body } = parseNote(raw);
|
|
213
|
-
meta.scope = scope;
|
|
214
|
-
// Snapshot the pre-edit frontmatter so the diff can name exactly what the setters changed.
|
|
215
|
-
const beforeMeta = { ...meta };
|
|
216
|
-
// Every edit runs against this one snapshot; nothing is written until all of them succeed,
|
|
217
|
-
// so a failing edit leaves the file byte-identical (frontmatter included).
|
|
218
|
-
let next = body;
|
|
219
|
-
operations.forEach((edit, index) => {
|
|
220
|
-
const oldText = edit?.oldText;
|
|
221
|
-
const newText = edit?.newText;
|
|
222
|
-
if (typeof oldText !== "string" || oldText.length === 0)
|
|
223
|
-
throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { edit_index: index });
|
|
224
|
-
if (typeof newText !== "string")
|
|
225
|
-
throw new NoteError("no_match", `edit ${index}: newText must be a string`, { edit_index: index });
|
|
226
|
-
const lines = matchLineNumbers(next, oldText);
|
|
227
|
-
if (lines.length === 0)
|
|
228
|
-
throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { edit_index: index });
|
|
229
|
-
if (lines.length > 1 && !opts.replaceAll) {
|
|
230
|
-
throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { line_numbers: lines, edit_index: index });
|
|
231
|
-
}
|
|
232
|
-
// Single replacement is positional splicing, never String.replace: user text must be
|
|
233
|
-
// inserted byte-for-byte, without $-pattern substitution ($&, $`, $', $1, $$).
|
|
234
|
-
if (opts.replaceAll) {
|
|
235
|
-
next = next.split(oldText).join(newText);
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
const matchIndex = next.indexOf(oldText);
|
|
239
|
-
next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
|
|
161
|
+
catch (error) {
|
|
162
|
+
try {
|
|
163
|
+
await rm(tmp, { force: true });
|
|
240
164
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
: bodyChanged
|
|
256
|
-
? generateDiffString(body, next).diff
|
|
257
|
-
: metadataChanged
|
|
258
|
-
? generateDiffString(frontmatterOf(beforeMeta), frontmatterOf(meta)).diff
|
|
259
|
-
: "";
|
|
260
|
-
atomicWrite(path, serialized);
|
|
261
|
-
return { meta, applied: operations.length, resolved_scope: scope, diff };
|
|
165
|
+
catch { /* Preserve the original write/rename failure. */ }
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** The byte cap is a write-time boundary rule, never a path-jail rule. */
|
|
170
|
+
function assertWritablePath(vpath) {
|
|
171
|
+
const bytes = Buffer.byteLength(vpath, "utf8");
|
|
172
|
+
if (bytes > MAX_NOTE_PATH_BYTES)
|
|
173
|
+
throw new NoteError("too_large", `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes (got ${bytes})`);
|
|
174
|
+
}
|
|
175
|
+
function assertSerializedSize(content) {
|
|
176
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
177
|
+
if (bytes > MAX_NOTE_BYTES)
|
|
178
|
+
throw new NoteError("too_large", `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes (serialized ${bytes})`);
|
|
262
179
|
}
|
|
263
|
-
/**
|
|
180
|
+
/** Frontmatter block only (the body separator stripped), for metadata-only change inputs. */
|
|
181
|
+
function frontmatterOf(meta) {
|
|
182
|
+
return serializeNote(meta, "").slice(0, -2);
|
|
183
|
+
}
|
|
184
|
+
/** Named agent/model homes are read-only to whoever is not running there. */
|
|
185
|
+
function assertWritableHome(scope, who, context) {
|
|
186
|
+
if (who === undefined)
|
|
187
|
+
return;
|
|
188
|
+
const current = scope === "agent" ? context.agent : context.model;
|
|
189
|
+
if (who === current)
|
|
190
|
+
return;
|
|
191
|
+
const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
|
|
192
|
+
throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
|
|
193
|
+
}
|
|
194
|
+
/** Normalize parsed metadata exactly as a read does, including its access mutation. */
|
|
264
195
|
function accessedMeta(meta, scope, now) {
|
|
265
196
|
const next = { ...meta, scope };
|
|
266
|
-
next.
|
|
267
|
-
next.
|
|
197
|
+
next.lastAccessed = now;
|
|
198
|
+
next.accessCount = (typeof next.accessCount === "number" ? next.accessCount : 0) + 1;
|
|
268
199
|
return next;
|
|
269
200
|
}
|
|
270
|
-
/**
|
|
271
|
-
export function
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
201
|
+
/** Create a store over one validated, immutable snapshot of the supplied explicit identity. */
|
|
202
|
+
export function createNotesStore(input) {
|
|
203
|
+
const context = snapshotNotesContext(input);
|
|
204
|
+
async function write(address, content, options = {}) {
|
|
205
|
+
const stableAddress = address;
|
|
206
|
+
const stableContent = content;
|
|
207
|
+
const stableOptions = { ...options };
|
|
208
|
+
const destination = assertAddress(stableAddress);
|
|
209
|
+
assertWritablePath(destination.path);
|
|
210
|
+
const scope = assertScope(destination.scope);
|
|
211
|
+
assertWritableHome(scope, destination.who, context);
|
|
212
|
+
const origin = assertOrigin(stableOptions.origin ?? "self");
|
|
213
|
+
const path = physicalPath(scope, destination.path, context, destination.who);
|
|
214
|
+
return withPathQueue(path, async () => {
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const cleanBody = stripLeadingFrontmatter(stableContent);
|
|
217
|
+
const existingRaw = await readFileIfExists(path);
|
|
218
|
+
const existing = existingRaw === undefined ? undefined : parseNote(existingRaw, now).meta;
|
|
219
|
+
const meta = existing ?? {
|
|
220
|
+
scope,
|
|
221
|
+
origin,
|
|
222
|
+
status: "active",
|
|
223
|
+
stale: false,
|
|
224
|
+
createdAt: now,
|
|
225
|
+
updatedAt: now,
|
|
226
|
+
lastAccessed: now,
|
|
227
|
+
accessCount: 0,
|
|
228
|
+
...(scope === "session" ? { project: context.projectKey } : {}),
|
|
229
|
+
};
|
|
295
230
|
meta.scope = scope;
|
|
296
|
-
|
|
297
|
-
|
|
231
|
+
meta.origin = origin;
|
|
232
|
+
meta.status = "active";
|
|
233
|
+
meta.stale = stableOptions.stale ?? false;
|
|
234
|
+
meta.updatedAt = now;
|
|
235
|
+
const serialized = serializeNote(meta, cleanBody);
|
|
236
|
+
assertSerializedSize(serialized);
|
|
237
|
+
await atomicWrite(path, serialized);
|
|
238
|
+
return { meta };
|
|
239
|
+
});
|
|
298
240
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
241
|
+
async function read(address) {
|
|
242
|
+
const stableAddress = address;
|
|
243
|
+
const destination = assertAddress(stableAddress);
|
|
244
|
+
const scope = assertScope(destination.scope);
|
|
245
|
+
const path = physicalPath(scope, destination.path, context, destination.who);
|
|
246
|
+
return withPathQueue(path, async () => {
|
|
247
|
+
const raw = await readFileIfExists(path);
|
|
248
|
+
if (raw === undefined)
|
|
249
|
+
return undefined;
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const parsed = parseNote(raw, now);
|
|
252
|
+
const meta = accessedMeta(parsed.meta, scope, now);
|
|
253
|
+
const text = serializeNote(meta, parsed.body);
|
|
254
|
+
await atomicWrite(path, text);
|
|
255
|
+
return { meta, body: parsed.body, text, resolvedScope: scope };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function edit(address, edits, options = {}) {
|
|
259
|
+
const stableAddress = address;
|
|
260
|
+
const operations = edits === undefined ? [] : edits.map((operation) => ({ ...operation }));
|
|
261
|
+
const stableOptions = { ...options };
|
|
262
|
+
const destination = assertAddress(stableAddress);
|
|
263
|
+
assertWritablePath(destination.path);
|
|
264
|
+
const scope = assertScope(destination.scope);
|
|
265
|
+
assertWritableHome(scope, destination.who, context);
|
|
266
|
+
if (operations.length === 0 && stableOptions.origin === undefined && stableOptions.stale === undefined) {
|
|
267
|
+
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
|
|
268
|
+
}
|
|
269
|
+
const path = physicalPath(scope, destination.path, context, destination.who);
|
|
270
|
+
return withPathQueue(path, async () => {
|
|
271
|
+
const raw = await readFileIfExists(path);
|
|
272
|
+
if (raw === undefined)
|
|
273
|
+
throw new NoteError("not_found", "note not found");
|
|
274
|
+
const { meta, body } = parseNote(raw);
|
|
314
275
|
meta.scope = scope;
|
|
276
|
+
const beforeMeta = { ...meta };
|
|
277
|
+
let next = body;
|
|
278
|
+
operations.forEach((operation, index) => {
|
|
279
|
+
const oldText = operation?.oldText;
|
|
280
|
+
const newText = operation?.newText;
|
|
281
|
+
if (typeof oldText !== "string" || oldText.length === 0)
|
|
282
|
+
throw new NoteError("no_match", `edit ${index}: oldText must be a non-empty string`, { editIndex: index });
|
|
283
|
+
if (typeof newText !== "string")
|
|
284
|
+
throw new NoteError("no_match", `edit ${index}: newText must be a string`, { editIndex: index });
|
|
285
|
+
const lines = matchLineNumbers(next, oldText);
|
|
286
|
+
if (lines.length === 0)
|
|
287
|
+
throw new NoteError("no_match", `edit ${index}: oldText does not occur in the note body`, { editIndex: index });
|
|
288
|
+
if (lines.length > 1 && !stableOptions.replaceAll) {
|
|
289
|
+
throw new NoteError("ambiguous_edit", `edit ${index}: oldText occurs ${lines.length} times (lines ${lines.join(", ")}); pass replace_all to replace every occurrence`, { lineNumbers: lines, editIndex: index });
|
|
290
|
+
}
|
|
291
|
+
// Positional splicing preserves user replacement text byte-for-byte.
|
|
292
|
+
if (stableOptions.replaceAll)
|
|
293
|
+
next = next.split(oldText).join(newText);
|
|
294
|
+
else {
|
|
295
|
+
const matchIndex = next.indexOf(oldText);
|
|
296
|
+
next = next.substring(0, matchIndex) + newText + next.substring(matchIndex + oldText.length);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
if (stableOptions.origin !== undefined)
|
|
300
|
+
meta.origin = assertOrigin(stableOptions.origin);
|
|
301
|
+
if (stableOptions.stale !== undefined)
|
|
302
|
+
meta.stale = stableOptions.stale;
|
|
303
|
+
meta.updatedAt = Date.now();
|
|
304
|
+
const serialized = serializeNote(meta, next);
|
|
305
|
+
assertSerializedSize(serialized);
|
|
306
|
+
const bodyChanged = body !== next;
|
|
307
|
+
const metadataChanged = beforeMeta.origin !== meta.origin || beforeMeta.stale !== meta.stale;
|
|
308
|
+
let change;
|
|
309
|
+
if (bodyChanged && metadataChanged)
|
|
310
|
+
change = { kind: "file", before: raw, after: serialized };
|
|
311
|
+
else if (bodyChanged)
|
|
312
|
+
change = { kind: "body", before: body, after: next };
|
|
313
|
+
else if (metadataChanged)
|
|
314
|
+
change = { kind: "metadata", before: frontmatterOf(beforeMeta), after: frontmatterOf(meta) };
|
|
315
|
+
else
|
|
316
|
+
change = { kind: "none", before: "", after: "" };
|
|
317
|
+
await atomicWrite(path, serialized);
|
|
318
|
+
return { meta, applied: operations.length, resolvedScope: scope, change };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async function* scan(options) {
|
|
322
|
+
const matcher = matcherFor(normalizePattern(options.pattern, context));
|
|
323
|
+
const homes = await homesFor(context, options);
|
|
324
|
+
for (const home of homes) {
|
|
325
|
+
const scope = home.scope;
|
|
326
|
+
const root = scopeDir(scope, context, home.who);
|
|
327
|
+
for (const path of await walkMarkdown(root)) {
|
|
328
|
+
const address = addressFor(context, scope, path, home.who);
|
|
329
|
+
if (matcher && !matcher.test(address))
|
|
330
|
+
continue;
|
|
331
|
+
const fullPath = join(root, path);
|
|
332
|
+
const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
|
|
333
|
+
const { meta, body } = parseNote(raw);
|
|
334
|
+
meta.scope = scope;
|
|
335
|
+
yield { address, scope, path, meta, body };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function list(options = {}) {
|
|
340
|
+
const stableOptions = { ...options };
|
|
341
|
+
const rows = [];
|
|
342
|
+
for await (const row of scan(stableOptions)) {
|
|
343
|
+
rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
|
|
344
|
+
}
|
|
345
|
+
rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
|
|
346
|
+
return rows;
|
|
347
|
+
}
|
|
348
|
+
async function search(queries, options = {}) {
|
|
349
|
+
const stableQueries = [...queries];
|
|
350
|
+
const stableOptions = { ...options };
|
|
351
|
+
const rows = [];
|
|
352
|
+
for await (const note of scan(stableOptions)) {
|
|
353
|
+
const { address, path, scope, meta, body } = note;
|
|
315
354
|
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
316
355
|
let baseChars = 0;
|
|
317
356
|
const matches = [];
|
|
318
357
|
for (const [index, line] of body.split("\n").entries()) {
|
|
319
|
-
if (
|
|
320
|
-
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line,
|
|
358
|
+
if (stableQueries.some((query) => line.includes(query))) {
|
|
359
|
+
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
|
|
321
360
|
}
|
|
322
361
|
baseChars += Array.from(line).length + 1;
|
|
323
362
|
}
|
|
324
363
|
if (matches.length > 0)
|
|
325
364
|
rows.push({ address, path, scope, meta, matches });
|
|
326
365
|
}
|
|
366
|
+
rows.sort((a, b) => a.address.localeCompare(b.address));
|
|
367
|
+
return rows;
|
|
327
368
|
}
|
|
328
|
-
|
|
329
|
-
return rows;
|
|
369
|
+
return { write, read, edit, list, search };
|
|
330
370
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { NotesContext } from "../../notes/context.js";
|
|
3
|
+
/** Pi's default notes root; environment access stays on the host side of the library boundary. */
|
|
4
|
+
export declare function notesRoot(): string;
|
|
5
|
+
/** Translate the current Pi runtime into a fresh, explicit identity snapshot. */
|
|
6
|
+
export declare function agentSlug(_ctx: ExtensionContext): string;
|
|
7
|
+
/** Pi's active model identity is resolved live so a mid-session switch retargets @model. */
|
|
8
|
+
export declare function modelSlug(ctx: ExtensionContext): string;
|
|
9
|
+
/** Translate the current Pi runtime into one explicit notes identity snapshot. */
|
|
10
|
+
export declare function notesContextFromPi(ctx: ExtensionContext, home?: string): NotesContext;
|
|
11
|
+
/** One-time host activation migration from the legacy personal/ directory. */
|
|
12
|
+
export declare function migrateLegacyHomes(home?: string): string | undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { projectKey, slugify } from "../../notes/paths.js";
|
|
5
|
+
/** Pi's default notes root; environment access stays on the host side of the library boundary. */
|
|
6
|
+
export function notesRoot() {
|
|
7
|
+
const override = process.env.PI_NOTES_HOME;
|
|
8
|
+
return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
|
|
9
|
+
}
|
|
10
|
+
/** Translate the current Pi runtime into a fresh, explicit identity snapshot. */
|
|
11
|
+
export function agentSlug(_ctx) {
|
|
12
|
+
return slugify(process.env.PI_NOTES_AGENT ?? "root");
|
|
13
|
+
}
|
|
14
|
+
/** Pi's active model identity is resolved live so a mid-session switch retargets @model. */
|
|
15
|
+
export function modelSlug(ctx) {
|
|
16
|
+
const id = ctx.model?.id;
|
|
17
|
+
return id ? slugify(id.split("/").pop() ?? id) : "default";
|
|
18
|
+
}
|
|
19
|
+
/** Translate the current Pi runtime into one explicit notes identity snapshot. */
|
|
20
|
+
export function notesContextFromPi(ctx, home = notesRoot()) {
|
|
21
|
+
return {
|
|
22
|
+
home,
|
|
23
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
24
|
+
projectKey: projectKey(ctx.cwd),
|
|
25
|
+
agent: agentSlug(ctx),
|
|
26
|
+
model: modelSlug(ctx),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** One-time host activation migration from the legacy personal/ directory. */
|
|
30
|
+
export function migrateLegacyHomes(home = notesRoot()) {
|
|
31
|
+
const legacy = join(home, "personal");
|
|
32
|
+
const modern = join(home, "human");
|
|
33
|
+
if (!existsSync(legacy))
|
|
34
|
+
return undefined;
|
|
35
|
+
if (existsSync(modern))
|
|
36
|
+
return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
|
|
37
|
+
renameSync(legacy, modern);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|