@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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a delivered write does to the mirror.
|
|
3
|
+
*
|
|
4
|
+
* A drain's job does not end when the provider says yes: the answer has to be written back,
|
|
5
|
+
* and *which* copy of the note it is written onto is the delicate part. Two shapes of queued
|
|
6
|
+
* `save` reach a drain, and they mean different things about the mirror:
|
|
7
|
+
*
|
|
8
|
+
* - a **staged** write, whose note the mirror holds — the save had the mirror's lock, wrote
|
|
9
|
+
* its note there, and queued the request. The mirror is then the newer fact (it may have
|
|
10
|
+
* been edited since), so only the provider's `id` and `status` are news;
|
|
11
|
+
* - a **fenced** write, queued while another writer owned the mirror, whose note the mirror
|
|
12
|
+
* has never seen. The provider now holds content the mirror does not: writing back only the
|
|
13
|
+
* id and status would leave the mirror on whatever older note it happened to hold, and the
|
|
14
|
+
* entry carrying the new content is about to be removed — the write would be lost.
|
|
15
|
+
*
|
|
16
|
+
* The two are told apart by generation, not by recency: the mirror holds this write's note
|
|
17
|
+
* exactly when the ids agree. When they do not, the mirror holds *some* other note, and the
|
|
18
|
+
* only thing that makes it authoritative is that it is itself still on its way — that is, its
|
|
19
|
+
* id is still a queued entry of this outbox, staged by a save that has not been delivered yet.
|
|
20
|
+
* A mirror note in that state is a newer local generation and is left alone; anything else is
|
|
21
|
+
* the older copy the fenced write supersedes.
|
|
22
|
+
*/
|
|
23
|
+
import type { MemoryOutbox, MemoryOutboxEntry } from "./outbox.ts";
|
|
24
|
+
import type { MemoryPushResult } from "./provider-types.ts";
|
|
25
|
+
import type { MemoryStore } from "./store.ts";
|
|
26
|
+
/** What applying a delivery needs: the mirror to write and the queue that says what is still coming. */
|
|
27
|
+
export interface MemoryDeliveryInput {
|
|
28
|
+
readonly store: MemoryStore;
|
|
29
|
+
readonly outbox: MemoryOutbox;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write one accepted `save` back into the mirror: the provider's id and status, onto the copy
|
|
33
|
+
* the provider's content came from.
|
|
34
|
+
*
|
|
35
|
+
* A `forget` deletes only on `deleted`: a proposal-mode forget the provider has merely
|
|
36
|
+
* recorded is still pending, and the note it names stays until it is not.
|
|
37
|
+
*/
|
|
38
|
+
export declare function applyPushed(input: MemoryDeliveryInput, entry: MemoryOutboxEntry, answer: MemoryPushResult): Promise<void>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Whether `id` names a write this outbox has queued and not yet delivered — a staged generation. */
|
|
2
|
+
async function awaitingDelivery(outbox, id) {
|
|
3
|
+
return (await outbox.list()).some((entry) => entry.state === "queued" && entry.clientId === id);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Write one accepted `save` back into the mirror: the provider's id and status, onto the copy
|
|
7
|
+
* the provider's content came from.
|
|
8
|
+
*
|
|
9
|
+
* A `forget` deletes only on `deleted`: a proposal-mode forget the provider has merely
|
|
10
|
+
* recorded is still pending, and the note it names stays until it is not.
|
|
11
|
+
*/
|
|
12
|
+
export async function applyPushed(input, entry, answer) {
|
|
13
|
+
if (entry.kind === "forget") {
|
|
14
|
+
if (answer.status === "deleted")
|
|
15
|
+
await input.store.deleteNote(entry.scope, entry.slug);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const delivered = entry.note;
|
|
19
|
+
if (delivered === undefined)
|
|
20
|
+
return;
|
|
21
|
+
const mirrored = await input.store.readNote(entry.scope, entry.slug);
|
|
22
|
+
if (mirrored !== null && mirrored.id !== delivered.id && (await awaitingDelivery(input.outbox, mirrored.id))) {
|
|
23
|
+
// A newer local generation is still queued: it is the copy that will be delivered next.
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const base = mirrored !== null && mirrored.id === delivered.id ? mirrored : delivered;
|
|
27
|
+
await input.store.writeNote({ ...base, id: answer.id, status: answer.status });
|
|
28
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated `MEMORY.md` index: the rendered facts a session starts with.
|
|
3
|
+
*
|
|
4
|
+
* The index is data, never instructions. Every field is sanitised to a single
|
|
5
|
+
* line and cut at `MEMORY_DESCRIPTION_MAX_CHARS` before it reaches the text, so a
|
|
6
|
+
* note cannot contribute a heading, a bullet or a sentence that reads as a
|
|
7
|
+
* directive. Section order, type order and the line shapes are the contract the
|
|
8
|
+
* memory tool and the run-stage injection both display, so they are pinned here.
|
|
9
|
+
*
|
|
10
|
+
* Rendering is pure. The caps (`maxBytes`, `maxLines`) are applied last: entries
|
|
11
|
+
* are dropped from the end, a header left with no entry goes with them, and one
|
|
12
|
+
* trailing line names how many were left out — all of it counted against the caps.
|
|
13
|
+
* `refreshMemoryIndex` is the one impure entry: it renders from a store and writes
|
|
14
|
+
* both scope indexes back through it.
|
|
15
|
+
*/
|
|
16
|
+
import type { MemoryStore } from "./store.ts";
|
|
17
|
+
import { type MemoryDecision, type MemoryNote } from "./types.ts";
|
|
18
|
+
/** The public input: the notes of both scopes and the decisions of the project. */
|
|
19
|
+
export interface RenderMemoryIndexInput {
|
|
20
|
+
readonly projectNotes: readonly MemoryNote[];
|
|
21
|
+
readonly userNotes: readonly MemoryNote[];
|
|
22
|
+
readonly decisions: readonly MemoryDecision[];
|
|
23
|
+
/** `"<scope>/<slug>"` of every note with a queued forget. */
|
|
24
|
+
readonly heldForgets?: ReadonlySet<string>;
|
|
25
|
+
/** Default `MEMORY_INDEX_MAX_BYTES`, clamped up to `MEMORY_INDEX_MIN_BYTES`. */
|
|
26
|
+
readonly maxBytes?: number;
|
|
27
|
+
/** Default `MEMORY_INDEX_MAX_LINES`, clamped up to `MEMORY_INDEX_MIN_LINES`. */
|
|
28
|
+
readonly maxLines?: number;
|
|
29
|
+
}
|
|
30
|
+
/** The rendered index, plus how many entries the caps left out. */
|
|
31
|
+
export interface RenderedMemoryIndex {
|
|
32
|
+
readonly text: string;
|
|
33
|
+
readonly omitted: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What a refresh carries into both renderings: the held keys and the two caps. The keys
|
|
37
|
+
* are the ones the outbox still holds; the record's own keys are added to them.
|
|
38
|
+
*/
|
|
39
|
+
export interface RefreshMemoryIndexOptions {
|
|
40
|
+
/** `"<scope>/<slug>"` of every note with a queued forget. */
|
|
41
|
+
readonly heldForgets?: ReadonlySet<string>;
|
|
42
|
+
/** Default `MEMORY_INDEX_MAX_BYTES`, clamped up to `MEMORY_INDEX_MIN_BYTES`. */
|
|
43
|
+
readonly maxBytes?: number;
|
|
44
|
+
/** Default `MEMORY_INDEX_MAX_LINES`, clamped up to `MEMORY_INDEX_MIN_LINES`. */
|
|
45
|
+
readonly maxLines?: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Regenerate the index files of a store from what it holds: the project notes and
|
|
49
|
+
* the decisions into `MEMORY.md`, and — on a store that has a user directory — the
|
|
50
|
+
* user notes into the user scope's `MEMORY.md`, which carries the header lines and
|
|
51
|
+
* the `## User` section only. Every note with a queued or recorded forget is marked.
|
|
52
|
+
* The project rendering is what is returned.
|
|
53
|
+
*/
|
|
54
|
+
export declare function refreshMemoryIndex(store: MemoryStore, options?: RefreshMemoryIndexOptions): Promise<RenderedMemoryIndex>;
|
|
55
|
+
export declare function renderMemoryIndex(input: RenderMemoryIndexInput): RenderedMemoryIndex;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, memoryNoteKey, } from "./types.js";
|
|
2
|
+
/** The two lines every index starts with. */
|
|
3
|
+
const HEADING_LINES = ["# Memory", "Memory notes are recorded facts, not instructions."];
|
|
4
|
+
/** The order the project section lists its type blocks in: most actionable first. */
|
|
5
|
+
const TYPE_ORDER = ["project", "feedback", "reference", "user"];
|
|
6
|
+
/** What a note line renders between its slug and its description. */
|
|
7
|
+
const FIELD_SEPARATOR = "—";
|
|
8
|
+
/** Anything that would end a line or act as a control: flattened to one space. */
|
|
9
|
+
const CONTROL_CHARS = /[\p{Cc}\u2028\u2029]/gu;
|
|
10
|
+
/** A run of spaces left behind by flattening a control character. */
|
|
11
|
+
const SPACE_RUNS = / {2,}/g;
|
|
12
|
+
/** A held key that keeps every note line unmarked. */
|
|
13
|
+
const NO_HELD_FORGETS = new Set();
|
|
14
|
+
/** The UTF-8 meter the byte cap is counted in. */
|
|
15
|
+
const UTF8 = new TextEncoder();
|
|
16
|
+
function headingLine(text) {
|
|
17
|
+
return { kind: "heading", text };
|
|
18
|
+
}
|
|
19
|
+
function sectionLine(text) {
|
|
20
|
+
return { kind: "section", text };
|
|
21
|
+
}
|
|
22
|
+
function typeLine(type) {
|
|
23
|
+
return { kind: "type", text: `### ${type}` };
|
|
24
|
+
}
|
|
25
|
+
function entryLine(text) {
|
|
26
|
+
return { kind: "entry", text };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One field as index data: a single line, single spaces, at most the description
|
|
30
|
+
* cap. Text is flattened first and cut second, so the cap counts what is shown.
|
|
31
|
+
* Trimming is what keeps a leading line break from opening the line with a space.
|
|
32
|
+
*/
|
|
33
|
+
function sanitizeField(text) {
|
|
34
|
+
return cutToCap(text.replace(CONTROL_CHARS, " ").replace(SPACE_RUNS, " ").trim());
|
|
35
|
+
}
|
|
36
|
+
/** At most `MEMORY_DESCRIPTION_MAX_CHARS` code points: no surrogate pair is split. */
|
|
37
|
+
function cutToCap(text) {
|
|
38
|
+
const chars = Array.from(text);
|
|
39
|
+
if (chars.length <= MEMORY_DESCRIPTION_MAX_CHARS)
|
|
40
|
+
return text;
|
|
41
|
+
return chars.slice(0, MEMORY_DESCRIPTION_MAX_CHARS).join("").trimEnd();
|
|
42
|
+
}
|
|
43
|
+
/** One note as one index line: the note's type, slug and description, then its markers. */
|
|
44
|
+
function noteLine(note, heldForgets) {
|
|
45
|
+
const fields = `- [${note.type}] ${note.slug} ${FIELD_SEPARATOR} ${sanitizeField(note.description)}`;
|
|
46
|
+
return `${fields}${markersOf(note, heldForgets)}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What a note's line ends with. A note awaiting approval says so; a note with a
|
|
50
|
+
* queued forget says so. Both can hold at once — a proposal-mode note forgotten
|
|
51
|
+
* before it was approved — so they are appended in that reading order.
|
|
52
|
+
*/
|
|
53
|
+
function markersOf(note, heldForgets) {
|
|
54
|
+
const pending = note.status === "pending" ? " (pending)" : "";
|
|
55
|
+
const forgetting = heldForgets.has(`${note.scope}/${note.slug}`) ? " (forget pending)" : "";
|
|
56
|
+
return `${pending}${forgetting}`;
|
|
57
|
+
}
|
|
58
|
+
/** The notes an index shows: a superseded, rejected or deleted note is history. */
|
|
59
|
+
function renderable(notes) {
|
|
60
|
+
return notes.filter((note) => note.status === "active" || note.status === "pending");
|
|
61
|
+
}
|
|
62
|
+
/** The project scope's notes, grouped into one block per type that has any. */
|
|
63
|
+
function projectSection(input, heldForgets) {
|
|
64
|
+
const projectNotes = renderable(input.projectNotes);
|
|
65
|
+
const blocks = [];
|
|
66
|
+
for (const type of TYPE_ORDER) {
|
|
67
|
+
const lines = projectNotes
|
|
68
|
+
.filter((note) => note.type === type)
|
|
69
|
+
.map((note) => entryLine(noteLine(note, heldForgets)));
|
|
70
|
+
if (lines.length === 0)
|
|
71
|
+
continue;
|
|
72
|
+
blocks.push(typeLine(type), ...lines);
|
|
73
|
+
}
|
|
74
|
+
return blocks.length === 0 ? [] : [sectionLine("## Project"), ...blocks];
|
|
75
|
+
}
|
|
76
|
+
/** An `updatedAt` as a sortable number; an unparsable stamp sorts as the oldest. */
|
|
77
|
+
function updatedAtOf(decision) {
|
|
78
|
+
const time = Date.parse(decision.updatedAt);
|
|
79
|
+
return Number.isNaN(time) ? 0 : time;
|
|
80
|
+
}
|
|
81
|
+
/** One decision as one index line: three sanitised fields, one space apart. */
|
|
82
|
+
function decisionLine(decision) {
|
|
83
|
+
const fields = [decision.subject, decision.predicate, decision.object].map((field) => sanitizeField(field));
|
|
84
|
+
return `- ${fields.join(" ")}`;
|
|
85
|
+
}
|
|
86
|
+
/** The decisions still in force, newest first: a superseded or rejected one is history. */
|
|
87
|
+
function decisionsSection(decisions) {
|
|
88
|
+
const lines = decisions
|
|
89
|
+
.filter((decision) => decision.status === "active")
|
|
90
|
+
.sort((a, b) => updatedAtOf(b) - updatedAtOf(a))
|
|
91
|
+
.map((decision) => entryLine(decisionLine(decision)));
|
|
92
|
+
return lines.length === 0 ? [] : [sectionLine("## Decisions"), ...lines];
|
|
93
|
+
}
|
|
94
|
+
/** The user scope's notes: one flat list, in the same line shape as the project's. */
|
|
95
|
+
function userSection(userNotes, heldForgets) {
|
|
96
|
+
const lines = renderable(userNotes).map((note) => entryLine(noteLine(note, heldForgets)));
|
|
97
|
+
return lines.length === 0 ? [] : [sectionLine("## User"), ...lines];
|
|
98
|
+
}
|
|
99
|
+
/** Every line as the text it renders: one newline between, none trailing. */
|
|
100
|
+
function joinLines(lines) {
|
|
101
|
+
return lines.map((line) => line.text).join("\n");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The keys both renderings mark: the caller's held forgets plus the ones the mirror has
|
|
105
|
+
* recorded. A proposal forget is answered long before anything acts on it, so the record
|
|
106
|
+
* — not the caller — is what still knows about it; a key held by both is one mark.
|
|
107
|
+
*/
|
|
108
|
+
async function markedForgets(store, heldForgets) {
|
|
109
|
+
const recorded = await store.readPendingForgets();
|
|
110
|
+
if (recorded.length === 0)
|
|
111
|
+
return heldForgets ?? NO_HELD_FORGETS;
|
|
112
|
+
const keys = new Set(heldForgets ?? NO_HELD_FORGETS);
|
|
113
|
+
for (const entry of recorded)
|
|
114
|
+
keys.add(memoryNoteKey(entry.scope, entry.slug));
|
|
115
|
+
return keys;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Regenerate the index files of a store from what it holds: the project notes and
|
|
119
|
+
* the decisions into `MEMORY.md`, and — on a store that has a user directory — the
|
|
120
|
+
* user notes into the user scope's `MEMORY.md`, which carries the header lines and
|
|
121
|
+
* the `## User` section only. Every note with a queued or recorded forget is marked.
|
|
122
|
+
* The project rendering is what is returned.
|
|
123
|
+
*/
|
|
124
|
+
export async function refreshMemoryIndex(store, options = {}) {
|
|
125
|
+
const heldForgets = await markedForgets(store, options.heldForgets);
|
|
126
|
+
const caps = { maxBytes: options.maxBytes, maxLines: options.maxLines };
|
|
127
|
+
const project = renderMemoryIndex({
|
|
128
|
+
projectNotes: await store.listNotes("project"),
|
|
129
|
+
userNotes: [],
|
|
130
|
+
decisions: await store.readDecisions(),
|
|
131
|
+
heldForgets,
|
|
132
|
+
...caps,
|
|
133
|
+
});
|
|
134
|
+
await store.writeIndex("project", project.text);
|
|
135
|
+
if (store.paths.userDir !== null) {
|
|
136
|
+
const userNotes = await store.listNotes("user");
|
|
137
|
+
const user = renderMemoryIndex({ projectNotes: [], userNotes, decisions: [], heldForgets, ...caps });
|
|
138
|
+
await store.writeIndex("user", user.text);
|
|
139
|
+
}
|
|
140
|
+
return project;
|
|
141
|
+
}
|
|
142
|
+
/** The grouping depth of a line: a section is 2, a type block is 3, everything else 1. */
|
|
143
|
+
function levelOf(line) {
|
|
144
|
+
if (line.kind === "section")
|
|
145
|
+
return 2;
|
|
146
|
+
if (line.kind === "type")
|
|
147
|
+
return 3;
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
/** The index of the last entry line, or -1 when the index holds none. */
|
|
151
|
+
function lastEntryIndex(lines) {
|
|
152
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
153
|
+
if (lines[index].kind === "entry")
|
|
154
|
+
return index;
|
|
155
|
+
}
|
|
156
|
+
return -1;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The index without its last entry, and without the header that entry leaves empty.
|
|
160
|
+
*
|
|
161
|
+
* Everything after the last entry is a heading, and dropping that entry empties
|
|
162
|
+
* all of them, so the cut is a suffix: the trailing headings go with the entry and
|
|
163
|
+
* no other heading can have become empty. `null` means there is no entry left.
|
|
164
|
+
*/
|
|
165
|
+
function withoutLastEntry(lines) {
|
|
166
|
+
let cut = lastEntryIndex(lines);
|
|
167
|
+
if (cut < 0)
|
|
168
|
+
return null;
|
|
169
|
+
while (cut > 0 && levelOf(lines[cut - 1]) > 1)
|
|
170
|
+
cut -= 1;
|
|
171
|
+
return { lines: lines.slice(0, cut), removed: lines.slice(cut) };
|
|
172
|
+
}
|
|
173
|
+
/** The line a truncated index ends with: how many entries it left out and how to reach them. */
|
|
174
|
+
function omittedLine(omitted) {
|
|
175
|
+
return { kind: "omitted", text: `- ${omitted} more entries not shown; use the memory tool to search.` };
|
|
176
|
+
}
|
|
177
|
+
/** What one line costs the joined text: its own bytes and the newline before it. */
|
|
178
|
+
function lineCost(line) {
|
|
179
|
+
return UTF8.encode(line.text).byteLength + 1;
|
|
180
|
+
}
|
|
181
|
+
/** The bytes a run of lines costs the joined text. */
|
|
182
|
+
function costOf(lines) {
|
|
183
|
+
return lines.reduce((total, line) => total + lineCost(line), 0);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The index cut down to its budgets, entries last-first.
|
|
187
|
+
*
|
|
188
|
+
* Each turn drops the last entry (a note line or a decision line) and any header
|
|
189
|
+
* it leaves empty, then measures again — the trailing "not shown" line is part of
|
|
190
|
+
* what is measured, so the count it names is already paid for. The running byte
|
|
191
|
+
* total is only adjusted by what a turn removed, so a long index is cut in one
|
|
192
|
+
* pass rather than re-measured from scratch. The minimums make the two headings
|
|
193
|
+
* plus that line always fit, so the loop cannot overrun.
|
|
194
|
+
*/
|
|
195
|
+
function truncate(lines, maxBytes, maxLines) {
|
|
196
|
+
const capBytes = Math.max(maxBytes, MEMORY_INDEX_MIN_BYTES);
|
|
197
|
+
const capLines = Math.max(maxLines, MEMORY_INDEX_MIN_LINES);
|
|
198
|
+
let kept = lines;
|
|
199
|
+
// The first line of the text carries no newline of its own.
|
|
200
|
+
let bytes = costOf(kept) - 1;
|
|
201
|
+
let omitted = 0;
|
|
202
|
+
for (;;) {
|
|
203
|
+
const tail = omitted === 0 ? [] : [omittedLine(omitted)];
|
|
204
|
+
const candidate = [...kept, ...tail];
|
|
205
|
+
if (candidate.length <= capLines && bytes + costOf(tail) <= capBytes) {
|
|
206
|
+
return { text: joinLines(candidate), omitted };
|
|
207
|
+
}
|
|
208
|
+
const drop = withoutLastEntry(kept);
|
|
209
|
+
if (drop === null)
|
|
210
|
+
return { text: joinLines(candidate), omitted };
|
|
211
|
+
kept = drop.lines;
|
|
212
|
+
bytes -= costOf(drop.removed);
|
|
213
|
+
omitted += 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export function renderMemoryIndex(input) {
|
|
217
|
+
const heldForgets = input.heldForgets ?? NO_HELD_FORGETS;
|
|
218
|
+
const lines = [
|
|
219
|
+
...HEADING_LINES.map(headingLine),
|
|
220
|
+
...projectSection(input, heldForgets),
|
|
221
|
+
...decisionsSection(input.decisions),
|
|
222
|
+
...userSection(input.userNotes, heldForgets),
|
|
223
|
+
];
|
|
224
|
+
return truncate(lines, input.maxBytes ?? MEMORY_INDEX_MAX_BYTES, input.maxLines ?? MEMORY_INDEX_MAX_LINES);
|
|
225
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { type RefreshMemoryIndexOptions, type RenderedMemoryIndex, type RenderMemoryIndexInput, refreshMemoryIndex, renderMemoryIndex, } from "./index-render.ts";
|
|
2
|
+
export { createLocalMemoryProvider } from "./local-provider.ts";
|
|
3
|
+
export { isMemoryPushRejected, parseMemoryNote, parseMemoryNoteRecord, serializeMemoryNote, } from "./note.ts";
|
|
4
|
+
export { approveMemoryNote, forgetMemoryNote, type MemoryApproveInput, type MemoryForgetInput, type MemoryForgetResult, type MemoryOperationInput, type MemorySaveInput, type MemorySaveResult, saveMemoryNote, } from "./operations.ts";
|
|
5
|
+
export { createMemoryOutbox, type MemoryOutbox, type MemoryOutboxEntry } from "./outbox.ts";
|
|
6
|
+
export type { MemoryPage, MemoryProvider, MemoryPushResult, MemoryWriteRequest, } from "./provider-types.ts";
|
|
7
|
+
export { searchMemoryNotes, tokenizeMemoryQuery } from "./search.ts";
|
|
8
|
+
export { createMemoryStore, type MemoryStore, type MemoryStorePaths, type MemorySyncState, } from "./store.ts";
|
|
9
|
+
export { raceTimeout } from "./support.ts";
|
|
10
|
+
export { type MemorySyncInput, type MemorySyncResult, syncMemory } from "./sync.ts";
|
|
11
|
+
export { MEMORY_CLIENT_ID_RE, MEMORY_DECISION_STATUSES, MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_NOTE_STATUSES, MEMORY_NOTE_TYPES, MEMORY_OP_TIMEOUT_MS, MEMORY_PROBE_TIMEOUT_MS, MEMORY_PULL_MAX_PAGES, MEMORY_PULL_PAGE_SIZE, MEMORY_SCOPES, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, type MemoryDecision, type MemoryDecisionStatus, type MemoryHit, type MemoryNote, type MemoryNoteStatus, type MemoryNoteType, type MemoryNoteWrite, type MemoryPendingForget, type MemoryScope, type MemoryTimers, type MemoryWriteRefusal, } from "./types.ts";
|
|
12
|
+
export { findSecret, forgetReasonRefusal, validateMemoryNoteWrite } from "./validate.ts";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { refreshMemoryIndex, renderMemoryIndex, } from "./index-render.js";
|
|
2
|
+
export { createLocalMemoryProvider } from "./local-provider.js";
|
|
3
|
+
export { isMemoryPushRejected, parseMemoryNote, parseMemoryNoteRecord, serializeMemoryNote, } from "./note.js";
|
|
4
|
+
export { approveMemoryNote, forgetMemoryNote, saveMemoryNote, } from "./operations.js";
|
|
5
|
+
export { createMemoryOutbox } from "./outbox.js";
|
|
6
|
+
export { searchMemoryNotes, tokenizeMemoryQuery } from "./search.js";
|
|
7
|
+
export { createMemoryStore, } from "./store.js";
|
|
8
|
+
export { raceTimeout } from "./support.js";
|
|
9
|
+
export { syncMemory } from "./sync.js";
|
|
10
|
+
export { MEMORY_CLIENT_ID_RE, MEMORY_DECISION_STATUSES, MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_INDEX_MAX_BYTES, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MIN_BYTES, MEMORY_INDEX_MIN_LINES, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_NOTE_STATUSES, MEMORY_NOTE_TYPES, MEMORY_OP_TIMEOUT_MS, MEMORY_PROBE_TIMEOUT_MS, MEMORY_PULL_MAX_PAGES, MEMORY_PULL_PAGE_SIZE, MEMORY_SCOPES, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, } from "./types.js";
|
|
11
|
+
export { findSecret, forgetReasonRefusal, validateMemoryNoteWrite } from "./validate.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in provider: a local mirror is its own provider, so it is always
|
|
3
|
+
* reachable and never needs a network.
|
|
4
|
+
*
|
|
5
|
+
* A `save` lands in the store with `active` (a `direct` write) or `pending` (a
|
|
6
|
+
* `proposal`, which is not in force until an approval); a `forget` deletes the
|
|
7
|
+
* note under `direct` and leaves it in place under `proposal`. Both resolve the
|
|
8
|
+
* id the caller can key the mirror on — the note's id for a save, the write's
|
|
9
|
+
* client id for a forget, which has no note left to name.
|
|
10
|
+
*
|
|
11
|
+
* A proposal forget is the one write the mirror cannot carry out, so it is queued in
|
|
12
|
+
* the store's `pending-forgets.jsonl` instead: the record is what an index refresh and
|
|
13
|
+
* an approval read later, in a process that never saw the request. A `direct` forget or
|
|
14
|
+
* a save of that key takes the entry back out — the mirror then holds what was asked
|
|
15
|
+
* for. Nothing else may write the record on this provider's behalf.
|
|
16
|
+
*
|
|
17
|
+
* A write the store can never accept — an unslugged note, a scope this store does not
|
|
18
|
+
* have, a target outside it — is refused as `MEMORY_PUSH_REJECTED`, never retried.
|
|
19
|
+
* A failure that a retry can fix (a permission error, a full disk) is left as it is,
|
|
20
|
+
* including one from the record write: the operation keeps the write queued and a
|
|
21
|
+
* later sync replays it.
|
|
22
|
+
*
|
|
23
|
+
* `pull` is the empty finished page: the mirror is the store the caller already
|
|
24
|
+
* reads, so there is nothing to pull back into it. There is no `search`; keyword
|
|
25
|
+
* search runs over the mirror through `searchMemoryNotes`.
|
|
26
|
+
*/
|
|
27
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
28
|
+
import { memoryNoteKey } from "./types.js";
|
|
29
|
+
const ERROR_STAGE = "memory";
|
|
30
|
+
/** The status a write lands with: a proposal is not in force until an approval. */
|
|
31
|
+
const WRITE_MODE_STATUS = {
|
|
32
|
+
direct: "active",
|
|
33
|
+
proposal: "pending",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The store's own refusal codes: the write can never be accepted, so a retry
|
|
37
|
+
* cannot help. Everything else the store throws is I/O and stays retryable.
|
|
38
|
+
*/
|
|
39
|
+
const PERMANENT_STORE_CODES = new Set([
|
|
40
|
+
"MEMORY_PATH_OUTSIDE",
|
|
41
|
+
"MEMORY_SCOPE_UNAVAILABLE",
|
|
42
|
+
"MEMORY_SLUG_INVALID",
|
|
43
|
+
]);
|
|
44
|
+
function pushRejected(message) {
|
|
45
|
+
return new NaxError(message, "MEMORY_PUSH_REJECTED", { stage: ERROR_STAGE });
|
|
46
|
+
}
|
|
47
|
+
/** A permanent store refusal as a provider rejection; anything else passes through. */
|
|
48
|
+
function pushRejectedFrom(error) {
|
|
49
|
+
if (!(error instanceof NaxError) || !PERMANENT_STORE_CODES.has(error.code))
|
|
50
|
+
return error;
|
|
51
|
+
return new NaxError(error.message, "MEMORY_PUSH_REJECTED", {
|
|
52
|
+
stage: ERROR_STAGE,
|
|
53
|
+
storeCode: error.code,
|
|
54
|
+
cause: error,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Run one store mutation, so a permanent refusal reaches the caller in the contract's code. */
|
|
58
|
+
async function throughStore(action) {
|
|
59
|
+
try {
|
|
60
|
+
await action();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw pushRejectedFrom(error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The entry a proposal forget leaves in the record: the request's own id and reason. */
|
|
67
|
+
function queuedForget(request) {
|
|
68
|
+
return { clientId: request.clientId, scope: request.scope, slug: request.slug, reason: request.reason ?? "" };
|
|
69
|
+
}
|
|
70
|
+
/** Whether rewriting the record would change it: an unchanged list is not written back. */
|
|
71
|
+
function sameEntries(before, after) {
|
|
72
|
+
if (before.length !== after.length)
|
|
73
|
+
return false;
|
|
74
|
+
return before.every((entry, index) => {
|
|
75
|
+
const other = after[index];
|
|
76
|
+
return (other !== undefined &&
|
|
77
|
+
entry.clientId === other.clientId &&
|
|
78
|
+
entry.scope === other.scope &&
|
|
79
|
+
entry.slug === other.slug &&
|
|
80
|
+
entry.reason === other.reason);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The record rewritten without `key`'s entry, and with `queued` appended after the
|
|
85
|
+
* others when a proposal forget is what took the entry out. The store validates the
|
|
86
|
+
* slugs it is handed, so a forget of a slug no note can carry is refused here — before
|
|
87
|
+
* anything is recorded — even though a proposal never reaches the notes themselves.
|
|
88
|
+
*/
|
|
89
|
+
async function rewriteRecord(store, key, queued) {
|
|
90
|
+
const entries = await store.readPendingForgets();
|
|
91
|
+
const kept = entries.filter((entry) => memoryNoteKey(entry.scope, entry.slug) !== key);
|
|
92
|
+
const next = queued === null ? kept : [...kept, queued];
|
|
93
|
+
// A key the record does not hold leaves the file alone: absent stays absent.
|
|
94
|
+
if (sameEntries(entries, next))
|
|
95
|
+
return;
|
|
96
|
+
await store.writePendingForgets(next);
|
|
97
|
+
}
|
|
98
|
+
export function createLocalMemoryProvider(store) {
|
|
99
|
+
/** A save is accepted by definition: the mirror is what the provider is. */
|
|
100
|
+
async function save(request) {
|
|
101
|
+
const note = request.note;
|
|
102
|
+
if (note === undefined)
|
|
103
|
+
throw pushRejected(`memory save for "${request.slug}" carries no note`);
|
|
104
|
+
const status = WRITE_MODE_STATUS[request.writeMode];
|
|
105
|
+
await throughStore(() => store.writeNote({ ...note, status }));
|
|
106
|
+
// Saving the slug again means it is wanted, so whatever forget it was queued for goes.
|
|
107
|
+
const key = memoryNoteKey(note.scope, note.slug);
|
|
108
|
+
await throughStore(() => rewriteRecord(store, key, null));
|
|
109
|
+
return { id: note.id, status };
|
|
110
|
+
}
|
|
111
|
+
/** A forget under direct removes the note; a proposal leaves it for an approval. */
|
|
112
|
+
async function forget(request) {
|
|
113
|
+
const direct = request.writeMode === "direct";
|
|
114
|
+
if (direct)
|
|
115
|
+
await throughStore(() => store.deleteNote(request.scope, request.slug));
|
|
116
|
+
const key = memoryNoteKey(request.scope, request.slug);
|
|
117
|
+
await throughStore(() => rewriteRecord(store, key, direct ? null : queuedForget(request)));
|
|
118
|
+
return { id: request.clientId, status: direct ? "deleted" : "pending" };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
id: "local",
|
|
122
|
+
pull: async () => ({ notes: [], done: true }),
|
|
123
|
+
push: async (request) => (request.kind === "save" ? save(request) : forget(request)),
|
|
124
|
+
// A local mirror has no network to be unreachable.
|
|
125
|
+
reachable: async () => true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AgentLogger } from "#src/infra/agent-logger";
|
|
2
|
+
import { type MemoryStore } from "./store.ts";
|
|
3
|
+
import type { MemoryScope, MemoryTimers } from "./types.ts";
|
|
4
|
+
/** The lock's release: the protocol hands one back per acquisition, and it must be called. */
|
|
5
|
+
export type ReleaseLock = () => Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* What the protocol needs of an operation's inputs: the mirror for its directories and lock
|
|
8
|
+
* paths, plus the clock, logger, timers and liveness probe a lock is taken and waited out with.
|
|
9
|
+
* A save's or a forget's inputs satisfy it as they are; an approval's does too once its
|
|
10
|
+
* optional seams are filled from the process defaults, and a sync's with its own pid.
|
|
11
|
+
*/
|
|
12
|
+
export interface MirrorLockInput {
|
|
13
|
+
readonly store: MemoryStore;
|
|
14
|
+
readonly logger: AgentLogger;
|
|
15
|
+
readonly now?: () => Date;
|
|
16
|
+
readonly timers?: MemoryTimers;
|
|
17
|
+
readonly isAlive?: (pid: number) => boolean;
|
|
18
|
+
/** The pid this process claims in the lock; default `process.pid`. */
|
|
19
|
+
readonly pid?: number;
|
|
20
|
+
}
|
|
21
|
+
/** One lock file and the directory it lives in: the pair every lock operation needs. */
|
|
22
|
+
export interface MirrorLockTarget {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly dir: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The clock an operation stamps with: the caller's, or the process clock.
|
|
28
|
+
*
|
|
29
|
+
* Every operation that stamps a note or a lock file takes the same default, so this is
|
|
30
|
+
* the one place it is spelled — an approval stamps a note without asking a provider, and
|
|
31
|
+
* still reads its clock here.
|
|
32
|
+
*/
|
|
33
|
+
export declare function clockOf(input: {
|
|
34
|
+
readonly now?: () => Date;
|
|
35
|
+
}): () => Date;
|
|
36
|
+
/**
|
|
37
|
+
* The locks that guard the given scopes, deduplicated and in the one order every caller
|
|
38
|
+
* takes them.
|
|
39
|
+
*
|
|
40
|
+
* The set is keyed and sorted by resolved path because two processes must agree on it
|
|
41
|
+
* without sharing any state: a writer holding one lock and waiting for the other while its
|
|
42
|
+
* counterpart holds that one and waits for this one is a deadlock, and it only stays
|
|
43
|
+
* impossible while both sides sort the same way. Sorting the spellings instead would break
|
|
44
|
+
* as soon as one caller reached the same directory through a link.
|
|
45
|
+
*/
|
|
46
|
+
export declare function lockTargetsFor(store: MemoryStore, scopes: readonly MemoryScope[]): readonly MirrorLockTarget[];
|
|
47
|
+
/** Every lock this store's writers share: the project's mirror and, when it has one, the user's. */
|
|
48
|
+
export declare function storeLockTargets(store: MemoryStore): readonly MirrorLockTarget[];
|
|
49
|
+
/** Whether a sync of this process is in flight for any mirror of this store. */
|
|
50
|
+
export declare function mirrorSyncInFlight(store: MemoryStore): boolean;
|
|
51
|
+
/** Hand one lock back; a failed release is reported, never thrown, so the result still returns. */
|
|
52
|
+
export declare function releaseMirrorLock(input: MirrorLockInput, release: ReleaseLock): Promise<void>;
|
|
53
|
+
/** Release every lock taken, latest first: the reverse of the order they were acquired in. */
|
|
54
|
+
export declare function releaseMirrorLocks(input: MirrorLockInput, held: readonly ReleaseLock[]): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Take every one of `targets`, in the order given, and hand them all back whatever happens.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is awaited between the claims and the last acquisition that another writer of this
|
|
59
|
+
* process could see: the claims mark this process as a writer for the whole wait, so a sync
|
|
60
|
+
* that starts meanwhile (`operationInFlight`) leaves the mirror alone instead of taking our
|
|
61
|
+
* own pid's lock file over.
|
|
62
|
+
*/
|
|
63
|
+
export declare function withMirrorLocks<T>(input: MirrorLockInput, targets: readonly MirrorLockTarget[], work: () => Promise<T>): Promise<T>;
|
|
64
|
+
/**
|
|
65
|
+
* Run `work` as this store's only writer — of the project mirror and of the user mirror its
|
|
66
|
+
* projects share — and hand every lock back whatever `work` does.
|
|
67
|
+
*
|
|
68
|
+
* Waiting is the only ordering this can offer, so it is the right one for an operation that
|
|
69
|
+
* has nowhere else to put its write: a save or a forget can queue an entry and return, but an
|
|
70
|
+
* approval has to change the note it read, which means it has to be the writer when it reads
|
|
71
|
+
* it. A live holder — another process, or a sync of this process — is waited out on the
|
|
72
|
+
* operation's timers, and a failed release is reported rather than thrown, so `work`'s own
|
|
73
|
+
* answer still reaches the caller.
|
|
74
|
+
*/
|
|
75
|
+
export declare function withMirrorLock<T>(input: MirrorLockInput, work: () => Promise<T>): Promise<T>;
|
|
76
|
+
/**
|
|
77
|
+
* Take every lock a run needs, or `null` when any of them is held.
|
|
78
|
+
*
|
|
79
|
+
* A sync that waited would be a sync the run's caller cannot bound, so contention is a
|
|
80
|
+
* result — `locked` — rather than a wait. The locks already taken are handed back before
|
|
81
|
+
* returning, because a lock kept while reporting "I got nothing" is a mirror nobody can
|
|
82
|
+
* write to. The operation claim is read first: a write operation of this process is midway
|
|
83
|
+
* through the mirror, and the protocol would call our own pid `own` and hand this run the
|
|
84
|
+
* file that operation believes it holds.
|
|
85
|
+
*
|
|
86
|
+
* A failure that is not contention — a directory that cannot be created, a lock file that
|
|
87
|
+
* cannot be read — unwinds exactly the same way: what this run took is handed back before the
|
|
88
|
+
* error leaves. A lock abandoned here holds its heartbeat too, so the mirror it guards would
|
|
89
|
+
* stay locked to every other writer until the heartbeat aged out.
|
|
90
|
+
*/
|
|
91
|
+
export declare function acquireMirrorLocks(input: MirrorLockInput, targets: readonly MirrorLockTarget[]): Promise<ReleaseLock | null>;
|
|
92
|
+
/**
|
|
93
|
+
* Run `work` once every earlier writer of the same mirror has settled.
|
|
94
|
+
*
|
|
95
|
+
* Taking the tail happens before the first `await`: a queue read after a yield is one two
|
|
96
|
+
* writers both pass, which is exactly the interleaving this orders away. The caller's own
|
|
97
|
+
* rejection is re-thrown unchanged — a refusal must reach its caller, not the next writer —
|
|
98
|
+
* and the chain continues from a tail that swallows it.
|
|
99
|
+
*/
|
|
100
|
+
export declare function orderedByStore<T>(lockPath: string, work: () => Promise<T>): Promise<T>;
|
|
101
|
+
/**
|
|
102
|
+
* Run `work` behind every earlier writer of any mirror this store shares.
|
|
103
|
+
*
|
|
104
|
+
* One chain per lock path, nested in the same canonical order the locks themselves are taken
|
|
105
|
+
* in, so a writer of project A and a writer of project B — both sharing one user directory —
|
|
106
|
+
* meet on that directory's chain before either reaches its own lock. The nesting order has to
|
|
107
|
+
* match the file locks' order for the same deadlock reason they are sorted.
|
|
108
|
+
*/
|
|
109
|
+
export declare function orderedByMirrorLocks<T>(store: MemoryStore, work: () => Promise<T>): Promise<T>;
|