@kisev/memomatic 1.0.0-dev.46.gfbe1e4c6992e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # memomatic
2
+
3
+ [Русская версия](README.ru.md)
4
+
5
+ `@kisev/memomatic` is a personal learning memory for agents following the OpenClaw
6
+ memory architecture: a tiered Markdown corpus you can read as plain files, a
7
+ rebuildable SQLite index with FTS5 and optional local embeddings, and a nightly
8
+ `dream` consolidation sweep.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @kisev/memomatic
14
+ ```
15
+
16
+ The corpus lives under `$XDG_STATE_HOME/memomatic/` (`MEMORY.md`, `USER.md`,
17
+ daily notes, `DREAMS.md`); rules live in `$XDG_CONFIG_HOME/memomatic/MEMORY_RULES.md`.
18
+ The `@kisev/agentomatic` installer deploys the OpenCode plugin automatically;
19
+ standalone use runs the CLI:
20
+
21
+ ```bash
22
+ memomatic dream --dry-run
23
+ ```
24
+
25
+ The nightly sweep is scheduled by the systemd user units in `assets/systemd/`
26
+ (`memomatic-dream.service` and `memomatic-dream.timer`).
27
+
28
+ ## Surfaces
29
+
30
+ - `memomatic` CLI: corpus inspection and the `dream` sweep.
31
+ - MCP stdio server with `memory_search`, `memory_get`, `memory_write`, and
32
+ `memory_forget` tools.
33
+ - The OpenCode plugin re-exported by `@kisev/agentomatic`.
34
+
35
+ Nothing is deleted without the explicit directives documented in
36
+ `MEMORY_RULES.md`.
package/README.ru.md ADDED
@@ -0,0 +1,35 @@
1
+ # memomatic
2
+
3
+ [English version](README.md)
4
+
5
+ `@kisev/memomatic` — персональная обучающая память для агентов по архитектуре
6
+ памяти OpenClaw: ярусный Markdown-корпус, который можно читать как обычные файлы,
7
+ пересобираемый SQLite-индекс с FTS5 и опциональными локальными эмбеддингами и
8
+ ночной проход консолидации `dream`.
9
+
10
+ ## Установка
11
+
12
+ ```bash
13
+ npm install @kisev/memomatic
14
+ ```
15
+
16
+ Корпус живёт в `$XDG_STATE_HOME/memomatic/` (`MEMORY.md`, `USER.md`, ежедневные
17
+ записи, `DREAMS.md`); правила — в `$XDG_CONFIG_HOME/memomatic/MEMORY_RULES.md`.
18
+ Установщик `@kisev/agentomatic` разворачивает плагин OpenCode автоматически;
19
+ автономное использование — через CLI:
20
+
21
+ ```bash
22
+ memomatic dream --dry-run
23
+ ```
24
+
25
+ Ночной проход планируют user-юниты systemd из `assets/systemd/`
26
+ (`memomatic-dream.service` и `memomatic-dream.timer`).
27
+
28
+ ## Поверхности
29
+
30
+ - CLI `memomatic`: инспекция корпуса и проход `dream`.
31
+ - MCP-сервер stdio с инструментами `memory_search`, `memory_get`,
32
+ `memory_write` и `memory_forget`.
33
+ - Плагин OpenCode, реэкспортируемый пакетом `@kisev/agentomatic`.
34
+
35
+ Без явных директив, описанных в `MEMORY_RULES.md`, ничего не удаляется.
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=Memomatic dream: background memory consolidation sweep
3
+ Documentation=https://github.com/kisev/skills#memomatic
4
+ After=network-online.target
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ ExecStart=/bin/sh -lc 'exec memomatic dream'
9
+ Environment=MEMOMATIC_DREAM=1
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Run the Memomatic dream sweep nightly
3
+
4
+ [Timer]
5
+ OnCalendar=*-*-* 03:30:00
6
+ RandomizedDelaySec=30m
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { openMemomatic, rebuildIndex, searchMemory } from "./service.js";
3
+ import { runDream } from "./dream.js";
4
+ import { OpenCodeExecutor } from "./executor.js";
5
+ import { handleMcpRequest } from "./mcp.js";
6
+ const USAGE = `usage: memomatic <command> [args]
7
+
8
+ commands:
9
+ dream [--dry-run] run the consolidation sweep (scheduled by memomatic-dream.timer)
10
+ search <query> search memory from the command line
11
+ status report corpus and index status
12
+ index rebuild the search index
13
+ mcp-serve run the MCP stdio server`;
14
+ function parseArguments(argv) {
15
+ const [command, ...rest] = argv;
16
+ if (!command || command === "help" || command === "--help") {
17
+ process.stderr.write(USAGE);
18
+ process.exitCode = command ? 0 : 2;
19
+ return { command: "", rest };
20
+ }
21
+ return { command, rest };
22
+ }
23
+ async function main() {
24
+ const { command, rest } = parseArguments(process.argv.slice(2));
25
+ if (!command)
26
+ return;
27
+ if (command === "mcp-serve") {
28
+ const { runMcpServer } = await import("./mcp.js");
29
+ await runMcpServer();
30
+ return;
31
+ }
32
+ if (command === "mcp-selftest") {
33
+ process.stdout.write(await handleMcpRequest({ id: 1, method: "tools/list" }));
34
+ return;
35
+ }
36
+ const context = await openMemomatic();
37
+ try {
38
+ if (command === "dream") {
39
+ const dryRun = rest.includes("--dry-run");
40
+ const executor = context.settings.dream.model
41
+ ? new OpenCodeExecutor({
42
+ model: context.settings.dream.model,
43
+ variant: context.settings.dream.variant,
44
+ })
45
+ : null;
46
+ const report = await runDream(context, executor, { dryRun });
47
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
48
+ return;
49
+ }
50
+ if (command === "search") {
51
+ const query = rest.join(" ").trim();
52
+ if (!query)
53
+ throw new Error("search requires a query");
54
+ for (const hit of await searchMemory(context, query))
55
+ process.stdout.write(`${hit.score.toFixed(3)} ${hit.entry.file.replace(`${context.paths.stateRoot}/`, "")}:${hit.entry.line} ${hit.snippet}\n`);
56
+ return;
57
+ }
58
+ if (command === "status") {
59
+ const count = await rebuildIndex(context);
60
+ const fts = context.store.hasFts();
61
+ process.stdout.write(`${JSON.stringify({ entries: count, fts5: fts, stateRoot: context.paths.stateRoot }, null, 2)}\n`);
62
+ return;
63
+ }
64
+ if (command === "index") {
65
+ process.stdout.write(`${await rebuildIndex(context)} entries indexed\n`);
66
+ return;
67
+ }
68
+ process.stderr.write(`unknown command: ${command}\n${USAGE}`);
69
+ process.exitCode = 2;
70
+ }
71
+ finally {
72
+ context.store.close();
73
+ }
74
+ }
75
+ await main();
@@ -0,0 +1,12 @@
1
+ import { type CorpusEntry } from "./entries.js";
2
+ import type { MemomaticPaths } from "./paths.js";
3
+ export declare function readTextIfExists(path: string): Promise<string | undefined>;
4
+ export declare function writeCorpusFile(paths: MemomaticPaths, file: string, next: string): Promise<void>;
5
+ export declare function dailyNotePath(paths: MemomaticPaths, date?: Date): string;
6
+ export declare function entryKind(file: string): "curated" | "user" | "episodic";
7
+ export declare function parseCorpusEntries(markdown: string, file: string): CorpusEntry[];
8
+ export declare function appendDailyEntry(paths: MemomaticPaths, line: string, date?: Date): Promise<string>;
9
+ export declare function replaceEntryLine(paths: MemomaticPaths, file: string, line: number, replacement: string | null): Promise<void>;
10
+ export declare function appendDreams(paths: MemomaticPaths, report: string): Promise<void>;
11
+ export declare function archiveFile(paths: MemomaticPaths, file: string): Promise<string>;
12
+ export declare function isCuratedFile(file: string): boolean;
package/dist/corpus.js ADDED
@@ -0,0 +1,138 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, rm } from "node:fs/promises";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { writeAtomic } from "@kisev/safe-fs";
5
+ import { parseEntryLine } from "./entries.js";
6
+ const CURATED_FILES = ["MEMORY.md", "USER.md"];
7
+ function inside(root, target) {
8
+ const value = relative(root, target);
9
+ return value === "" || (!value.startsWith("..") && value !== "..");
10
+ }
11
+ async function safeDirectory(path, boundary, create) {
12
+ if (!inside(boundary, path))
13
+ throw new Error("memomatic path escapes its root");
14
+ if (create)
15
+ await mkdir(path, { mode: 0o700, recursive: true });
16
+ let current = boundary;
17
+ for (const piece of relative(current, path).split("/").filter(Boolean)) {
18
+ current = join(current, piece);
19
+ const info = await lstat(current);
20
+ if (!info.isDirectory() || info.isSymbolicLink())
21
+ throw new Error(`memomatic directory is unsafe: ${current}`);
22
+ }
23
+ }
24
+ async function writePreImage(paths, source, previous) {
25
+ await safeDirectory(paths.historyDir, paths.stateRoot, true);
26
+ const name = `${createHash("sha256").update(previous).digest("hex")}.md`;
27
+ const target = join(paths.historyDir, name);
28
+ try {
29
+ const file = await open(target, "wx", 0o600);
30
+ try {
31
+ await file.writeFile(previous);
32
+ await file.sync();
33
+ }
34
+ finally {
35
+ await file.close();
36
+ }
37
+ }
38
+ catch (error) {
39
+ if (error.code !== "EEXIST")
40
+ throw error;
41
+ const info = await lstat(target);
42
+ if (!info.isFile() || info.isSymbolicLink() || !(await readFile(target)).equals(previous))
43
+ throw new Error("memomatic history was changed");
44
+ }
45
+ }
46
+ export async function readTextIfExists(path) {
47
+ try {
48
+ return await readFile(path, "utf8");
49
+ }
50
+ catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return undefined;
53
+ throw error;
54
+ }
55
+ }
56
+ export async function writeCorpusFile(paths, file, next) {
57
+ if (!inside(paths.stateRoot, file))
58
+ throw new Error("memomatic path escapes its root");
59
+ const previous = await readFile(file).catch(() => undefined);
60
+ if (previous !== undefined && previous.toString("utf8") === next)
61
+ return;
62
+ await safeDirectory(dirname(file), paths.stateRoot, true);
63
+ if (previous !== undefined)
64
+ await writePreImage(paths, file, previous);
65
+ await writeAtomic(file, Buffer.from(next), 0o600);
66
+ }
67
+ export function dailyNotePath(paths, date = new Date()) {
68
+ const iso = date.toISOString().slice(0, 10);
69
+ return join(paths.dailyDir, `${iso}.md`);
70
+ }
71
+ export function entryKind(file) {
72
+ const name = file.slice(file.lastIndexOf("/") + 1);
73
+ if (name === "MEMORY.md")
74
+ return "curated";
75
+ if (name === "USER.md")
76
+ return "user";
77
+ return "episodic";
78
+ }
79
+ export function parseCorpusEntries(markdown, file) {
80
+ const entries = [];
81
+ const lines = markdown.split("\n");
82
+ for (let index = 0; index < lines.length; index += 1) {
83
+ const parsed = parseEntryLine(lines[index]);
84
+ if (!parsed)
85
+ continue;
86
+ entries.push({ file, line: index + 1, text: parsed.text, annotations: parsed.annotations });
87
+ }
88
+ return entries;
89
+ }
90
+ export async function appendDailyEntry(paths, line, date = new Date()) {
91
+ const file = dailyNotePath(paths, date);
92
+ const current = (await readTextIfExists(file)) ?? "";
93
+ const next = current.length ? `${current.trimEnd()}\n${line}\n` : `${line}\n`;
94
+ await writeCorpusFile(paths, file, next);
95
+ return file;
96
+ }
97
+ export async function replaceEntryLine(paths, file, line, replacement) {
98
+ const content = await readFile(file, "utf8");
99
+ const lines = content.split("\n");
100
+ if (line < 1 || line > lines.length)
101
+ throw new Error("entry line is out of range");
102
+ if (replacement === null) {
103
+ lines.splice(line - 1, 1);
104
+ const rest = lines.join("\n");
105
+ await writeCorpusFile(paths, file, rest.length ? `${rest}` : "");
106
+ return;
107
+ }
108
+ lines[line - 1] = replacement;
109
+ await writeCorpusFile(paths, file, lines.join("\n"));
110
+ }
111
+ export async function appendDreams(paths, report) {
112
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
113
+ const current = (await readTextIfExists(paths.dreamsFile)) ?? "";
114
+ const block = `## ${stamp}\n\n${report.trim()}\n\n`;
115
+ const next = `${current}${current.length ? "\n" : ""}${block}`;
116
+ await writeCorpusFile(paths, paths.dreamsFile, next);
117
+ }
118
+ export async function archiveFile(paths, file) {
119
+ if (!inside(paths.stateRoot, file))
120
+ throw new Error("memomatic path escapes its root");
121
+ const name = file.slice(file.lastIndexOf("/") + 1);
122
+ const target = join(paths.archiveDir, `${name}`);
123
+ await safeDirectory(paths.archiveDir, paths.stateRoot, true);
124
+ const content = await readFile(file);
125
+ const existing = await readFile(target).catch(() => undefined);
126
+ if (existing !== undefined) {
127
+ const merged = `${existing.toString("utf8").trimEnd()}\n${content.toString("utf8").trimEnd()}\n`;
128
+ await writeAtomic(target, Buffer.from(merged), 0o600);
129
+ }
130
+ else {
131
+ await writeAtomic(target, content, 0o600);
132
+ }
133
+ await rm(file);
134
+ return target;
135
+ }
136
+ export function isCuratedFile(file) {
137
+ return CURATED_FILES.some((name) => file.endsWith(name));
138
+ }
@@ -0,0 +1,37 @@
1
+ import { type ModelExecutor } from "./executor.js";
2
+ import type { MemomaticContext } from "./service.js";
3
+ export type DreamReport = {
4
+ sessionsIngested: number;
5
+ candidatesExtracted: number;
6
+ promoted: string[];
7
+ superseded: string[];
8
+ dropped: string[];
9
+ rejected: Array<{
10
+ text: string;
11
+ reason: string;
12
+ }>;
13
+ archived: string[];
14
+ appendOnlyFallback: boolean;
15
+ dryRun: boolean;
16
+ };
17
+ export type ExtractedCandidate = {
18
+ text: string;
19
+ key: string | null;
20
+ reason: string;
21
+ };
22
+ export declare function parseExtraction(response: string): ExtractedCandidate[];
23
+ export type ConsolidationOperation = {
24
+ op: "add";
25
+ line: string;
26
+ } | {
27
+ op: "supersede";
28
+ key: string;
29
+ line: string;
30
+ } | {
31
+ op: "drop";
32
+ key: string;
33
+ };
34
+ export declare function parseConsolidation(response: string): ConsolidationOperation[];
35
+ export declare function runDream(context: MemomaticContext, executor: ModelExecutor | null, options?: {
36
+ dryRun?: boolean;
37
+ }): Promise<DreamReport>;
package/dist/dream.js ADDED
@@ -0,0 +1,249 @@
1
+ import { entryLine, parseEntryLine } from "./entries.js";
2
+ import { appendDailyEntry, appendDreams, readTextIfExists, writeCorpusFile } from "./corpus.js";
3
+ import { promotionCandidates } from "./gates.js";
4
+ import { extractJson } from "./executor.js";
5
+ import { loadRecentSessions, opencodeDatabasePath } from "./ingest.js";
6
+ import { isForbidden } from "./rules.js";
7
+ import { reindex } from "./search.js";
8
+ import { archiveOldEpisodic } from "./service.js";
9
+ const WATERMARK_KEY = "ingest-watermark";
10
+ const EXTRACT_SYSTEM = `You distill durable engineering memory from a coding session.
11
+ Reply with exactly one JSON object of the form
12
+ {"candidates":[{"text":string,"key":string|null,"reason":string}]}.
13
+ Include only standing decisions with rationale, discoveries, failed attempts with
14
+ the reason they were rejected, session outcomes, and action-sensitive boundaries
15
+ (approval requirements, temporary constraints, handoffs, expiry conditions).
16
+ Each "text" is imperative, self-contained, at most two sentences, without secrets,
17
+ credentials, or machine-local paths. "key" is a stable kebab-case identifier when
18
+ the item clearly has a durable identity, otherwise null. Return {"candidates":[]}
19
+ when nothing qualifies.`;
20
+ const CONSOLIDATE_SYSTEM = `You consolidate a long-term memory file.
21
+ Reply with exactly one JSON object of the form
22
+ {"operations":[{"op":"add","line":string},{"op":"supersede","key":string,"line":string},{"op":"drop","key":string}]}.
23
+ Merge duplicates, supersede outdated entries sharing a key instead of appending,
24
+ keep every line compact (at most two sentences), keep or assign "key" annotations
25
+ in the form <!-- key: ... -->, and add <!-- trigger: ... --> phrases only when a
26
+ clear activation context exists. Never invent keys for "supersede" or "drop" that
27
+ are not present in the current file.`;
28
+ export function parseExtraction(response) {
29
+ const parsed = extractJson(response);
30
+ if (!Array.isArray(parsed.candidates))
31
+ throw new Error("extraction response has no candidates");
32
+ const result = [];
33
+ for (const raw of parsed.candidates) {
34
+ const value = raw;
35
+ if (typeof value.text !== "string" || !value.text.trim())
36
+ continue;
37
+ result.push({
38
+ text: value.text.trim(),
39
+ key: typeof value.key === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.key)
40
+ ? value.key
41
+ : null,
42
+ reason: typeof value.reason === "string" ? value.reason : "",
43
+ });
44
+ }
45
+ return result.slice(0, 24);
46
+ }
47
+ export function parseConsolidation(response) {
48
+ const parsed = extractJson(response);
49
+ if (!Array.isArray(parsed.operations))
50
+ throw new Error("consolidation response has no operations");
51
+ const result = [];
52
+ for (const raw of parsed.operations) {
53
+ const value = raw;
54
+ if (value.op === "add" && typeof value.line === "string") {
55
+ const line = value.line.trim();
56
+ if (parseEntryLine(line))
57
+ result.push({ op: "add", line });
58
+ }
59
+ else if (value.op === "supersede" &&
60
+ typeof value.key === "string" &&
61
+ typeof value.line === "string") {
62
+ const line = value.line.trim();
63
+ if (parseEntryLine(line))
64
+ result.push({ op: "supersede", key: value.key, line });
65
+ }
66
+ else if (value.op === "drop" && typeof value.key === "string") {
67
+ result.push({ op: "drop", key: value.key });
68
+ }
69
+ }
70
+ return result.slice(0, 32);
71
+ }
72
+ async function ingestSessions(context, executor) {
73
+ const watermark = Number.parseInt(context.store.getMeta(WATERMARK_KEY) ?? "0", 10) || 0;
74
+ const sessions = loadRecentSessions(opencodeDatabasePath(), watermark, {
75
+ before: Date.now() - 10 * 60_000,
76
+ maxSessions: 20,
77
+ maxCharsPerSession: 24_000,
78
+ });
79
+ const extracted = [];
80
+ const rejected = [];
81
+ let latest = watermark;
82
+ for (const session of sessions) {
83
+ latest = Math.max(latest, session.timeCreated);
84
+ if (!executor)
85
+ continue;
86
+ const transcript = session.messages
87
+ .map((message) => `${message.role}: ${message.text}`)
88
+ .join("\n\n")
89
+ .slice(0, 24_000);
90
+ const response = await executor.complete({
91
+ system: EXTRACT_SYSTEM,
92
+ prompt: `Session title: ${session.title}\nWorking directory: ${session.directory}\n\n${transcript}`,
93
+ });
94
+ for (const candidate of parseExtraction(response)) {
95
+ if (isForbidden(candidate.text, context.rules)) {
96
+ rejected.push({ text: candidate.text, reason: "never-save rule" });
97
+ continue;
98
+ }
99
+ extracted.push(candidate);
100
+ }
101
+ }
102
+ return {
103
+ sessions: sessions.length,
104
+ extracted,
105
+ watermark: sessions.length ? latest : null,
106
+ rejected,
107
+ };
108
+ }
109
+ export async function runDream(context, executor, options = {}) {
110
+ const dryRun = options.dryRun === true;
111
+ const report = {
112
+ sessionsIngested: 0,
113
+ candidatesExtracted: 0,
114
+ promoted: [],
115
+ superseded: [],
116
+ dropped: [],
117
+ rejected: [],
118
+ archived: [],
119
+ appendOnlyFallback: false,
120
+ dryRun,
121
+ };
122
+ await reindex(context.paths, context.settings, context.store);
123
+ const ingestion = await ingestSessions(context, executor);
124
+ report.sessionsIngested = ingestion.sessions;
125
+ report.candidatesExtracted = ingestion.extracted.length;
126
+ report.rejected = ingestion.rejected;
127
+ if (!dryRun) {
128
+ for (const candidate of ingestion.extracted) {
129
+ await appendDailyEntry(context.paths, entryLine(candidate.text, {
130
+ key: candidate.key ?? undefined,
131
+ origin: "agent",
132
+ observed: new Date().toISOString().slice(0, 10),
133
+ }));
134
+ }
135
+ if (ingestion.watermark !== null)
136
+ context.store.setMeta(WATERMARK_KEY, String(ingestion.watermark));
137
+ }
138
+ await reindex(context.paths, context.settings, context.store);
139
+ const relevance = new Map();
140
+ for (const entry of context.store.allEntries()) {
141
+ if (entry.kind !== "episodic")
142
+ continue;
143
+ const usage = context.store.usageFor(entry.stableId);
144
+ if (usage.useful < context.settings.dream.minUseful)
145
+ continue;
146
+ const match = context.store.ftsSearch(entry.text, 10).get(entry.stableId);
147
+ if (match)
148
+ relevance.set(entry.stableId, match);
149
+ }
150
+ const finalCandidates = promotionCandidates(context.store, context.settings, relevance);
151
+ if (executor && finalCandidates.length) {
152
+ const current = (await readTextIfExists(context.paths.memoryFile)) ?? "";
153
+ const response = await executor.complete({
154
+ system: CONSOLIDATE_SYSTEM,
155
+ prompt: `Current MEMORY.md:\n${current || "(empty)"}\n\nCandidates:\n${finalCandidates
156
+ .map((candidate) => `- ${candidate.entry.text} <!-- key: ${candidate.entry.key ?? candidate.entry.stableId} -->`)
157
+ .join("\n")}`,
158
+ });
159
+ const operations = parseConsolidation(response);
160
+ const outcome = await applyConsolidation(context, operations, current, dryRun);
161
+ report.promoted = outcome.promoted;
162
+ report.superseded = outcome.superseded;
163
+ report.dropped = outcome.dropped;
164
+ report.appendOnlyFallback = outcome.appendOnlyFallback;
165
+ }
166
+ if (!dryRun)
167
+ report.archived = await archiveOldEpisodic(context);
168
+ await reindex(context.paths, context.settings, context.store);
169
+ const summary = [
170
+ `- sessions ingested: ${report.sessionsIngested}`,
171
+ `- candidates extracted: ${report.candidatesExtracted}`,
172
+ `- promoted: ${report.promoted.length}${report.promoted.length ? ` (${report.promoted.join(", ")})` : ""}`,
173
+ `- superseded: ${report.superseded.length ? report.superseded.join(", ") : "none"}`,
174
+ `- dropped: ${report.dropped.length ? report.dropped.join(", ") : "none"}`,
175
+ `- rejected by rules: ${report.rejected.length}`,
176
+ `- archived files: ${report.archived.length ? report.archived.join(", ") : "none"}`,
177
+ `- append-only fallback: ${report.appendOnlyFallback}`,
178
+ `- dry run: ${report.dryRun}`,
179
+ ].join("\n");
180
+ if (!dryRun)
181
+ await appendDreams(context.paths, summary);
182
+ return report;
183
+ }
184
+ async function applyConsolidation(context, operations, currentMemory, dryRun) {
185
+ const result = {
186
+ promoted: [],
187
+ superseded: [],
188
+ dropped: [],
189
+ appendOnlyFallback: false,
190
+ };
191
+ if (!operations.length)
192
+ return result;
193
+ const lines = currentMemory.split("\n").filter((line) => line.trim().length > 0);
194
+ const existingKeys = new Set(lines
195
+ .map((line) => parseEntryLine(line)?.annotations.key)
196
+ .filter((key) => Boolean(key)));
197
+ const removals = operations.filter((operation) => operation.op !== "add");
198
+ const lossRatio = lines.length ? removals.length / lines.length : 0;
199
+ const adds = operations.filter((operation) => operation.op === "add");
200
+ const supersessions = operations.filter((operation) => operation.op === "supersede" && existingKeys.has(operation.key));
201
+ const drops = operations.filter((operation) => operation.op === "drop" && existingKeys.has(operation.key));
202
+ if (removals.length !== supersessions.length + drops.length)
203
+ result.appendOnlyFallback = true;
204
+ if (!result.appendOnlyFallback && lossRatio > context.settings.dream.maxPriorEntryLoss)
205
+ result.appendOnlyFallback = true;
206
+ if (result.appendOnlyFallback) {
207
+ const budget = context.settings.dream.memoryBudgetLines;
208
+ if (lines.length + adds.length > budget)
209
+ return result;
210
+ const next = `${lines.join("\n")}\n${adds.map((operation) => operation.line).join("\n")}\n`;
211
+ if (!dryRun)
212
+ await writeCorpusFile(context.paths, context.paths.memoryFile, next);
213
+ result.promoted = adds.map((operation) => operation.line);
214
+ return result;
215
+ }
216
+ const nextLines = [...lines];
217
+ for (const operation of supersessions) {
218
+ const index = nextLines.findIndex((line) => parseEntryLine(line)?.annotations.key === operation.key);
219
+ if (index === -1)
220
+ continue;
221
+ nextLines[index] = operation.line;
222
+ result.superseded.push(operation.key);
223
+ }
224
+ for (const operation of drops) {
225
+ const index = nextLines.findIndex((line) => parseEntryLine(line)?.annotations.key === operation.key);
226
+ if (index === -1)
227
+ continue;
228
+ nextLines.splice(index, 1);
229
+ result.dropped.push(operation.key);
230
+ }
231
+ for (const operation of adds) {
232
+ const key = parseEntryLine(operation.line)?.annotations.key;
233
+ if (key && nextLines.some((line) => parseEntryLine(line)?.annotations.key === key))
234
+ continue;
235
+ nextLines.push(operation.line);
236
+ result.promoted.push(operation.line);
237
+ }
238
+ if (nextLines.length > context.settings.dream.memoryBudgetLines) {
239
+ result.appendOnlyFallback = true;
240
+ result.promoted = [];
241
+ result.superseded = [];
242
+ result.dropped = [];
243
+ return result;
244
+ }
245
+ const next = `${nextLines.join("\n")}\n`;
246
+ if (!dryRun)
247
+ await writeCorpusFile(context.paths, context.paths.memoryFile, next);
248
+ return result;
249
+ }
@@ -0,0 +1,25 @@
1
+ export type EntryAnnotations = {
2
+ key?: string;
3
+ status?: "active" | "superseded";
4
+ origin?: "user" | "agent";
5
+ observed?: string;
6
+ project?: string;
7
+ importance?: number;
8
+ trigger?: string[];
9
+ pinned?: boolean;
10
+ };
11
+ export type CorpusEntry = {
12
+ file: string;
13
+ line: number;
14
+ text: string;
15
+ annotations: EntryAnnotations;
16
+ };
17
+ export declare function parseEntryLine(line: string): {
18
+ text: string;
19
+ annotations: EntryAnnotations;
20
+ } | null;
21
+ export declare function serializeAnnotations(annotations: EntryAnnotations): string;
22
+ export declare function entryLine(text: string, annotations: EntryAnnotations): string;
23
+ export declare function entryKey(entry: CorpusEntry): string | null;
24
+ export declare function entryImportance(entry: CorpusEntry): number;
25
+ export declare function entryObservedAt(entry: CorpusEntry): number;