@kisev/memomatic 1.0.0 → 12.0.1
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 +18 -5
- package/README.ru.md +19 -5
- package/dist/executor.js +55 -77
- package/dist/ingest.js +7 -0
- package/dist/ingestion.js +34 -64
- package/dist/opencode-config.d.ts +1 -0
- package/dist/opencode-config.js +84 -0
- package/dist/search.d.ts +5 -1
- package/dist/search.js +112 -35
- package/dist/service.d.ts +5 -1
- package/dist/settings.d.ts +59 -7
- package/dist/settings.js +323 -33
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -4,15 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
`@kisev/memomatic` is a personal learning memory for agents following the OpenClaw
|
|
6
6
|
memory architecture: a tiered Markdown corpus you can read as plain files, a
|
|
7
|
-
rebuildable SQLite index with FTS5
|
|
8
|
-
extraction pass, and a nightly `dream` consolidation sweep.
|
|
7
|
+
rebuildable SQLite index with FTS5, optional local embeddings and an optional
|
|
8
|
+
reranker, a `sessions` extraction pass, and a nightly `dream` consolidation sweep.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
+
# Registry version
|
|
14
|
+
npm view --prefer-online @kisev/memomatic@latest version
|
|
15
|
+
|
|
16
|
+
# Install
|
|
13
17
|
npm install --global @kisev/memomatic
|
|
18
|
+
|
|
19
|
+
# Installed package and active CLI
|
|
20
|
+
npm list --global @kisev/memomatic --depth=0
|
|
21
|
+
memomatic --version
|
|
14
22
|
```
|
|
15
23
|
|
|
24
|
+
Tags can move between preview and installation; the last commands show the
|
|
25
|
+
installed package and the CLI resolved from PATH. Use `@dev` in both the view
|
|
26
|
+
and install commands for the development channel.
|
|
27
|
+
|
|
16
28
|
The corpus lives under `$XDG_STATE_HOME/memomatic/` (`MEMORY.md`, `USER.md`,
|
|
17
29
|
daily notes, `DREAMS.md`); rules live in `$XDG_CONFIG_HOME/memomatic/MEMORY_RULES.md`.
|
|
18
30
|
Requires Node.js 22.13+. Connect `memomatic mcp-serve` as a local stdio MCP server
|
|
@@ -30,8 +42,9 @@ Nightly runs are scheduled by the systemd user units in `assets/systemd/`
|
|
|
30
42
|
`memomatic-dream.service`/`.timer` for consolidation).
|
|
31
43
|
|
|
32
44
|
Configure `sessions.model` (or keep a legacy `dream.model`) and an OpenCode
|
|
33
|
-
provider before expecting session extraction; without a model, the session
|
|
34
|
-
watermark is preserved.
|
|
45
|
+
V2 provider before expecting session extraction; without a model, the session
|
|
46
|
+
watermark is preserved. Session extraction and its model server require
|
|
47
|
+
OpenCode `2.x`; a pre-V2 session database is not parsed. Follow the
|
|
35
48
|
[setup, first-entry, and scheduling guide](../../docs/how-to/memomatic.md).
|
|
36
49
|
|
|
37
50
|
## CLI, Sessions, and Dream
|
|
@@ -65,7 +78,7 @@ when memomatic is absent.
|
|
|
65
78
|
|
|
66
79
|
Every entry can carry a `source` annotation. Visibility derives from it:
|
|
67
80
|
`team-*`, `gitlab`, and `spec-manage` entries may be quoted in team-facing
|
|
68
|
-
artifacts; every other source (`people-journal`, `
|
|
81
|
+
artifacts; every other source (`people-journal`, `handoff`,
|
|
69
82
|
`mattermost-triage`, `task-*`, `docs-*`, `user`) is personal-only. The label
|
|
70
83
|
is exposed in search responses.
|
|
71
84
|
|
package/README.ru.md
CHANGED
|
@@ -4,15 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
`@kisev/memomatic` — персональная обучающая память для агентов по архитектуре
|
|
6
6
|
памяти OpenClaw: ярусный Markdown-корпус, который можно читать как обычные файлы,
|
|
7
|
-
пересобираемый SQLite-индекс с FTS5
|
|
8
|
-
проход извлечения `sessions` и ночной проход
|
|
7
|
+
пересобираемый SQLite-индекс с FTS5, опциональными локальными эмбеддингами и
|
|
8
|
+
опциональным реранкером, проход извлечения `sessions` и ночной проход
|
|
9
|
+
консолидации `dream`.
|
|
9
10
|
|
|
10
11
|
## Установка
|
|
11
12
|
|
|
12
13
|
```bash
|
|
14
|
+
# Registry version
|
|
15
|
+
npm view --prefer-online @kisev/memomatic@latest version
|
|
16
|
+
|
|
17
|
+
# Install
|
|
13
18
|
npm install --global @kisev/memomatic
|
|
19
|
+
|
|
20
|
+
# Installed package and active CLI
|
|
21
|
+
npm list --global @kisev/memomatic --depth=0
|
|
22
|
+
memomatic --version
|
|
14
23
|
```
|
|
15
24
|
|
|
25
|
+
Тег может сдвинуться между просмотром и установкой; последние команды показывают
|
|
26
|
+
установленный пакет и CLI из PATH. Для dev-канала укажите `@dev` и в просмотре,
|
|
27
|
+
и в установке.
|
|
28
|
+
|
|
16
29
|
Корпус живёт в `$XDG_STATE_HOME/memomatic/` (`MEMORY.md`, `USER.md`, ежедневные
|
|
17
30
|
записи, `DREAMS.md`); правила — в `$XDG_CONFIG_HOME/memomatic/MEMORY_RULES.md`.
|
|
18
31
|
Нужен Node.js 22.13+. Подключите `memomatic mcp-serve` как локальный MCP-сервер stdio
|
|
@@ -30,8 +43,9 @@ memomatic dream # консолидация: inbox + продвижение
|
|
|
30
43
|
`memomatic-dream.service`/`.timer` для консолидации).
|
|
31
44
|
|
|
32
45
|
Перед извлечением сессий настройте `sessions.model` (или оставьте старый
|
|
33
|
-
`dream.model`) и провайдера OpenCode; без модели отметка обработанных сессий
|
|
34
|
-
сохраняется.
|
|
46
|
+
`dream.model`) и провайдера OpenCode V2; без модели отметка обработанных сессий
|
|
47
|
+
сохраняется. Извлечение сессий и его сервер модели требуют OpenCode `2.x`;
|
|
48
|
+
база сессий до V2 не разбирается. См.
|
|
35
49
|
[настройку, первую запись и расписание](../../docs/ru/how-to/memomatic.md).
|
|
36
50
|
|
|
37
51
|
## CLI, Sessions и Dream
|
|
@@ -65,7 +79,7 @@ inbox, бэклог сессий, кандидатов на продвижени
|
|
|
65
79
|
|
|
66
80
|
Каждая запись может нести аннотацию `source`. Из неё выводится видимость:
|
|
67
81
|
записи `team-*`, `gitlab` и `spec-manage` можно цитировать в командных
|
|
68
|
-
артефактах; остальные источники (`people-journal`, `
|
|
82
|
+
артефактах; остальные источники (`people-journal`, `handoff`,
|
|
69
83
|
`mattermost-triage`, `task-*`, `docs-*`, `user`) — personal-only. Метка
|
|
70
84
|
возвращается в поиске.
|
|
71
85
|
|
package/dist/executor.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rm, writeFile } 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";
|
|
6
7
|
export function extractJson(response) {
|
|
7
8
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(response);
|
|
8
9
|
for (const candidate of [fenced?.[1], response]) {
|
|
@@ -49,30 +50,34 @@ export class OpenCodeExecutor {
|
|
|
49
50
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
50
51
|
throw new Error("invalid OpenCode URL");
|
|
51
52
|
this.url = url.href.replace(/\/$/, "");
|
|
52
|
-
|
|
53
|
-
|
|
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")}`;
|
|
54
56
|
return;
|
|
55
57
|
}
|
|
56
|
-
|
|
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
|
-
}
|
|
58
|
+
const inherited = await isolatedProviderConfig();
|
|
68
59
|
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 });
|
|
69
74
|
const password = randomBytes(24).toString("hex");
|
|
70
75
|
this.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
|
|
71
76
|
this.options.observe?.({
|
|
72
77
|
phase: "opencode.start",
|
|
73
78
|
message: "Starting one isolated OpenCode server for this run",
|
|
74
79
|
});
|
|
75
|
-
const child = spawn(this.options.binary ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0"
|
|
80
|
+
const child = spawn(this.options.binary ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
|
|
76
81
|
cwd: this.directory,
|
|
77
82
|
detached: process.platform !== "win32",
|
|
78
83
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -80,25 +85,11 @@ export class OpenCodeExecutor {
|
|
|
80
85
|
...process.env,
|
|
81
86
|
OPENCODE_SERVER_USERNAME: "opencode",
|
|
82
87
|
OPENCODE_SERVER_PASSWORD: password,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
}),
|
|
88
|
+
OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
|
89
|
+
OPENCODE_CONFIG_DIR: this.directory,
|
|
90
|
+
OPENCODE_CONFIG: configFile,
|
|
91
|
+
OPENCODE_CONFIG_CONTENT: "{}",
|
|
92
|
+
OPENCODE_PASSWORD: password,
|
|
102
93
|
},
|
|
103
94
|
});
|
|
104
95
|
this.child = child;
|
|
@@ -141,9 +132,9 @@ export class OpenCodeExecutor {
|
|
|
141
132
|
child.stderr?.resume();
|
|
142
133
|
this.options.observe?.({ phase: "opencode.ready", message: "OpenCode server ready" });
|
|
143
134
|
}
|
|
144
|
-
async request(path, body, signal) {
|
|
135
|
+
async request(path, body, signal, method = "POST") {
|
|
145
136
|
const response = await fetch(`${this.url}${path}`, {
|
|
146
|
-
method
|
|
137
|
+
method,
|
|
147
138
|
headers: {
|
|
148
139
|
"content-type": "application/json",
|
|
149
140
|
...(this.authorization ? { authorization: this.authorization } : {}),
|
|
@@ -152,8 +143,8 @@ export class OpenCodeExecutor {
|
|
|
152
143
|
signal,
|
|
153
144
|
});
|
|
154
145
|
if (!response.ok)
|
|
155
|
-
throw new Error(`OpenCode ${path.includes("
|
|
156
|
-
return (await response.json());
|
|
146
|
+
throw new Error(`OpenCode ${path.includes("generate") ? "model request" : "session request"} failed: HTTP ${response.status}`);
|
|
147
|
+
return response.status === 204 ? {} : (await response.json());
|
|
157
148
|
}
|
|
158
149
|
async complete(request) {
|
|
159
150
|
this.options.signal?.throwIfAborted();
|
|
@@ -174,56 +165,43 @@ export class OpenCodeExecutor {
|
|
|
174
165
|
}), 15000);
|
|
175
166
|
heartbeat.unref();
|
|
176
167
|
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;
|
|
184
168
|
const slash = this.options.model?.indexOf("/") ?? -1;
|
|
185
169
|
if (this.options.model && slash < 1)
|
|
186
170
|
throw new Error("model must use provider/model format");
|
|
187
|
-
this.
|
|
188
|
-
|
|
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`, {
|
|
171
|
+
const session = await this.request("/api/session", {
|
|
172
|
+
title: "[memomatic-internal] memory extraction",
|
|
196
173
|
agent: this.options.url ? "build" : "memomatic",
|
|
197
|
-
|
|
198
|
-
|
|
174
|
+
...(this.directory ? { location: { directory: this.directory } } : {}),
|
|
175
|
+
permissions: [{ action: "*", resource: "*", effect: "deny" }],
|
|
199
176
|
...(this.options.model
|
|
200
177
|
? {
|
|
201
178
|
model: {
|
|
202
179
|
providerID: this.options.model.slice(0, slash),
|
|
203
|
-
|
|
180
|
+
id: this.options.model.slice(slash + 1),
|
|
181
|
+
...(this.options.variant ? { variant: this.options.variant } : {}),
|
|
204
182
|
},
|
|
205
183
|
}
|
|
206
184
|
: {}),
|
|
207
|
-
...(this.options.variant ? { variant: this.options.variant } : {}),
|
|
208
|
-
parts: [{ type: "text", text: `[memomatic-internal] ${request.prompt}` }],
|
|
209
185
|
}, signal);
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
throw new Error("OpenCode
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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")
|
|
222
202
|
throw new Error("OpenCode model response is malformed");
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.map((part) => part.text ?? "")
|
|
226
|
-
.join("\n"));
|
|
203
|
+
// V2 transient generation returns text only, not exact token usage.
|
|
204
|
+
const parsed = extractJson(generated.text);
|
|
227
205
|
this.options.observe?.({
|
|
228
206
|
phase: "model.done",
|
|
229
207
|
message: "Model response received",
|
|
@@ -234,7 +212,7 @@ export class OpenCodeExecutor {
|
|
|
234
212
|
}
|
|
235
213
|
catch (error) {
|
|
236
214
|
if (id)
|
|
237
|
-
await this.request(`/session/${encodeURIComponent(id)}/
|
|
215
|
+
await this.request(`/api/session/${encodeURIComponent(id)}/interrupt?resume=false`, {}, AbortSignal.timeout(3000)).catch(() => undefined);
|
|
238
216
|
if (this.options.signal?.aborted)
|
|
239
217
|
throw this.options.signal.reason;
|
|
240
218
|
if (attempt === (this.options.retries ?? 1))
|
package/dist/ingest.js
CHANGED
|
@@ -4,5 +4,12 @@ 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
|
+
}
|
|
7
14
|
return join(base, "opencode", "opencode.db");
|
|
8
15
|
}
|
package/dist/ingestion.js
CHANGED
|
@@ -21,24 +21,20 @@ 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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
hasUpdated &&
|
|
31
|
-
["message", "part"].every((table) => db
|
|
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
|
|
32
30
|
.prepare(`PRAGMA table_info(${table})`)
|
|
33
31
|
.all()
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.all()
|
|
39
|
-
.some((row) => row.name === "session_id");
|
|
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
|
+
}
|
|
40
36
|
const sessions = db
|
|
41
|
-
.prepare(
|
|
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")
|
|
42
38
|
.all(options.before);
|
|
43
39
|
for (const [position, session] of sessions.entries()) {
|
|
44
40
|
if (session.title.startsWith("[memomatic-internal]")) {
|
|
@@ -47,66 +43,40 @@ export function planIngestion(file, store, options) {
|
|
|
47
43
|
}
|
|
48
44
|
const sealKey = `ingest-session-v2:${digest(session.id)}`;
|
|
49
45
|
let seal;
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
.prepare("SELECT
|
|
53
|
-
.
|
|
54
|
-
|
|
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) {
|
|
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)) {
|
|
58
51
|
result.skipped++;
|
|
59
52
|
continue;
|
|
60
53
|
}
|
|
61
|
-
const revision = digest(JSON.stringify([session.revision, session.title, session.directory,
|
|
54
|
+
const revision = digest(JSON.stringify([session.revision, session.title, session.directory, revisions]));
|
|
62
55
|
if (store.getMeta(sealKey) === revision) {
|
|
63
56
|
result.cachedSessions++;
|
|
64
57
|
continue;
|
|
65
58
|
}
|
|
66
59
|
seal = [sealKey, revision];
|
|
67
60
|
}
|
|
68
|
-
const rows =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
id: String(row.id),
|
|
83
|
-
message: row.data,
|
|
84
|
-
part: JSON.stringify(part),
|
|
85
|
-
}));
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
});
|
|
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);
|
|
91
75
|
const messages = new Map();
|
|
92
76
|
for (const row of rows) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
}
|
|
77
|
+
const item = messages.get(row.id) ?? { role: row.role, texts: [] };
|
|
78
|
+
item.texts.push(row.text);
|
|
79
|
+
messages.set(row.id, item);
|
|
110
80
|
}
|
|
111
81
|
const firstUser = [...messages.values()].find((message) => message.role === "user");
|
|
112
82
|
if (firstUser?.texts.join("\n").trimStart().startsWith("[memomatic-internal]")) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isolatedProviderConfig(): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
}
|
package/dist/search.d.ts
CHANGED
|
@@ -14,7 +14,11 @@ export type SearchHit = {
|
|
|
14
14
|
exact: boolean;
|
|
15
15
|
recency: number;
|
|
16
16
|
importance: number;
|
|
17
|
-
reason: "lexical" | "semantic" | "both";
|
|
17
|
+
reason: "lexical" | "semantic" | "both" | "rerank";
|
|
18
|
+
rerank?: {
|
|
19
|
+
candidates: number;
|
|
20
|
+
threshold: number;
|
|
21
|
+
};
|
|
18
22
|
};
|
|
19
23
|
};
|
|
20
24
|
export declare function corpusFiles(paths: MemomaticPaths): Promise<string[]>;
|
package/dist/search.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { cosineSimilarity, stableIdFor } from "./store.js";
|
|
4
4
|
import { entryKind, parseCorpusEntries } from "./corpus.js";
|
|
5
5
|
import { entryImportance, entryObservedAt } from "./entries.js";
|
|
6
|
-
import { embedTexts, embeddingFingerprint } from "./settings.js";
|
|
6
|
+
import { embedTexts, embeddingFingerprint, rerankTexts } from "./settings.js";
|
|
7
7
|
import { check } from "./operations.js";
|
|
8
8
|
export async function corpusFiles(paths) {
|
|
9
9
|
const files = [];
|
|
@@ -73,9 +73,10 @@ export async function reindex(paths, settings, store, options = {}) {
|
|
|
73
73
|
completed: 0,
|
|
74
74
|
cached: entries.length - missing.length,
|
|
75
75
|
});
|
|
76
|
-
|
|
76
|
+
const batchSize = Math.min(32, settings.embedding?.maxBatchTexts ?? 32);
|
|
77
|
+
for (let start = 0; start < missing.length; start += batchSize) {
|
|
77
78
|
check(options);
|
|
78
|
-
const batch = missing.slice(start, start +
|
|
79
|
+
const batch = missing.slice(start, start + batchSize);
|
|
79
80
|
const computed = await embedTexts(settings, batch.map((item) => item.entry.text), "document", options.signal);
|
|
80
81
|
if (vectors && computed)
|
|
81
82
|
batch.forEach((item, index) => {
|
|
@@ -85,7 +86,7 @@ export async function reindex(paths, settings, store, options = {}) {
|
|
|
85
86
|
phase: "index.embeddings",
|
|
86
87
|
message: "Embedding batch complete",
|
|
87
88
|
total: missing.length,
|
|
88
|
-
completed: Math.min(start +
|
|
89
|
+
completed: Math.min(start + batchSize, missing.length),
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
check(options);
|
|
@@ -117,40 +118,21 @@ export function queryTokens(query) {
|
|
|
117
118
|
.match(/[\p{L}\p{N}]+/gu) ?? []),
|
|
118
119
|
].filter((token) => token.length >= 2);
|
|
119
120
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
store.getMeta("embeddingFingerprint") !== embeddingFingerprint(settings))
|
|
130
|
-
throw new Error("embedding index is missing or belongs to another model; run memomatic index");
|
|
131
|
-
const queryVector = (await embedTexts(settings, [query], "query"))?.[0];
|
|
132
|
-
if (queryVector?.length) {
|
|
133
|
-
for (const row of storedVectors) {
|
|
134
|
-
if (row.data.length !== queryVector.length)
|
|
135
|
-
throw new Error("embedding dimensions changed; run memomatic index");
|
|
136
|
-
const similarity = cosineSimilarity(queryVector, row.data);
|
|
137
|
-
if (similarity > 0.05)
|
|
138
|
-
vector.set(row.stableId, similarity);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const candidates = new Set([...keyword.keys(), ...vector.keys()]);
|
|
142
|
-
const entries = store
|
|
143
|
-
.allEntries()
|
|
144
|
-
.filter((item) => options.project === undefined || item.project === options.project || item.kind === "user");
|
|
145
|
-
const byId = new Map(entries.map((entry) => [entry.stableId, entry]));
|
|
121
|
+
function lexicalCoverage(entry, tokens, keyword) {
|
|
122
|
+
const words = queryTokens(entry.text);
|
|
123
|
+
const matchedTokens = tokens.filter((token) => words.some((word) => word.startsWith(token)));
|
|
124
|
+
return {
|
|
125
|
+
matchedTokens,
|
|
126
|
+
lexical: keyword.has(entry.stableId) && tokens.length ? matchedTokens.length / tokens.length : 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function gatedHits(tokens, keyword, vector, byId, settings) {
|
|
146
130
|
const hits = [];
|
|
147
|
-
for (const stableId of
|
|
131
|
+
for (const stableId of new Set([...keyword.keys(), ...vector.keys()])) {
|
|
148
132
|
const entry = byId.get(stableId);
|
|
149
133
|
if (!entry)
|
|
150
134
|
continue;
|
|
151
|
-
const
|
|
152
|
-
const matchedTokens = tokens.filter((token) => words.some((word) => word.startsWith(token)));
|
|
153
|
-
const lexical = keyword.has(stableId) && tokens.length ? matchedTokens.length / tokens.length : 0;
|
|
135
|
+
const { matchedTokens, lexical } = lexicalCoverage(entry, tokens, keyword);
|
|
154
136
|
const semantic = vector.get(stableId) ?? null;
|
|
155
137
|
const lexicalAccepted = lexical >= settings.search.minScore && lexical >= 0.5;
|
|
156
138
|
const semanticAccepted = semantic !== null &&
|
|
@@ -164,6 +146,7 @@ export async function search(store, query, settings, options = {}) {
|
|
|
164
146
|
const score = relevance * recency * (0.85 + 0.15 * importance);
|
|
165
147
|
if (score < settings.search.minScore)
|
|
166
148
|
continue;
|
|
149
|
+
const words = queryTokens(entry.text);
|
|
167
150
|
hits.push({
|
|
168
151
|
entry,
|
|
169
152
|
score,
|
|
@@ -180,10 +163,104 @@ export async function search(store, query, settings, options = {}) {
|
|
|
180
163
|
},
|
|
181
164
|
});
|
|
182
165
|
}
|
|
183
|
-
hits.sort((left, right) => Number(right.explanation.exact) - Number(left.explanation.exact) ||
|
|
166
|
+
return hits.sort((left, right) => Number(right.explanation.exact) - Number(left.explanation.exact) ||
|
|
184
167
|
right.score - left.score ||
|
|
185
168
|
(right.explanation.semantic ?? 0) - (left.explanation.semantic ?? 0) ||
|
|
186
169
|
left.entry.stableId.localeCompare(right.entry.stableId));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reranker path: collect a bounded wide candidate set from lexical and vector
|
|
173
|
+
* search before the strict relevance gates, apply the project filter, and let
|
|
174
|
+
* the reranker decide final relevance and order without giving literal
|
|
175
|
+
* matches an unconditional priority.
|
|
176
|
+
*/
|
|
177
|
+
async function rerankedHits(query, tokens, keyword, vector, byId, settings) {
|
|
178
|
+
const reranker = settings.reranker;
|
|
179
|
+
// The project filter narrows the pool before top-N selection, so hidden
|
|
180
|
+
// entries cannot consume the candidate budget.
|
|
181
|
+
const visible = (scores) => {
|
|
182
|
+
const filtered = new Map();
|
|
183
|
+
for (const [stableId, score] of scores)
|
|
184
|
+
if (byId.has(stableId))
|
|
185
|
+
filtered.set(stableId, score);
|
|
186
|
+
return filtered;
|
|
187
|
+
};
|
|
188
|
+
const widest = (scores) => [...scores.entries()]
|
|
189
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
190
|
+
.slice(0, reranker.candidates)
|
|
191
|
+
.flatMap(([stableId]) => {
|
|
192
|
+
const entry = byId.get(stableId);
|
|
193
|
+
return entry ? [entry] : [];
|
|
194
|
+
});
|
|
195
|
+
const candidates = [];
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
for (const entry of [...widest(visible(keyword)), ...widest(visible(vector))]) {
|
|
198
|
+
if (seen.has(entry.stableId))
|
|
199
|
+
continue;
|
|
200
|
+
seen.add(entry.stableId);
|
|
201
|
+
candidates.push(entry);
|
|
202
|
+
}
|
|
203
|
+
if (!candidates.length)
|
|
204
|
+
return [];
|
|
205
|
+
// Reranker documents stay raw entry text without embedding prefixes.
|
|
206
|
+
const ranked = await rerankTexts(settings, query, candidates.map((entry) => entry.text));
|
|
207
|
+
const hits = [];
|
|
208
|
+
for (const { index, score } of ranked) {
|
|
209
|
+
if (score < reranker.minScore)
|
|
210
|
+
continue;
|
|
211
|
+
const entry = candidates[index];
|
|
212
|
+
const { matchedTokens, lexical } = lexicalCoverage(entry, tokens, keyword);
|
|
213
|
+
const semantic = vector.get(entry.stableId) ?? null;
|
|
214
|
+
const recency = recencyMultiplier(entry, settings.search.halfLifeDays);
|
|
215
|
+
const importance = Math.max(0, Math.min(1, (entry.importance - 1) / 9));
|
|
216
|
+
const words = queryTokens(entry.text);
|
|
217
|
+
hits.push({
|
|
218
|
+
entry,
|
|
219
|
+
score,
|
|
220
|
+
snippet: entry.text.slice(0, 240),
|
|
221
|
+
explanation: {
|
|
222
|
+
matchedTokens,
|
|
223
|
+
lexical,
|
|
224
|
+
semantic,
|
|
225
|
+
relevance: score,
|
|
226
|
+
recency,
|
|
227
|
+
importance,
|
|
228
|
+
exact: tokens.length > 0 && tokens.every((token) => words.includes(token)),
|
|
229
|
+
reason: "rerank",
|
|
230
|
+
rerank: { candidates: candidates.length, threshold: reranker.minScore },
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return hits;
|
|
235
|
+
}
|
|
236
|
+
export async function search(store, query, settings, options = {}) {
|
|
237
|
+
if (!query.trim())
|
|
238
|
+
throw new Error("query is required");
|
|
239
|
+
const tokens = queryTokens(query);
|
|
240
|
+
const keyword = store.ftsSearch(query, settings.reranker ? Math.max(200, settings.reranker.candidates) : 200);
|
|
241
|
+
const vector = new Map();
|
|
242
|
+
const storedVectors = store.vectors();
|
|
243
|
+
if (settings.embedding &&
|
|
244
|
+
store.allEntries().length &&
|
|
245
|
+
store.getMeta("embeddingFingerprint") !== embeddingFingerprint(settings))
|
|
246
|
+
throw new Error("embedding index is missing or belongs to another model; run memomatic index");
|
|
247
|
+
const queryVector = (await embedTexts(settings, [query], "query"))?.[0];
|
|
248
|
+
if (queryVector?.length) {
|
|
249
|
+
for (const row of storedVectors) {
|
|
250
|
+
if (row.data.length !== queryVector.length)
|
|
251
|
+
throw new Error("embedding dimensions changed; run memomatic index");
|
|
252
|
+
const similarity = cosineSimilarity(queryVector, row.data);
|
|
253
|
+
if (similarity > 0.05)
|
|
254
|
+
vector.set(row.stableId, similarity);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const entries = store
|
|
258
|
+
.allEntries()
|
|
259
|
+
.filter((item) => options.project === undefined || item.project === options.project || item.kind === "user");
|
|
260
|
+
const byId = new Map(entries.map((entry) => [entry.stableId, entry]));
|
|
261
|
+
const hits = settings.reranker
|
|
262
|
+
? await rerankedHits(query, tokens, keyword, vector, byId, settings)
|
|
263
|
+
: gatedHits(tokens, keyword, vector, byId, settings);
|
|
187
264
|
const limited = hits.slice(0, settings.search.maxResults);
|
|
188
265
|
if (options.markSurfaced !== false && limited.length)
|
|
189
266
|
store.markSurfaced(limited.map((hit) => hit.entry.stableId), query);
|
package/dist/service.d.ts
CHANGED
|
@@ -45,7 +45,11 @@ export declare function searchMemoryResults(context: MemomaticContext, query: st
|
|
|
45
45
|
exact: boolean;
|
|
46
46
|
recency: number;
|
|
47
47
|
importance: number;
|
|
48
|
-
reason: "lexical" | "semantic" | "both";
|
|
48
|
+
reason: "lexical" | "semantic" | "both" | "rerank";
|
|
49
|
+
rerank?: {
|
|
50
|
+
candidates: number;
|
|
51
|
+
threshold: number;
|
|
52
|
+
};
|
|
49
53
|
} | undefined;
|
|
50
54
|
source: string | null;
|
|
51
55
|
visibility: import("./visibility.js").EntryVisibility;
|
package/dist/settings.d.ts
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
export type EmbeddingSettings = {
|
|
2
|
+
url: string;
|
|
3
|
+
model: string;
|
|
4
|
+
/** Explicit query instruction; null keeps the built-in Qwen3 auto-detection. */
|
|
5
|
+
queryInstruction: string | null;
|
|
6
|
+
documentPrefix: string;
|
|
7
|
+
queryPrefix: string;
|
|
8
|
+
bearerEnv: string | null;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
maxBatchTexts: number;
|
|
11
|
+
maxTextChars: number;
|
|
12
|
+
};
|
|
13
|
+
export type RerankerSettings = {
|
|
14
|
+
url: string;
|
|
15
|
+
model: string;
|
|
16
|
+
bearerEnv: string | null;
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
candidates: number;
|
|
19
|
+
minScore: number;
|
|
20
|
+
maxDocuments: number;
|
|
21
|
+
maxChars: number;
|
|
22
|
+
};
|
|
1
23
|
export type MemomaticSettings = {
|
|
2
|
-
embedding:
|
|
3
|
-
|
|
4
|
-
model: string;
|
|
5
|
-
queryInstruction?: string;
|
|
6
|
-
} | null;
|
|
24
|
+
embedding: EmbeddingSettings | null;
|
|
25
|
+
reranker: RerankerSettings | null;
|
|
7
26
|
sessions: {
|
|
8
27
|
model: string | null;
|
|
9
28
|
variant: string | null;
|
|
@@ -39,10 +58,43 @@ export type MemomaticSettings = {
|
|
|
39
58
|
days: number;
|
|
40
59
|
};
|
|
41
60
|
};
|
|
61
|
+
export declare const DEFAULT_MAX_TEXT_CHARS = 8000;
|
|
62
|
+
export declare const RERANKER_TEMPLATE_ALLOWANCE_CHARS = 1024;
|
|
63
|
+
export declare const defaultEmbedding: (url: string, model: string) => EmbeddingSettings;
|
|
64
|
+
export declare const defaultReranker: (url: string, model: string) => RerankerSettings;
|
|
42
65
|
export declare const defaultSettings: () => MemomaticSettings;
|
|
43
66
|
export declare function normalizeSettings(raw: unknown): MemomaticSettings;
|
|
44
67
|
export declare function loadSettings(settingsFile: string): Promise<MemomaticSettings>;
|
|
45
|
-
|
|
68
|
+
/** Splits long text at paragraph, line or space boundaries within the budget. */
|
|
69
|
+
export declare function chunkText(text: string, maxChars: number): string[];
|
|
46
70
|
export declare const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
|
|
47
|
-
export declare function
|
|
71
|
+
export declare function embeddingQueryText(embedding: EmbeddingSettings, query: string): string;
|
|
72
|
+
/** Headroom a formatted query chunk needs beyond its raw text. */
|
|
73
|
+
export declare function embeddingQueryOverhead(embedding: EmbeddingSettings): number;
|
|
74
|
+
export declare function embeddingDocumentText(embedding: EmbeddingSettings, text: string): string;
|
|
75
|
+
/** Headroom a formatted document chunk needs beyond its raw text. */
|
|
76
|
+
export declare function embeddingDocumentOverhead(embedding: EmbeddingSettings): number;
|
|
77
|
+
/**
|
|
78
|
+
* Embeds texts through an OpenAI-compatible `embeddings` endpoint. Raw inputs
|
|
79
|
+
* are split with chunkText first and every chunk is formatted afterwards, so
|
|
80
|
+
* each document chunk carries the document prefix and each query chunk the
|
|
81
|
+
* query prefix plus the Qwen instruction, all inside maxTextChars. Chunks are
|
|
82
|
+
* embedded chunk-wise and mean-pooled back onto one vector per input text.
|
|
83
|
+
* Request character budgets are a conservative proxy, not exact tokenization.
|
|
84
|
+
*/
|
|
85
|
+
export declare function embedTexts(settings: MemomaticSettings, texts: string[], purpose?: "document" | "query", signal?: AbortSignal): Promise<Float32Array[] | null>;
|
|
86
|
+
export type RerankHit = {
|
|
87
|
+
index: number;
|
|
88
|
+
score: number;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Ranks documents against a query through a `/rerank`-style endpoint
|
|
92
|
+
* (`{model, query, documents, top_n}` request, `results[].index` and
|
|
93
|
+
* `results[].relevance_score` response). Documents are chunked so the query,
|
|
94
|
+
* one document chunk and the server template stay inside maxChars; a query
|
|
95
|
+
* too large to leave room for a chunk fails before the request. Responses
|
|
96
|
+
* must contain exactly top_n results, and chunk scores aggregate to one best
|
|
97
|
+
* score per input document.
|
|
98
|
+
*/
|
|
99
|
+
export declare function rerankTexts(settings: MemomaticSettings, query: string, documents: string[], signal?: AbortSignal): Promise<RerankHit[]>;
|
|
48
100
|
export declare function embeddingFingerprint(settings: MemomaticSettings): string;
|
package/dist/settings.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
// Conservative character proxy for an input budget of roughly 4096 tokens:
|
|
4
|
+
// dense scripts need about two characters per token, so 8000 characters stay
|
|
5
|
+
// inside the example budget without claiming exact tokenization. Long texts
|
|
6
|
+
// are split at chunkText instead of measured with a tokenizer.
|
|
7
|
+
export const DEFAULT_MAX_TEXT_CHARS = 8000;
|
|
8
|
+
// Headroom for the server-side rerank template wrapped around query and document.
|
|
9
|
+
export const RERANKER_TEMPLATE_ALLOWANCE_CHARS = 1024;
|
|
10
|
+
export const defaultEmbedding = (url, model) => ({
|
|
11
|
+
url,
|
|
12
|
+
model,
|
|
13
|
+
queryInstruction: null,
|
|
14
|
+
documentPrefix: "",
|
|
15
|
+
queryPrefix: "",
|
|
16
|
+
bearerEnv: null,
|
|
17
|
+
timeoutMs: 30_000,
|
|
18
|
+
maxBatchTexts: 32,
|
|
19
|
+
maxTextChars: DEFAULT_MAX_TEXT_CHARS,
|
|
20
|
+
});
|
|
21
|
+
export const defaultReranker = (url, model) => ({
|
|
22
|
+
url,
|
|
23
|
+
model,
|
|
24
|
+
bearerEnv: null,
|
|
25
|
+
timeoutMs: 30_000,
|
|
26
|
+
candidates: 64,
|
|
27
|
+
minScore: 0,
|
|
28
|
+
maxDocuments: 64,
|
|
29
|
+
maxChars: DEFAULT_MAX_TEXT_CHARS,
|
|
30
|
+
});
|
|
3
31
|
export const defaultSettings = () => ({
|
|
4
32
|
embedding: null,
|
|
33
|
+
reranker: null,
|
|
5
34
|
sessions: {
|
|
6
35
|
model: null,
|
|
7
36
|
variant: null,
|
|
@@ -50,10 +79,56 @@ const SESSIONS_KEYS = [
|
|
|
50
79
|
"idleMs",
|
|
51
80
|
"opencodeUrl",
|
|
52
81
|
];
|
|
82
|
+
function httpUrl(value, section) {
|
|
83
|
+
if (typeof value !== "string")
|
|
84
|
+
throw new Error(`${section}.url must be a string`);
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = new URL(value);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error(`${section}.url must be an absolute http(s) URL`);
|
|
91
|
+
}
|
|
92
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
93
|
+
throw new Error(`${section}.url must use http or https`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function bearerEnvName(value, section) {
|
|
97
|
+
if (value === null || value === undefined)
|
|
98
|
+
return null;
|
|
99
|
+
if (typeof value !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
100
|
+
throw new Error(`${section}.bearerEnv must name an environment variable`);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function boundedInt(value, section, key, min, max) {
|
|
104
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
105
|
+
throw new Error(`invalid ${section}.${key}; expected an integer between ${min} and ${max}`);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function optionalText(value, section, key) {
|
|
109
|
+
if (typeof value !== "string")
|
|
110
|
+
throw new Error(`${section}.${key} must be a string`);
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
function serviceEndpoint(raw, section, defaults, validate) {
|
|
114
|
+
if (raw === null || raw === undefined)
|
|
115
|
+
return null;
|
|
116
|
+
if (typeof raw !== "object")
|
|
117
|
+
throw new Error(`invalid ${section} settings`);
|
|
118
|
+
const url = httpUrl(raw.url, section);
|
|
119
|
+
if (typeof raw.model !== "string" || !raw.model.trim())
|
|
120
|
+
throw new Error(`${section}.model must be a non-empty string`);
|
|
121
|
+
const merged = defaults(url, raw.model);
|
|
122
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
123
|
+
if (key in merged && value !== null && value !== undefined)
|
|
124
|
+
merged[key] = value;
|
|
125
|
+
}
|
|
126
|
+
validate(merged);
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
53
129
|
export function normalizeSettings(raw) {
|
|
54
130
|
const defaults = defaultSettings();
|
|
55
131
|
const source = (raw ?? {});
|
|
56
|
-
const embedding = source.embedding;
|
|
57
132
|
const search = mergeSection(defaults.search, source.search);
|
|
58
133
|
const dream = mergeSection(defaults.dream, source.dream);
|
|
59
134
|
// Legacy single-model configs put extraction knobs under `dream`; adopt them
|
|
@@ -106,16 +181,32 @@ export function normalizeSettings(raw) {
|
|
|
106
181
|
search.halfLifeDays <= 0 ||
|
|
107
182
|
![search.minScore, search.minSemanticScore].every((value) => Number.isFinite(value) && value >= 0 && value <= 1))
|
|
108
183
|
throw new Error("invalid search settings");
|
|
184
|
+
const embedding = serviceEndpoint((source.embedding ?? null), "embedding", (url, model) => defaultEmbedding(url, model), (merged) => {
|
|
185
|
+
if (merged.queryInstruction !== null && typeof merged.queryInstruction !== "string")
|
|
186
|
+
throw new Error("embedding.queryInstruction must be a string or null");
|
|
187
|
+
merged.queryInstruction =
|
|
188
|
+
typeof merged.queryInstruction === "string" ? merged.queryInstruction : null;
|
|
189
|
+
merged.documentPrefix = optionalText(merged.documentPrefix, "embedding", "documentPrefix");
|
|
190
|
+
merged.queryPrefix = optionalText(merged.queryPrefix, "embedding", "queryPrefix");
|
|
191
|
+
merged.bearerEnv = bearerEnvName(merged.bearerEnv, "embedding");
|
|
192
|
+
merged.timeoutMs = boundedInt(merged.timeoutMs, "embedding", "timeoutMs", 1, Number.MAX_SAFE_INTEGER);
|
|
193
|
+
merged.maxBatchTexts = boundedInt(merged.maxBatchTexts, "embedding", "maxBatchTexts", 1, 512);
|
|
194
|
+
merged.maxTextChars = boundedInt(merged.maxTextChars, "embedding", "maxTextChars", 256, 1_000_000);
|
|
195
|
+
});
|
|
196
|
+
const reranker = serviceEndpoint((source.reranker ?? null), "reranker", (url, model) => defaultReranker(url, model), (merged) => {
|
|
197
|
+
merged.bearerEnv = bearerEnvName(merged.bearerEnv, "reranker");
|
|
198
|
+
merged.timeoutMs = boundedInt(merged.timeoutMs, "reranker", "timeoutMs", 1, Number.MAX_SAFE_INTEGER);
|
|
199
|
+
merged.candidates = boundedInt(merged.candidates, "reranker", "candidates", 1, 1000);
|
|
200
|
+
// Scores are ranking signals whose scale depends on the model, so the
|
|
201
|
+
// threshold is any finite non-negative number, not a probability bound.
|
|
202
|
+
if (!Number.isFinite(merged.minScore) || merged.minScore < 0)
|
|
203
|
+
throw new Error("invalid reranker.minScore; expected a finite number of at least 0");
|
|
204
|
+
merged.maxDocuments = boundedInt(merged.maxDocuments, "reranker", "maxDocuments", 1, 512);
|
|
205
|
+
merged.maxChars = boundedInt(merged.maxChars, "reranker", "maxChars", 256, 1_000_000);
|
|
206
|
+
});
|
|
109
207
|
return {
|
|
110
|
-
embedding
|
|
111
|
-
|
|
112
|
-
url: embedding.url,
|
|
113
|
-
model: embedding.model,
|
|
114
|
-
...(typeof embedding.queryInstruction === "string"
|
|
115
|
-
? { queryInstruction: embedding.queryInstruction }
|
|
116
|
-
: {}),
|
|
117
|
-
}
|
|
118
|
-
: null,
|
|
208
|
+
embedding,
|
|
209
|
+
reranker,
|
|
119
210
|
sessions,
|
|
120
211
|
dream,
|
|
121
212
|
search,
|
|
@@ -132,25 +223,92 @@ export async function loadSettings(settingsFile) {
|
|
|
132
223
|
throw error;
|
|
133
224
|
}
|
|
134
225
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
226
|
+
/** Splits long text at paragraph, line or space boundaries within the budget. */
|
|
227
|
+
export function chunkText(text, maxChars) {
|
|
228
|
+
if (text.length <= maxChars)
|
|
229
|
+
return [text];
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let start = 0;
|
|
232
|
+
while (start < text.length) {
|
|
233
|
+
let end = Math.min(start + maxChars, text.length);
|
|
234
|
+
if (end < text.length) {
|
|
235
|
+
const window = text.slice(start, end);
|
|
236
|
+
const boundary = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"), window.lastIndexOf(" "));
|
|
237
|
+
if (boundary >= Math.floor(maxChars / 2))
|
|
238
|
+
end = start + boundary + 1;
|
|
239
|
+
}
|
|
240
|
+
const chunk = text.slice(start, end).trim();
|
|
241
|
+
if (chunk)
|
|
242
|
+
chunks.push(chunk);
|
|
243
|
+
start = end;
|
|
244
|
+
}
|
|
245
|
+
return chunks.length ? chunks : [text.slice(0, maxChars)];
|
|
246
|
+
}
|
|
247
|
+
function bearerToken(bearerEnv, service) {
|
|
248
|
+
if (!bearerEnv)
|
|
249
|
+
return undefined;
|
|
250
|
+
const token = process.env[bearerEnv];
|
|
251
|
+
if (!token)
|
|
252
|
+
throw new Error(`${service} bearerEnv "${bearerEnv}" is not set`);
|
|
253
|
+
// A control character makes the Authorization header value invalid; the
|
|
254
|
+
// fetch-level rejection echoes the token back, so reject it here where the
|
|
255
|
+
// diagnostic stays free of the secret.
|
|
256
|
+
if (/[\u0000-\u001f\u007f]/.test(token))
|
|
257
|
+
throw new Error(`${service} bearerEnv "${bearerEnv}" contains a control character; expected a single-line token`);
|
|
258
|
+
return token;
|
|
259
|
+
}
|
|
260
|
+
async function postJson(url, body, token, timeoutMs, signal, service) {
|
|
261
|
+
const response = await fetch(url, {
|
|
262
|
+
body: JSON.stringify(body),
|
|
263
|
+
headers: {
|
|
264
|
+
"content-type": "application/json",
|
|
265
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
266
|
+
},
|
|
144
267
|
method: "POST",
|
|
145
268
|
signal: signal
|
|
146
|
-
? AbortSignal.any([signal, AbortSignal.timeout(
|
|
147
|
-
: AbortSignal.timeout(
|
|
269
|
+
? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
|
|
270
|
+
: AbortSignal.timeout(timeoutMs),
|
|
271
|
+
}).catch((error) => {
|
|
272
|
+
const failure = error;
|
|
273
|
+
if (failure.name === "TimeoutError")
|
|
274
|
+
throw new Error(`${service} request timed out after ${timeoutMs}ms`);
|
|
275
|
+
if (failure.name === "AbortError")
|
|
276
|
+
throw new Error(`${service} request was aborted`);
|
|
277
|
+
throw new Error(`${service} request failed: ${failure.message}`);
|
|
148
278
|
});
|
|
149
279
|
if (!response.ok)
|
|
150
|
-
throw new Error(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
280
|
+
throw new Error(`${service} request failed: ${response.status} ${(await response.text()).slice(0, 2000)}`);
|
|
281
|
+
try {
|
|
282
|
+
return await response.json();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
throw new Error(`${service} response is not valid JSON`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
|
|
289
|
+
function queryInstructionFor(embedding) {
|
|
290
|
+
return (embedding.queryInstruction ??
|
|
291
|
+
(/qwen3[-_]?embedding/i.test(embedding.model) ? MEMORY_QUERY_INSTRUCTION : ""));
|
|
292
|
+
}
|
|
293
|
+
export function embeddingQueryText(embedding, query) {
|
|
294
|
+
const instruction = queryInstructionFor(embedding);
|
|
295
|
+
return `${embedding.queryPrefix}${instruction ? `Instruct: ${instruction}\nQuery:${query}` : query}`;
|
|
296
|
+
}
|
|
297
|
+
/** Headroom a formatted query chunk needs beyond its raw text. */
|
|
298
|
+
export function embeddingQueryOverhead(embedding) {
|
|
299
|
+
const instruction = queryInstructionFor(embedding);
|
|
300
|
+
return (embedding.queryPrefix.length + (instruction ? `Instruct: ${instruction}\nQuery:`.length : 0));
|
|
301
|
+
}
|
|
302
|
+
export function embeddingDocumentText(embedding, text) {
|
|
303
|
+
return `${embedding.documentPrefix}${text}`;
|
|
304
|
+
}
|
|
305
|
+
/** Headroom a formatted document chunk needs beyond its raw text. */
|
|
306
|
+
export function embeddingDocumentOverhead(embedding) {
|
|
307
|
+
return embedding.documentPrefix.length;
|
|
308
|
+
}
|
|
309
|
+
function parseEmbeddingPayload(payload, count) {
|
|
310
|
+
const list = (payload.data ?? []);
|
|
311
|
+
if (!Array.isArray(list) || list.length !== count)
|
|
154
312
|
throw new Error("embedding response is incomplete");
|
|
155
313
|
const byIndex = new Map();
|
|
156
314
|
for (const item of list) {
|
|
@@ -160,21 +318,145 @@ export async function embedTexts(settings, texts, purpose = "document", signal)
|
|
|
160
318
|
!item.embedding.some((value) => value !== 0))
|
|
161
319
|
throw new Error("embedding response is malformed");
|
|
162
320
|
const index = item.index ?? byIndex.size;
|
|
163
|
-
if (!Number.isInteger(index) || index < 0 || index >=
|
|
321
|
+
if (!Number.isInteger(index) || index < 0 || index >= count || byIndex.has(index))
|
|
164
322
|
throw new Error("embedding response has invalid indices");
|
|
165
323
|
byIndex.set(index, item.embedding);
|
|
166
324
|
}
|
|
167
|
-
const vectors =
|
|
325
|
+
const vectors = Array.from({ length: count }, (_, index) => Float32Array.from(byIndex.get(index)));
|
|
168
326
|
if (!vectors.every((vector) => vector.length === vectors[0].length && vector.every(Number.isFinite)))
|
|
169
327
|
throw new Error("embedding response has incompatible vectors");
|
|
170
328
|
return vectors;
|
|
171
329
|
}
|
|
172
|
-
|
|
173
|
-
|
|
330
|
+
function meanPool(vectors) {
|
|
331
|
+
if (vectors.length === 1)
|
|
332
|
+
return vectors[0];
|
|
333
|
+
const pooled = new Float32Array(vectors[0].length);
|
|
334
|
+
for (const vector of vectors) {
|
|
335
|
+
if (vector.length !== pooled.length)
|
|
336
|
+
throw new Error("embedding response has incompatible vectors");
|
|
337
|
+
for (let index = 0; index < vector.length; index += 1)
|
|
338
|
+
pooled[index] += vector[index];
|
|
339
|
+
}
|
|
340
|
+
for (let index = 0; index < pooled.length; index += 1)
|
|
341
|
+
pooled[index] /= vectors.length;
|
|
342
|
+
return pooled;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Embeds texts through an OpenAI-compatible `embeddings` endpoint. Raw inputs
|
|
346
|
+
* are split with chunkText first and every chunk is formatted afterwards, so
|
|
347
|
+
* each document chunk carries the document prefix and each query chunk the
|
|
348
|
+
* query prefix plus the Qwen instruction, all inside maxTextChars. Chunks are
|
|
349
|
+
* embedded chunk-wise and mean-pooled back onto one vector per input text.
|
|
350
|
+
* Request character budgets are a conservative proxy, not exact tokenization.
|
|
351
|
+
*/
|
|
352
|
+
export async function embedTexts(settings, texts, purpose = "document", signal) {
|
|
353
|
+
if (!settings.embedding || !texts.length)
|
|
354
|
+
return null;
|
|
174
355
|
const embedding = settings.embedding;
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
356
|
+
const token = bearerToken(embedding.bearerEnv, "embedding");
|
|
357
|
+
const format = purpose === "query"
|
|
358
|
+
? (text) => embeddingQueryText(embedding, text)
|
|
359
|
+
: (text) => embeddingDocumentText(embedding, text);
|
|
360
|
+
const overhead = purpose === "query" ? embeddingQueryOverhead(embedding) : embeddingDocumentOverhead(embedding);
|
|
361
|
+
const chunkBudget = embedding.maxTextChars - overhead;
|
|
362
|
+
if (chunkBudget < 1)
|
|
363
|
+
throw new Error(`embedding ${purpose} formatting needs ${overhead} characters of headroom and leaves no room inside maxTextChars=${embedding.maxTextChars}`);
|
|
364
|
+
// Split the raw text with formatting headroom reserved, then format each
|
|
365
|
+
// chunk so prefixes and instructions cover every piece, not just the first.
|
|
366
|
+
const chunkLists = texts.map((text) => chunkText(text, chunkBudget).map(format));
|
|
367
|
+
const chunks = chunkLists.flat();
|
|
368
|
+
const vectors = new Array(chunks.length);
|
|
369
|
+
for (let start = 0; start < chunks.length; start += embedding.maxBatchTexts) {
|
|
370
|
+
const batch = chunks.slice(start, start + embedding.maxBatchTexts);
|
|
371
|
+
const payload = await postJson(embedding.url, { input: batch, model: embedding.model, encoding_format: "float" }, token, embedding.timeoutMs, signal, "embedding");
|
|
372
|
+
parseEmbeddingPayload(payload, batch.length).forEach((vector, index) => (vectors[start + index] = vector));
|
|
373
|
+
}
|
|
374
|
+
const pooled = [];
|
|
375
|
+
let offset = 0;
|
|
376
|
+
for (const list of chunkLists) {
|
|
377
|
+
pooled.push(meanPool(vectors.slice(offset, offset + list.length)));
|
|
378
|
+
offset += list.length;
|
|
379
|
+
}
|
|
380
|
+
return pooled;
|
|
381
|
+
}
|
|
382
|
+
function parseRerankResults(payload, count, offset) {
|
|
383
|
+
const results = payload.results;
|
|
384
|
+
if (!Array.isArray(results))
|
|
385
|
+
throw new Error("reranker response is missing results");
|
|
386
|
+
// top_n equals the number of sent documents, so a complete answer ranks
|
|
387
|
+
// every candidate; an empty or shortened response is a failure, not an
|
|
388
|
+
// empty success.
|
|
389
|
+
if (results.length !== count)
|
|
390
|
+
throw new Error("reranker response is incomplete");
|
|
391
|
+
const seen = new Set();
|
|
392
|
+
const hits = [];
|
|
393
|
+
for (const item of results) {
|
|
394
|
+
const record = item;
|
|
395
|
+
const index = record?.index;
|
|
396
|
+
const score = record?.relevance_score;
|
|
397
|
+
if (!Number.isInteger(index) || index < 0 || index >= count)
|
|
398
|
+
throw new Error("reranker response has invalid indices");
|
|
399
|
+
if (typeof score !== "number" || !Number.isFinite(score))
|
|
400
|
+
throw new Error("reranker response is malformed");
|
|
401
|
+
if (seen.has(index))
|
|
402
|
+
throw new Error("reranker response has invalid indices");
|
|
403
|
+
seen.add(index);
|
|
404
|
+
hits.push({ index: offset + index, score });
|
|
405
|
+
}
|
|
406
|
+
return hits;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Ranks documents against a query through a `/rerank`-style endpoint
|
|
410
|
+
* (`{model, query, documents, top_n}` request, `results[].index` and
|
|
411
|
+
* `results[].relevance_score` response). Documents are chunked so the query,
|
|
412
|
+
* one document chunk and the server template stay inside maxChars; a query
|
|
413
|
+
* too large to leave room for a chunk fails before the request. Responses
|
|
414
|
+
* must contain exactly top_n results, and chunk scores aggregate to one best
|
|
415
|
+
* score per input document.
|
|
416
|
+
*/
|
|
417
|
+
export async function rerankTexts(settings, query, documents, signal) {
|
|
418
|
+
const reranker = settings.reranker;
|
|
419
|
+
if (!reranker || !documents.length)
|
|
420
|
+
return [];
|
|
421
|
+
const token = bearerToken(reranker.bearerEnv, "reranker");
|
|
422
|
+
// Query, one document chunk and the template allowance share maxChars. A
|
|
423
|
+
// small remainder is honored as-is; inflating it would push the request
|
|
424
|
+
// past the model input budget.
|
|
425
|
+
const remaining = reranker.maxChars - RERANKER_TEMPLATE_ALLOWANCE_CHARS - query.length;
|
|
426
|
+
if (remaining < 1)
|
|
427
|
+
throw new Error(`reranker query of ${query.length} characters leaves no document room inside maxChars=${reranker.maxChars}`);
|
|
428
|
+
const chunkLists = documents.map((document) => chunkText(document, remaining));
|
|
429
|
+
const offsets = [];
|
|
430
|
+
let total = 0;
|
|
431
|
+
for (const list of chunkLists) {
|
|
432
|
+
offsets.push(total);
|
|
433
|
+
total += list.length;
|
|
434
|
+
}
|
|
435
|
+
const scores = new Map();
|
|
436
|
+
const chunks = chunkLists.flat();
|
|
437
|
+
for (let start = 0; start < total; start += reranker.maxDocuments) {
|
|
438
|
+
const batch = chunks.slice(start, start + reranker.maxDocuments);
|
|
439
|
+
const payload = await postJson(reranker.url, {
|
|
440
|
+
model: reranker.model,
|
|
441
|
+
query,
|
|
442
|
+
documents: batch,
|
|
443
|
+
top_n: batch.length,
|
|
444
|
+
}, token, reranker.timeoutMs, signal, "reranker");
|
|
445
|
+
for (const { index, score } of parseRerankResults(payload, batch.length, start))
|
|
446
|
+
scores.set(index, Math.max(scores.get(index) ?? Number.NEGATIVE_INFINITY, score));
|
|
447
|
+
}
|
|
448
|
+
const hits = [];
|
|
449
|
+
chunkLists.forEach((list, index) => {
|
|
450
|
+
let best = null;
|
|
451
|
+
for (let offset = 0; offset < list.length; offset += 1) {
|
|
452
|
+
const score = scores.get(offsets[index] + offset);
|
|
453
|
+
if (score !== undefined && (best === null || score > best))
|
|
454
|
+
best = score;
|
|
455
|
+
}
|
|
456
|
+
if (best !== null)
|
|
457
|
+
hits.push({ index, score: best });
|
|
458
|
+
});
|
|
459
|
+
return hits.sort((left, right) => right.score - left.score || left.index - right.index);
|
|
178
460
|
}
|
|
179
461
|
export function embeddingFingerprint(settings) {
|
|
180
462
|
return createHash("sha256")
|
|
@@ -182,7 +464,15 @@ export function embeddingFingerprint(settings) {
|
|
|
182
464
|
? {
|
|
183
465
|
url: settings.embedding.url,
|
|
184
466
|
model: settings.embedding.model,
|
|
185
|
-
|
|
467
|
+
document: {
|
|
468
|
+
prefix: settings.embedding.documentPrefix,
|
|
469
|
+
maxChars: settings.embedding.maxTextChars,
|
|
470
|
+
pooling: "mean-v1",
|
|
471
|
+
// Chunking now happens before formatting, so every chunk
|
|
472
|
+
// carries the prefix/instruction; bumping this version
|
|
473
|
+
// invalidates indexes built by the previous algorithm.
|
|
474
|
+
formatting: "per-chunk-v2",
|
|
475
|
+
},
|
|
186
476
|
}
|
|
187
477
|
: null))
|
|
188
478
|
.digest("hex");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kisev/memomatic",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.1",
|
|
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,12 +30,13 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "node scripts/clean-dist.mjs && tsc --project tsconfig.json",
|
|
33
|
-
"test": "node --test test
|
|
33
|
+
"test": "node --test test/*.test.mjs",
|
|
34
34
|
"pack:check": "node test/pack-allowlist.mjs"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@kisev/safe-fs": "^
|
|
38
|
-
"commander": "15.0.0"
|
|
37
|
+
"@kisev/safe-fs": "^12.0.1",
|
|
38
|
+
"commander": "15.0.0",
|
|
39
|
+
"jsonc-parser": "3.3.1"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@types/node": "22.15.30",
|