@nathapp/nax-agent 0.91.7 → 0.91.8-canary.2
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/CHANGELOG.md +8 -0
- package/dist/config/catalog-overrides.d.ts +13 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/internal/atomic-write.d.ts +22 -7
- package/dist/internal/atomic-write.js +107 -12
- package/dist/internal/pinned-directory-worker.d.ts +52 -0
- package/dist/internal/pinned-directory-worker.js +105 -0
- package/dist/internal/pinned-directory.d.ts +48 -0
- package/dist/internal/pinned-directory.js +168 -0
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +2 -0
- package/dist/memory/delivery.d.ts +38 -0
- package/dist/memory/delivery.js +28 -0
- package/dist/memory/index-render.d.ts +55 -0
- package/dist/memory/index-render.js +225 -0
- package/dist/memory/index.d.ts +12 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/local-provider.d.ts +3 -0
- package/dist/memory/local-provider.js +127 -0
- package/dist/memory/mirror-lock.d.ts +109 -0
- package/dist/memory/mirror-lock.js +334 -0
- package/dist/memory/note.d.ts +15 -0
- package/dist/memory/note.js +131 -0
- package/dist/memory/operations.d.ts +123 -0
- package/dist/memory/operations.js +471 -0
- package/dist/memory/outbox.d.ts +35 -0
- package/dist/memory/outbox.js +289 -0
- package/dist/memory/provider-types.d.ts +50 -0
- package/dist/memory/provider-types.js +1 -0
- package/dist/memory/records.d.ts +41 -0
- package/dist/memory/records.js +93 -0
- package/dist/memory/refusal.d.ts +36 -0
- package/dist/memory/refusal.js +25 -0
- package/dist/memory/search.d.ts +17 -0
- package/dist/memory/search.js +112 -0
- package/dist/memory/store.d.ts +106 -0
- package/dist/memory/store.js +459 -0
- package/dist/memory/support.d.ts +63 -0
- package/dist/memory/support.js +98 -0
- package/dist/memory/sync-flight.d.ts +25 -0
- package/dist/memory/sync-flight.js +65 -0
- package/dist/memory/sync.d.ts +82 -0
- package/dist/memory/sync.js +344 -0
- package/dist/memory/types.d.ts +108 -0
- package/dist/memory/types.js +51 -0
- package/dist/memory/validate.d.ts +31 -0
- package/dist/memory/validate.js +104 -0
- package/dist/native/adapter-deps.d.ts +9 -0
- package/dist/native/adapter-deps.js +9 -0
- package/dist/native/models.js +3 -0
- package/dist/native/session/compaction.d.ts +15 -1
- package/dist/native/session/compaction.js +90 -23
- package/dist/native/session/manual-compaction.d.ts +57 -0
- package/dist/native/session/manual-compaction.js +72 -0
- package/dist/native/session/session-summarizer.d.ts +75 -0
- package/dist/native/session/session-summarizer.js +73 -0
- package/dist/native/session/transcript-identity.d.ts +12 -0
- package/dist/native/session/transcript-identity.js +16 -0
- package/dist/native/session/turn-compaction-step.d.ts +30 -2
- package/dist/native/session/turn-compaction-step.js +7 -1
- package/dist/native/session/turn-loop-round-trip.js +71 -17
- package/dist/native/session/turn-loop.js +2 -6
- package/dist/native/session-adapter.d.ts +19 -0
- package/dist/native/session-adapter.js +42 -64
- package/dist/sandbox/srt-backend.js +9 -16
- package/dist/session/agent-session-options.js +1 -0
- package/dist/session/agent-session-turn.d.ts +6 -0
- package/dist/session/agent-session-turn.js +5 -4
- package/dist/session/agent-session-types.d.ts +38 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +163 -20
- package/dist/session/native-backend.js +8 -0
- package/dist/session/session-backend.d.ts +19 -1
- package/dist/session/session-types.d.ts +6 -0
- package/dist/session/steer-channel.d.ts +36 -0
- package/dist/session/steer-channel.js +39 -0
- package/dist/session-store/index.d.ts +1 -1
- package/dist/session-store/index.js +1 -1
- package/dist/session-store/session-lock.d.ts +2 -2
- package/dist/session-store/session-lock.js +4 -2
- package/dist/tools/ask-request.d.ts +8 -0
- package/dist/tools/ask-request.js +34 -1
- package/dist/tools/provider-adapt.js +5 -1
- package/dist/tools/provider-types.d.ts +7 -0
- package/dist/tools/types.d.ts +16 -0
- package/package.json +4 -4
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed note mirror. Slugs are validated before I/O; directory and existing
|
|
3
|
+
* note targets must resolve inside their scope root.
|
|
4
|
+
*
|
|
5
|
+
* Reads and mutations are both run relative to a *pinned* parent. The parent's
|
|
6
|
+
* identity is verified against the directory the store resolved, the helper process
|
|
7
|
+
* then chdirs into it, and every later operation is resolved by that process against
|
|
8
|
+
* its own cwd. A name on disk can be renamed or replaced afterwards without moving
|
|
9
|
+
* the pinned directory, so nothing the store verified can be redirected underneath it:
|
|
10
|
+
* a read takes the entry's bytes from the pinned parent, and a write stages, publishes
|
|
11
|
+
* and cleans up there. Revalidating a pathname instead would leave the window this
|
|
12
|
+
* closes — a parent swapped out of the root, restored for the check, and swapped out
|
|
13
|
+
* again for the syscall names an outside file on both sides of the comparison.
|
|
14
|
+
*
|
|
15
|
+
* The caller's global cwd is never changed. Post-checks report moved directories;
|
|
16
|
+
* they are not the mechanism that prevents access to replacements.
|
|
17
|
+
*
|
|
18
|
+
* The store also owns the record files beside the notes: the `decisions.jsonl`
|
|
19
|
+
* mirror (one JSON object per line), the `pending-forgets.jsonl` record of queued
|
|
20
|
+
* forgets, `sync.json` (the sync state) and each scope's generated `MEMORY.md`.
|
|
21
|
+
* They hold no user-authored paths, so they are read and written without a slug to
|
|
22
|
+
* validate — save a pending forget's own slug, which names a note and must be one.
|
|
23
|
+
* The only scope rule is that the user files need a user directory. A record read is
|
|
24
|
+
* still confined: it is read under the project directory's pin and never follows a
|
|
25
|
+
* link out of the store.
|
|
26
|
+
*/
|
|
27
|
+
import type { Dirent } from "node:fs";
|
|
28
|
+
import type { AgentLogger } from "#src/infra/agent-logger";
|
|
29
|
+
import { withPinnedDirectory } from "#src/internal/pinned-directory";
|
|
30
|
+
import { type MemoryDecision, type MemoryNote, type MemoryPendingForget, type MemoryScope } from "./types.ts";
|
|
31
|
+
interface Identity {
|
|
32
|
+
readonly dev: number;
|
|
33
|
+
readonly ino: number;
|
|
34
|
+
}
|
|
35
|
+
/** One open file: read through its descriptor, never through its path. */
|
|
36
|
+
interface OpenedFile {
|
|
37
|
+
readText(): Promise<string>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/** Open one file for reading without following a link at it. */
|
|
41
|
+
type OpenFile = (path: string, flags: number) => Promise<OpenedFile>;
|
|
42
|
+
type StatPath = (path: string) => Promise<Identity>;
|
|
43
|
+
type MakeDir = (path: string, options: {
|
|
44
|
+
recursive: true;
|
|
45
|
+
}) => Promise<string | undefined>;
|
|
46
|
+
/** Directory entries carry their type, so a listing never has to open a path to learn what it is. */
|
|
47
|
+
type ReadDirEntries = (path: string, options: {
|
|
48
|
+
withFileTypes: true;
|
|
49
|
+
}) => Promise<Dirent[]>;
|
|
50
|
+
type RemoveFile = (path: string, options: {
|
|
51
|
+
force: true;
|
|
52
|
+
}) => Promise<void>;
|
|
53
|
+
/** I/O seam: reads run under the pinned parent; deletes use the active one; `pin` takes it. */
|
|
54
|
+
export declare const _memoryStoreDeps: {
|
|
55
|
+
open: OpenFile;
|
|
56
|
+
stat: StatPath;
|
|
57
|
+
mkdir: MakeDir;
|
|
58
|
+
readdir: ReadDirEntries;
|
|
59
|
+
rm: RemoveFile;
|
|
60
|
+
pin: typeof withPinnedDirectory;
|
|
61
|
+
};
|
|
62
|
+
/** The files this store owns, spelled out once so every caller agrees on them. */
|
|
63
|
+
export interface MemoryStorePaths {
|
|
64
|
+
readonly projectDir: string;
|
|
65
|
+
readonly userDir: string | null;
|
|
66
|
+
readonly index: string;
|
|
67
|
+
readonly notesDir: string;
|
|
68
|
+
readonly decisions: string;
|
|
69
|
+
readonly pendingForgets: string;
|
|
70
|
+
readonly syncState: string;
|
|
71
|
+
readonly lock: string;
|
|
72
|
+
readonly outboxDir: string;
|
|
73
|
+
readonly userIndex: string | null;
|
|
74
|
+
readonly userNotesDir: string | null;
|
|
75
|
+
}
|
|
76
|
+
/** Where the sync loop has got to: the provider it talks to and the two cursors. */
|
|
77
|
+
export interface MemorySyncState {
|
|
78
|
+
readonly provider: string | null;
|
|
79
|
+
readonly cursor: {
|
|
80
|
+
readonly project: string | null;
|
|
81
|
+
readonly user: string | null;
|
|
82
|
+
};
|
|
83
|
+
readonly lastPullAt: string | null;
|
|
84
|
+
readonly lastError: string | null;
|
|
85
|
+
}
|
|
86
|
+
/** The mirror: notes, plus the record files sync and the index refresh maintain. */
|
|
87
|
+
export interface MemoryStore {
|
|
88
|
+
readonly paths: MemoryStorePaths;
|
|
89
|
+
readNote(scope: MemoryScope, slug: string): Promise<MemoryNote | null>;
|
|
90
|
+
writeNote(note: MemoryNote): Promise<void>;
|
|
91
|
+
deleteNote(scope: MemoryScope, slug: string): Promise<void>;
|
|
92
|
+
listNotes(scope: MemoryScope): Promise<MemoryNote[]>;
|
|
93
|
+
readDecisions(): Promise<MemoryDecision[]>;
|
|
94
|
+
writeDecisions(decisions: readonly MemoryDecision[]): Promise<void>;
|
|
95
|
+
readPendingForgets(): Promise<MemoryPendingForget[]>;
|
|
96
|
+
writePendingForgets(entries: readonly MemoryPendingForget[]): Promise<void>;
|
|
97
|
+
readSyncState(): Promise<MemorySyncState>;
|
|
98
|
+
writeSyncState(state: MemorySyncState): Promise<void>;
|
|
99
|
+
writeIndex(scope: MemoryScope, text: string): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
export declare function createMemoryStore(input: {
|
|
102
|
+
readonly projectDir: string;
|
|
103
|
+
readonly userDir?: string | null;
|
|
104
|
+
readonly logger: AgentLogger;
|
|
105
|
+
}): MemoryStore;
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open, readdir, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { errorMessage } from "#src/infra/errors";
|
|
5
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
6
|
+
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
7
|
+
import { openPinnedRead, pinnedOperation, resolvePinned, withPinnedDirectory } from "#src/internal/pinned-directory";
|
|
8
|
+
import { isInside, realOrRaw } from "#src/internal/realpath";
|
|
9
|
+
import { byCodePoint } from "#src/internal/sort";
|
|
10
|
+
import { parseMemoryNote, serializeMemoryNote } from "./note.js";
|
|
11
|
+
import { decodeRecordLines, defaultSyncState, encodeRecordLines, parseDecisionLine, parsePendingForgetLine, parseSyncStateText, } from "./records.js";
|
|
12
|
+
import { MEMORY_SLUG_RE, } from "./types.js";
|
|
13
|
+
const ERROR_STAGE = "memory";
|
|
14
|
+
const INDEX_FILE = "MEMORY.md";
|
|
15
|
+
const NOTES_DIR = "notes";
|
|
16
|
+
const NOTE_SUFFIX = ".md";
|
|
17
|
+
const DECISIONS_FILE = "decisions.jsonl";
|
|
18
|
+
const SYNC_STATE_FILE = "sync.json";
|
|
19
|
+
const PENDING_FORGETS_FILE = "pending-forgets.jsonl";
|
|
20
|
+
/** Reads refuse final-component links; the verified parent pin protects their ancestors. */
|
|
21
|
+
const READ_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
22
|
+
function identityOf(info) {
|
|
23
|
+
return { dev: info.dev, ino: info.ino };
|
|
24
|
+
}
|
|
25
|
+
/** I/O seam: reads run under the pinned parent; deletes use the active one; `pin` takes it. */
|
|
26
|
+
export const _memoryStoreDeps = {
|
|
27
|
+
open: async (path, flags) => {
|
|
28
|
+
const pinned = await openPinnedRead(path, flags);
|
|
29
|
+
if (pinned !== null)
|
|
30
|
+
return pinned;
|
|
31
|
+
const handle = await open(path, flags);
|
|
32
|
+
return {
|
|
33
|
+
stat: async () => identityOf(await handle.stat()),
|
|
34
|
+
readText: () => handle.readFile({ encoding: "utf8" }),
|
|
35
|
+
close: () => handle.close(),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
stat: async (path) => identityOf(await stat(path)),
|
|
39
|
+
mkdir: (path, options) => mkdir(path, options),
|
|
40
|
+
readdir: (path, options) => readdir(path, options),
|
|
41
|
+
rm: (path, options) => pinnedOperation(path, "rm") ?? rm(path, options),
|
|
42
|
+
pin: withPinnedDirectory,
|
|
43
|
+
};
|
|
44
|
+
/** `undefined`, `null` and `""` all mean "this store has no user directory". */
|
|
45
|
+
function dirOrNull(dir) {
|
|
46
|
+
return dir === undefined || dir === null || dir === "" ? null : dir;
|
|
47
|
+
}
|
|
48
|
+
function memoryStorePaths(projectDir, userDir) {
|
|
49
|
+
return {
|
|
50
|
+
projectDir,
|
|
51
|
+
userDir,
|
|
52
|
+
index: join(projectDir, INDEX_FILE),
|
|
53
|
+
notesDir: join(projectDir, NOTES_DIR),
|
|
54
|
+
decisions: join(projectDir, DECISIONS_FILE),
|
|
55
|
+
pendingForgets: join(projectDir, PENDING_FORGETS_FILE),
|
|
56
|
+
syncState: join(projectDir, SYNC_STATE_FILE),
|
|
57
|
+
lock: join(projectDir, "sync.lock"),
|
|
58
|
+
outboxDir: join(projectDir, "outbox"),
|
|
59
|
+
userIndex: userDir === null ? null : join(userDir, INDEX_FILE),
|
|
60
|
+
userNotesDir: userDir === null ? null : join(userDir, NOTES_DIR),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function isMissing(error) {
|
|
64
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
65
|
+
return false;
|
|
66
|
+
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
|
67
|
+
}
|
|
68
|
+
/** Only regular files and confined links with valid `<slug>.md` names are notes. */
|
|
69
|
+
function noteSlugIn(entry) {
|
|
70
|
+
if (!entry.isFile() && !entry.isSymbolicLink())
|
|
71
|
+
return null;
|
|
72
|
+
const name = entry.name;
|
|
73
|
+
if (!name.endsWith(NOTE_SUFFIX))
|
|
74
|
+
return null;
|
|
75
|
+
const slug = name.slice(0, -NOTE_SUFFIX.length);
|
|
76
|
+
return MEMORY_SLUG_RE.test(slug) ? slug : null;
|
|
77
|
+
}
|
|
78
|
+
function invalidSlug(slug) {
|
|
79
|
+
return new NaxError(`memory slug "${slug}" is not a valid note slug`, "MEMORY_SLUG_INVALID", {
|
|
80
|
+
stage: ERROR_STAGE,
|
|
81
|
+
slug,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function scopeUnavailable(scope) {
|
|
85
|
+
return new NaxError(`memory scope "${scope}" is unavailable: this store has no user directory`, "MEMORY_SCOPE_UNAVAILABLE", {
|
|
86
|
+
stage: ERROR_STAGE,
|
|
87
|
+
scope,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function outsideRoot(scope, path, root, cause) {
|
|
91
|
+
return new NaxError(`memory path "${path}" is outside the memory scope root "${root}"`, "MEMORY_PATH_OUTSIDE", {
|
|
92
|
+
stage: ERROR_STAGE,
|
|
93
|
+
scope,
|
|
94
|
+
path,
|
|
95
|
+
root,
|
|
96
|
+
...(cause === undefined ? {} : { cause }),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function isOutsidePath(error) {
|
|
100
|
+
return error instanceof NaxError && error.code === "MEMORY_PATH_OUTSIDE";
|
|
101
|
+
}
|
|
102
|
+
function isSymlinkLoop(error) {
|
|
103
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
104
|
+
return false;
|
|
105
|
+
return error.code === "ELOOP";
|
|
106
|
+
}
|
|
107
|
+
function sameIdentity(a, b) {
|
|
108
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
109
|
+
}
|
|
110
|
+
/** The slug, validated: the one check that must happen before anything touches the filesystem. */
|
|
111
|
+
function validSlug(slug) {
|
|
112
|
+
if (!MEMORY_SLUG_RE.test(slug))
|
|
113
|
+
throw invalidSlug(slug);
|
|
114
|
+
}
|
|
115
|
+
export function createMemoryStore(input) {
|
|
116
|
+
const { logger } = input;
|
|
117
|
+
const paths = memoryStorePaths(input.projectDir, dirOrNull(input.userDir));
|
|
118
|
+
/** The notes directory of one scope, proved to sit inside that scope's root. */
|
|
119
|
+
function confinedNotesDir(scope) {
|
|
120
|
+
const root = scope === "user" ? paths.userDir : paths.projectDir;
|
|
121
|
+
const notesDir = scope === "user" ? paths.userNotesDir : paths.notesDir;
|
|
122
|
+
if (root === null || notesDir === null)
|
|
123
|
+
throw scopeUnavailable(scope);
|
|
124
|
+
if (!isInside(root, notesDir))
|
|
125
|
+
throw outsideRoot(scope, notesDir, root);
|
|
126
|
+
return { root, notesDir };
|
|
127
|
+
}
|
|
128
|
+
/** The resolved form of `path`, refused unless it is still inside the root. */
|
|
129
|
+
function confinedRealPath(scope, root, path) {
|
|
130
|
+
const resolved = realOrRaw(path);
|
|
131
|
+
if (!isInside(root, resolved))
|
|
132
|
+
throw outsideRoot(scope, path, root);
|
|
133
|
+
return resolved;
|
|
134
|
+
}
|
|
135
|
+
/** The identity of whatever is at `path`, or `null` when nothing is. */
|
|
136
|
+
async function identityAt(path) {
|
|
137
|
+
try {
|
|
138
|
+
return await _memoryStoreDeps.stat(path);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (isMissing(error))
|
|
142
|
+
return null;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The note's entry in a verified directory — the one place a note path is spelled. */
|
|
147
|
+
function entryPathIn(handle, slug) {
|
|
148
|
+
return join(handle.dir, `${slug}${NOTE_SUFFIX}`);
|
|
149
|
+
}
|
|
150
|
+
/** The same entry as the caller spelled the directory: what a warning names. */
|
|
151
|
+
function entryName(handle, slug) {
|
|
152
|
+
return join(handle.spelling, `${slug}${NOTE_SUFFIX}`);
|
|
153
|
+
}
|
|
154
|
+
/** The scope's notes directory, resolved, confined and identified. */
|
|
155
|
+
async function openNotesDir(scope) {
|
|
156
|
+
const { root, notesDir } = confinedNotesDir(scope);
|
|
157
|
+
const dir = confinedRealPath(scope, root, notesDir);
|
|
158
|
+
const identity = await identityAt(dir);
|
|
159
|
+
return identity === null ? null : { scope, root, spelling: notesDir, dir, identity };
|
|
160
|
+
}
|
|
161
|
+
/** The same, creating the directory (and its parents) first: the write path's entry point. */
|
|
162
|
+
async function ensureNotesDir(scope) {
|
|
163
|
+
const { root, notesDir } = confinedNotesDir(scope);
|
|
164
|
+
await _memoryStoreDeps.mkdir(notesDir, { recursive: true });
|
|
165
|
+
const dir = confinedRealPath(scope, root, notesDir);
|
|
166
|
+
// A directory that vanishes between the `mkdir` and this `stat` is a genuine failure,
|
|
167
|
+
// not a missing mirror: the error propagates rather than being reported as "empty".
|
|
168
|
+
const identity = await _memoryStoreDeps.stat(dir);
|
|
169
|
+
return { scope, root, spelling: notesDir, dir, identity };
|
|
170
|
+
}
|
|
171
|
+
/** Detect movement of the original path; the pin is what keeps operations off a replacement. */
|
|
172
|
+
async function assertSameDirectory(handle) {
|
|
173
|
+
const dir = confinedRealPath(handle.scope, handle.root, handle.dir);
|
|
174
|
+
const identity = await identityAt(dir);
|
|
175
|
+
if (dir !== handle.dir || identity === null || !sameIdentity(identity, handle.identity)) {
|
|
176
|
+
throw outsideRoot(handle.scope, handle.dir, handle.root);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* One entry's text, read relative to the verified, pinned parent — never to a pathname.
|
|
181
|
+
*
|
|
182
|
+
* The worker resolves the entry against the cwd it pinned and verified, so a directory
|
|
183
|
+
* renamed or replaced afterwards cannot redirect the answer. A plain file is then opened by
|
|
184
|
+
* name inside that same session: no path in this process is revalidated, so the window a
|
|
185
|
+
* check leaves — a parent swapped out of the root, restored for the check and swapped out
|
|
186
|
+
* again for the syscall — does not exist here. A link is a name whose bytes live elsewhere,
|
|
187
|
+
* so its target is confined against the scope root and read where it resolves.
|
|
188
|
+
*/
|
|
189
|
+
async function readEntryText(handle, entry) {
|
|
190
|
+
try {
|
|
191
|
+
const resolved = await resolvePinned(entry);
|
|
192
|
+
// Only reachable inside the pinned session; a vanished entry reads as missing.
|
|
193
|
+
if (resolved === null)
|
|
194
|
+
return null;
|
|
195
|
+
if (resolved !== entry)
|
|
196
|
+
return await readConfinedTarget(handle, resolved);
|
|
197
|
+
const file = await openPinnedRead(entry, READ_FLAGS);
|
|
198
|
+
if (file === null)
|
|
199
|
+
return null;
|
|
200
|
+
try {
|
|
201
|
+
return await file.readText();
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await file.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (isMissing(error))
|
|
209
|
+
return null;
|
|
210
|
+
if (isSymlinkLoop(error))
|
|
211
|
+
throw outsideRoot(handle.scope, entry, handle.root, error);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** A link's destination has its own ancestors: the notes-directory pin cannot protect them. */
|
|
216
|
+
async function readConfinedTarget(handle, target) {
|
|
217
|
+
const resolved = confinedRealPath(handle.scope, handle.root, target);
|
|
218
|
+
const dir = dirname(resolved);
|
|
219
|
+
const identity = await identityAt(dir);
|
|
220
|
+
if (identity === null)
|
|
221
|
+
return null;
|
|
222
|
+
return inPinnedDirectory({ ...handle, dir, spelling: dir, identity }, async () => {
|
|
223
|
+
// Stat and spawn may have encountered a replaced ancestor. Prove confinement from
|
|
224
|
+
// the destination pin itself, not by reopening its absolute spelling. Once proved,
|
|
225
|
+
// the final NOFOLLOW open stays relative to this same inode even if its name moves.
|
|
226
|
+
const pinnedTarget = await resolvePinned(resolved);
|
|
227
|
+
if (pinnedTarget === null)
|
|
228
|
+
return null;
|
|
229
|
+
confinedRealPath(handle.scope, handle.root, pinnedTarget);
|
|
230
|
+
if (pinnedTarget !== resolved)
|
|
231
|
+
throw outsideRoot(handle.scope, target, handle.root);
|
|
232
|
+
const file = await openConfined(handle.scope, handle.root, resolved, resolved);
|
|
233
|
+
if (file === null)
|
|
234
|
+
return null;
|
|
235
|
+
try {
|
|
236
|
+
return await file.readText();
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await file.close();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** One note's text, in a pinned session of its own. */
|
|
244
|
+
function readNoteText(handle, slug) {
|
|
245
|
+
return inPinnedDirectory(handle, () => readEntryText(handle, entryPathIn(handle, slug)));
|
|
246
|
+
}
|
|
247
|
+
/** The confined handle for a file, or `null` when it vanished; a link at it is refused. */
|
|
248
|
+
async function openConfined(scope, root, path, target) {
|
|
249
|
+
try {
|
|
250
|
+
return await _memoryStoreDeps.open(target, READ_FLAGS);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (isMissing(error))
|
|
254
|
+
return null;
|
|
255
|
+
if (isSymlinkLoop(error))
|
|
256
|
+
throw outsideRoot(scope, path, root, error);
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function readNote(scope, slug) {
|
|
261
|
+
validSlug(slug);
|
|
262
|
+
const handle = await openNotesDir(scope);
|
|
263
|
+
if (handle === null)
|
|
264
|
+
return null;
|
|
265
|
+
const text = await readNoteText(handle, slug);
|
|
266
|
+
return text === null ? null : parseMemoryNote(text);
|
|
267
|
+
}
|
|
268
|
+
async function writeNote(note) {
|
|
269
|
+
validSlug(note.slug);
|
|
270
|
+
const handle = await ensureNotesDir(note.scope);
|
|
271
|
+
const target = entryPathIn(handle, note.slug);
|
|
272
|
+
// Check an existing symlink's destination, but replace the note entry itself atomically.
|
|
273
|
+
confinedRealPath(handle.scope, handle.root, target);
|
|
274
|
+
// All staging, rename and cleanup operations stay relative to the pinned parent.
|
|
275
|
+
// Checks report a moved directory without touching its replacement.
|
|
276
|
+
await inPinnedDirectory(handle, () => writeFileAtomic(target, serializeMemoryNote(note), {
|
|
277
|
+
beforePublish: () => assertSameDirectory(handle),
|
|
278
|
+
afterPublish: (stage) => verifyPublished(handle, target, stage),
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
/** The note that was just published must be inside the root, and it is taken back if not. */
|
|
282
|
+
async function verifyPublished(handle, target, stage) {
|
|
283
|
+
try {
|
|
284
|
+
await assertSameDirectory(handle);
|
|
285
|
+
confinedRealPath(handle.scope, handle.root, target);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const landed = await pinnedOperation(target, "stat");
|
|
289
|
+
if (landed !== null && sameIdentity(landed, stage))
|
|
290
|
+
await _memoryStoreDeps.rm(target, { force: true });
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function inPinnedDirectory(handle, action) {
|
|
295
|
+
return _memoryStoreDeps.pin({
|
|
296
|
+
path: handle.dir,
|
|
297
|
+
identity: handle.identity,
|
|
298
|
+
changed: () => outsideRoot(handle.scope, handle.dir, handle.root),
|
|
299
|
+
}, action);
|
|
300
|
+
}
|
|
301
|
+
async function deleteNote(scope, slug) {
|
|
302
|
+
validSlug(slug);
|
|
303
|
+
const handle = await openNotesDir(scope);
|
|
304
|
+
if (handle === null)
|
|
305
|
+
return;
|
|
306
|
+
const entry = entryPathIn(handle, slug);
|
|
307
|
+
// A note that resolves out of the root is refused, then the directory is proved to be
|
|
308
|
+
// the one verified: what goes is that entry, in that directory, never a link's target.
|
|
309
|
+
confinedRealPath(handle.scope, handle.root, entry);
|
|
310
|
+
await inPinnedDirectory(handle, async () => {
|
|
311
|
+
await assertSameDirectory(handle);
|
|
312
|
+
await _memoryStoreDeps.rm(entry, { force: true });
|
|
313
|
+
await assertSameDirectory(handle);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** The listing reports a skipped file once, whatever the reason, and carries on. */
|
|
317
|
+
function warnSkipped(path, error) {
|
|
318
|
+
const message = isOutsidePath(error)
|
|
319
|
+
? "skipping memory note outside the scope root"
|
|
320
|
+
: "skipping unreadable memory note";
|
|
321
|
+
logger.warn("memory", message, { path, error: errorMessage(error) });
|
|
322
|
+
}
|
|
323
|
+
/** The note one listed file holds, or `null` when the file is skipped (never throws). */
|
|
324
|
+
async function listedNote(handle, slug) {
|
|
325
|
+
try {
|
|
326
|
+
const text = await readEntryText(handle, entryPathIn(handle, slug));
|
|
327
|
+
// A file that vanished between the listing and the read is skipped without a warning.
|
|
328
|
+
return text === null ? null : parseMemoryNote(text);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
warnSkipped(entryName(handle, slug), error);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** The notes of one listing, read inside the one pinned session that session opened. */
|
|
336
|
+
async function listedNotes(handle, slugs) {
|
|
337
|
+
const notes = [];
|
|
338
|
+
for (const slug of slugs) {
|
|
339
|
+
const note = await listedNote(handle, slug);
|
|
340
|
+
if (note !== null)
|
|
341
|
+
notes.push(note);
|
|
342
|
+
}
|
|
343
|
+
return notes;
|
|
344
|
+
}
|
|
345
|
+
async function listNotes(scope) {
|
|
346
|
+
const handle = await openNotesDir(scope);
|
|
347
|
+
if (handle === null)
|
|
348
|
+
return [];
|
|
349
|
+
await assertSameDirectory(handle);
|
|
350
|
+
let entries;
|
|
351
|
+
try {
|
|
352
|
+
entries = await _memoryStoreDeps.readdir(handle.dir, { withFileTypes: true });
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
if (isMissing(error))
|
|
356
|
+
return [];
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
// Names read from a directory that was replaced while it was listed cannot be trusted to
|
|
360
|
+
// name its notes, and a listing cannot tell — so it is refused rather than returned.
|
|
361
|
+
await assertSameDirectory(handle);
|
|
362
|
+
const slugs = entries
|
|
363
|
+
.map((entry) => noteSlugIn(entry))
|
|
364
|
+
.filter((slug) => slug !== null)
|
|
365
|
+
.sort(byCodePoint);
|
|
366
|
+
if (slugs.length === 0)
|
|
367
|
+
return [];
|
|
368
|
+
return await inPinnedDirectory(handle, () => listedNotes(handle, slugs));
|
|
369
|
+
}
|
|
370
|
+
/** A scope's root directory itself, verified and identified: records sit directly inside it. */
|
|
371
|
+
async function openScopeDir(scope) {
|
|
372
|
+
const root = scope === "user" ? paths.userDir : paths.projectDir;
|
|
373
|
+
if (root === null)
|
|
374
|
+
throw scopeUnavailable(scope);
|
|
375
|
+
const dir = confinedRealPath(scope, root, root);
|
|
376
|
+
const identity = await identityAt(dir);
|
|
377
|
+
return identity === null ? null : { scope, root, spelling: root, dir, identity };
|
|
378
|
+
}
|
|
379
|
+
/** One record file's text, read under the project directory's pin, never outside it. */
|
|
380
|
+
async function readRecordText(path) {
|
|
381
|
+
const handle = await openScopeDir("project");
|
|
382
|
+
if (handle === null)
|
|
383
|
+
return null;
|
|
384
|
+
const entry = join(handle.dir, basename(path));
|
|
385
|
+
return await inPinnedDirectory(handle, () => readEntryText(handle, entry));
|
|
386
|
+
}
|
|
387
|
+
/** Publish one record file, creating its directory on demand: staging needs one. */
|
|
388
|
+
async function writeRecordFile(path, text) {
|
|
389
|
+
await _memoryStoreDeps.mkdir(dirname(path), { recursive: true });
|
|
390
|
+
await writeFileAtomic(path, text);
|
|
391
|
+
}
|
|
392
|
+
/** One skipped mirror line, reported once, naming the line so it can be found. */
|
|
393
|
+
function warnSkippedDecision(line) {
|
|
394
|
+
logger.warn("memory", "skipping a decisions line that is not a memory decision", {
|
|
395
|
+
path: paths.decisions,
|
|
396
|
+
line,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function warnSkippedPendingForget(line) {
|
|
400
|
+
logger.warn("memory", "skipping a pending-forgets line that is not a pending forget", {
|
|
401
|
+
path: paths.pendingForgets,
|
|
402
|
+
line,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
async function readDecisions() {
|
|
406
|
+
return decodeRecordLines(await readRecordText(paths.decisions), parseDecisionLine, warnSkippedDecision);
|
|
407
|
+
}
|
|
408
|
+
async function writeDecisions(decisions) {
|
|
409
|
+
await writeRecordFile(paths.decisions, encodeRecordLines(decisions));
|
|
410
|
+
}
|
|
411
|
+
async function readPendingForgets() {
|
|
412
|
+
const text = await readRecordText(paths.pendingForgets);
|
|
413
|
+
return decodeRecordLines(text, parsePendingForgetLine, warnSkippedPendingForget);
|
|
414
|
+
}
|
|
415
|
+
/** The record rewritten whole; every entry's slug must be one a note can carry. */
|
|
416
|
+
async function writePendingForgets(entries) {
|
|
417
|
+
for (const entry of entries)
|
|
418
|
+
validSlug(entry.slug);
|
|
419
|
+
await writeRecordFile(paths.pendingForgets, encodeRecordLines(entries));
|
|
420
|
+
}
|
|
421
|
+
async function readSyncState() {
|
|
422
|
+
const text = await readRecordText(paths.syncState);
|
|
423
|
+
// A missing file is the state of a store that has never synced; nothing to report.
|
|
424
|
+
if (text === null)
|
|
425
|
+
return defaultSyncState();
|
|
426
|
+
const state = parseSyncStateText(text);
|
|
427
|
+
if (state !== null)
|
|
428
|
+
return state;
|
|
429
|
+
logger.warn("memory", "memory sync state is unreadable; using the default state", { path: paths.syncState });
|
|
430
|
+
return defaultSyncState();
|
|
431
|
+
}
|
|
432
|
+
async function writeSyncState(state) {
|
|
433
|
+
await writeRecordFile(paths.syncState, `${JSON.stringify(state)}\n`);
|
|
434
|
+
}
|
|
435
|
+
/** The generated index of one scope: the user index is the one a store may not have. */
|
|
436
|
+
function indexPathFor(scope) {
|
|
437
|
+
const path = scope === "user" ? paths.userIndex : paths.index;
|
|
438
|
+
if (path === null)
|
|
439
|
+
throw scopeUnavailable(scope);
|
|
440
|
+
return path;
|
|
441
|
+
}
|
|
442
|
+
async function writeIndex(scope, text) {
|
|
443
|
+
await writeRecordFile(indexPathFor(scope), text);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
paths,
|
|
447
|
+
readNote,
|
|
448
|
+
writeNote,
|
|
449
|
+
deleteNote,
|
|
450
|
+
listNotes,
|
|
451
|
+
readDecisions,
|
|
452
|
+
writeDecisions,
|
|
453
|
+
readPendingForgets,
|
|
454
|
+
writePendingForgets,
|
|
455
|
+
readSyncState,
|
|
456
|
+
writeSyncState,
|
|
457
|
+
writeIndex,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pieces every memory module races, reports and logs with.
|
|
3
|
+
*
|
|
4
|
+
* They live apart from the modules that use them because the memory modules form a
|
|
5
|
+
* dependency order of their own: sync needs the lock protocol, and the lock protocol needs
|
|
6
|
+
* the timer seam and the never-throwing warn — but not each other. Keeping these three here
|
|
7
|
+
* is what lets `mirror-lock.ts` and `sync.ts` both reach them without the import cycle a
|
|
8
|
+
* shared helper in either one would create.
|
|
9
|
+
*
|
|
10
|
+
* - `GLOBAL_MEMORY_TIMERS` boxes the process timer handles, so a fake clock can stand in for
|
|
11
|
+
* them through the `MemoryTimers` contract without any caller casting a timer id.
|
|
12
|
+
* - `raceTimeout` bounds an operation nobody can cancel.
|
|
13
|
+
* - `providerAnswers` turns a probe's three failures into the one fact they share.
|
|
14
|
+
* - `warnQuietly` reports a failure without letting the reporting fail its caller.
|
|
15
|
+
*/
|
|
16
|
+
import type { AgentLogger } from "#src/infra/agent-logger";
|
|
17
|
+
import type { MemoryProvider } from "./provider-types.ts";
|
|
18
|
+
import { type MemoryTimers } from "./types.ts";
|
|
19
|
+
/**
|
|
20
|
+
* The timers an operation races against when the caller injects none: the process
|
|
21
|
+
* globals, read at call time so a substituted global is still observed. The handle
|
|
22
|
+
* is handed back boxed — `MemoryTimers` keeps the timer id opaque precisely so a
|
|
23
|
+
* fake clock can stand in for the real one, and a box clears the handle this race
|
|
24
|
+
* armed without ever casting one.
|
|
25
|
+
*/
|
|
26
|
+
export declare const GLOBAL_MEMORY_TIMERS: MemoryTimers;
|
|
27
|
+
/**
|
|
28
|
+
* `promise`'s value if it settles within `ms`, and a `MEMORY_TIMEOUT` rejection if it does not.
|
|
29
|
+
*
|
|
30
|
+
* The timer is armed when the race starts and cleared the moment either side
|
|
31
|
+
* settles, so a race that finished leaves nothing armed behind it — the property
|
|
32
|
+
* a fake clock reads as `pending() === 0`. `setTimeout` is the cancellable kind
|
|
33
|
+
* here on purpose (the `forbidden-patterns-source` exception): `Bun.sleep` cannot
|
|
34
|
+
* be cancelled, and a sleep that outlives the answer is exactly the leak this
|
|
35
|
+
* clears.
|
|
36
|
+
*
|
|
37
|
+
* A timer race does not cancel the work it raced: the provider call keeps running
|
|
38
|
+
* after a timeout, because nothing on that side of the seam accepts a signal. Its
|
|
39
|
+
* late rejection is therefore claimed here rather than left to surface as an
|
|
40
|
+
* unhandled rejection in whatever process this library runs in.
|
|
41
|
+
*/
|
|
42
|
+
export declare function raceTimeout<T>(promise: Promise<T>, ms: number, timers: MemoryTimers): Promise<T>;
|
|
43
|
+
/** Ask the provider whether it answers within the probe deadline: a throw and a timeout are `false`. */
|
|
44
|
+
export declare function providerAnswers(provider: MemoryProvider, timers: MemoryTimers): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Report a failure without letting the reporting fail the caller.
|
|
47
|
+
*
|
|
48
|
+
* The logger is an input, not a guarantee: a host logger that throws would
|
|
49
|
+
* otherwise turn the never-throws contract into a rejection at exactly the
|
|
50
|
+
* moment a result is owed, so a logger failure is discarded on purpose. Shared
|
|
51
|
+
* with the save path, whose rollback is also reported rather than thrown.
|
|
52
|
+
*/
|
|
53
|
+
export declare function warnQuietly(logger: AgentLogger, message: string, data: Record<string, unknown>): void;
|
|
54
|
+
/**
|
|
55
|
+
* Run one bookkeeping step whose failure must not become the caller's answer.
|
|
56
|
+
*
|
|
57
|
+
* A provider's refusal is the error the caller is owed, and what follows it — taking the
|
|
58
|
+
* refused write back off the queue, putting back the note the mirror held — is a consequence
|
|
59
|
+
* of that error, not an alternative to it. A step that throws is reported and the rest still
|
|
60
|
+
* runs: a mirror left missing a note it holds, because a queue file could not be removed, is
|
|
61
|
+
* worse than an entry surviving to be refused again.
|
|
62
|
+
*/
|
|
63
|
+
export declare function bookkeeping(logger: AgentLogger, message: string, data: Record<string, unknown>, step: () => Promise<void>): Promise<void>;
|