@kisev/memomatic 1.0.0-dev.100.g21366c4ad740

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.
Files changed (51) hide show
  1. package/README.md +97 -0
  2. package/README.ru.md +95 -0
  3. package/assets/systemd/memomatic-dream.service +9 -0
  4. package/assets/systemd/memomatic-dream.timer +10 -0
  5. package/assets/systemd/memomatic-sessions.service +9 -0
  6. package/assets/systemd/memomatic-sessions.timer +10 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +309 -0
  9. package/dist/corpus.d.ts +12 -0
  10. package/dist/corpus.js +138 -0
  11. package/dist/dream.d.ts +31 -0
  12. package/dist/dream.js +234 -0
  13. package/dist/entries.d.ts +31 -0
  14. package/dist/entries.js +119 -0
  15. package/dist/executor.d.ts +45 -0
  16. package/dist/executor.js +267 -0
  17. package/dist/gates.d.ts +27 -0
  18. package/dist/gates.js +69 -0
  19. package/dist/generated/cli.d.ts +28 -0
  20. package/dist/generated/cli.js +181 -0
  21. package/dist/inbox.d.ts +34 -0
  22. package/dist/inbox.js +224 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.js +6 -0
  25. package/dist/ingest.d.ts +1 -0
  26. package/dist/ingest.js +15 -0
  27. package/dist/ingestion.d.ts +30 -0
  28. package/dist/ingestion.js +179 -0
  29. package/dist/mcp.d.ts +15 -0
  30. package/dist/mcp.js +205 -0
  31. package/dist/opencode-config.d.ts +1 -0
  32. package/dist/opencode-config.js +84 -0
  33. package/dist/operations.d.ts +7 -0
  34. package/dist/operations.js +3 -0
  35. package/dist/paths.d.ts +18 -0
  36. package/dist/paths.js +55 -0
  37. package/dist/rules.d.ts +13 -0
  38. package/dist/rules.js +39 -0
  39. package/dist/search.d.ts +28 -0
  40. package/dist/search.js +191 -0
  41. package/dist/service.d.ts +63 -0
  42. package/dist/service.js +163 -0
  43. package/dist/sessions.d.ts +36 -0
  44. package/dist/sessions.js +238 -0
  45. package/dist/settings.d.ts +48 -0
  46. package/dist/settings.js +189 -0
  47. package/dist/store.d.ts +55 -0
  48. package/dist/store.js +286 -0
  49. package/dist/visibility.d.ts +7 -0
  50. package/dist/visibility.js +13 -0
  51. package/package.json +48 -0
