@frockbot/plugin-memory 0.0.0 → 0.1.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/src/chunker.ts ADDED
@@ -0,0 +1,104 @@
1
+ const CHUNK_SIZE_CHARS = 1600;
2
+ const OVERLAP_CHARS = 320;
3
+
4
+ export interface MemoryChunk {
5
+ content: string;
6
+ startLine: number;
7
+ endLine: number;
8
+ hash: string;
9
+ }
10
+
11
+ interface LineRange {
12
+ text: string;
13
+ startLine: number;
14
+ endLine: number;
15
+ }
16
+
17
+ export async function hashMemoryContent(text: string): Promise<string> {
18
+ const digest = await crypto.subtle.digest(
19
+ "SHA-256",
20
+ new TextEncoder().encode(text),
21
+ );
22
+ return [...new Uint8Array(digest)]
23
+ .map((byte) => byte.toString(16).padStart(2, "0"))
24
+ .join("");
25
+ }
26
+
27
+ function paragraphs(lines: string[]): LineRange[] {
28
+ const result: LineRange[] = [];
29
+ let block: string[] = [];
30
+ let startLine = -1;
31
+ const flush = (endLine: number) => {
32
+ if (block.length === 0) return;
33
+ result.push({ text: block.join("\n"), startLine, endLine });
34
+ block = [];
35
+ startLine = -1;
36
+ };
37
+ for (let index = 0; index < lines.length; index += 1) {
38
+ const line = lines[index] ?? "";
39
+ if (line.trim()) {
40
+ if (startLine === -1) startLine = index + 1;
41
+ block.push(line);
42
+ } else {
43
+ flush(index);
44
+ }
45
+ }
46
+ flush(lines.length);
47
+ return result;
48
+ }
49
+
50
+ function splitOversized(range: LineRange): LineRange[] {
51
+ if (range.text.length <= CHUNK_SIZE_CHARS) return [range];
52
+ const result: LineRange[] = [];
53
+ const step = CHUNK_SIZE_CHARS - OVERLAP_CHARS;
54
+ for (let offset = 0; offset < range.text.length; offset += step) {
55
+ const text = range.text.slice(offset, offset + CHUNK_SIZE_CHARS).trim();
56
+ if (text) result.push({ ...range, text });
57
+ if (offset + CHUNK_SIZE_CHARS >= range.text.length) break;
58
+ }
59
+ return result;
60
+ }
61
+
62
+ export async function chunkMarkdown(content: string): Promise<MemoryChunk[]> {
63
+ if (!content.trim()) return [];
64
+ const blocks = paragraphs(content.split("\n")).flatMap(splitOversized);
65
+ const chunks: MemoryChunk[] = [];
66
+ let index = 0;
67
+ while (index < blocks.length) {
68
+ let text = "";
69
+ const startLine = blocks[index]?.startLine ?? 1;
70
+ let endLine = blocks[index]?.endLine ?? startLine;
71
+ let cursor = index;
72
+ while (cursor < blocks.length) {
73
+ const block = blocks[cursor];
74
+ if (!block) break;
75
+ const addition = text ? `\n\n${block.text}` : block.text;
76
+ if (text && text.length + addition.length > CHUNK_SIZE_CHARS) break;
77
+ text += addition;
78
+ endLine = block.endLine;
79
+ cursor += 1;
80
+ }
81
+ const normalized = text.trim();
82
+ if (normalized) {
83
+ chunks.push({
84
+ content: normalized,
85
+ startLine,
86
+ endLine,
87
+ hash: await hashMemoryContent(normalized),
88
+ });
89
+ }
90
+ if (cursor >= blocks.length) break;
91
+ let overlap = 0;
92
+ let next = cursor;
93
+ for (
94
+ let candidate = cursor - 1;
95
+ candidate > index && overlap < OVERLAP_CHARS;
96
+ candidate -= 1
97
+ ) {
98
+ overlap += (blocks[candidate]?.text.length ?? 0) + 2;
99
+ next = candidate;
100
+ }
101
+ index = next <= index ? cursor : next;
102
+ }
103
+ return chunks;
104
+ }
@@ -0,0 +1,97 @@
1
+ // Memory files as documents, for the derived index.
2
+ //
3
+ // "Indexes, embeddings, and summaries are derived from Memory files and are
4
+ // always rebuildable from them." That sentence is only true if there is one
5
+ // place the index reads its inputs from, and it is the files — not a sidecar,
6
+ // not a cache, not a second store. This module is that place: it enumerates
7
+ // every Memory file of every tier a Bot can see, through the same
8
+ // `WorkspaceReadsV1` the renderer uses, and hands back bytes and content
9
+ // addresses. Nothing derived is stored here.
10
+ import type {
11
+ MemoryScopeNameV1,
12
+ WorkspaceMemoryRootV1,
13
+ WorkspaceReadsV1,
14
+ } from "@frockbot/kernel-contracts";
15
+ import {
16
+ memoryFileKindV1,
17
+ memoryProjectIdOfRootV1,
18
+ memoryScopeOfRootV1,
19
+ } from "./roots.js";
20
+ import { MEMORY_MAX_FILES_PER_TIER, MEMORY_MAX_LIST_PAGES } from "./store.js";
21
+
22
+ /** One Memory file, addressed by its content and its generation. */
23
+ export interface MemoryDocumentV1 {
24
+ scope: MemoryScopeNameV1;
25
+ projectId: string;
26
+ /** Relative to its root, shard prefix included. */
27
+ path: string;
28
+ /** The Bot whose shard holds it. */
29
+ botId: string;
30
+ kind: "profile" | "log";
31
+ text: string;
32
+ contentHash: string;
33
+ generationId: string;
34
+ }
35
+
36
+ /** A stable key for one document across every tier. */
37
+ export function memoryDocumentKeyV1(document: {
38
+ scope: MemoryScopeNameV1;
39
+ projectId: string;
40
+ path: string;
41
+ }): string {
42
+ return `${document.scope}:${document.projectId}:${document.path}`;
43
+ }
44
+
45
+ /**
46
+ * Reads every Memory file under one root. A file that cannot be read is
47
+ * skipped rather than thrown: an index is derived state, and a partial rebuild
48
+ * that says so beats a Turn that fails because one object was briefly
49
+ * unreachable.
50
+ */
51
+ export async function listMemoryDocumentsV1(
52
+ reads: WorkspaceReadsV1,
53
+ root: WorkspaceMemoryRootV1,
54
+ ): Promise<MemoryDocumentV1[]> {
55
+ const scope = memoryScopeOfRootV1(root);
56
+ const projectId = memoryProjectIdOfRootV1(root);
57
+ const documents: MemoryDocumentV1[] = [];
58
+ let cursor: string | undefined;
59
+ for (let page = 0; page < MEMORY_MAX_LIST_PAGES; page += 1) {
60
+ const outcome = await reads.list(
61
+ cursor === undefined ? { root } : { root, cursor },
62
+ );
63
+ if (outcome.status !== "ok") return documents;
64
+ for (const entry of outcome.entries) {
65
+ if (documents.length >= MEMORY_MAX_FILES_PER_TIER) return documents;
66
+ const classified = memoryFileKindV1(root, entry.path.path);
67
+ if (!classified) continue;
68
+ const read = await reads.read(entry.path);
69
+ if (read.status !== "ok") continue;
70
+ documents.push({
71
+ scope,
72
+ projectId,
73
+ path: entry.path.path,
74
+ botId: classified.shard,
75
+ kind: classified.kind,
76
+ text: new TextDecoder().decode(read.file.bytes),
77
+ contentHash: read.file.generation.contentHash,
78
+ generationId: read.file.generation.generationId,
79
+ });
80
+ }
81
+ if (!outcome.cursor) break;
82
+ cursor = outcome.cursor;
83
+ }
84
+ return documents;
85
+ }
86
+
87
+ /** Every Memory document of every root a Bot can see, in tier order. */
88
+ export async function listAllMemoryDocumentsV1(
89
+ reads: WorkspaceReadsV1,
90
+ roots: WorkspaceMemoryRootV1[],
91
+ ): Promise<MemoryDocumentV1[]> {
92
+ const documents: MemoryDocumentV1[] = [];
93
+ for (const root of roots) {
94
+ documents.push(...(await listMemoryDocumentsV1(reads, root)));
95
+ }
96
+ return documents;
97
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ EMBEDDING_MODEL,
3
+ type EmbedMemory,
4
+ type MemoryAiBinding,
5
+ } from "./types.js";
6
+
7
+ const MAX_BATCH_SIZE = 100;
8
+
9
+ export function createMemoryEmbedder(
10
+ ai: MemoryAiBinding,
11
+ model = EMBEDDING_MODEL,
12
+ ): EmbedMemory {
13
+ return async (texts) => {
14
+ const vectors: number[][] = [];
15
+ for (let index = 0; index < texts.length; index += MAX_BATCH_SIZE) {
16
+ const response = await ai.run(model, {
17
+ text: texts.slice(index, index + MAX_BATCH_SIZE),
18
+ });
19
+ vectors.push(...response.data);
20
+ }
21
+ return vectors;
22
+ };
23
+ }
@@ -0,0 +1,126 @@
1
+ // The typed markers on a fact text: `[note] ` and the reserved `[episode] `.
2
+ //
3
+ // The claim every case here defends is that typing the markers changed no
4
+ // bytes. A file written before markers were parsed parses to the same facts,
5
+ // renders to the same file, and injects the same line.
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ MEMORY_MARKERS_V1,
9
+ isMemoryRetractionV1,
10
+ memoryFactBodyV1,
11
+ parseMemoryFileV1,
12
+ parseMemoryMarkerV1,
13
+ renderInjectedFactLineV1,
14
+ renderMemoryFileV1,
15
+ renderMemoryMarkerV1,
16
+ retractedFactTextV1,
17
+ } from "./facts.ts";
18
+
19
+ describe("parseMemoryMarkerV1", () => {
20
+ test("recognises exactly `[note] ` and `[episode] `", () => {
21
+ expect(MEMORY_MARKERS_V1).toEqual(["note", "episode"]);
22
+ expect(parseMemoryMarkerV1("[note] we ship on Friday")).toEqual({
23
+ marker: "note",
24
+ body: "we ship on Friday",
25
+ });
26
+ expect(parseMemoryMarkerV1("[episode] the gym build week")).toEqual({
27
+ marker: "episode",
28
+ body: "the gym build week",
29
+ });
30
+ });
31
+
32
+ test("an unrecognised bracket prefix is fact text, not a marker", () => {
33
+ for (const text of [
34
+ "[todo] buy rubber matting",
35
+ "[via School] Tim lives in Wollongong.",
36
+ "[note]no space",
37
+ "[NOTE] shouting",
38
+ "a note about the floor",
39
+ ]) {
40
+ expect(parseMemoryMarkerV1(text)).toEqual({ body: text });
41
+ expect(memoryFactBodyV1(text)).toBe(text);
42
+ }
43
+ });
44
+
45
+ test("a retraction is checked first, so `[forgotten] [note] x` retracts the note", () => {
46
+ const retraction = "[forgotten] [note] we ship on Friday";
47
+ expect(isMemoryRetractionV1(retraction)).toBe(true);
48
+ // The retraction itself carries no marker…
49
+ expect(parseMemoryMarkerV1(retraction)).toEqual({ body: retraction });
50
+ // …and what it retracts is the note, marker and all.
51
+ expect(parseMemoryMarkerV1(retractedFactTextV1(retraction))).toEqual({
52
+ marker: "note",
53
+ body: "we ship on Friday",
54
+ });
55
+ });
56
+
57
+ test("render is the exact inverse of parse, byte for byte", () => {
58
+ for (const text of [
59
+ "[note] we ship on Friday",
60
+ "[episode] the gym build week",
61
+ "[todo] buy rubber matting",
62
+ "plain",
63
+ "[note] two spaces after the marker",
64
+ ]) {
65
+ const { marker, body } = parseMemoryMarkerV1(text);
66
+ expect(renderMemoryMarkerV1(marker, body)).toBe(text);
67
+ }
68
+ });
69
+ });
70
+
71
+ describe("markers through the file format", () => {
72
+ const FILE = [
73
+ "- (2026-08-30) Tim prefers blunt answers.",
74
+ "- (2026-08-31) [note] we ship on Friday",
75
+ "- (2026-08-31) [episode] the gym build week",
76
+ "- (2026-08-31) [todo] buy rubber matting",
77
+ "",
78
+ ].join("\n");
79
+
80
+ test("a file written before this change parses identically, and round-trips", () => {
81
+ const facts = parseMemoryFileV1(FILE);
82
+ expect(facts.map((fact) => fact.text)).toEqual([
83
+ "Tim prefers blunt answers.",
84
+ "[note] we ship on Friday",
85
+ "[episode] the gym build week",
86
+ "[todo] buy rubber matting",
87
+ ]);
88
+ // `marker`/`body` are added *beside* `text`, which still holds the prefix.
89
+ expect(facts.map((fact) => fact.marker)).toEqual([
90
+ undefined,
91
+ "note",
92
+ "episode",
93
+ undefined,
94
+ ]);
95
+ expect(facts[1]?.body).toBe("we ship on Friday");
96
+ expect(facts[3]?.body).toBe("[todo] buy rubber matting");
97
+ // disk → parse → disk, byte-identical.
98
+ expect(renderMemoryFileV1(facts)).toBe(FILE);
99
+ });
100
+
101
+ test("the injected line is `(learned d) [via b] [note] body`", () => {
102
+ expect(
103
+ renderInjectedFactLineV1(
104
+ { date: "2026-08-31", text: "[note] we ship on Friday", via: "School" },
105
+ 500,
106
+ ),
107
+ ).toBe("- (learned 2026-08-31) [via School] [note] we ship on Friday");
108
+ expect(
109
+ renderInjectedFactLineV1(
110
+ { date: "2026-08-31", text: "[note] we ship on Friday" },
111
+ 500,
112
+ ),
113
+ ).toBe("- (learned 2026-08-31) [note] we ship on Friday");
114
+ });
115
+
116
+ test("the clamp applies to the whole fact text, marker included", () => {
117
+ const long = `[note] ${"x".repeat(900)}`;
118
+ const line = renderInjectedFactLineV1(
119
+ { date: "2026-08-31", text: long },
120
+ 500,
121
+ );
122
+ expect(line.startsWith("- (learned 2026-08-31) [note] xxx")).toBe(true);
123
+ expect(line.endsWith("…")).toBe(true);
124
+ expect(line.length).toBe("- (learned 2026-08-31) ".length + 500);
125
+ });
126
+ });
package/src/facts.ts ADDED
@@ -0,0 +1,258 @@
1
+ // The Memory file format, both halves of it.
2
+ //
3
+ // GrokBot writes and injects a fact in two different shapes
4
+ // (`docs/research/grokbot-computer.md` §4.1b), and matching parity means
5
+ // matching both:
6
+ //
7
+ // on disk - (YYYY-MM-DD) <fact>
8
+ // injected - (learned YYYY-MM-DD) [via <bot>] <fact>
9
+ //
10
+ // `[note] ` and `[episode] ` are prefixes *on the fact text*, not separate
11
+ // files, so they survive a round trip through the disk form untouched. They
12
+ // are also *parsed* here — `MEMORY_MARKERS_V1` is the one vocabulary the
13
+ // writer, the renderer, the fade and `forget` all share — because a marker
14
+ // that is only ever opaque text cannot mean anything at read time, and the
15
+ // note tier's whole claim is that it fades.
16
+ //
17
+ // One shape this Package adds, which GrokBot has no equivalent for: a
18
+ // retraction. "a forget on a shared tier writes a retraction in the Bot's own
19
+ // shard — newest wins — never edits another shard", so a forget the Bot cannot
20
+ // perform by deleting a line it owns is recorded as `[forgotten] <fact>` in
21
+ // its own shard, and the reader drops the fact when the retraction is newer.
22
+ // It is deliberately the same line grammar: a Bot or a User reading the file
23
+ // with ordinary tools sees why the fact went away.
24
+
25
+ /** The marker a retraction carries, as a prefix on the fact text. */
26
+ export const MEMORY_FORGOTTEN_PREFIX = "[forgotten] ";
27
+
28
+ /**
29
+ * The markers a fact text may carry, as a `[<marker>] ` prefix.
30
+ *
31
+ * `note` is written by `memory_write`'s `note` tier. `episode` is **reserved
32
+ * and not produced**: GrokBot exposes no episode tier either (§2.2 lists
33
+ * `profile|log|note`), and FrockBot has no episodic summariser, so inventing a
34
+ * producer would be product surface added to close a register row. It is
35
+ * recognised so that a fact carrying it — written by hand, or by a summariser
36
+ * that lands later — is typed rather than opaque, and it fades on the same
37
+ * rule as a note.
38
+ */
39
+ export const MEMORY_MARKERS_V1 = ["note", "episode"] as const;
40
+
41
+ /** One of the `[note] `/`[episode] ` markers a fact text may carry. */
42
+ export type MemoryMarkerV1 = (typeof MEMORY_MARKERS_V1)[number];
43
+
44
+ /** One fact, as it was parsed from a Memory file. */
45
+ export interface MemoryFactV1 {
46
+ /** `YYYY-MM-DD`, the day the fact was recorded. */
47
+ date: string;
48
+ /** The fact text, including any `[note] `/`[episode] ` prefix. */
49
+ text: string;
50
+ /**
51
+ * The marker `text` carries, when it carries one.
52
+ *
53
+ * Derived, never authoritative: `text` is what is on disk, and
54
+ * `parseMemoryMarkerV1(text)` recovers this field at any time. A fact built
55
+ * by hand may omit it, which is why every reader in this Package parses
56
+ * `text` rather than trusting the field — one source of truth, and the bytes
57
+ * are it.
58
+ */
59
+ marker?: MemoryMarkerV1;
60
+ /** The fact text with its marker removed; equal to `text` when unmarked. */
61
+ body?: string;
62
+ }
63
+
64
+ /** A fact together with where it came from, for rendering and precedence. */
65
+ export interface SourcedMemoryFactV1 extends MemoryFactV1 {
66
+ /** The Bot whose shard holds it. */
67
+ botId: string;
68
+ /** The display name of that Bot, or its id when no name is known. */
69
+ via: string;
70
+ /** `profile.md` or a monthly log. */
71
+ kind: "profile" | "log";
72
+ /** The generation the line was read from; ordering's tiebreak. */
73
+ generationId: string;
74
+ }
75
+
76
+ const FACT_LINE = /^-\s+\((\d{4}-\d{2}-\d{2})\)\s+(.*)$/;
77
+
78
+ /**
79
+ * Splits a fact text into its marker and its body.
80
+ *
81
+ * Three rules, each chosen so the on-disk bytes are exactly what they were
82
+ * before markers were typed:
83
+ *
84
+ * - Only `[note] ` and `[episode] ` — the exact prefix, one space — are
85
+ * markers. An unrecognised bracket prefix (`[todo] `, a `[via …]` a User
86
+ * typed by hand) is ordinary fact text: `marker` is `undefined` and `body`
87
+ * is the whole text. Guessing at brackets would silently reclassify a
88
+ * User's own words.
89
+ * - A retraction is checked first and is never a marker, so
90
+ * `[forgotten] [note] x` is a retraction whose retracted text is
91
+ * `[note] x` — the note, not a fact named `[forgotten] …`.
92
+ * - The body is sliced, not trimmed, so
93
+ * `renderMemoryMarkerV1(marker, body) === text` byte for byte.
94
+ */
95
+ export function parseMemoryMarkerV1(text: string): {
96
+ marker?: MemoryMarkerV1;
97
+ body: string;
98
+ } {
99
+ if (!isMemoryRetractionV1(text)) {
100
+ for (const marker of MEMORY_MARKERS_V1) {
101
+ const prefix = `[${marker}] `;
102
+ if (text.startsWith(prefix)) {
103
+ return { marker, body: text.slice(prefix.length) };
104
+ }
105
+ }
106
+ }
107
+ return { body: text };
108
+ }
109
+
110
+ /** The fact text one marker and one body make; the inverse of the parse. */
111
+ export function renderMemoryMarkerV1(
112
+ marker: MemoryMarkerV1 | undefined,
113
+ body: string,
114
+ ): string {
115
+ return marker ? `[${marker}] ${body}` : body;
116
+ }
117
+
118
+ /** The marker-stripped body of a fact text, for matching by what it says. */
119
+ export function memoryFactBodyV1(text: string): string {
120
+ return parseMemoryMarkerV1(text).body;
121
+ }
122
+
123
+ /** `YYYY-MM-DD` in UTC. Memory dates are days, never instants. */
124
+ export function memoryDayV1(at: Date): string {
125
+ return at.toISOString().slice(0, 10);
126
+ }
127
+
128
+ /** The on-disk line for one fact. */
129
+ export function renderMemoryFactLineV1(fact: MemoryFactV1): string {
130
+ return `- (${fact.date}) ${fact.text}`;
131
+ }
132
+
133
+ /**
134
+ * Parses one Memory file. Lines that are not facts — a heading a User typed, a
135
+ * blank line, a stray paragraph — are ignored rather than refused: a Memory
136
+ * file is a file the User may edit by hand, and one malformed line must not
137
+ * cost the Bot the rest of its Memory.
138
+ */
139
+ export function parseMemoryFileV1(
140
+ text: string,
141
+ options: { maxFacts?: number } = {},
142
+ ): MemoryFactV1[] {
143
+ const maximum = options.maxFacts ?? 5_000;
144
+ const facts: MemoryFactV1[] = [];
145
+ for (const line of text.split("\n")) {
146
+ if (facts.length >= maximum) break;
147
+ const match = FACT_LINE.exec(line.trim());
148
+ if (!match) continue;
149
+ const text = (match[2] ?? "").trim();
150
+ if (!text) continue;
151
+ // The marker stays in `text`: a file written before markers were typed
152
+ // parses to the same `text` it always did, and nothing downstream that
153
+ // reads `text` changes behaviour. `marker`/`body` are added beside it.
154
+ const { marker, body } = parseMemoryMarkerV1(text);
155
+ facts.push({
156
+ date: match[1] ?? "",
157
+ text,
158
+ ...(marker ? { marker } : {}),
159
+ body,
160
+ });
161
+ }
162
+ return facts;
163
+ }
164
+
165
+ /** Renders a whole Memory file from its facts, oldest first. */
166
+ export function renderMemoryFileV1(facts: MemoryFactV1[]): string {
167
+ if (facts.length === 0) return "";
168
+ return `${facts.map(renderMemoryFactLineV1).join("\n")}\n`;
169
+ }
170
+
171
+ /** The comparison key for "the same fact": trimmed, case-folded text. */
172
+ export function memoryFactKeyV1(text: string): string {
173
+ return text.trim().replace(/\s+/g, " ").toLowerCase();
174
+ }
175
+
176
+ /** True when a fact text is a retraction rather than an assertion. */
177
+ export function isMemoryRetractionV1(text: string): boolean {
178
+ return text.startsWith(MEMORY_FORGOTTEN_PREFIX);
179
+ }
180
+
181
+ /** The fact a retraction retracts. */
182
+ export function retractedFactTextV1(text: string): string {
183
+ return text.slice(MEMORY_FORGOTTEN_PREFIX.length).trim();
184
+ }
185
+
186
+ /** The retraction line for one fact. */
187
+ export function memoryRetractionTextV1(text: string): string {
188
+ return `${MEMORY_FORGOTTEN_PREFIX}${text.trim()}`;
189
+ }
190
+
191
+ /**
192
+ * Newest-wins resolution within one tier.
193
+ *
194
+ * "readers merge shards, newest fact wins on conflict". Newest is the day
195
+ * first and the minted generation id second, because two Bots writing the same
196
+ * fact on the same day still have an order — the generation ledger's — and
197
+ * ordering by text or by shard would make the answer depend on the listing.
198
+ *
199
+ * A retraction is a fact like any other in that ordering: when the newest
200
+ * entry for a key is `[forgotten] …`, the fact is gone; when a later
201
+ * assertion follows it, the fact is back.
202
+ */
203
+ export function resolveMemoryFactsV1(
204
+ facts: SourcedMemoryFactV1[],
205
+ ): SourcedMemoryFactV1[] {
206
+ const newest = new Map<string, SourcedMemoryFactV1>();
207
+ for (const fact of facts) {
208
+ const asserted = isMemoryRetractionV1(fact.text)
209
+ ? retractedFactTextV1(fact.text)
210
+ : fact.text;
211
+ const key = memoryFactKeyV1(asserted);
212
+ const current = newest.get(key);
213
+ if (!current || isNewerMemoryFactV1(fact, current)) newest.set(key, fact);
214
+ }
215
+ return [...newest.values()].filter(
216
+ (fact) => !isMemoryRetractionV1(fact.text),
217
+ );
218
+ }
219
+
220
+ /** Strictly newer: by day, then by the minted generation id. */
221
+ export function isNewerMemoryFactV1(
222
+ candidate: SourcedMemoryFactV1,
223
+ current: SourcedMemoryFactV1,
224
+ ): boolean {
225
+ if (candidate.date !== current.date) return candidate.date > current.date;
226
+ return candidate.generationId > current.generationId;
227
+ }
228
+
229
+ /** Newest first: the order every rendered block lists facts in. */
230
+ export function sortMemoryFactsV1(
231
+ facts: SourcedMemoryFactV1[],
232
+ ): SourcedMemoryFactV1[] {
233
+ return [...facts].sort((left, right) =>
234
+ isNewerMemoryFactV1(left, right) ? -1 : 1,
235
+ );
236
+ }
237
+
238
+ /**
239
+ * The injected line: `- (learned YYYY-MM-DD) [via <bot>] [note] <body>`.
240
+ *
241
+ * The marker sits *after* `[via …]` because §4.1b puts `[via …]` before "the
242
+ * fact text" and §2.2 puts the marker "on the fact text" — the order is
243
+ * derived from the two citations rather than chosen. It is re-emitted through
244
+ * `renderMemoryMarkerV1` rather than left riding along inside `text`, so the
245
+ * one vocabulary decides where a marker appears; the bytes are identical
246
+ * either way, and the clamp still applies to the whole fact text, marker
247
+ * included.
248
+ */
249
+ export function renderInjectedFactLineV1(
250
+ fact: { date: string; text: string; via?: string },
251
+ clamp: number,
252
+ ): string {
253
+ const via = fact.via ? `[via ${fact.via}] ` : "";
254
+ const { marker, body } = parseMemoryMarkerV1(fact.text);
255
+ const full = renderMemoryMarkerV1(marker, body);
256
+ const text = full.length > clamp ? `${full.slice(0, clamp - 1)}…` : full;
257
+ return `- (learned ${fact.date}) ${via}${text}`;
258
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./agent.js";
2
+ export * from "./chunker.js";
3
+ export * from "./documents.js";
4
+ export * from "./embeddings.js";
5
+ export * from "./facts.js";
6
+ export * from "./indexer.js";
7
+ export { default as memoryManifest } from "./manifest.js";
8
+ export * from "./projects.js";
9
+ export * from "./render.js";
10
+ export * from "./roots.js";
11
+ export * from "./searcher.js";
12
+ export * from "./secrets.js";
13
+ export * from "./store.js";
14
+ export * from "./testing.js";
15
+ export * from "./types.js";