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