package/dist/inbox.js ADDED
@@ -0,0 +1,224 @@
1
+ import { mkdir, open, readdir, rename, rm, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { check } from "./operations.js";
5
+ import { writeAtomic } from "@kisev/safe-fs";
6
+ import { appendDailyEntry, readTextIfExists, replaceEntryLine, writeCorpusFile } from "./corpus.js";
7
+ import { entryLine, parseEntryLine } from "./entries.js";
8
+ import { corpusFiles, reindex } from "./search.js";
9
+ import { isForbidden } from "./rules.js";
10
+ const SOURCE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
11
+ const LIST_PREFIX = /^- /;
12
+ /** parseEntryLine keeps the leading "- "; strip it before re-serializing. */
13
+ function entryText(parsed) {
14
+ return LIST_PREFIX.test(parsed.text) ? parsed.text.slice(2) : parsed.text;
15
+ }
16
+ const LOCK_STALE_MS = 30 * 60_000;
17
+ export function inboxFileName(source, now = new Date()) {
18
+ const stamp = now.toISOString().replace(/[-:]/g, "").slice(0, 15);
19
+ return `${source}-${stamp}-${randomBytes(4).toString("hex")}.md`;
20
+ }
21
+ /**
22
+ * Append one inbox drop file. Producers write Markdown entry lines that the
23
+ * deterministic `process` pass later validates, gates, and moves into the
24
+ * corpus. Nothing here touches corpus files.
25
+ */
26
+ export async function dropToInbox(paths, lines, source) {
27
+ if (!SOURCE_PATTERN.test(source))
28
+ throw new Error(`inbox source is invalid: ${source}`);
29
+ const content = `${lines.filter((line) => line.trim()).join("\n")}\n`;
30
+ if (!content.trim())
31
+ throw new Error("inbox drop is empty");
32
+ const file = join(paths.inboxDir, inboxFileName(source));
33
+ await writeAtomic(file, Buffer.from(content, "utf8"), 0o600);
34
+ return file;
35
+ }
36
+ async function scanCorpus(paths) {
37
+ const index = { texts: new Set(), keys: new Map() };
38
+ for (const file of await corpusFiles(paths)) {
39
+ const markdown = (await readTextIfExists(file)) ?? "";
40
+ const lines = markdown.split("\n");
41
+ for (let position = 0; position < lines.length; position += 1) {
42
+ const parsed = parseEntryLine(lines[position]);
43
+ if (!parsed)
44
+ continue;
45
+ index.texts.add(parsed.text);
46
+ if (parsed.annotations.key && parsed.annotations.status !== "superseded")
47
+ index.keys.set(parsed.annotations.key, { file, line: position + 1 });
48
+ }
49
+ }
50
+ return index;
51
+ }
52
+ async function moveRejected(paths, name) {
53
+ await mkdir(paths.rejectedDir, { mode: 0o700, recursive: true });
54
+ await rename(join(paths.inboxDir, name), join(paths.rejectedDir, name));
55
+ }
56
+ async function appendLine(context, file, line) {
57
+ const current = (await readTextIfExists(file)) ?? "";
58
+ await writeCorpusFile(context.paths, file, `${current.trimEnd()}\n${line}\n`);
59
+ }
60
+ function routeFor(annotations) {
61
+ if (annotations.target === "user" && annotations.origin === "user")
62
+ return "user";
63
+ if (annotations.target === "curated" && annotations.origin === "user")
64
+ return "curated";
65
+ return "episodic";
66
+ }
67
+ /**
68
+ * Deterministic inbox pass: validate entry lines, enforce never-save rules,
69
+ * deduplicate exact texts, supersede entries sharing a key, route user-origin
70
+ * targets, then rebuild the index with embeddings. No model turns.
71
+ */
72
+ export async function processInbox(context, options = {}) {
73
+ const dryRun = options.dryRun === true;
74
+ const report = {
75
+ filesProcessed: 0,
76
+ filesRejected: 0,
77
+ entriesAppended: 0,
78
+ entriesSuperseded: 0,
79
+ entriesDuplicated: 0,
80
+ linesForbidden: 0,
81
+ linesInvalid: 0,
82
+ dryRun,
83
+ };
84
+ const names = (await readdir(context.paths.inboxDir).catch(() => []))
85
+ .filter((name) => name.endsWith(".md"))
86
+ .sort();
87
+ if (!names.length)
88
+ return report;
89
+ const corpus = await scanCorpus(context.paths);
90
+ for (const name of names) {
91
+ check(options);
92
+ options.observe?.({
93
+ phase: "inbox",
94
+ message: "Processing inbox file",
95
+ completed: report.filesProcessed + report.filesRejected,
96
+ total: names.length,
97
+ });
98
+ const markdown = (await readTextIfExists(join(context.paths.inboxDir, name))) ?? "";
99
+ const rawLines = markdown.split("\n");
100
+ const parsedLines = rawLines.filter((line) => line.trim()).map((line) => parseEntryLine(line));
101
+ report.linesInvalid += parsedLines.filter((parsed) => !parsed).length;
102
+ const allowed = parsedLines.filter((parsed) => {
103
+ if (!parsed)
104
+ return false;
105
+ if (isForbidden(parsed.text, context.rules)) {
106
+ report.linesForbidden += 1;
107
+ return false;
108
+ }
109
+ return true;
110
+ });
111
+ if (!allowed.length) {
112
+ if (!dryRun)
113
+ await moveRejected(context.paths, name);
114
+ report.filesRejected += 1;
115
+ continue;
116
+ }
117
+ let accepted = false;
118
+ for (const parsed of allowed) {
119
+ check(options);
120
+ if (corpus.texts.has(parsed.text)) {
121
+ report.entriesDuplicated += 1;
122
+ continue;
123
+ }
124
+ const annotations = {
125
+ ...parsed.annotations,
126
+ target: undefined,
127
+ observed: parsed.annotations.observed ?? new Date().toISOString().slice(0, 10),
128
+ };
129
+ const line = entryLine(entryText(parsed), annotations, { dropTarget: true });
130
+ const route = routeFor(parsed.annotations);
131
+ const existing = route === "episodic" && parsed.annotations.key
132
+ ? corpus.keys.get(parsed.annotations.key)
133
+ : undefined;
134
+ if (existing) {
135
+ if (!dryRun)
136
+ await replaceEntryLine(context.paths, existing.file, existing.line, line);
137
+ report.entriesSuperseded += 1;
138
+ }
139
+ else if (route === "user" || route === "curated") {
140
+ if (!dryRun)
141
+ await appendLine(context, route === "user" ? context.paths.userFile : context.paths.memoryFile, line);
142
+ report.entriesAppended += 1;
143
+ }
144
+ else {
145
+ if (!dryRun)
146
+ await appendDailyEntry(context.paths, line);
147
+ report.entriesAppended += 1;
148
+ }
149
+ corpus.texts.add(parsed.text);
150
+ accepted = true;
151
+ }
152
+ if (accepted) {
153
+ if (!dryRun)
154
+ await unlink(join(context.paths.inboxDir, name)).catch(() => undefined);
155
+ report.filesProcessed += 1;
156
+ }
157
+ else {
158
+ if (!dryRun)
159
+ await moveRejected(context.paths, name);
160
+ report.filesRejected += 1;
161
+ }
162
+ }
163
+ if (!dryRun && !options.deferIndex)
164
+ await reindex(context.paths, context.settings, context.store, options);
165
+ return report;
166
+ }
167
+ /**
168
+ * Serialize whole runs (dream or process) with a stale-tolerant lock file so a
169
+ * nightly sweep and a manual CLI invocation never mutate the corpus together.
170
+ */
171
+ export async function withRunLock(paths, work) {
172
+ const owner = `${process.pid} ${new Date().toISOString()} ${randomBytes(12).toString("hex")}\n`;
173
+ const acquire = async () => {
174
+ try {
175
+ const handle = await open(paths.runLockFile, "wx", 0o600);
176
+ try {
177
+ await handle.writeFile(owner);
178
+ }
179
+ finally {
180
+ await handle.close();
181
+ }
182
+ }
183
+ catch (error) {
184
+ if (error.code !== "EEXIST")
185
+ throw error;
186
+ let stale = false;
187
+ try {
188
+ const handle = await open(paths.runLockFile, "r");
189
+ try {
190
+ const info = await handle.stat();
191
+ const owner = Number.parseInt((await handle.readFile("utf8")).split(" ")[0], 10);
192
+ if (Number.isSafeInteger(owner) && owner > 0) {
193
+ try {
194
+ process.kill(owner, 0);
195
+ }
196
+ catch (cause) {
197
+ stale = cause.code === "ESRCH";
198
+ }
199
+ }
200
+ else
201
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MS;
202
+ }
203
+ finally {
204
+ await handle.close();
205
+ }
206
+ }
207
+ catch {
208
+ return acquire();
209
+ }
210
+ if (!stale)
211
+ throw new Error("another memomatic run is active");
212
+ await rm(paths.runLockFile, { force: true });
213
+ return acquire();
214
+ }
215
+ };
216
+ await acquire();
217
+ try {
218
+ return await work();
219
+ }
220
+ finally {
221
+ if ((await readTextIfExists(paths.runLockFile)) === owner)
222
+ await rm(paths.runLockFile, { force: true }).catch(() => undefined);
223
+ }
224
+ }
@@ -0,0 +1,7 @@
1
+ export { writeCorpusFile } from "./corpus.js";
2
+ export { entryLine, parseEntryLine } from "./entries.js";
3
+ export { visibilityForSource } from "./visibility.js";
4
+ export { dropToInbox, processInbox, withRunLock } from "./inbox.js";
5
+ export { forgetEntry, getEntry, openMemomatic, searchMemory, searchMemoryResults, writeEntry, } from "./service.js";
6
+ export type { MemomaticContext } from "./service.js";
7
+ export { opencodeDatabasePath } from "./ingest.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { writeCorpusFile } from "./corpus.js";
2
+ export { entryLine, parseEntryLine } from "./entries.js";
3
+ export { visibilityForSource } from "./visibility.js";
4
+ export { dropToInbox, processInbox, withRunLock } from "./inbox.js";
5
+ export { forgetEntry, getEntry, openMemomatic, searchMemory, searchMemoryResults, writeEntry, } from "./service.js";
6
+ export { opencodeDatabasePath } from "./ingest.js";
@@ -0,0 +1 @@
1
+ export declare function opencodeDatabasePath(): string;
package/dist/ingest.js ADDED
@@ -0,0 +1,15 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+ export function opencodeDatabasePath() {
4
+ const base = process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share");
5
+ if (!isAbsolute(base))
6
+ throw new Error("XDG_DATA_HOME must be an absolute path");
7
+ if (process.env.OPENCODE_DB) {
8
+ if (process.env.OPENCODE_DB === ":memory:")
9
+ throw new Error("An in-memory OpenCode database cannot be ingested from another process");
10
+ return isAbsolute(process.env.OPENCODE_DB)
11
+ ? process.env.OPENCODE_DB
12
+ : join(base, "opencode", process.env.OPENCODE_DB);
13
+ }
14
+ return join(base, "opencode", "opencode.db");
15
+ }
@@ -0,0 +1,30 @@
1
+ import type { MemoryStore } from "./store.js";
2
+ export type Fragment = {
3
+ id: string;
4
+ sessionId: string;
5
+ messageIds: string[];
6
+ title: string;
7
+ directory: string;
8
+ text: string;
9
+ context: string;
10
+ checkpointKeys: string[];
11
+ seal?: [string, string];
12
+ };
13
+ export type IngestionPlan = {
14
+ fragments: Fragment[];
15
+ sessions: number;
16
+ messages: number;
17
+ cached: number;
18
+ skipped: number;
19
+ characters: number;
20
+ watermark: number;
21
+ cachedSessions: number;
22
+ unscannedSessions: number;
23
+ seals: Array<[string, string]>;
24
+ };
25
+ export declare function planIngestion(file: string, store: MemoryStore, options: {
26
+ before: number;
27
+ maxChars: number;
28
+ maxSessions?: number;
29
+ contextChars?: number;
30
+ }): IngestionPlan;
@@ -0,0 +1,179 @@
1
+ import { createHash } from "node:crypto";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
4
+ export function planIngestion(file, store, options) {
5
+ if (!Number.isSafeInteger(options.maxChars) ||
6
+ options.maxChars < 2 ||
7
+ !Number.isFinite(options.before))
8
+ throw new Error("invalid ingestion limits");
9
+ const result = {
10
+ fragments: [],
11
+ sessions: 0,
12
+ messages: 0,
13
+ cached: 0,
14
+ skipped: 0,
15
+ characters: 0,
16
+ watermark: 0,
17
+ cachedSessions: 0,
18
+ unscannedSessions: 0,
19
+ seals: [],
20
+ };
21
+ const db = new DatabaseSync(file, { readOnly: true });
22
+ try {
23
+ db.exec("BEGIN");
24
+ const required = {
25
+ session_v2: ["id", "title", "directory", "time_created", "time_updated"],
26
+ session_message: ["id", "session_id", "type", "seq", "data", "time_created", "time_updated"],
27
+ };
28
+ for (const [table, fields] of Object.entries(required)) {
29
+ const columns = new Set(db
30
+ .prepare(`PRAGMA table_info(${table})`)
31
+ .all()
32
+ .map((row) => row.name));
33
+ if (!fields.every((field) => columns.has(field)))
34
+ throw new Error("OpenCode V2 session projections are unavailable; start V2 to migrate history before ingestion");
35
+ }
36
+ const sessions = db
37
+ .prepare("SELECT id,COALESCE(title,'') AS title,directory,time_created,time_updated AS revision FROM session_v2 WHERE time_updated < ? ORDER BY time_created,id")
38
+ .all(options.before);
39
+ for (const [position, session] of sessions.entries()) {
40
+ if (session.title.startsWith("[memomatic-internal]")) {
41
+ result.skipped++;
42
+ continue;
43
+ }
44
+ const sealKey = `ingest-session-v2:${digest(session.id)}`;
45
+ let seal;
46
+ {
47
+ const revisions = db
48
+ .prepare("SELECT id,seq,time_updated FROM session_message WHERE session_id=? ORDER BY seq,id")
49
+ .all(session.id);
50
+ if (revisions.some((message) => Number(message.time_updated) >= options.before)) {
51
+ result.skipped++;
52
+ continue;
53
+ }
54
+ const revision = digest(JSON.stringify([session.revision, session.title, session.directory, revisions]));
55
+ if (store.getMeta(sealKey) === revision) {
56
+ result.cachedSessions++;
57
+ continue;
58
+ }
59
+ seal = [sealKey, revision];
60
+ }
61
+ const rows = db
62
+ .prepare(`
63
+ SELECT id,type AS role,json_extract(data,'$.text') AS text,seq,0 AS part_index
64
+ FROM session_message WHERE session_id=? AND type='user'
65
+ AND CASE WHEN json_valid(data) THEN json_type(data,'$.text') END='text'
66
+ UNION ALL
67
+ SELECT m.id,m.type AS role,json_extract(p.value,'$.text') AS text,m.seq,CAST(p.key AS INTEGER) AS part_index
68
+ FROM session_message m JOIN json_each(CASE WHEN json_valid(m.data) THEN json_extract(m.data,'$.content') ELSE '[]' END) p
69
+ WHERE m.session_id=? AND m.type='assistant'
70
+ AND CASE WHEN json_valid(p.value) THEN json_extract(p.value,'$.type') END='text'
71
+ AND CASE WHEN json_valid(p.value) THEN json_type(p.value,'$.text') END='text'
72
+ ORDER BY seq,part_index
73
+ `)
74
+ .all(session.id, session.id);
75
+ const messages = new Map();
76
+ for (const row of rows) {
77
+ const item = messages.get(row.id) ?? { role: row.role, texts: [] };
78
+ item.texts.push(row.text);
79
+ messages.set(row.id, item);
80
+ }
81
+ const firstUser = [...messages.values()].find((message) => message.role === "user");
82
+ if (firstUser?.texts.join("\n").trimStart().startsWith("[memomatic-internal]")) {
83
+ result.skipped++;
84
+ continue;
85
+ }
86
+ let batch = [];
87
+ let ids = [];
88
+ let keys = [];
89
+ let context = "";
90
+ let previous = "";
91
+ let length = 0;
92
+ let changed = false;
93
+ const flush = () => {
94
+ if (!batch.length)
95
+ return;
96
+ result.fragments.push({
97
+ id: digest(keys.join("\n")),
98
+ sessionId: session.id,
99
+ messageIds: ids,
100
+ title: session.title,
101
+ directory: session.directory,
102
+ text: batch.join("\n\n"),
103
+ context,
104
+ checkpointKeys: keys,
105
+ });
106
+ batch = [];
107
+ ids = [];
108
+ keys = [];
109
+ length = 0;
110
+ };
111
+ let lastMessage = "";
112
+ for (const [id, message] of messages) {
113
+ const body = message.texts.join("\n").trim();
114
+ if (!body) {
115
+ result.skipped++;
116
+ continue;
117
+ }
118
+ result.messages++;
119
+ const full = `${message.role}: ${body}`;
120
+ // Only adjacent exact duplicate messages with the same role are redundant.
121
+ if (full === lastMessage) {
122
+ result.skipped++;
123
+ continue;
124
+ }
125
+ lastMessage = full;
126
+ for (let offset = 0; offset < body.length;) {
127
+ const start = offset;
128
+ let end = Math.min(body.length, offset + options.maxChars);
129
+ if (end < body.length &&
130
+ /[\uD800-\uDBFF]/.test(body[end - 1]) &&
131
+ /[\uDC00-\uDFFF]/.test(body[end]))
132
+ end--;
133
+ const text = `${message.role}: ${body.slice(start, end)}`;
134
+ offset = end;
135
+ const preceding = Array.from(previous)
136
+ .slice(-(options.contextChars ?? 2000))
137
+ .join("");
138
+ const key = `ingest-v2:${digest(`${session.id}\n${id}\n${start}\n${preceding}\n${text}`)}`;
139
+ if (store.getMeta(key)) {
140
+ result.cached++;
141
+ previous = text;
142
+ continue;
143
+ }
144
+ if (batch.length && length + text.length > options.maxChars)
145
+ flush();
146
+ if (!batch.length)
147
+ context = preceding;
148
+ batch.push(text);
149
+ ids.push(id);
150
+ keys.push(key);
151
+ length += text.length;
152
+ result.characters += text.length;
153
+ changed = true;
154
+ previous = text;
155
+ }
156
+ }
157
+ flush();
158
+ if (seal) {
159
+ if (changed)
160
+ result.fragments.at(-1).seal = seal;
161
+ else
162
+ result.seals.push(seal);
163
+ }
164
+ if (changed) {
165
+ result.sessions++;
166
+ result.watermark = Math.max(result.watermark, session.time_created);
167
+ }
168
+ if (options.maxSessions && result.sessions >= options.maxSessions) {
169
+ result.unscannedSessions = sessions.length - position - 1;
170
+ break;
171
+ }
172
+ }
173
+ db.exec("COMMIT");
174
+ return result;
175
+ }
176
+ finally {
177
+ db.close();
178
+ }
179
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ type JsonRpcRequest = {
2
+ jsonrpc?: string;
3
+ id?: unknown;
4
+ method?: string;
5
+ params?: Record<string, unknown>;
6
+ };
7
+ export declare function mcpResponse(id: unknown, result: unknown): string;
8
+ export declare function mcpError(id: unknown, code: number, message: string): string;
9
+ export declare function handleMcpRequest(request: JsonRpcRequest): Promise<string>;
10
+ export declare function runMcpServer(input?: NodeJS.ReadStream & {
11
+ fd: 0;
12
+ }, output?: NodeJS.WriteStream & {
13
+ fd: 1;
14
+ }): Promise<void>;
15
+ export {};