@kisev/memomatic 1.0.0-dev.100.g21366c4ad740
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/README.ru.md +95 -0
- package/assets/systemd/memomatic-dream.service +9 -0
- package/assets/systemd/memomatic-dream.timer +10 -0
- package/assets/systemd/memomatic-sessions.service +9 -0
- package/assets/systemd/memomatic-sessions.timer +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +309 -0
- package/dist/corpus.d.ts +12 -0
- package/dist/corpus.js +138 -0
- package/dist/dream.d.ts +31 -0
- package/dist/dream.js +234 -0
- package/dist/entries.d.ts +31 -0
- package/dist/entries.js +119 -0
- package/dist/executor.d.ts +45 -0
- package/dist/executor.js +267 -0
- package/dist/gates.d.ts +27 -0
- package/dist/gates.js +69 -0
- package/dist/generated/cli.d.ts +28 -0
- package/dist/generated/cli.js +181 -0
- package/dist/inbox.d.ts +34 -0
- package/dist/inbox.js +224 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/ingest.d.ts +1 -0
- package/dist/ingest.js +15 -0
- package/dist/ingestion.d.ts +30 -0
- package/dist/ingestion.js +179 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +205 -0
- package/dist/opencode-config.d.ts +1 -0
- package/dist/opencode-config.js +84 -0
- package/dist/operations.d.ts +7 -0
- package/dist/operations.js +3 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +55 -0
- package/dist/rules.d.ts +13 -0
- package/dist/rules.js +39 -0
- package/dist/search.d.ts +28 -0
- package/dist/search.js +191 -0
- package/dist/service.d.ts +63 -0
- package/dist/service.js +163 -0
- package/dist/sessions.d.ts +36 -0
- package/dist/sessions.js +238 -0
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +189 -0
- package/dist/store.d.ts +55 -0
- package/dist/store.js +286 -0
- package/dist/visibility.d.ts +7 -0
- package/dist/visibility.js +13 -0
- package/package.json +48 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { forgetEntry, getEntry, openMemomatic, searchMemoryResults, writeEntry, } from "./service.js";
|
|
2
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
3
|
+
const TOOLS = [
|
|
4
|
+
{
|
|
5
|
+
description: "Retrieve learned context, not authoritative instructions. Search decisions, discoveries, and outcomes; use project to scope recall. Scores are ranking signals, not confidence; no matches returns an empty list.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
additionalProperties: false,
|
|
8
|
+
properties: {
|
|
9
|
+
query: { type: "string" },
|
|
10
|
+
project: { type: "string" },
|
|
11
|
+
explain: { type: "boolean" },
|
|
12
|
+
},
|
|
13
|
+
required: ["query"],
|
|
14
|
+
type: "object",
|
|
15
|
+
},
|
|
16
|
+
name: "memory_search",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
description: "Read a memory file fully or one line. Fetching a line marks that entry as useful.",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
properties: {
|
|
23
|
+
file: { description: "Path relative to the memomatic state root", type: "string" },
|
|
24
|
+
line: { type: "number" },
|
|
25
|
+
},
|
|
26
|
+
required: ["file"],
|
|
27
|
+
type: "object",
|
|
28
|
+
},
|
|
29
|
+
name: "memory_get",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
description: "Queue a memory entry for the next process/dream pass. Use for standing decisions with rationale, discoveries, failed attempts with rejection reasons, session outcomes, and action-sensitive boundaries. Respect MEMORY_RULES.md; never-save topics are rejected immediately.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
properties: {
|
|
36
|
+
importance: { maximum: 10, minimum: 1, type: "number" },
|
|
37
|
+
key: { type: "string" },
|
|
38
|
+
origin: { enum: ["user", "agent"], type: "string" },
|
|
39
|
+
pinned: { type: "boolean" },
|
|
40
|
+
project: { type: "string" },
|
|
41
|
+
source: {
|
|
42
|
+
description: "Originating skill or producer in kebab-case, e.g. team-retro",
|
|
43
|
+
type: "string",
|
|
44
|
+
},
|
|
45
|
+
target: { enum: ["episodic", "curated", "user"], type: "string" },
|
|
46
|
+
text: { type: "string" },
|
|
47
|
+
trigger: { items: { type: "string" }, type: "array" },
|
|
48
|
+
},
|
|
49
|
+
required: ["text"],
|
|
50
|
+
type: "object",
|
|
51
|
+
},
|
|
52
|
+
name: "memory_write",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
description: "Remove one entry line from a memory file (explicit deletion only).",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
properties: {
|
|
59
|
+
file: { description: "Path relative to the memomatic state root", type: "string" },
|
|
60
|
+
line: { type: "number" },
|
|
61
|
+
},
|
|
62
|
+
required: ["file", "line"],
|
|
63
|
+
type: "object",
|
|
64
|
+
},
|
|
65
|
+
name: "memory_forget",
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
async function callTool(name, args) {
|
|
69
|
+
const context = await openMemomatic();
|
|
70
|
+
try {
|
|
71
|
+
switch (name) {
|
|
72
|
+
case "memory_search": {
|
|
73
|
+
if (typeof args.query !== "string" || !args.query.trim())
|
|
74
|
+
throw new Error("query is required");
|
|
75
|
+
if (args.project !== undefined && typeof args.project !== "string")
|
|
76
|
+
throw new Error("project must be a string");
|
|
77
|
+
if (args.explain !== undefined && typeof args.explain !== "boolean")
|
|
78
|
+
throw new Error("explain must be a boolean");
|
|
79
|
+
return await searchMemoryResults(context, args.query, {
|
|
80
|
+
project: args.project,
|
|
81
|
+
explain: args.explain === true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
case "memory_get": {
|
|
85
|
+
if (typeof args.file !== "string" || !args.file)
|
|
86
|
+
throw new Error("file is required");
|
|
87
|
+
const line = typeof args.line === "number" ? args.line : undefined;
|
|
88
|
+
const result = await getEntry(context, args.file, line);
|
|
89
|
+
return {
|
|
90
|
+
content: result.content,
|
|
91
|
+
file: result.file.replace(`${context.paths.stateRoot}/`, ""),
|
|
92
|
+
line: result.line,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
case "memory_write": {
|
|
96
|
+
if (typeof args.text !== "string" || !args.text.trim())
|
|
97
|
+
throw new Error("text is required");
|
|
98
|
+
const source = typeof args.source === "string" && args.source
|
|
99
|
+
? args.source
|
|
100
|
+
: args.origin === "user"
|
|
101
|
+
? "user"
|
|
102
|
+
: "agent";
|
|
103
|
+
const file = await writeEntry(context, {
|
|
104
|
+
importance: typeof args.importance === "number" ? args.importance : undefined,
|
|
105
|
+
key: typeof args.key === "string" ? args.key : undefined,
|
|
106
|
+
origin: args.origin === "user" ? "user" : "agent",
|
|
107
|
+
pinned: args.pinned === true,
|
|
108
|
+
project: typeof args.project === "string" ? args.project : undefined,
|
|
109
|
+
source,
|
|
110
|
+
target: args.target === "curated" || args.target === "user" ? args.target : "episodic",
|
|
111
|
+
text: args.text,
|
|
112
|
+
trigger: Array.isArray(args.trigger)
|
|
113
|
+
? args.trigger.filter((item) => typeof item === "string")
|
|
114
|
+
: undefined,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
file: file.replace(`${context.paths.stateRoot}/`, ""),
|
|
118
|
+
flushHint: "run `memomatic process` to index queued entries immediately",
|
|
119
|
+
queued: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
case "memory_forget": {
|
|
123
|
+
if (typeof args.file !== "string" || typeof args.line !== "number")
|
|
124
|
+
throw new Error("file and line are required");
|
|
125
|
+
const file = await forgetEntry(context, { file: args.file, line: args.line });
|
|
126
|
+
return { file: file.replace(`${context.paths.stateRoot}/`, ""), forgotten: true };
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`unknown tool: ${name}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
context.store.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function mcpResponse(id, result) {
|
|
137
|
+
return `${JSON.stringify({ id, jsonrpc: "2.0", result })}\n`;
|
|
138
|
+
}
|
|
139
|
+
export function mcpError(id, code, message) {
|
|
140
|
+
return `${JSON.stringify({ error: { code, message }, id, jsonrpc: "2.0" })}\n`;
|
|
141
|
+
}
|
|
142
|
+
export async function handleMcpRequest(request) {
|
|
143
|
+
const { id, method } = request;
|
|
144
|
+
if (method === "initialize")
|
|
145
|
+
return mcpResponse(id, {
|
|
146
|
+
capabilities: { tools: {} },
|
|
147
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
148
|
+
serverInfo: { name: "memomatic", version: "0.1.0" },
|
|
149
|
+
});
|
|
150
|
+
if (method === "ping")
|
|
151
|
+
return mcpResponse(id, {});
|
|
152
|
+
if (method === "tools/list")
|
|
153
|
+
return mcpResponse(id, { tools: TOOLS });
|
|
154
|
+
if (method === "tools/call") {
|
|
155
|
+
const params = request.params ?? {};
|
|
156
|
+
const name = typeof params.name === "string" ? params.name : "";
|
|
157
|
+
const args = params.arguments && typeof params.arguments === "object"
|
|
158
|
+
? params.arguments
|
|
159
|
+
: {};
|
|
160
|
+
try {
|
|
161
|
+
const content = await callTool(name, args);
|
|
162
|
+
return mcpResponse(id, {
|
|
163
|
+
content: [{ text: JSON.stringify(content), type: "text" }],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return mcpResponse(id, {
|
|
168
|
+
content: [
|
|
169
|
+
{
|
|
170
|
+
text: JSON.stringify({ error: error.message }),
|
|
171
|
+
type: "text",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
isError: true,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (method?.startsWith("notifications/"))
|
|
179
|
+
return "";
|
|
180
|
+
return mcpError(id, -32601, `method not found: ${method ?? "(none)"}`);
|
|
181
|
+
}
|
|
182
|
+
export async function runMcpServer(input = process.stdin, output = process.stdout) {
|
|
183
|
+
let buffer = "";
|
|
184
|
+
input.setEncoding("utf8");
|
|
185
|
+
for await (const chunk of input) {
|
|
186
|
+
buffer += chunk;
|
|
187
|
+
let newline = buffer.indexOf("\n");
|
|
188
|
+
while (newline !== -1) {
|
|
189
|
+
const frame = buffer.slice(0, newline).trim();
|
|
190
|
+
buffer = buffer.slice(newline + 1);
|
|
191
|
+
if (frame) {
|
|
192
|
+
try {
|
|
193
|
+
const request = JSON.parse(frame);
|
|
194
|
+
const response = await handleMcpRequest(request);
|
|
195
|
+
if (response)
|
|
196
|
+
output.write(response);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
output.write(mcpError(null, -32700, "parse error"));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
newline = buffer.indexOf("\n");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -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/paths.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type MemomaticPaths = {
|
|
2
|
+
stateRoot: string;
|
|
3
|
+
configRoot: string;
|
|
4
|
+
memoryFile: string;
|
|
5
|
+
userFile: string;
|
|
6
|
+
dreamsFile: string;
|
|
7
|
+
rulesFile: string;
|
|
8
|
+
settingsFile: string;
|
|
9
|
+
dailyDir: string;
|
|
10
|
+
archiveDir: string;
|
|
11
|
+
historyDir: string;
|
|
12
|
+
indexFile: string;
|
|
13
|
+
inboxDir: string;
|
|
14
|
+
rejectedDir: string;
|
|
15
|
+
runLockFile: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function memomaticPaths(): MemomaticPaths;
|
|
18
|
+
export declare function verifyMemomaticRoots(paths: MemomaticPaths): Promise<void>;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
function safeXdg(name, fallback) {
|
|
5
|
+
const configured = process.env[name];
|
|
6
|
+
if (configured) {
|
|
7
|
+
if (!isAbsolute(configured) || configured.split(sep).includes(".."))
|
|
8
|
+
throw new Error(`${name} must be an absolute safe path`);
|
|
9
|
+
return resolve(configured);
|
|
10
|
+
}
|
|
11
|
+
const home = process.env.HOME ?? homedir();
|
|
12
|
+
if (!isAbsolute(home) || home.split(sep).includes(".."))
|
|
13
|
+
throw new Error("HOME must be an absolute safe path");
|
|
14
|
+
return join(resolve(home), fallback);
|
|
15
|
+
}
|
|
16
|
+
async function assertDirectory(path) {
|
|
17
|
+
try {
|
|
18
|
+
const info = await lstat(path);
|
|
19
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
20
|
+
throw new Error(`memomatic directory is unsafe: ${path}`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code !== "ENOENT")
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function memomaticPaths() {
|
|
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");
|
|
34
|
+
const configRoot = join(safeXdg("XDG_CONFIG_HOME", ".config"), "memomatic");
|
|
35
|
+
return {
|
|
36
|
+
stateRoot,
|
|
37
|
+
configRoot,
|
|
38
|
+
memoryFile: join(stateRoot, "MEMORY.md"),
|
|
39
|
+
userFile: join(stateRoot, "USER.md"),
|
|
40
|
+
dreamsFile: join(stateRoot, "DREAMS.md"),
|
|
41
|
+
rulesFile: join(configRoot, "MEMORY_RULES.md"),
|
|
42
|
+
settingsFile: process.env.MEMOMATIC_CONFIG ?? join(configRoot, "settings.json"),
|
|
43
|
+
dailyDir: join(stateRoot, "memory"),
|
|
44
|
+
archiveDir: join(stateRoot, "archive"),
|
|
45
|
+
historyDir: join(stateRoot, "history"),
|
|
46
|
+
indexFile: join(stateRoot, "index.sqlite"),
|
|
47
|
+
inboxDir: join(stateRoot, "inbox"),
|
|
48
|
+
rejectedDir: join(stateRoot, "inbox", "rejected"),
|
|
49
|
+
runLockFile: join(stateRoot, "run.lock"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function verifyMemomaticRoots(paths) {
|
|
53
|
+
await assertDirectory(paths.stateRoot);
|
|
54
|
+
await assertDirectory(paths.configRoot);
|
|
55
|
+
}
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type MemoryRules = {
|
|
2
|
+
neverSave: string[];
|
|
3
|
+
autoClean: {
|
|
4
|
+
olderThanDays: number;
|
|
5
|
+
scope: "episodic";
|
|
6
|
+
source?: string;
|
|
7
|
+
unusedAfterDays?: number;
|
|
8
|
+
} | null;
|
|
9
|
+
};
|
|
10
|
+
export declare const emptyRules: () => MemoryRules;
|
|
11
|
+
export declare function parseRules(markdown: string): MemoryRules;
|
|
12
|
+
export declare function loadRules(rulesFile: string): Promise<MemoryRules>;
|
|
13
|
+
export declare function isForbidden(text: string, rules: MemoryRules): boolean;
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
const NEVER_SAVE = /^-\s*never-save:\s*(.+?)\s*$/gim;
|
|
3
|
+
const AUTO_CLEAN = /^-\s*auto-clean:\s*older-than=(\d+)d\s+scope=episodic(?:\s+source=([a-z0-9-]+))?(?:\s+unused-after=(\d+)d)?\s*$/im;
|
|
4
|
+
export const emptyRules = () => ({ neverSave: [], autoClean: null });
|
|
5
|
+
export function parseRules(markdown) {
|
|
6
|
+
const rules = emptyRules();
|
|
7
|
+
for (const match of markdown.matchAll(NEVER_SAVE)) {
|
|
8
|
+
const topic = match[1].trim().toLowerCase();
|
|
9
|
+
if (topic)
|
|
10
|
+
rules.neverSave.push(topic);
|
|
11
|
+
}
|
|
12
|
+
const autoClean = AUTO_CLEAN.exec(markdown);
|
|
13
|
+
if (autoClean) {
|
|
14
|
+
const days = Number.parseInt(autoClean[1], 10);
|
|
15
|
+
const unused = autoClean[3] ? Number.parseInt(autoClean[3], 10) : NaN;
|
|
16
|
+
if (Number.isFinite(days) && days > 0)
|
|
17
|
+
rules.autoClean = {
|
|
18
|
+
olderThanDays: days,
|
|
19
|
+
scope: "episodic",
|
|
20
|
+
source: autoClean[2],
|
|
21
|
+
...(Number.isFinite(unused) && unused > 0 ? { unusedAfterDays: unused } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return rules;
|
|
25
|
+
}
|
|
26
|
+
export async function loadRules(rulesFile) {
|
|
27
|
+
try {
|
|
28
|
+
return parseRules(await readFile(rulesFile, "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return emptyRules();
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function isForbidden(text, rules) {
|
|
37
|
+
const lowered = text.toLowerCase();
|
|
38
|
+
return rules.neverSave.some((topic) => lowered.includes(topic));
|
|
39
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type IndexedEntry, MemoryStore } from "./store.js";
|
|
2
|
+
import type { MemomaticPaths } from "./paths.js";
|
|
3
|
+
import type { MemomaticSettings } from "./settings.js";
|
|
4
|
+
import { type OperationOptions } from "./operations.js";
|
|
5
|
+
export type SearchHit = {
|
|
6
|
+
entry: IndexedEntry;
|
|
7
|
+
score: number;
|
|
8
|
+
snippet: string;
|
|
9
|
+
explanation: {
|
|
10
|
+
matchedTokens: string[];
|
|
11
|
+
lexical: number;
|
|
12
|
+
semantic: number | null;
|
|
13
|
+
relevance: number;
|
|
14
|
+
exact: boolean;
|
|
15
|
+
recency: number;
|
|
16
|
+
importance: number;
|
|
17
|
+
reason: "lexical" | "semantic" | "both";
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare function corpusFiles(paths: MemomaticPaths): Promise<string[]>;
|
|
21
|
+
export declare function reindex(paths: MemomaticPaths, settings: MemomaticSettings, store: MemoryStore, options?: OperationOptions): Promise<number>;
|
|
22
|
+
export type SearchOptions = {
|
|
23
|
+
includeArchived?: boolean;
|
|
24
|
+
markSurfaced?: boolean;
|
|
25
|
+
project?: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function queryTokens(query: string): string[];
|
|
28
|
+
export declare function search(store: MemoryStore, query: string, settings: MemomaticSettings, options?: SearchOptions): Promise<SearchHit[]>;
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cosineSimilarity, stableIdFor } from "./store.js";
|
|
4
|
+
import { entryKind, parseCorpusEntries } from "./corpus.js";
|
|
5
|
+
import { entryImportance, entryObservedAt } from "./entries.js";
|
|
6
|
+
import { embedTexts, embeddingFingerprint } from "./settings.js";
|
|
7
|
+
import { check } from "./operations.js";
|
|
8
|
+
export async function corpusFiles(paths) {
|
|
9
|
+
const files = [];
|
|
10
|
+
for (const name of ["MEMORY.md", "USER.md"]) {
|
|
11
|
+
const file = join(paths.stateRoot, name);
|
|
12
|
+
if (await readFile(file, "utf8").then(() => true, () => false))
|
|
13
|
+
files.push(file);
|
|
14
|
+
}
|
|
15
|
+
for (const name of (await readdir(paths.dailyDir).catch(() => [])).sort()) {
|
|
16
|
+
if (name.endsWith(".md"))
|
|
17
|
+
files.push(join(paths.dailyDir, name));
|
|
18
|
+
}
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
export async function reindex(paths, settings, store, options = {}) {
|
|
22
|
+
check(options);
|
|
23
|
+
options.observe?.({ phase: "index.scan", message: "Reading memory files" });
|
|
24
|
+
const entries = [];
|
|
25
|
+
const indexedIds = new Set();
|
|
26
|
+
for (const file of await corpusFiles(paths)) {
|
|
27
|
+
const markdown = await readFile(file, "utf8");
|
|
28
|
+
for (const entry of parseCorpusEntries(markdown, file)) {
|
|
29
|
+
if (entry.annotations.status === "superseded")
|
|
30
|
+
continue;
|
|
31
|
+
const stableId = stableIdFor(file, entry.text, entry.annotations.key ?? null);
|
|
32
|
+
if (indexedIds.has(stableId))
|
|
33
|
+
continue;
|
|
34
|
+
indexedIds.add(stableId);
|
|
35
|
+
entries.push({
|
|
36
|
+
stableId,
|
|
37
|
+
file: entry.file,
|
|
38
|
+
line: entry.line,
|
|
39
|
+
kind: entryKind(file),
|
|
40
|
+
key: entry.annotations.key ?? null,
|
|
41
|
+
text: entry.text,
|
|
42
|
+
trigger: entry.annotations.trigger ?? [],
|
|
43
|
+
importance: entryImportance(entry),
|
|
44
|
+
pinned: entry.annotations.pinned === true,
|
|
45
|
+
project: entry.annotations.project ?? null,
|
|
46
|
+
origin: entry.annotations.origin ?? null,
|
|
47
|
+
observedAt: Number.isNaN(entryObservedAt(entry)) ? null : entryObservedAt(entry),
|
|
48
|
+
status: entry.annotations.status === "active" ? "active" : null,
|
|
49
|
+
source: entry.annotations.source ?? null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
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");
|
|
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
|
+
});
|
|
102
|
+
return entries.length;
|
|
103
|
+
}
|
|
104
|
+
function recencyMultiplier(entry, halfLifeDays, now = Date.now()) {
|
|
105
|
+
if (entry.kind !== "episodic" || entry.pinned)
|
|
106
|
+
return 1;
|
|
107
|
+
if (!entry.observedAt)
|
|
108
|
+
return 0.5;
|
|
109
|
+
const ageDays = Math.max(0, (now - entry.observedAt) / 86_400_000);
|
|
110
|
+
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
111
|
+
}
|
|
112
|
+
export function queryTokens(query) {
|
|
113
|
+
return [
|
|
114
|
+
...new Set(query
|
|
115
|
+
.normalize("NFKC")
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.match(/[\p{L}\p{N}]+/gu) ?? []),
|
|
118
|
+
].filter((token) => token.length >= 2);
|
|
119
|
+
}
|
|
120
|
+
export async function search(store, query, settings, options = {}) {
|
|
121
|
+
if (!query.trim())
|
|
122
|
+
throw new Error("query is required");
|
|
123
|
+
const tokens = queryTokens(query);
|
|
124
|
+
const keyword = store.ftsSearch(query, 200);
|
|
125
|
+
const vector = new Map();
|
|
126
|
+
const storedVectors = store.vectors();
|
|
127
|
+
if (settings.embedding &&
|
|
128
|
+
store.allEntries().length &&
|
|
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]));
|
|
146
|
+
const hits = [];
|
|
147
|
+
for (const stableId of candidates) {
|
|
148
|
+
const entry = byId.get(stableId);
|
|
149
|
+
if (!entry)
|
|
150
|
+
continue;
|
|
151
|
+
const words = queryTokens(entry.text);
|
|
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;
|
|
154
|
+
const semantic = vector.get(stableId) ?? null;
|
|
155
|
+
const lexicalAccepted = lexical >= settings.search.minScore && lexical >= 0.5;
|
|
156
|
+
const semanticAccepted = semantic !== null &&
|
|
157
|
+
semantic >= settings.search.minSemanticScore &&
|
|
158
|
+
semantic >= settings.search.minScore;
|
|
159
|
+
if (!lexicalAccepted && !semanticAccepted)
|
|
160
|
+
continue;
|
|
161
|
+
const relevance = Math.max(lexicalAccepted ? lexical : 0, semanticAccepted ? semantic : 0);
|
|
162
|
+
const recency = recencyMultiplier(entry, settings.search.halfLifeDays);
|
|
163
|
+
const importance = Math.max(0, Math.min(1, (entry.importance - 1) / 9));
|
|
164
|
+
const score = relevance * recency * (0.85 + 0.15 * importance);
|
|
165
|
+
if (score < settings.search.minScore)
|
|
166
|
+
continue;
|
|
167
|
+
hits.push({
|
|
168
|
+
entry,
|
|
169
|
+
score,
|
|
170
|
+
snippet: entry.text.slice(0, 240),
|
|
171
|
+
explanation: {
|
|
172
|
+
matchedTokens,
|
|
173
|
+
lexical,
|
|
174
|
+
semantic,
|
|
175
|
+
relevance,
|
|
176
|
+
recency,
|
|
177
|
+
importance,
|
|
178
|
+
exact: tokens.length > 0 && tokens.every((token) => words.includes(token)),
|
|
179
|
+
reason: lexicalAccepted ? (semanticAccepted ? "both" : "lexical") : "semantic",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
hits.sort((left, right) => Number(right.explanation.exact) - Number(left.explanation.exact) ||
|
|
184
|
+
right.score - left.score ||
|
|
185
|
+
(right.explanation.semantic ?? 0) - (left.explanation.semantic ?? 0) ||
|
|
186
|
+
left.entry.stableId.localeCompare(right.entry.stableId));
|
|
187
|
+
const limited = hits.slice(0, settings.search.maxResults);
|
|
188
|
+
if (options.markSurfaced !== false && limited.length)
|
|
189
|
+
store.markSurfaced(limited.map((hit) => hit.entry.stableId), query);
|
|
190
|
+
return limited;
|
|
191
|
+
}
|