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