@kisev/memomatic 1.0.0-dev.59.g0506803c61e5 → 1.0.0-dev.61.gdf462654550b

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 CHANGED
@@ -10,13 +10,13 @@ rebuildable SQLite index with FTS5 and optional local embeddings, and a nightly
10
10
  ## Install
11
11
 
12
12
  ```bash
13
- npm install @kisev/memomatic
13
+ npm install --global @kisev/memomatic
14
14
  ```
15
15
 
16
16
  The corpus lives under `$XDG_STATE_HOME/memomatic/` (`MEMORY.md`, `USER.md`,
17
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:
18
+ Requires Node.js 22+. Explicitly select `memomatic` in the `@kisev/agentomatic`
19
+ installer and restart OpenCode. For standalone use, run the CLI:
20
20
 
21
21
  ```bash
22
22
  memomatic process # validate the inbox and index accepted entries (no model)
@@ -26,6 +26,10 @@ memomatic dream # full sweep: inbox + sessions + consolidation
26
26
  The nightly sweep is scheduled by the systemd user units in `assets/systemd/`
27
27
  (`memomatic-dream.service` and `memomatic-dream.timer`).
28
28
 
29
+ Configure `dream.model` and an OpenCode provider before expecting session
30
+ extraction; without a model, the session watermark is preserved. Follow the
31
+ [setup, first-entry, and scheduling guide](../../docs/how-to/memomatic.md).
32
+
29
33
  ## Inbox
30
34
 
31
35
  All writes are asynchronous. Skills, agents, and the `memory_write` tool
package/README.ru.md CHANGED
@@ -10,13 +10,13 @@
10
10
  ## Установка
11
11
 
12
12
  ```bash
13
- npm install @kisev/memomatic
13
+ npm install --global @kisev/memomatic
14
14
  ```
15
15
 
16
16
  Корпус живёт в `$XDG_STATE_HOME/memomatic/` (`MEMORY.md`, `USER.md`, ежедневные
17
17
  записи, `DREAMS.md`); правила — в `$XDG_CONFIG_HOME/memomatic/MEMORY_RULES.md`.
18
- Установщик `@kisev/agentomatic` разворачивает плагин OpenCode автоматически;
19
- автономное использование — через CLI:
18
+ Нужен Node.js 22+. Явно выберите `memomatic` в установщике `@kisev/agentomatic`
19
+ и перезапустите OpenCode. Автономное использование доступно через CLI:
20
20
 
21
21
  ```bash
22
22
  memomatic process # валидация inbox и индексация принятого (без модели)
@@ -26,6 +26,10 @@ memomatic dream # полный проход: inbox + сессии + кон
26
26
  Ночной проход планируют user-юниты systemd из `assets/systemd/`
27
27
  (`memomatic-dream.service` и `memomatic-dream.timer`).
28
28
 
29
+ Перед извлечением сессий настройте `dream.model` и провайдера OpenCode;
30
+ без модели отметка обработанных сессий сохраняется. См.
31
+ [настройку, первую запись и расписание](../../docs/ru/how-to/memomatic.md).
32
+
29
33
  ## Inbox
30
34
 
31
35
  Все записи асинхронны. Скиллы, агенты и инструмент `memory_write` кладут
package/dist/cli.js CHANGED
@@ -41,11 +41,11 @@ async function main() {
41
41
  process.stdout.write(`${version}\n`);
42
42
  return;
43
43
  }
44
- const context = await openMemomatic();
44
+ const dryRun = (command === "dream" || command === "process") && rest.includes("--dry-run");
45
+ const context = await openMemomatic({ readOnly: dryRun });
45
46
  try {
46
47
  if (command === "dream" || command === "process") {
47
- const dryRun = rest.includes("--dry-run");
48
- const result = await withRunLock(context.paths, async () => {
48
+ const execute = async () => {
49
49
  if (command === "process")
50
50
  return processInbox(context, { dryRun });
51
51
  const executor = context.settings.dream.model
@@ -55,7 +55,8 @@ async function main() {
55
55
  })
56
56
  : null;
57
57
  return runDream(context, executor, { dryRun });
58
- });
58
+ };
59
+ const result = dryRun ? await execute() : await withRunLock(context.paths, execute);
59
60
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
60
61
  return;
61
62
  }
package/dist/dream.js CHANGED
@@ -71,6 +71,8 @@ export function parseConsolidation(response) {
71
71
  return result.slice(0, 32);
72
72
  }
73
73
  async function ingestSessions(context, executor) {
74
+ if (!executor)
75
+ return { sessions: 0, extracted: [], watermark: null, rejected: [] };
74
76
  const watermark = Number.parseInt(context.store.getMeta(WATERMARK_KEY) ?? "0", 10) || 0;
75
77
  const sessions = loadRecentSessions(opencodeDatabasePath(), watermark, {
76
78
  before: Date.now() - 10 * 60_000,
@@ -82,8 +84,6 @@ async function ingestSessions(context, executor) {
82
84
  let latest = watermark;
83
85
  for (const session of sessions) {
84
86
  latest = Math.max(latest, session.timeCreated);
85
- if (!executor)
86
- continue;
87
87
  const transcript = session.messages
88
88
  .map((message) => `${message.role}: ${message.text}`)
89
89
  .join("\n\n")
@@ -108,6 +108,17 @@ async function ingestSessions(context, executor) {
108
108
  };
109
109
  }
110
110
  export async function runDream(context, executor, options = {}) {
111
+ if (!options.dryRun)
112
+ return executeDream(context, executor, options);
113
+ const store = context.store.fork();
114
+ try {
115
+ return await executeDream({ ...context, store }, executor, options);
116
+ }
117
+ finally {
118
+ store.close();
119
+ }
120
+ }
121
+ async function executeDream(context, executor, options) {
111
122
  const dryRun = options.dryRun === true;
112
123
  const report = {
113
124
  inbox: {
@@ -168,12 +179,27 @@ export async function runDream(context, executor, options = {}) {
168
179
  .map((candidate) => `- ${candidate.entry.text} <!-- key: ${candidate.entry.key ?? candidate.entry.stableId} -->`)
169
180
  .join("\n")}`,
170
181
  });
171
- const operations = parseConsolidation(response);
182
+ let operations;
183
+ let invalidResponse = false;
184
+ try {
185
+ operations = parseConsolidation(response);
186
+ }
187
+ catch {
188
+ invalidResponse = true;
189
+ operations = finalCandidates.map(({ entry }) => ({
190
+ op: "add",
191
+ line: entryLine(entry.text.replace(/^- /, ""), {
192
+ key: entry.key ?? undefined,
193
+ origin: entry.origin ?? "agent",
194
+ source: entry.source ?? undefined,
195
+ }),
196
+ }));
197
+ }
172
198
  const outcome = await applyConsolidation(context, operations, current, dryRun);
173
199
  report.promoted = outcome.promoted;
174
200
  report.superseded = outcome.superseded;
175
201
  report.dropped = outcome.dropped;
176
- report.appendOnlyFallback = outcome.appendOnlyFallback;
202
+ report.appendOnlyFallback = invalidResponse || outcome.appendOnlyFallback;
177
203
  }
178
204
  if (!dryRun)
179
205
  report.archived = await archiveOldEpisodic(context);
@@ -215,6 +241,9 @@ async function applyConsolidation(context, operations, currentMemory, dryRun) {
215
241
  const adds = operations.filter((operation) => operation.op === "add");
216
242
  const supersessions = operations.filter((operation) => operation.op === "supersede" && existingKeys.has(operation.key));
217
243
  const drops = operations.filter((operation) => operation.op === "drop" && existingKeys.has(operation.key));
244
+ // Consolidation never grants permission to delete curated memory.
245
+ if (drops.length)
246
+ result.appendOnlyFallback = true;
218
247
  if (removals.length !== supersessions.length + drops.length)
219
248
  result.appendOnlyFallback = true;
220
249
  if (!result.appendOnlyFallback && lossRatio > context.settings.dream.maxPriorEntryLoss)
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { writeCorpusFile } from "./corpus.js";
2
2
  export { entryLine, parseEntryLine } from "./entries.js";
3
3
  export { visibilityForSource } from "./visibility.js";
4
4
  export { dropToInbox, processInbox, withRunLock } from "./inbox.js";
5
- export { forgetEntry, getEntry, openMemomatic, searchMemory, writeEntry } from "./service.js";
5
+ export { forgetEntry, getEntry, openMemomatic, searchMemory, searchMemoryResults, writeEntry, } from "./service.js";
6
6
  export type { MemomaticContext } from "./service.js";
7
7
  export { bootstrapContext, resolveProject } from "./bootstrap.js";
8
8
  export { opencodeDatabasePath, sessionFacts } from "./ingest.js";
package/dist/index.js CHANGED
@@ -2,6 +2,6 @@ export { writeCorpusFile } from "./corpus.js";
2
2
  export { entryLine, parseEntryLine } from "./entries.js";
3
3
  export { visibilityForSource } from "./visibility.js";
4
4
  export { dropToInbox, processInbox, withRunLock } from "./inbox.js";
5
- export { forgetEntry, getEntry, openMemomatic, searchMemory, writeEntry } from "./service.js";
5
+ export { forgetEntry, getEntry, openMemomatic, searchMemory, searchMemoryResults, writeEntry, } from "./service.js";
6
6
  export { bootstrapContext, resolveProject } from "./bootstrap.js";
7
7
  export { opencodeDatabasePath, sessionFacts } from "./ingest.js";
package/dist/mcp.js CHANGED
@@ -1,5 +1,4 @@
1
- import { forgetEntry, getEntry, openMemomatic, searchMemory, writeEntry } from "./service.js";
2
- import { visibilityForSource } from "./visibility.js";
1
+ import { forgetEntry, getEntry, openMemomatic, searchMemoryResults, writeEntry, } from "./service.js";
3
2
  const PROTOCOL_VERSION = "2025-06-18";
4
3
  const TOOLS = [
5
4
  {
@@ -69,16 +68,7 @@ async function callTool(name, args) {
69
68
  case "memory_search": {
70
69
  if (typeof args.query !== "string" || !args.query.trim())
71
70
  throw new Error("query is required");
72
- const hits = await searchMemory(context, args.query);
73
- return hits.map((hit) => ({
74
- file: hit.entry.file.replace(`${context.paths.stateRoot}/`, ""),
75
- kind: hit.entry.kind,
76
- line: hit.entry.line,
77
- score: Number(hit.score.toFixed(4)),
78
- snippet: hit.snippet,
79
- source: hit.entry.source,
80
- visibility: visibilityForSource(hit.entry.source),
81
- }));
71
+ return await searchMemoryResults(context, args.query);
82
72
  }
83
73
  case "memory_get": {
84
74
  if (typeof args.file !== "string" || !args.file)
package/dist/service.d.ts CHANGED
@@ -20,9 +20,20 @@ export type MemomaticContext = {
20
20
  rules: MemoryRules;
21
21
  store: MemoryStore;
22
22
  };
23
- export declare function openMemomatic(): Promise<MemomaticContext>;
23
+ export declare function openMemomatic(options?: {
24
+ readOnly?: boolean;
25
+ }): Promise<MemomaticContext>;
24
26
  export declare function writeEntry(context: MemomaticContext, request: WriteRequest): Promise<string>;
25
27
  export declare function searchMemory(context: MemomaticContext, query: string): Promise<SearchHit[]>;
28
+ export declare function searchMemoryResults(context: MemomaticContext, query: string): Promise<{
29
+ file: string;
30
+ kind: import("./store.js").EntryKind;
31
+ line: number;
32
+ score: number;
33
+ snippet: string;
34
+ source: string | null;
35
+ visibility: import("./visibility.js").EntryVisibility;
36
+ }[]>;
26
37
  export declare function getEntry(context: MemomaticContext, file: string, line?: number): Promise<{
27
38
  file: string;
28
39
  line: number;
package/dist/service.js CHANGED
@@ -1,27 +1,29 @@
1
- import { entryLine } from "./entries.js";
2
- import { archiveFile, readTextIfExists, replaceEntryLine } from "./corpus.js";
1
+ import { entryLine, parseEntryLine } from "./entries.js";
2
+ import { readTextIfExists, replaceEntryLine, writeCorpusFile } from "./corpus.js";
3
+ import { assertSafePath } from "@kisev/safe-fs";
4
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { rm } from "node:fs/promises";
3
6
  import { dropToInbox } from "./inbox.js";
4
7
  import { isForbidden, loadRules } from "./rules.js";
5
8
  import { memomaticPaths } from "./paths.js";
6
9
  import { loadSettings } from "./settings.js";
7
10
  import { reindex, search } from "./search.js";
8
11
  import { MemoryStore } from "./store.js";
9
- import { stat } from "node:fs/promises";
10
- async function exists(path) {
11
- try {
12
- await stat(path);
13
- return true;
14
- }
15
- catch {
16
- return false;
17
- }
12
+ import { visibilityForSource } from "./visibility.js";
13
+ async function memoryPath(context, file) {
14
+ const target = resolve(context.paths.stateRoot, file);
15
+ const part = relative(context.paths.stateRoot, target);
16
+ if (!part || part === ".." || part.startsWith(`..${sep}`) || isAbsolute(part))
17
+ throw new Error("path escapes memomatic state root");
18
+ await assertSafePath(target, { target: "file" });
19
+ return target;
18
20
  }
19
- export async function openMemomatic() {
21
+ export async function openMemomatic(options = {}) {
20
22
  const paths = memomaticPaths();
21
23
  const [settings, rules, store] = await Promise.all([
22
24
  loadSettings(paths.settingsFile),
23
25
  loadRules(paths.rulesFile),
24
- MemoryStore.open(paths.indexFile),
26
+ options.readOnly ? MemoryStore.preview(paths.indexFile) : MemoryStore.open(paths.indexFile),
25
27
  ]);
26
28
  return { paths, settings, rules, store };
27
29
  }
@@ -49,19 +51,26 @@ export async function writeEntry(context, request) {
49
51
  export async function searchMemory(context, query) {
50
52
  return search(context.store, query, context.settings);
51
53
  }
54
+ export async function searchMemoryResults(context, query) {
55
+ return (await searchMemory(context, query)).map((hit) => ({
56
+ file: hit.entry.file.replace(`${context.paths.stateRoot}/`, ""),
57
+ kind: hit.entry.kind,
58
+ line: hit.entry.line,
59
+ score: Number(hit.score.toFixed(4)),
60
+ snippet: hit.snippet,
61
+ source: hit.entry.source,
62
+ visibility: visibilityForSource(hit.entry.source),
63
+ }));
64
+ }
52
65
  export async function getEntry(context, file, line) {
53
- const target = file.startsWith("/")
54
- ? file
55
- : `${context.paths.stateRoot}/${file.replace(/^\/+/, "")}`;
56
- if (!target.startsWith(context.paths.stateRoot))
57
- throw new Error("path escapes memomatic state root");
66
+ const target = await memoryPath(context, file);
58
67
  const content = await readTextIfExists(target);
59
68
  if (content === undefined)
60
69
  throw new Error(`memory file not found: ${file}`);
61
70
  if (line === undefined)
62
71
  return { file: target, line: 0, content };
63
72
  const lines = content.split("\n");
64
- if (line < 1 || line > lines.length)
73
+ if (!Number.isSafeInteger(line) || line < 1 || line > lines.length)
65
74
  throw new Error("line is out of range");
66
75
  context.store.markUseful(context.store
67
76
  .allEntries()
@@ -70,16 +79,12 @@ export async function getEntry(context, file, line) {
70
79
  return { file: target, line, content: lines[line - 1] };
71
80
  }
72
81
  export async function forgetEntry(context, target) {
73
- const file = target.file.startsWith("/")
74
- ? target.file
75
- : `${context.paths.stateRoot}/${target.file.replace(/^\/+/, "")}`;
76
- if (!file.startsWith(context.paths.stateRoot))
77
- throw new Error("path escapes memomatic state root");
82
+ const file = await memoryPath(context, target.file);
78
83
  const content = await readTextIfExists(file);
79
84
  if (content === undefined)
80
85
  throw new Error(`memory file not found: ${target.file}`);
81
86
  const lines = content.split("\n");
82
- if (target.line < 1 || target.line > lines.length)
87
+ if (!Number.isSafeInteger(target.line) || target.line < 1 || target.line > lines.length)
83
88
  throw new Error("line is out of range");
84
89
  await replaceEntryLine(context.paths, file, target.line, null);
85
90
  return file;
@@ -90,20 +95,38 @@ export async function archiveOldEpisodic(context) {
90
95
  return archived;
91
96
  const rule = context.rules.autoClean;
92
97
  const cutoff = Date.now() - rule.olderThanDays * 86_400_000;
93
- for (const entry of context.store.allEntries()) {
94
- if (entry.kind !== "episodic" || !entry.observedAt || entry.observedAt >= cutoff)
95
- continue;
96
- if (rule.source && entry.source !== rule.source)
97
- continue;
98
- if (entry.pinned)
98
+ const files = new Set(context.store
99
+ .allEntries()
100
+ .filter((entry) => entry.kind === "episodic")
101
+ .map((entry) => entry.file));
102
+ for (const file of files) {
103
+ await memoryPath(context, file);
104
+ const content = await readTextIfExists(file);
105
+ if (content === undefined)
99
106
  continue;
100
- if (!(await exists(entry.file)))
107
+ const selected = [];
108
+ const remaining = content.split("\n").filter((line) => {
109
+ const entry = parseEntryLine(line)?.annotations;
110
+ const observed = entry?.observed ? Date.parse(entry.observed) : NaN;
111
+ if (!entry ||
112
+ !Number.isFinite(observed) ||
113
+ observed >= cutoff ||
114
+ entry.pinned ||
115
+ (rule.source && entry.source !== rule.source))
116
+ return true;
117
+ selected.push(line);
118
+ return false;
119
+ });
120
+ if (!selected.length)
101
121
  continue;
102
- const alreadyDone = archived.includes(entry.file);
103
- if (!alreadyDone) {
104
- await archiveFile(context.paths, entry.file);
105
- archived.push(entry.file);
106
- }
122
+ const target = join(context.paths.archiveDir, basename(file));
123
+ const prior = (await readTextIfExists(target)) ?? "";
124
+ const seen = new Set(prior.split("\n"));
125
+ await writeCorpusFile(context.paths, target, `${[prior.trimEnd(), ...selected.filter((line) => !seen.has(line))].filter(Boolean).join("\n")}\n`);
126
+ await writeCorpusFile(context.paths, file, remaining.join("\n"));
127
+ if (!remaining.some((line) => line.trim()))
128
+ await rm(file);
129
+ archived.push(file);
107
130
  }
108
131
  return archived;
109
132
  }
package/dist/store.d.ts CHANGED
@@ -28,6 +28,9 @@ export declare class MemoryStore {
28
28
  private constructor();
29
29
  private migrate;
30
30
  static open(indexFile: string): Promise<MemoryStore>;
31
+ private static copy;
32
+ fork(): MemoryStore;
33
+ static preview(indexFile: string): Promise<MemoryStore>;
31
34
  private ensureFts;
32
35
  hasFts(): boolean;
33
36
  replaceEntries(entries: IndexedEntry[]): void;
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir } from "node:fs/promises";
2
+ import { mkdir, stat } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  export function stableIdFor(file, text, key) {
@@ -60,6 +60,44 @@ export class MemoryStore {
60
60
  await mkdir(dirname(indexFile), { mode: 0o700, recursive: true });
61
61
  return new MemoryStore(indexFile);
62
62
  }
63
+ static copy(database) {
64
+ const copy = new MemoryStore(":memory:");
65
+ try {
66
+ for (const table of ["entries", "usage", "vectors", "meta"]) {
67
+ for (const row of database.prepare(`SELECT * FROM ${table}`).all()) {
68
+ const columns = Object.keys(row);
69
+ copy.db
70
+ .prepare(`INSERT INTO ${table} (${columns.join(",")}) VALUES (${columns.map(() => "?").join(",")})`)
71
+ .run(...Object.values(row));
72
+ }
73
+ }
74
+ return copy;
75
+ }
76
+ catch (error) {
77
+ copy.close();
78
+ throw error;
79
+ }
80
+ }
81
+ fork() {
82
+ return MemoryStore.copy(this.db);
83
+ }
84
+ static async preview(indexFile) {
85
+ try {
86
+ await stat(indexFile);
87
+ }
88
+ catch (error) {
89
+ if (error.code === "ENOENT")
90
+ return new MemoryStore(":memory:");
91
+ throw error;
92
+ }
93
+ const database = new DatabaseSync(indexFile, { readOnly: true });
94
+ try {
95
+ return MemoryStore.copy(database);
96
+ }
97
+ finally {
98
+ database.close();
99
+ }
100
+ }
63
101
  ensureFts() {
64
102
  try {
65
103
  this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(stable_id UNINDEXED, text);");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/memomatic",
3
- "version": "1.0.0-dev.59.g0506803c61e5",
3
+ "version": "1.0.0-dev.61.gdf462654550b",
4
4
  "description": "Personal learning memory for agents: tiered Markdown corpus, rebuildable index, dream sweep.",
5
5
  "homepage": "https://github.com/kisev/skills#readme",
6
6
  "bugs": {
@@ -34,7 +34,7 @@
34
34
  "pack:check": "node test/pack-allowlist.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@kisev/safe-fs": "1.0.0-dev.59.g0506803c61e5"
37
+ "@kisev/safe-fs": "1.0.0-dev.61.gdf462654550b"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "22.15.30",