@kisev/memomatic 1.0.0-dev.92.gc67bdc97fcd1 → 1.0.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/README.md CHANGED
@@ -30,9 +30,8 @@ Nightly runs are scheduled by the systemd user units in `assets/systemd/`
30
30
  `memomatic-dream.service`/`.timer` for consolidation).
31
31
 
32
32
  Configure `sessions.model` (or keep a legacy `dream.model`) and an OpenCode
33
- V2 provider before expecting session extraction; without a model, the session
34
- watermark is preserved. Session extraction and its model server require
35
- OpenCode `2.x`; a pre-V2 session database is not parsed. Follow the
33
+ provider before expecting session extraction; without a model, the session
34
+ watermark is preserved. Follow the
36
35
  [setup, first-entry, and scheduling guide](../../docs/how-to/memomatic.md).
37
36
 
38
37
  ## CLI, Sessions, and Dream
package/README.ru.md CHANGED
@@ -30,9 +30,8 @@ memomatic dream # консолидация: inbox + продвижение
30
30
  `memomatic-dream.service`/`.timer` для консолидации).
31
31
 
32
32
  Перед извлечением сессий настройте `sessions.model` (или оставьте старый
33
- `dream.model`) и провайдера OpenCode V2; без модели отметка обработанных сессий
34
- сохраняется. Извлечение сессий и его сервер модели требуют OpenCode `2.x`;
35
- база сессий до V2 не разбирается. См.
33
+ `dream.model`) и провайдера OpenCode; без модели отметка обработанных сессий
34
+ сохраняется. См.
36
35
  [настройку, первую запись и расписание](../../docs/ru/how-to/memomatic.md).
37
36
 
38
37
  ## CLI, Sessions и Dream
