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