@kisev/memomatic 1.0.0-dev.69.gd644a007a7ee → 1.0.0-dev.71.gee4e37a5bb61
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 +10 -1
- package/README.ru.md +10 -1
- package/dist/cli.js +201 -92
- package/dist/dream.d.ts +17 -3
- package/dist/dream.js +197 -40
- package/dist/executor.d.ts +29 -6
- package/dist/executor.js +256 -45
- package/dist/generated/cli.d.ts +28 -0
- package/dist/generated/cli.js +181 -0
- package/dist/inbox.d.ts +3 -1
- package/dist/inbox.js +27 -6
- package/dist/ingest.d.ts +0 -15
- package/dist/ingest.js +1 -100
- package/dist/ingestion.d.ts +30 -0
- package/dist/ingestion.js +209 -0
- package/dist/operations.d.ts +7 -0
- package/dist/operations.js +3 -0
- package/dist/paths.js +7 -2
- package/dist/search.d.ts +2 -1
- package/dist/search.js +52 -2
- package/dist/service.d.ts +4 -2
- package/dist/service.js +11 -8
- package/dist/settings.d.ts +8 -1
- package/dist/settings.js +31 -3
- package/dist/store.js +2 -0
- package/package.json +4 -3
package/dist/ingest.js
CHANGED
|
@@ -1,107 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
4
3
|
export function opencodeDatabasePath() {
|
|
5
|
-
const base = process.env.XDG_DATA_HOME
|
|
6
|
-
? process.env.XDG_DATA_HOME
|
|
7
|
-
: join(homedir(), ".local", "share");
|
|
4
|
+
const base = process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share");
|
|
8
5
|
if (!isAbsolute(base))
|
|
9
6
|
throw new Error("XDG_DATA_HOME must be an absolute path");
|
|
10
7
|
return join(base, "opencode", "opencode.db");
|
|
11
8
|
}
|
|
12
|
-
function readMessages(db, sessionId) {
|
|
13
|
-
const hasParts = db
|
|
14
|
-
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'part'")
|
|
15
|
-
.get();
|
|
16
|
-
const rows = db
|
|
17
|
-
.prepare(`SELECT ${hasParts ? "id, " : ""}data FROM message WHERE session_id = ? ORDER BY time_created ASC;`)
|
|
18
|
-
.all(sessionId);
|
|
19
|
-
if (!hasParts)
|
|
20
|
-
return rows;
|
|
21
|
-
const parts = db.prepare("SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC, id ASC");
|
|
22
|
-
return rows.map((row) => {
|
|
23
|
-
try {
|
|
24
|
-
const message = JSON.parse(row.data);
|
|
25
|
-
message.parts = parts.all(row.id).flatMap((part) => {
|
|
26
|
-
try {
|
|
27
|
-
return [JSON.parse(part.data)];
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
return { data: JSON.stringify(message) };
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return row;
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
export function loadRecentSessions(databaseFile, afterTimeCreated, options = {
|
|
41
|
-
maxSessions: 20,
|
|
42
|
-
maxCharsPerSession: 24_000,
|
|
43
|
-
}) {
|
|
44
|
-
let db;
|
|
45
|
-
try {
|
|
46
|
-
db = new DatabaseSync(databaseFile, { readOnly: true });
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
try {
|
|
52
|
-
const before = options.before ?? Number.MAX_SAFE_INTEGER;
|
|
53
|
-
const sessions = db
|
|
54
|
-
.prepare(`SELECT id, title, directory, time_created FROM session
|
|
55
|
-
WHERE time_created > ? AND time_created < ? ORDER BY time_created ASC LIMIT ?;`)
|
|
56
|
-
.all(afterTimeCreated, before, options.maxSessions);
|
|
57
|
-
const result = [];
|
|
58
|
-
for (const session of sessions) {
|
|
59
|
-
const messages = [];
|
|
60
|
-
let budget = options.maxCharsPerSession;
|
|
61
|
-
let internal = false;
|
|
62
|
-
const rows = readMessages(db, session.id);
|
|
63
|
-
for (const row of rows) {
|
|
64
|
-
if (budget <= 0)
|
|
65
|
-
break;
|
|
66
|
-
let parsed;
|
|
67
|
-
try {
|
|
68
|
-
parsed = JSON.parse(row.data);
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const texts = [];
|
|
74
|
-
for (const part of parsed.parts ?? []) {
|
|
75
|
-
const value = part;
|
|
76
|
-
if (value.type === "text" && typeof value.text === "string")
|
|
77
|
-
texts.push(value.text);
|
|
78
|
-
}
|
|
79
|
-
const text = texts.join("\n").trim();
|
|
80
|
-
if (!text)
|
|
81
|
-
continue;
|
|
82
|
-
if (text.includes("[memomatic-internal]"))
|
|
83
|
-
internal = true;
|
|
84
|
-
if (budget - text.length < 0) {
|
|
85
|
-
messages.push({ role: parsed.role ?? "unknown", text: text.slice(0, budget) });
|
|
86
|
-
budget = 0;
|
|
87
|
-
break;
|
|
88
|
-
}
|
|
89
|
-
budget -= text.length;
|
|
90
|
-
messages.push({ role: parsed.role ?? "unknown", text });
|
|
91
|
-
}
|
|
92
|
-
if (internal || !messages.length)
|
|
93
|
-
continue;
|
|
94
|
-
result.push({
|
|
95
|
-
id: session.id,
|
|
96
|
-
title: session.title,
|
|
97
|
-
directory: session.directory,
|
|
98
|
-
timeCreated: session.time_created,
|
|
99
|
-
messages,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
return result;
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
db.close();
|
|
106
|
-
}
|
|
107
|
-
}
|
|
@@ -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,209 @@
|
|
|
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 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
|
|
32
|
+
.prepare(`PRAGMA table_info(${table})`)
|
|
33
|
+
.all()
|
|
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");
|
|
40
|
+
const sessions = db
|
|
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`)
|
|
42
|
+
.all(options.before);
|
|
43
|
+
for (const [position, session] of sessions.entries()) {
|
|
44
|
+
if (session.title.startsWith("[memomatic-internal]")) {
|
|
45
|
+
result.skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const sealKey = `ingest-session-v2:${digest(session.id)}`;
|
|
49
|
+
let seal;
|
|
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) {
|
|
58
|
+
result.skipped++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const revision = digest(JSON.stringify([session.revision, session.title, session.directory, messages, parts]));
|
|
62
|
+
if (store.getMeta(sealKey) === revision) {
|
|
63
|
+
result.cachedSessions++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
seal = [sealKey, revision];
|
|
67
|
+
}
|
|
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
|
+
});
|
|
91
|
+
const messages = new Map();
|
|
92
|
+
for (const row of rows) {
|
|
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
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const firstUser = [...messages.values()].find((message) => message.role === "user");
|
|
112
|
+
if (firstUser?.texts.join("\n").trimStart().startsWith("[memomatic-internal]")) {
|
|
113
|
+
result.skipped++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let batch = [];
|
|
117
|
+
let ids = [];
|
|
118
|
+
let keys = [];
|
|
119
|
+
let context = "";
|
|
120
|
+
let previous = "";
|
|
121
|
+
let length = 0;
|
|
122
|
+
let changed = false;
|
|
123
|
+
const flush = () => {
|
|
124
|
+
if (!batch.length)
|
|
125
|
+
return;
|
|
126
|
+
result.fragments.push({
|
|
127
|
+
id: digest(keys.join("\n")),
|
|
128
|
+
sessionId: session.id,
|
|
129
|
+
messageIds: ids,
|
|
130
|
+
title: session.title,
|
|
131
|
+
directory: session.directory,
|
|
132
|
+
text: batch.join("\n\n"),
|
|
133
|
+
context,
|
|
134
|
+
checkpointKeys: keys,
|
|
135
|
+
});
|
|
136
|
+
batch = [];
|
|
137
|
+
ids = [];
|
|
138
|
+
keys = [];
|
|
139
|
+
length = 0;
|
|
140
|
+
};
|
|
141
|
+
let lastMessage = "";
|
|
142
|
+
for (const [id, message] of messages) {
|
|
143
|
+
const body = message.texts.join("\n").trim();
|
|
144
|
+
if (!body) {
|
|
145
|
+
result.skipped++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
result.messages++;
|
|
149
|
+
const full = `${message.role}: ${body}`;
|
|
150
|
+
// Only adjacent exact duplicate messages with the same role are redundant.
|
|
151
|
+
if (full === lastMessage) {
|
|
152
|
+
result.skipped++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
lastMessage = full;
|
|
156
|
+
for (let offset = 0; offset < body.length;) {
|
|
157
|
+
const start = offset;
|
|
158
|
+
let end = Math.min(body.length, offset + options.maxChars);
|
|
159
|
+
if (end < body.length &&
|
|
160
|
+
/[\uD800-\uDBFF]/.test(body[end - 1]) &&
|
|
161
|
+
/[\uDC00-\uDFFF]/.test(body[end]))
|
|
162
|
+
end--;
|
|
163
|
+
const text = `${message.role}: ${body.slice(start, end)}`;
|
|
164
|
+
offset = end;
|
|
165
|
+
const preceding = Array.from(previous)
|
|
166
|
+
.slice(-(options.contextChars ?? 2000))
|
|
167
|
+
.join("");
|
|
168
|
+
const key = `ingest-v2:${digest(`${session.id}\n${id}\n${start}\n${preceding}\n${text}`)}`;
|
|
169
|
+
if (store.getMeta(key)) {
|
|
170
|
+
result.cached++;
|
|
171
|
+
previous = text;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (batch.length && length + text.length > options.maxChars)
|
|
175
|
+
flush();
|
|
176
|
+
if (!batch.length)
|
|
177
|
+
context = preceding;
|
|
178
|
+
batch.push(text);
|
|
179
|
+
ids.push(id);
|
|
180
|
+
keys.push(key);
|
|
181
|
+
length += text.length;
|
|
182
|
+
result.characters += text.length;
|
|
183
|
+
changed = true;
|
|
184
|
+
previous = text;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
flush();
|
|
188
|
+
if (seal) {
|
|
189
|
+
if (changed)
|
|
190
|
+
result.fragments.at(-1).seal = seal;
|
|
191
|
+
else
|
|
192
|
+
result.seals.push(seal);
|
|
193
|
+
}
|
|
194
|
+
if (changed) {
|
|
195
|
+
result.sessions++;
|
|
196
|
+
result.watermark = Math.max(result.watermark, session.time_created);
|
|
197
|
+
}
|
|
198
|
+
if (options.maxSessions && result.sessions >= options.maxSessions) {
|
|
199
|
+
result.unscannedSessions = sessions.length - position - 1;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
db.exec("COMMIT");
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -25,7 +25,12 @@ async function assertDirectory(path) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
export function memomaticPaths() {
|
|
28
|
-
const
|
|
28
|
+
const configured = process.env.MEMOMATIC_HOME;
|
|
29
|
+
if (configured && (!isAbsolute(configured) || configured.split(sep).includes("..")))
|
|
30
|
+
throw new Error("MEMOMATIC_HOME must be an absolute safe path");
|
|
31
|
+
const stateRoot = configured
|
|
32
|
+
? resolve(configured)
|
|
33
|
+
: join(safeXdg("XDG_STATE_HOME", ".local/state"), "memomatic");
|
|
29
34
|
const configRoot = join(safeXdg("XDG_CONFIG_HOME", ".config"), "memomatic");
|
|
30
35
|
return {
|
|
31
36
|
stateRoot,
|
|
@@ -34,7 +39,7 @@ export function memomaticPaths() {
|
|
|
34
39
|
userFile: join(stateRoot, "USER.md"),
|
|
35
40
|
dreamsFile: join(stateRoot, "DREAMS.md"),
|
|
36
41
|
rulesFile: join(configRoot, "MEMORY_RULES.md"),
|
|
37
|
-
settingsFile: join(configRoot, "settings.json"),
|
|
42
|
+
settingsFile: process.env.MEMOMATIC_CONFIG ?? join(configRoot, "settings.json"),
|
|
38
43
|
dailyDir: join(stateRoot, "memory"),
|
|
39
44
|
archiveDir: join(stateRoot, "archive"),
|
|
40
45
|
historyDir: join(stateRoot, "history"),
|
package/dist/search.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type IndexedEntry, MemoryStore } from "./store.js";
|
|
2
2
|
import type { MemomaticPaths } from "./paths.js";
|
|
3
3
|
import type { MemomaticSettings } from "./settings.js";
|
|
4
|
+
import { type OperationOptions } from "./operations.js";
|
|
4
5
|
export type SearchHit = {
|
|
5
6
|
entry: IndexedEntry;
|
|
6
7
|
score: number;
|
|
@@ -17,7 +18,7 @@ export type SearchHit = {
|
|
|
17
18
|
};
|
|
18
19
|
};
|
|
19
20
|
export declare function corpusFiles(paths: MemomaticPaths): Promise<string[]>;
|
|
20
|
-
export declare function reindex(paths: MemomaticPaths, settings: MemomaticSettings, store: MemoryStore): Promise<number>;
|
|
21
|
+
export declare function reindex(paths: MemomaticPaths, settings: MemomaticSettings, store: MemoryStore, options?: OperationOptions): Promise<number>;
|
|
21
22
|
export type SearchOptions = {
|
|
22
23
|
includeArchived?: boolean;
|
|
23
24
|
markSurfaced?: boolean;
|
package/dist/search.js
CHANGED
|
@@ -4,6 +4,7 @@ import { cosineSimilarity, stableIdFor } from "./store.js";
|
|
|
4
4
|
import { entryKind, parseCorpusEntries } from "./corpus.js";
|
|
5
5
|
import { entryImportance, entryObservedAt } from "./entries.js";
|
|
6
6
|
import { embedTexts, embeddingFingerprint } from "./settings.js";
|
|
7
|
+
import { check } from "./operations.js";
|
|
7
8
|
export async function corpusFiles(paths) {
|
|
8
9
|
const files = [];
|
|
9
10
|
for (const name of ["MEMORY.md", "USER.md"]) {
|
|
@@ -17,7 +18,9 @@ export async function corpusFiles(paths) {
|
|
|
17
18
|
}
|
|
18
19
|
return files;
|
|
19
20
|
}
|
|
20
|
-
export async function reindex(paths, settings, store) {
|
|
21
|
+
export async function reindex(paths, settings, store, options = {}) {
|
|
22
|
+
check(options);
|
|
23
|
+
options.observe?.({ phase: "index.scan", message: "Reading memory files" });
|
|
21
24
|
const entries = [];
|
|
22
25
|
const indexedIds = new Set();
|
|
23
26
|
for (const file of await corpusFiles(paths)) {
|
|
@@ -47,8 +50,55 @@ export async function reindex(paths, settings, store) {
|
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
|
-
const
|
|
53
|
+
const prior = new Map(store.allEntries().map((entry) => [entry.stableId, entry.text]));
|
|
54
|
+
const cache = new Map(store.vectors().map((row) => [row.stableId, row.data]));
|
|
55
|
+
const compatible = !options.force && store.getMeta("embeddingFingerprint") === embeddingFingerprint(settings);
|
|
56
|
+
const vectors = settings.embedding ? [] : null;
|
|
57
|
+
const missing = entries
|
|
58
|
+
.map((entry, index) => ({ entry, index }))
|
|
59
|
+
.filter(({ entry, index }) => {
|
|
60
|
+
const cached = compatible && prior.get(entry.stableId) === entry.text
|
|
61
|
+
? cache.get(entry.stableId)
|
|
62
|
+
: undefined;
|
|
63
|
+
if (cached && vectors) {
|
|
64
|
+
vectors[index] = cached;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return Boolean(vectors);
|
|
68
|
+
});
|
|
69
|
+
options.observe?.({
|
|
70
|
+
phase: "index.embeddings",
|
|
71
|
+
message: "Embedding changed entries",
|
|
72
|
+
total: missing.length,
|
|
73
|
+
completed: 0,
|
|
74
|
+
cached: entries.length - missing.length,
|
|
75
|
+
});
|
|
76
|
+
for (let start = 0; start < missing.length; start += 32) {
|
|
77
|
+
check(options);
|
|
78
|
+
const batch = missing.slice(start, start + 32);
|
|
79
|
+
const computed = await embedTexts(settings, batch.map((item) => item.entry.text), "document", options.signal);
|
|
80
|
+
if (vectors && computed)
|
|
81
|
+
batch.forEach((item, index) => {
|
|
82
|
+
vectors[item.index] = computed[index];
|
|
83
|
+
});
|
|
84
|
+
options.observe?.({
|
|
85
|
+
phase: "index.embeddings",
|
|
86
|
+
message: "Embedding batch complete",
|
|
87
|
+
total: missing.length,
|
|
88
|
+
completed: Math.min(start + 32, missing.length),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
check(options);
|
|
92
|
+
if (vectors?.length && vectors.some((vector) => vector.length !== vectors[0].length))
|
|
93
|
+
throw new Error("embedding dimensions changed; run memomatic index --force");
|
|
51
94
|
store.replaceIndex(entries, vectors, embeddingFingerprint(settings));
|
|
95
|
+
options.observe?.({
|
|
96
|
+
phase: "index.done",
|
|
97
|
+
message: "Index committed",
|
|
98
|
+
entries: entries.length,
|
|
99
|
+
embedded: missing.length,
|
|
100
|
+
cached: entries.length - missing.length,
|
|
101
|
+
});
|
|
52
102
|
return entries.length;
|
|
53
103
|
}
|
|
54
104
|
function recencyMultiplier(entry, halfLifeDays, now = Date.now()) {
|
package/dist/service.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type MemoryRules } from "./rules.js";
|
|
2
2
|
import { type MemomaticPaths } from "./paths.js";
|
|
3
3
|
import { type MemomaticSettings } from "./settings.js";
|
|
4
|
+
import { type OperationOptions } from "./operations.js";
|
|
4
5
|
import { type SearchHit, type SearchOptions } from "./search.js";
|
|
5
6
|
import { MemoryStore } from "./store.js";
|
|
6
7
|
export type WriteRequest = {
|
|
@@ -22,6 +23,7 @@ export type MemomaticContext = {
|
|
|
22
23
|
};
|
|
23
24
|
export declare function openMemomatic(options?: {
|
|
24
25
|
readOnly?: boolean;
|
|
26
|
+
configure?: (settings: MemomaticSettings) => MemomaticSettings;
|
|
25
27
|
}): Promise<MemomaticContext>;
|
|
26
28
|
export declare function writeEntry(context: MemomaticContext, request: WriteRequest): Promise<string>;
|
|
27
29
|
export declare function searchMemory(context: MemomaticContext, query: string, options?: SearchOptions): Promise<SearchHit[]>;
|
|
@@ -57,5 +59,5 @@ export declare function forgetEntry(context: MemomaticContext, target: {
|
|
|
57
59
|
file: string;
|
|
58
60
|
line: number;
|
|
59
61
|
}): Promise<string>;
|
|
60
|
-
export declare function archiveOldEpisodic(context: MemomaticContext): Promise<string[]>;
|
|
61
|
-
export declare function rebuildIndex(context: MemomaticContext): Promise<number>;
|
|
62
|
+
export declare function archiveOldEpisodic(context: MemomaticContext, options?: OperationOptions): Promise<string[]>;
|
|
63
|
+
export declare function rebuildIndex(context: MemomaticContext, options?: OperationOptions): Promise<number>;
|
package/dist/service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { dropToInbox, withRunLock } from "./inbox.js";
|
|
|
7
7
|
import { isForbidden, loadRules } from "./rules.js";
|
|
8
8
|
import { memomaticPaths } from "./paths.js";
|
|
9
9
|
import { loadSettings } from "./settings.js";
|
|
10
|
+
import { check } from "./operations.js";
|
|
10
11
|
import { reindex, search } from "./search.js";
|
|
11
12
|
import { MemoryStore } from "./store.js";
|
|
12
13
|
import { visibilityForSource } from "./visibility.js";
|
|
@@ -20,11 +21,12 @@ async function memoryPath(context, file) {
|
|
|
20
21
|
}
|
|
21
22
|
export async function openMemomatic(options = {}) {
|
|
22
23
|
const paths = memomaticPaths();
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
const loaded = await loadSettings(paths.settingsFile);
|
|
25
|
+
const settings = options.configure ? options.configure(loaded) : loaded;
|
|
26
|
+
const rules = await loadRules(paths.rulesFile);
|
|
27
|
+
const store = options.readOnly
|
|
28
|
+
? await MemoryStore.preview(paths.indexFile)
|
|
29
|
+
: await MemoryStore.open(paths.indexFile);
|
|
28
30
|
return { paths, settings, rules, store };
|
|
29
31
|
}
|
|
30
32
|
export async function writeEntry(context, request) {
|
|
@@ -100,7 +102,7 @@ export async function forgetEntry(context, target) {
|
|
|
100
102
|
return file;
|
|
101
103
|
});
|
|
102
104
|
}
|
|
103
|
-
export async function archiveOldEpisodic(context) {
|
|
105
|
+
export async function archiveOldEpisodic(context, options = {}) {
|
|
104
106
|
const archived = [];
|
|
105
107
|
if (!context.rules.autoClean)
|
|
106
108
|
return archived;
|
|
@@ -111,6 +113,7 @@ export async function archiveOldEpisodic(context) {
|
|
|
111
113
|
.filter((entry) => entry.kind === "episodic")
|
|
112
114
|
.map((entry) => entry.file));
|
|
113
115
|
for (const file of files) {
|
|
116
|
+
check(options);
|
|
114
117
|
await memoryPath(context, file);
|
|
115
118
|
const content = await readTextIfExists(file);
|
|
116
119
|
if (content === undefined)
|
|
@@ -141,6 +144,6 @@ export async function archiveOldEpisodic(context) {
|
|
|
141
144
|
}
|
|
142
145
|
return archived;
|
|
143
146
|
}
|
|
144
|
-
export async function rebuildIndex(context) {
|
|
145
|
-
return withRunLock(context.paths, () => reindex(context.paths, context.settings, context.store));
|
|
147
|
+
export async function rebuildIndex(context, options = {}) {
|
|
148
|
+
return withRunLock(context.paths, () => reindex(context.paths, context.settings, context.store, options));
|
|
146
149
|
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export type MemomaticSettings = {
|
|
|
13
13
|
maxCandidates: number;
|
|
14
14
|
memoryBudgetLines: number;
|
|
15
15
|
maxPriorEntryLoss: number;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
maxDurationMs: number;
|
|
18
|
+
maxSessions: number;
|
|
19
|
+
maxChars: number;
|
|
20
|
+
retries: number;
|
|
21
|
+
idleMs: number;
|
|
22
|
+
opencodeUrl: string | null;
|
|
16
23
|
};
|
|
17
24
|
search: {
|
|
18
25
|
maxResults: number;
|
|
@@ -28,7 +35,7 @@ export type MemomaticSettings = {
|
|
|
28
35
|
export declare const defaultSettings: () => MemomaticSettings;
|
|
29
36
|
export declare function normalizeSettings(raw: unknown): MemomaticSettings;
|
|
30
37
|
export declare function loadSettings(settingsFile: string): Promise<MemomaticSettings>;
|
|
31
|
-
export declare function embedTexts(settings: MemomaticSettings, texts: string[], purpose?: "document" | "query"): Promise<Float32Array[] | null>;
|
|
38
|
+
export declare function embedTexts(settings: MemomaticSettings, texts: string[], purpose?: "document" | "query", signal?: AbortSignal): Promise<Float32Array[] | null>;
|
|
32
39
|
export declare const MEMORY_QUERY_INSTRUCTION = "Given a search query, retrieve relevant personal memory entries that answer the query.";
|
|
33
40
|
export declare function embeddingQuery(settings: MemomaticSettings, query: string): string;
|
|
34
41
|
export declare function embeddingFingerprint(settings: MemomaticSettings): string;
|
package/dist/settings.js
CHANGED
|
@@ -11,6 +11,13 @@ export const defaultSettings = () => ({
|
|
|
11
11
|
maxCandidates: 12,
|
|
12
12
|
memoryBudgetLines: 80,
|
|
13
13
|
maxPriorEntryLoss: 0.25,
|
|
14
|
+
timeoutMs: 180000,
|
|
15
|
+
maxDurationMs: 0,
|
|
16
|
+
maxSessions: 0,
|
|
17
|
+
maxChars: 24000,
|
|
18
|
+
retries: 1,
|
|
19
|
+
idleMs: 600000,
|
|
20
|
+
opencodeUrl: null,
|
|
14
21
|
},
|
|
15
22
|
search: { maxResults: 6, minScore: 0.35, minSemanticScore: 0.45, halfLifeDays: 30 },
|
|
16
23
|
archive: { enabled: false, days: 60 },
|
|
@@ -30,6 +37,25 @@ export function normalizeSettings(raw) {
|
|
|
30
37
|
const source = (raw ?? {});
|
|
31
38
|
const embedding = source.embedding;
|
|
32
39
|
const search = mergeSection(defaults.search, source.search);
|
|
40
|
+
const dream = mergeSection(defaults.dream, source.dream);
|
|
41
|
+
for (const key of [
|
|
42
|
+
"timeoutMs",
|
|
43
|
+
"maxDurationMs",
|
|
44
|
+
"maxSessions",
|
|
45
|
+
"maxChars",
|
|
46
|
+
"retries",
|
|
47
|
+
"idleMs",
|
|
48
|
+
])
|
|
49
|
+
if (!Number.isSafeInteger(dream[key]) || dream[key] < 0)
|
|
50
|
+
throw new Error(`invalid dream.${key}`);
|
|
51
|
+
if (dream.timeoutMs < 1 || dream.maxChars < 256 || dream.maxChars > 200000 || dream.retries > 5)
|
|
52
|
+
throw new Error("invalid Dream request limits");
|
|
53
|
+
if (dream.model !== null && (typeof dream.model !== "string" || !/^[^/]+\/.+/.test(dream.model)))
|
|
54
|
+
throw new Error("model must use provider/model format");
|
|
55
|
+
if (dream.variant !== null && typeof dream.variant !== "string")
|
|
56
|
+
throw new Error("dream.variant must be a string or null");
|
|
57
|
+
if (dream.opencodeUrl !== null && typeof dream.opencodeUrl !== "string")
|
|
58
|
+
throw new Error("dream.opencodeUrl must be a string or null");
|
|
33
59
|
if (!Number.isInteger(search.maxResults) ||
|
|
34
60
|
search.maxResults < 1 ||
|
|
35
61
|
search.maxResults > 200 ||
|
|
@@ -47,7 +73,7 @@ export function normalizeSettings(raw) {
|
|
|
47
73
|
: {}),
|
|
48
74
|
}
|
|
49
75
|
: null,
|
|
50
|
-
dream
|
|
76
|
+
dream,
|
|
51
77
|
search,
|
|
52
78
|
archive: mergeSection(defaults.archive, source.archive),
|
|
53
79
|
};
|
|
@@ -62,7 +88,7 @@ export async function loadSettings(settingsFile) {
|
|
|
62
88
|
throw error;
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
|
-
export async function embedTexts(settings, texts, purpose = "document") {
|
|
91
|
+
export async function embedTexts(settings, texts, purpose = "document", signal) {
|
|
66
92
|
if (!settings.embedding || !texts.length)
|
|
67
93
|
return null;
|
|
68
94
|
const response = await fetch(settings.embedding.url, {
|
|
@@ -72,7 +98,9 @@ export async function embedTexts(settings, texts, purpose = "document") {
|
|
|
72
98
|
}),
|
|
73
99
|
headers: { "content-type": "application/json" },
|
|
74
100
|
method: "POST",
|
|
75
|
-
signal:
|
|
101
|
+
signal: signal
|
|
102
|
+
? AbortSignal.any([signal, AbortSignal.timeout(30_000)])
|
|
103
|
+
: AbortSignal.timeout(30_000),
|
|
76
104
|
});
|
|
77
105
|
if (!response.ok)
|
|
78
106
|
throw new Error(`embedding request failed: ${response.status} ${await response.text()}`);
|