package/dist/executor.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomBytes } from "node:crypto";
6
- import { isolatedProviderConfig } from "./opencode-config.js";
7
6
  export function extractJson(response) {
8
7
  const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(response);
9
8
  for (const candidate of [fenced?.[1], response]) {
@@ -50,34 +49,30 @@ export class OpenCodeExecutor {
50
49
  if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
51
50
  throw new Error("invalid OpenCode URL");
52
51
  this.url = url.href.replace(/\/$/, "");
53
- const password = process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD;
54
- if (password)
55
- this.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
52
+ if (process.env.OPENCODE_SERVER_PASSWORD)
53
+ this.authorization = `Basic ${Buffer.from(`${process.env.OPENCODE_SERVER_USERNAME ?? "opencode"}:${process.env.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`;
56
54
  return;
57
55
  }
58
- const inherited = await isolatedProviderConfig();
56
+ let inherited = {};
57
+ if (process.env.OPENCODE_CONFIG_CONTENT) {
58
+ try {
59
+ const value = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT);
60
+ if (!value || typeof value !== "object" || Array.isArray(value))
61
+ throw new Error();
62
+ inherited = value;
63
+ }
64
+ catch {
65
+ throw new Error("OpenCode inline configuration must be a JSON object");
66
+ }
67
+ }
59
68
  this.directory = await mkdtemp(join(tmpdir(), "memomatic-opencode-"));
60
- const configFile = join(this.directory, "opencode.json");
61
- await writeFile(configFile, JSON.stringify({
62
- ...inherited,
63
- plugins: ["-opencode.skill", "-opencode.config.skill", "-opencode.tool.skill"],
64
- permissions: [{ action: "*", resource: "*", effect: "deny" }],
65
- agents: {
66
- memomatic: {
67
- mode: "primary",
68
- description: "Bounded memory extraction",
69
- system: "Return only the requested JSON. Do not use tools.",
70
- permissions: [{ action: "*", resource: "*", effect: "deny" }],
71
- },
72
- },
73
- }), { mode: 0o600 });
74
69
  const password = randomBytes(24).toString("hex");
75
70
  this.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
76
71
  this.options.observe?.({
77
72
  phase: "opencode.start",
78
73
  message: "Starting one isolated OpenCode server for this run",
79
74
  });
80
- const child = spawn(this.options.binary ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
75
+ const child = spawn(this.options.binary ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0", "--pure"], {
81
76
  cwd: this.directory,
82
77
  detached: process.platform !== "win32",
83
78
  stdio: ["ignore", "pipe", "pipe"],
@@ -85,11 +80,25 @@ export class OpenCodeExecutor {
85
80
  ...process.env,
86
81
  OPENCODE_SERVER_USERNAME: "opencode",
87
82
  OPENCODE_SERVER_PASSWORD: password,
88
- OPENCODE_CONFIG_PROJECT_DISABLE: "1",
89
- OPENCODE_CONFIG_DIR: this.directory,
90
- OPENCODE_CONFIG: configFile,
91
- OPENCODE_CONFIG_CONTENT: "{}",
92
- OPENCODE_PASSWORD: password,
83
+ OPENCODE_DISABLE_PROJECT_CONFIG: "1",
84
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
85
+ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
86
+ OPENCODE_DISABLE_DEFAULT_PLUGINS: "1",
87
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
88
+ ...inherited,
89
+ permission: "deny",
90
+ tools: { "*": false },
91
+ agent: {
92
+ ...inherited.agent,
93
+ memomatic: {
94
+ mode: "primary",
95
+ description: "Bounded memory extraction",
96
+ prompt: "Return only the requested JSON. Do not use tools.",
97
+ permission: "deny",
98
+ tools: { "*": false },
99
+ },
100
+ },
101
+ }),
93
102
  },
94
103
  });
95
104
  this.child = child;
@@ -132,9 +141,9 @@ export class OpenCodeExecutor {
132
141
  child.stderr?.resume();
133
142
  this.options.observe?.({ phase: "opencode.ready", message: "OpenCode server ready" });
134
143
  }
135
- async request(path, body, signal, method = "POST") {
144
+ async request(path, body, signal) {
136
145
  const response = await fetch(`${this.url}${path}`, {
137
- method,
146
+ method: "POST",
138
147
  headers: {
139
148
  "content-type": "application/json",
140
149
  ...(this.authorization ? { authorization: this.authorization } : {}),
@@ -143,8 +152,8 @@ export class OpenCodeExecutor {
143
152
  signal,
144
153
  });
145
154
  if (!response.ok)
146
- throw new Error(`OpenCode ${path.includes("generate") ? "model request" : "session request"} failed: HTTP ${response.status}`);
147
- return response.status === 204 ? {} : (await response.json());
155
+ throw new Error(`OpenCode ${path.includes("message") ? "model request" : "session request"} failed: HTTP ${response.status}`);
156
+ return (await response.json());
148
157
  }
149
158
  async complete(request) {
150
159
  this.options.signal?.throwIfAborted();
@@ -165,43 +174,56 @@ export class OpenCodeExecutor {
165
174
  }), 15000);
166
175
  heartbeat.unref();
167
176
  try {
177
+ const session = await this.request("/session", {
178
+ title: "[memomatic-internal] memory extraction",
179
+ permission: [{ permission: "*", pattern: "*", action: "deny" }],
180
+ }, signal);
181
+ if (typeof session.id !== "string")
182
+ throw new Error("OpenCode session response is malformed");
183
+ id = session.id;
168
184
  const slash = this.options.model?.indexOf("/") ?? -1;
169
185
  if (this.options.model && slash < 1)
170
186
  throw new Error("model must use provider/model format");
171
- const session = await this.request("/api/session", {
172
- title: "[memomatic-internal] memory extraction",
187
+ this.usage.calls++;
188
+ this.usage.charactersSent += request.system.length + request.prompt.length;
189
+ this.options.observe?.({
190
+ phase: "model.request",
191
+ message: "Sending prepared fragment to OpenCode",
192
+ characters: request.prompt.length,
193
+ attempt: attempt + 1,
194
+ });
195
+ const response = await this.request(`/session/${encodeURIComponent(id)}/message`, {
173
196
  agent: this.options.url ? "build" : "memomatic",
174
- ...(this.directory ? { location: { directory: this.directory } } : {}),
175
- permissions: [{ action: "*", resource: "*", effect: "deny" }],
197
+ system: request.system,
198
+ tools: { "*": false },
176
199
  ...(this.options.model
177
200
  ? {
178
201
  model: {
179
202
  providerID: this.options.model.slice(0, slash),
180
- id: this.options.model.slice(slash + 1),
181
- ...(this.options.variant ? { variant: this.options.variant } : {}),
203
+ modelID: this.options.model.slice(slash + 1),
182
204
  },
183
205
  }
184
206
  : {}),
207
+ ...(this.options.variant ? { variant: this.options.variant } : {}),
208
+ parts: [{ type: "text", text: `[memomatic-internal] ${request.prompt}` }],
185
209
  }, signal);
186
- const data = session.data;
187
- if (typeof data?.id !== "string")
188
- throw new Error("OpenCode session response is malformed");
189
- id = data.id;
190
- await this.request(`/api/experimental/session/${encodeURIComponent(id)}/instructions/entries/memomatic`, { value: request.system }, signal, "PUT");
191
- this.usage.calls++;
192
- this.usage.charactersSent += request.system.length + request.prompt.length;
193
- this.options.observe?.({
194
- phase: "model.request",
195
- message: "Sending prepared fragment to OpenCode",
196
- characters: request.prompt.length,
197
- attempt: attempt + 1,
198
- });
199
- const response = await this.request(`/api/session/${encodeURIComponent(id)}/generate`, { prompt: `[memomatic-internal] ${request.prompt}` }, signal);
200
- const generated = response.data;
201
- if (typeof generated?.text !== "string")
210
+ const info = response.info;
211
+ if (info?.error)
212
+ throw new Error("OpenCode model returned an error; inspect the configured provider");
213
+ if (info?.tokens) {
214
+ this.usage.usageAvailable = true;
215
+ this.usage.inputTokens += info.tokens.input ?? 0;
216
+ this.usage.outputTokens += info.tokens.output ?? 0;
217
+ this.usage.cacheReadTokens += info.tokens.cache?.read ?? 0;
218
+ this.usage.cacheWriteTokens += info.tokens.cache?.write ?? 0;
219
+ }
220
+ const parts = response.parts;
221
+ if (!Array.isArray(parts))
202
222
  throw new Error("OpenCode model response is malformed");
203
- // V2 transient generation returns text only, not exact token usage.
204
- const parsed = extractJson(generated.text);
223
+ const parsed = extractJson(parts
224
+ .filter((part) => part.type === "text")
225
+ .map((part) => part.text ?? "")
226
+ .join("\n"));
205
227
  this.options.observe?.({
206
228
  phase: "model.done",
207
229
  message: "Model response received",
@@ -212,7 +234,7 @@ export class OpenCodeExecutor {
212
234
  }
213
235
  catch (error) {
214
236
  if (id)
215
- await this.request(`/api/session/${encodeURIComponent(id)}/interrupt?resume=false`, {}, AbortSignal.timeout(3000)).catch(() => undefined);
237
+ await this.request(`/session/${encodeURIComponent(id)}/abort`, {}, AbortSignal.timeout(3000)).catch(() => undefined);
216
238
  if (this.options.signal?.aborted)
217
239
  throw this.options.signal.reason;
218
240
  if (attempt === (this.options.retries ?? 1))
package/dist/ingest.js CHANGED
@@ -4,12 +4,5 @@ export function opencodeDatabasePath() {
4
4
  const base = process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share");
5
5
  if (!isAbsolute(base))
6
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
7
  return join(base, "opencode", "opencode.db");
15
8
  }
package/dist/ingestion.js CHANGED
@@ -21,20 +21,24 @@ export function planIngestion(file, store, options) {
21
21
  const db = new DatabaseSync(file, { readOnly: true });
22
22
  try {
23
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
24
+ const hasParts = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE name='part' AND type='table'").get());
25
+ const hasUpdated = db
26
+ .prepare("PRAGMA table_info(session)")
27
+ .all()
28
+ .some((row) => row.name === "time_updated");
29
+ const fastRevision = hasParts &&
30
+ hasUpdated &&
31
+ ["message", "part"].every((table) => db
30
32
  .prepare(`PRAGMA table_info(${table})`)
31
33
  .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
- }
34
+ .some((row) => row.name === "time_updated"));
35
+ const partHasSession = hasParts &&
36
+ db
37
+ .prepare("PRAGMA table_info(part)")
38
+ .all()
39
+ .some((row) => row.name === "session_id");
36
40
  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")
41
+ .prepare(`SELECT id,title,directory,time_created,${hasUpdated ? "time_updated" : "time_created"} AS revision FROM session WHERE ${hasUpdated ? "time_updated" : "time_created"} < ? ORDER BY time_created,id`)
38
42
  .all(options.before);
39
43
  for (const [position, session] of sessions.entries()) {
40
44
  if (session.title.startsWith("[memomatic-internal]")) {
@@ -43,40 +47,66 @@ export function planIngestion(file, store, options) {
43
47
  }
44
48
  const sealKey = `ingest-session-v2:${digest(session.id)}`;
45
49
  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)) {
50
+ if (fastRevision) {
51
+ const messages = db
52
+ .prepare("SELECT COUNT(*) AS count,MAX(time_updated) AS updated FROM message WHERE session_id=?")
53
+ .get(session.id);
54
+ const parts = db
55
+ .prepare(`SELECT COUNT(*) AS count,MAX(time_updated) AS updated FROM part WHERE ${partHasSession ? "session_id=?" : "message_id IN (SELECT id FROM message WHERE session_id=?)"}`)
56
+ .get(session.id);
57
+ if (Math.max(Number(messages.updated), Number(parts.updated)) >= options.before) {
51
58
  result.skipped++;
52
59
  continue;
53
60
  }
54
- const revision = digest(JSON.stringify([session.revision, session.title, session.directory, revisions]));
61
+ const revision = digest(JSON.stringify([session.revision, session.title, session.directory, messages, parts]));
55
62
  if (store.getMeta(sealKey) === revision) {
56
63
  result.cachedSessions++;
57
64
  continue;
58
65
  }
59
66
  seal = [sealKey, revision];
60
67
  }
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);
68
+ const rows = hasParts
69
+ ? db
70
+ .prepare(`SELECT m.id,m.data AS message,p.data AS part FROM message m JOIN part p ON p.message_id=m.id
71
+ WHERE ${partHasSession ? "p" : "m"}.session_id=?
72
+ AND CASE WHEN json_valid(p.data) THEN json_extract(p.data,'$.type') END='text'
73
+ AND CASE WHEN json_valid(m.data) THEN json_extract(m.data,'$.role') END IN ('user','assistant')
74
+ ORDER BY m.time_created,m.id,p.time_created,p.id`)
75
+ .all(session.id)
76
+ : db
77
+ .prepare("SELECT rowid AS id,data FROM message WHERE session_id=? ORDER BY time_created,rowid")
78
+ .all(session.id).flatMap((row) => {
79
+ try {
80
+ const message = JSON.parse(row.data);
81
+ return (message.parts ?? []).map((part) => ({
82
+ id: String(row.id),
83
+ message: row.data,
84
+ part: JSON.stringify(part),
85
+ }));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ });
75
91
  const messages = new Map();
76
92
  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);
93
+ try {
94
+ const message = JSON.parse(row.message);
95
+ const part = JSON.parse(row.part);
96
+ if (!["user", "assistant"].includes(message.role) ||
97
+ part.type !== "text" ||
98
+ typeof part.text !== "string")
99
+ continue;
100
+ const item = messages.get(row.id) ?? {
101
+ role: message.role,
102
+ texts: [],
103
+ };
104
+ item.texts.push(part.text);
105
+ messages.set(row.id, item);
106
+ }
107
+ catch {
108
+ result.skipped++;
109
+ }
80
110
  }
81
111
  const firstUser = [...messages.values()].find((message) => message.role === "user");
82
112
  if (firstUser?.texts.join("\n").trimStart().startsWith("[memomatic-internal]")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/memomatic",
3
- "version": "1.0.0-dev.92.gc67bdc97fcd1",
3
+ "version": "1.0.0",
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": {
@@ -30,13 +30,12 @@
30
30
  },
31
31
  "scripts": {
32
32
  "build": "node scripts/clean-dist.mjs && tsc --project tsconfig.json",
33
- "test": "node --test test/*.test.mjs",
33
+ "test": "node --test test/",
34
34
  "pack:check": "node test/pack-allowlist.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@kisev/safe-fs": "1.0.0-dev.92.gc67bdc97fcd1",
38
- "commander": "15.0.0",
39
- "jsonc-parser": "3.3.1"
37
+ "@kisev/safe-fs": "^1.0.0",
38
+ "commander": "15.0.0"
40
39
  },
41
40
  "devDependencies": {
42
41
  "@types/node": "22.15.30",
@@ -1 +0,0 @@
1
- export declare function isolatedProviderConfig(): Promise<Record<string, unknown>>;
@@ -1,84 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
4
- import { parse } from "jsonc-parser";
5
- const PROVIDER_FIELDS = [
6
- "providers",
7
- "provider",
8
- "model",
9
- "enabled_providers",
10
- "disabled_providers",
11
- "experimental",
12
- "enterprise",
13
- ];
14
- function record(value) {
15
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
16
- }
17
- function merge(left, right, key = "") {
18
- if (key === "policies" && Array.isArray(left) && Array.isArray(right))
19
- return [...left, ...right];
20
- if (!record(left) || !record(right))
21
- return right;
22
- return Object.fromEntries([...new Set([...Object.keys(left), ...Object.keys(right)])].map((key) => [
23
- key,
24
- key in right ? merge(left[key], right[key], key) : left[key],
25
- ]));
26
- }
27
- function anchorFiles(value, directory) {
28
- if (typeof value === "string")
29
- return value.replace(/\{file:([^}]+)\}/g, (original, path) => isAbsolute(path) || path.startsWith("~/") ? original : `{file:${resolve(directory, path)}}`);
30
- if (Array.isArray(value))
31
- return value.map((entry) => anchorFiles(entry, directory));
32
- if (record(value))
33
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, anchorFiles(entry, directory)]));
34
- return value;
35
- }
36
- export async function isolatedProviderConfig() {
37
- const root = process.env.OPENCODE_CONFIG_DIR ??
38
- join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode");
39
- const paths = [
40
- join(root, "opencode.json"),
41
- join(root, "opencode.jsonc"),
42
- ...(process.env.OPENCODE_CONFIG ? [resolve(process.env.OPENCODE_CONFIG)] : []),
43
- ];
44
- let result = {};
45
- const include = (value, directory) => {
46
- if (!record(value))
47
- throw new Error("OpenCode configuration must be a JSON object");
48
- const selected = Object.fromEntries(PROVIDER_FIELDS.filter((key) => key in value).map((key) => [
49
- key,
50
- anchorFiles(value[key], directory),
51
- ]));
52
- if (record(selected.experimental))
53
- selected.experimental = { policies: selected.experimental.policies ?? [] };
54
- result = merge(result, selected);
55
- };
56
- for (const path of [...new Set(paths)]) {
57
- let text;
58
- try {
59
- text = await readFile(path, "utf8");
60
- }
61
- catch (error) {
62
- if (error.code === "ENOENT" &&
63
- path !== (process.env.OPENCODE_CONFIG ? resolve(process.env.OPENCODE_CONFIG) : undefined))
64
- continue;
65
- throw new Error("Cannot read OpenCode provider configuration");
66
- }
67
- const errors = [];
68
- const value = parse(text, errors, { allowTrailingComma: true });
69
- if (errors.length)
70
- throw new Error("OpenCode provider configuration is not valid JSON/JSONC");
71
- include(value, dirname(path));
72
- }
73
- if (process.env.OPENCODE_CONFIG_CONTENT) {
74
- let value;
75
- try {
76
- value = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT);
77
- }
78
- catch {
79
- throw new Error("OpenCode inline configuration must be a JSON object");
80
- }
81
- include(value, process.cwd());
82
- }
83
- return result;
84
- }