@kisev/memomatic 1.0.0-dev.46.gfbe1e4c6992e
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 +36 -0
- package/README.ru.md +35 -0
- package/assets/systemd/memomatic-dream.service +9 -0
- package/assets/systemd/memomatic-dream.timer +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/corpus.d.ts +12 -0
- package/dist/corpus.js +138 -0
- package/dist/dream.d.ts +37 -0
- package/dist/dream.js +249 -0
- package/dist/entries.d.ts +25 -0
- package/dist/entries.js +102 -0
- package/dist/executor.d.ts +22 -0
- package/dist/executor.js +68 -0
- package/dist/gates.d.ts +22 -0
- package/dist/gates.js +52 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/ingest.d.ts +16 -0
- package/dist/ingest.js +81 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +187 -0
- package/dist/paths.d.ts +15 -0
- package/dist/paths.js +47 -0
- package/dist/rules.d.ts +11 -0
- package/dist/rules.js +33 -0
- package/dist/search.d.ts +13 -0
- package/dist/search.js +102 -0
- package/dist/service.d.ts +35 -0
- package/dist/service.js +105 -0
- package/dist/settings.d.ts +29 -0
- package/dist/settings.js +72 -0
- package/dist/store.d.ts +49 -0
- package/dist/store.js +221 -0
- package/package.json +46 -0
package/dist/rules.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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*$/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
|
+
if (Number.isFinite(days) && days > 0)
|
|
16
|
+
rules.autoClean = { olderThanDays: days, scope: "episodic" };
|
|
17
|
+
}
|
|
18
|
+
return rules;
|
|
19
|
+
}
|
|
20
|
+
export async function loadRules(rulesFile) {
|
|
21
|
+
try {
|
|
22
|
+
return parseRules(await readFile(rulesFile, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === "ENOENT")
|
|
26
|
+
return emptyRules();
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function isForbidden(text, rules) {
|
|
31
|
+
const lowered = text.toLowerCase();
|
|
32
|
+
return rules.neverSave.some((topic) => lowered.includes(topic));
|
|
33
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type IndexedEntry, MemoryStore } from "./store.js";
|
|
2
|
+
import type { MemomaticPaths } from "./paths.js";
|
|
3
|
+
import type { MemomaticSettings } from "./settings.js";
|
|
4
|
+
export type SearchHit = {
|
|
5
|
+
entry: IndexedEntry;
|
|
6
|
+
score: number;
|
|
7
|
+
snippet: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function reindex(paths: MemomaticPaths, settings: MemomaticSettings, store: MemoryStore): Promise<number>;
|
|
10
|
+
export declare function search(store: MemoryStore, query: string, settings: MemomaticSettings, options?: {
|
|
11
|
+
includeArchived?: boolean;
|
|
12
|
+
markSurfaced?: boolean;
|
|
13
|
+
}): Promise<SearchHit[]>;
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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 } from "./settings.js";
|
|
7
|
+
async function corpusFiles(paths) {
|
|
8
|
+
const files = [];
|
|
9
|
+
for (const name of ["MEMORY.md", "USER.md"]) {
|
|
10
|
+
const file = join(paths.stateRoot, name);
|
|
11
|
+
if (await readFile(file, "utf8").then(() => true, () => false))
|
|
12
|
+
files.push(file);
|
|
13
|
+
}
|
|
14
|
+
for (const name of (await readdir(paths.dailyDir).catch(() => [])).sort()) {
|
|
15
|
+
if (name.endsWith(".md"))
|
|
16
|
+
files.push(join(paths.dailyDir, name));
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
}
|
|
20
|
+
export async function reindex(paths, settings, store) {
|
|
21
|
+
const entries = [];
|
|
22
|
+
for (const file of await corpusFiles(paths)) {
|
|
23
|
+
const markdown = await readFile(file, "utf8");
|
|
24
|
+
for (const entry of parseCorpusEntries(markdown, file)) {
|
|
25
|
+
if (entry.annotations.status === "superseded")
|
|
26
|
+
continue;
|
|
27
|
+
entries.push({
|
|
28
|
+
stableId: stableIdFor(file, entry.text, entry.annotations.key ?? null),
|
|
29
|
+
file: entry.file,
|
|
30
|
+
line: entry.line,
|
|
31
|
+
kind: entryKind(file),
|
|
32
|
+
key: entry.annotations.key ?? null,
|
|
33
|
+
text: entry.text,
|
|
34
|
+
trigger: entry.annotations.trigger ?? [],
|
|
35
|
+
importance: entryImportance(entry),
|
|
36
|
+
pinned: entry.annotations.pinned === true,
|
|
37
|
+
project: entry.annotations.project ?? null,
|
|
38
|
+
origin: entry.annotations.origin ?? null,
|
|
39
|
+
observedAt: Number.isNaN(entryObservedAt(entry)) ? null : entryObservedAt(entry),
|
|
40
|
+
status: entry.annotations.status === "active" ? "active" : null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
store.replaceEntries(entries);
|
|
45
|
+
const vectors = await embedTexts(settings, entries.map((entry) => entry.text)).catch(() => null);
|
|
46
|
+
if (vectors)
|
|
47
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
48
|
+
if (vectors[index].length)
|
|
49
|
+
store.setVector(entries[index].stableId, vectors[index]);
|
|
50
|
+
}
|
|
51
|
+
return entries.length;
|
|
52
|
+
}
|
|
53
|
+
function recencyMultiplier(entry, halfLifeDays, now = Date.now()) {
|
|
54
|
+
if (entry.kind !== "episodic" || entry.pinned)
|
|
55
|
+
return 1;
|
|
56
|
+
if (!entry.observedAt)
|
|
57
|
+
return 0.5;
|
|
58
|
+
const ageDays = Math.max(0, (now - entry.observedAt) / 86_400_000);
|
|
59
|
+
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
60
|
+
}
|
|
61
|
+
function importanceMultiplier(entry) {
|
|
62
|
+
return 1 + (entry.importance - 5) * 0.06;
|
|
63
|
+
}
|
|
64
|
+
export async function search(store, query, settings, options = {}) {
|
|
65
|
+
const keyword = store.ftsSearch(query, 200);
|
|
66
|
+
const vector = new Map();
|
|
67
|
+
const queryVector = (await embedTexts(settings, [query]).catch(() => null))?.[0];
|
|
68
|
+
if (queryVector?.length) {
|
|
69
|
+
for (const row of store.vectors()) {
|
|
70
|
+
const similarity = cosineSimilarity(queryVector, row.data);
|
|
71
|
+
if (similarity > 0.05)
|
|
72
|
+
vector.set(row.stableId, similarity);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const candidates = new Set([...keyword.keys(), ...vector.keys()]);
|
|
76
|
+
const entries = store.allEntries().filter((item) => item.status === null);
|
|
77
|
+
const byId = new Map(entries.map((entry) => [entry.stableId, entry]));
|
|
78
|
+
const hits = [];
|
|
79
|
+
for (const stableId of candidates) {
|
|
80
|
+
const entry = byId.get(stableId);
|
|
81
|
+
if (!entry)
|
|
82
|
+
continue;
|
|
83
|
+
const keywordScore = keyword.get(stableId) ?? 0;
|
|
84
|
+
const vectorScore = vector.get(stableId) ?? 0;
|
|
85
|
+
const hybrid = vectorScore
|
|
86
|
+
? keywordScore
|
|
87
|
+
? 0.65 * vectorScore + 0.35 * keywordScore
|
|
88
|
+
: vectorScore
|
|
89
|
+
: keywordScore;
|
|
90
|
+
if (hybrid <= 0)
|
|
91
|
+
continue;
|
|
92
|
+
const score = hybrid * recencyMultiplier(entry, settings.search.halfLifeDays) * importanceMultiplier(entry);
|
|
93
|
+
if (score < settings.search.minScore)
|
|
94
|
+
continue;
|
|
95
|
+
hits.push({ entry, score, snippet: entry.text.slice(0, 240) });
|
|
96
|
+
}
|
|
97
|
+
hits.sort((left, right) => right.score - left.score);
|
|
98
|
+
const limited = hits.slice(0, settings.search.maxResults);
|
|
99
|
+
if (options.markSurfaced !== false && limited.length)
|
|
100
|
+
store.markSurfaced(limited.map((hit) => hit.entry.stableId), query);
|
|
101
|
+
return limited;
|
|
102
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type MemoryRules } from "./rules.js";
|
|
2
|
+
import { type MemomaticPaths } from "./paths.js";
|
|
3
|
+
import { type MemomaticSettings } from "./settings.js";
|
|
4
|
+
import { type SearchHit } from "./search.js";
|
|
5
|
+
import { MemoryStore } from "./store.js";
|
|
6
|
+
export type WriteRequest = {
|
|
7
|
+
text: string;
|
|
8
|
+
key?: string;
|
|
9
|
+
trigger?: string[];
|
|
10
|
+
importance?: number;
|
|
11
|
+
project?: string;
|
|
12
|
+
origin?: "user" | "agent";
|
|
13
|
+
target?: "episodic" | "curated" | "user";
|
|
14
|
+
pinned?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type MemomaticContext = {
|
|
17
|
+
paths: MemomaticPaths;
|
|
18
|
+
settings: MemomaticSettings;
|
|
19
|
+
rules: MemoryRules;
|
|
20
|
+
store: MemoryStore;
|
|
21
|
+
};
|
|
22
|
+
export declare function openMemomatic(): Promise<MemomaticContext>;
|
|
23
|
+
export declare function writeEntry(context: MemomaticContext, request: WriteRequest): Promise<string>;
|
|
24
|
+
export declare function searchMemory(context: MemomaticContext, query: string): Promise<SearchHit[]>;
|
|
25
|
+
export declare function getEntry(context: MemomaticContext, file: string, line?: number): Promise<{
|
|
26
|
+
file: string;
|
|
27
|
+
line: number;
|
|
28
|
+
content: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function forgetEntry(context: MemomaticContext, target: {
|
|
31
|
+
file: string;
|
|
32
|
+
line: number;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
export declare function archiveOldEpisodic(context: MemomaticContext): Promise<string[]>;
|
|
35
|
+
export declare function rebuildIndex(context: MemomaticContext): Promise<number>;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { entryLine } from "./entries.js";
|
|
2
|
+
import { appendDailyEntry, archiveFile, readTextIfExists, replaceEntryLine } from "./corpus.js";
|
|
3
|
+
import { isForbidden, loadRules } from "./rules.js";
|
|
4
|
+
import { memomaticPaths } from "./paths.js";
|
|
5
|
+
import { loadSettings } from "./settings.js";
|
|
6
|
+
import { reindex, search } from "./search.js";
|
|
7
|
+
import { MemoryStore } from "./store.js";
|
|
8
|
+
export async function openMemomatic() {
|
|
9
|
+
const paths = memomaticPaths();
|
|
10
|
+
const [settings, rules, store] = await Promise.all([
|
|
11
|
+
loadSettings(paths.settingsFile),
|
|
12
|
+
loadRules(paths.rulesFile),
|
|
13
|
+
MemoryStore.open(paths.indexFile),
|
|
14
|
+
]);
|
|
15
|
+
return { paths, settings, rules, store };
|
|
16
|
+
}
|
|
17
|
+
export async function writeEntry(context, request) {
|
|
18
|
+
const text = request.text.trim();
|
|
19
|
+
if (!text)
|
|
20
|
+
throw new Error("memory text is empty");
|
|
21
|
+
if (isForbidden(text, context.rules))
|
|
22
|
+
throw new Error("memory text matches a never-save rule in MEMORY_RULES.md");
|
|
23
|
+
const origin = request.origin ?? "agent";
|
|
24
|
+
const observed = new Date().toISOString().slice(0, 10);
|
|
25
|
+
const line = entryLine(text, {
|
|
26
|
+
key: request.key,
|
|
27
|
+
status: origin === "user" && request.target === "user" ? "active" : undefined,
|
|
28
|
+
origin,
|
|
29
|
+
observed,
|
|
30
|
+
project: request.project,
|
|
31
|
+
importance: request.importance,
|
|
32
|
+
trigger: request.trigger,
|
|
33
|
+
pinned: request.pinned,
|
|
34
|
+
});
|
|
35
|
+
if (request.target === "curated" || request.target === "user") {
|
|
36
|
+
if (origin !== "user")
|
|
37
|
+
throw new Error("curated and user entries require origin=user");
|
|
38
|
+
const file = request.target === "user" ? context.paths.userFile : context.paths.memoryFile;
|
|
39
|
+
const current = (await readTextIfExists(file)) ?? "";
|
|
40
|
+
const next = `${current.trimEnd()}\n${line}\n`;
|
|
41
|
+
const { writeCorpusFile } = await import("./corpus.js");
|
|
42
|
+
await writeCorpusFile(context.paths, file, next);
|
|
43
|
+
return file;
|
|
44
|
+
}
|
|
45
|
+
return appendDailyEntry(context.paths, line);
|
|
46
|
+
}
|
|
47
|
+
export async function searchMemory(context, query) {
|
|
48
|
+
return search(context.store, query, context.settings);
|
|
49
|
+
}
|
|
50
|
+
export async function getEntry(context, file, line) {
|
|
51
|
+
const target = file.startsWith("/")
|
|
52
|
+
? file
|
|
53
|
+
: `${context.paths.stateRoot}/${file.replace(/^\/+/, "")}`;
|
|
54
|
+
if (!target.startsWith(context.paths.stateRoot))
|
|
55
|
+
throw new Error("path escapes memomatic state root");
|
|
56
|
+
const content = await readTextIfExists(target);
|
|
57
|
+
if (content === undefined)
|
|
58
|
+
throw new Error(`memory file not found: ${file}`);
|
|
59
|
+
if (line === undefined)
|
|
60
|
+
return { file: target, line: 0, content };
|
|
61
|
+
const lines = content.split("\n");
|
|
62
|
+
if (line < 1 || line > lines.length)
|
|
63
|
+
throw new Error("line is out of range");
|
|
64
|
+
context.store.markUseful(context.store
|
|
65
|
+
.allEntries()
|
|
66
|
+
.filter((entry) => entry.file === target && entry.line === line)
|
|
67
|
+
.map((entry) => entry.stableId));
|
|
68
|
+
return { file: target, line, content: lines[line - 1] };
|
|
69
|
+
}
|
|
70
|
+
export async function forgetEntry(context, target) {
|
|
71
|
+
const file = target.file.startsWith("/")
|
|
72
|
+
? target.file
|
|
73
|
+
: `${context.paths.stateRoot}/${target.file.replace(/^\/+/, "")}`;
|
|
74
|
+
if (!file.startsWith(context.paths.stateRoot))
|
|
75
|
+
throw new Error("path escapes memomatic state root");
|
|
76
|
+
const content = await readTextIfExists(file);
|
|
77
|
+
if (content === undefined)
|
|
78
|
+
throw new Error(`memory file not found: ${target.file}`);
|
|
79
|
+
const lines = content.split("\n");
|
|
80
|
+
if (target.line < 1 || target.line > lines.length)
|
|
81
|
+
throw new Error("line is out of range");
|
|
82
|
+
await replaceEntryLine(context.paths, file, target.line, null);
|
|
83
|
+
return file;
|
|
84
|
+
}
|
|
85
|
+
export async function archiveOldEpisodic(context) {
|
|
86
|
+
const archived = [];
|
|
87
|
+
if (!context.rules.autoClean)
|
|
88
|
+
return archived;
|
|
89
|
+
const cutoff = Date.now() - context.rules.autoClean.olderThanDays * 86_400_000;
|
|
90
|
+
for (const entry of context.store.allEntries()) {
|
|
91
|
+
if (entry.kind !== "episodic" || !entry.observedAt || entry.observedAt >= cutoff)
|
|
92
|
+
continue;
|
|
93
|
+
if (entry.pinned)
|
|
94
|
+
continue;
|
|
95
|
+
const alreadyDone = archived.includes(entry.file);
|
|
96
|
+
if (!alreadyDone) {
|
|
97
|
+
await archiveFile(context.paths, entry.file);
|
|
98
|
+
archived.push(entry.file);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return archived;
|
|
102
|
+
}
|
|
103
|
+
export async function rebuildIndex(context) {
|
|
104
|
+
return reindex(context.paths, context.settings, context.store);
|
|
105
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type MemomaticSettings = {
|
|
2
|
+
embedding: {
|
|
3
|
+
url: string;
|
|
4
|
+
model: string;
|
|
5
|
+
} | null;
|
|
6
|
+
dream: {
|
|
7
|
+
model: string | null;
|
|
8
|
+
variant: string | null;
|
|
9
|
+
minScore: number;
|
|
10
|
+
minUseful: number;
|
|
11
|
+
minUniqueQueries: number;
|
|
12
|
+
maxCandidates: number;
|
|
13
|
+
memoryBudgetLines: number;
|
|
14
|
+
maxPriorEntryLoss: number;
|
|
15
|
+
};
|
|
16
|
+
search: {
|
|
17
|
+
maxResults: number;
|
|
18
|
+
minScore: number;
|
|
19
|
+
halfLifeDays: number;
|
|
20
|
+
};
|
|
21
|
+
archive: {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
days: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export declare const defaultSettings: () => MemomaticSettings;
|
|
27
|
+
export declare function normalizeSettings(raw: unknown): MemomaticSettings;
|
|
28
|
+
export declare function loadSettings(settingsFile: string): Promise<MemomaticSettings>;
|
|
29
|
+
export declare function embedTexts(settings: MemomaticSettings, texts: string[]): Promise<Float32Array[] | null>;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
export const defaultSettings = () => ({
|
|
3
|
+
embedding: null,
|
|
4
|
+
dream: {
|
|
5
|
+
model: null,
|
|
6
|
+
variant: null,
|
|
7
|
+
minScore: 0.45,
|
|
8
|
+
minUseful: 2,
|
|
9
|
+
minUniqueQueries: 2,
|
|
10
|
+
maxCandidates: 12,
|
|
11
|
+
memoryBudgetLines: 80,
|
|
12
|
+
maxPriorEntryLoss: 0.25,
|
|
13
|
+
},
|
|
14
|
+
search: { maxResults: 6, minScore: 0.35, halfLifeDays: 30 },
|
|
15
|
+
archive: { enabled: false, days: 60 },
|
|
16
|
+
});
|
|
17
|
+
function mergeSection(base, override) {
|
|
18
|
+
if (!override || typeof override !== "object")
|
|
19
|
+
return base;
|
|
20
|
+
const merged = { ...base };
|
|
21
|
+
for (const [key, value] of Object.entries(override)) {
|
|
22
|
+
if (key in base && value !== null && value !== undefined)
|
|
23
|
+
merged[key] = value;
|
|
24
|
+
}
|
|
25
|
+
return merged;
|
|
26
|
+
}
|
|
27
|
+
export function normalizeSettings(raw) {
|
|
28
|
+
const defaults = defaultSettings();
|
|
29
|
+
const source = (raw ?? {});
|
|
30
|
+
const embedding = source.embedding;
|
|
31
|
+
return {
|
|
32
|
+
embedding: embedding && typeof embedding.url === "string" && typeof embedding.model === "string"
|
|
33
|
+
? { url: embedding.url, model: embedding.model }
|
|
34
|
+
: null,
|
|
35
|
+
dream: mergeSection(defaults.dream, source.dream),
|
|
36
|
+
search: mergeSection(defaults.search, source.search),
|
|
37
|
+
archive: mergeSection(defaults.archive, source.archive),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function loadSettings(settingsFile) {
|
|
41
|
+
try {
|
|
42
|
+
return normalizeSettings(JSON.parse(await readFile(settingsFile, "utf8")));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code === "ENOENT")
|
|
46
|
+
return defaultSettings();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function embedTexts(settings, texts) {
|
|
51
|
+
if (!settings.embedding || !texts.length)
|
|
52
|
+
return null;
|
|
53
|
+
const response = await fetch(settings.embedding.url, {
|
|
54
|
+
body: JSON.stringify({ input: texts, model: settings.embedding.model }),
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
method: "POST",
|
|
57
|
+
signal: AbortSignal.timeout(30_000),
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok)
|
|
60
|
+
throw new Error(`embedding request failed: ${response.status} ${await response.text()}`);
|
|
61
|
+
const payload = (await response.json());
|
|
62
|
+
const list = payload.data ?? [];
|
|
63
|
+
if (list.length !== texts.length)
|
|
64
|
+
throw new Error("embedding response is incomplete");
|
|
65
|
+
const byIndex = new Map();
|
|
66
|
+
for (const item of list) {
|
|
67
|
+
if (!Array.isArray(item.embedding))
|
|
68
|
+
throw new Error("embedding response is malformed");
|
|
69
|
+
byIndex.set(item.index ?? byIndex.size, item.embedding);
|
|
70
|
+
}
|
|
71
|
+
return texts.map((_, index) => Float32Array.from(byIndex.get(index) ?? []));
|
|
72
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
export type EntryKind = "curated" | "user" | "episodic";
|
|
3
|
+
export type IndexedEntry = {
|
|
4
|
+
stableId: string;
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
kind: EntryKind;
|
|
8
|
+
key: string | null;
|
|
9
|
+
text: string;
|
|
10
|
+
trigger: string[];
|
|
11
|
+
importance: number;
|
|
12
|
+
pinned: boolean;
|
|
13
|
+
project: string | null;
|
|
14
|
+
origin: "user" | "agent" | null;
|
|
15
|
+
observedAt: number | null;
|
|
16
|
+
status: "active" | null;
|
|
17
|
+
};
|
|
18
|
+
export type VectorRow = {
|
|
19
|
+
stableId: string;
|
|
20
|
+
dim: number;
|
|
21
|
+
data: Float32Array;
|
|
22
|
+
};
|
|
23
|
+
export declare function stableIdFor(file: string, text: string, key: string | null): string;
|
|
24
|
+
export declare class MemoryStore {
|
|
25
|
+
readonly db: DatabaseSync;
|
|
26
|
+
private readonly fts;
|
|
27
|
+
private constructor();
|
|
28
|
+
static open(indexFile: string): Promise<MemoryStore>;
|
|
29
|
+
private ensureFts;
|
|
30
|
+
hasFts(): boolean;
|
|
31
|
+
replaceEntries(entries: IndexedEntry[]): void;
|
|
32
|
+
allEntries(): IndexedEntry[];
|
|
33
|
+
ftsSearch(query: string, limit: number): Map<string, number>;
|
|
34
|
+
markSurfaced(stableIds: string[], query: string, now?: number): void;
|
|
35
|
+
markUseful(stableIds: string[], now?: number): void;
|
|
36
|
+
usageFor(stableId: string): {
|
|
37
|
+
surfaced: number;
|
|
38
|
+
useful: number;
|
|
39
|
+
queries: string[];
|
|
40
|
+
days: string[];
|
|
41
|
+
lastUsefulAt: number | null;
|
|
42
|
+
};
|
|
43
|
+
setVector(stableId: string, vector: Float32Array): void;
|
|
44
|
+
vectors(): VectorRow[];
|
|
45
|
+
getMeta(key: string): string | null;
|
|
46
|
+
setMeta(key: string, value: string): void;
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
|
49
|
+
export declare function cosineSimilarity(left: Float32Array, right: Float32Array): number;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
export function stableIdFor(file, text, key) {
|
|
6
|
+
if (key)
|
|
7
|
+
return `key:${key}`;
|
|
8
|
+
return `sha:${createHash("sha256").update(`${file}\n${text}`).digest("hex").slice(0, 24)}`;
|
|
9
|
+
}
|
|
10
|
+
const SCHEMA = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
12
|
+
stable_id TEXT PRIMARY KEY,
|
|
13
|
+
file TEXT NOT NULL,
|
|
14
|
+
line INTEGER NOT NULL,
|
|
15
|
+
kind TEXT NOT NULL,
|
|
16
|
+
key TEXT,
|
|
17
|
+
text TEXT NOT NULL,
|
|
18
|
+
trigger_phrases TEXT NOT NULL DEFAULT '[]',
|
|
19
|
+
importance INTEGER NOT NULL DEFAULT 5,
|
|
20
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
project TEXT,
|
|
22
|
+
origin TEXT,
|
|
23
|
+
observed_at INTEGER,
|
|
24
|
+
status TEXT
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
27
|
+
stable_id TEXT PRIMARY KEY,
|
|
28
|
+
surfaced INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
useful INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
queries TEXT NOT NULL DEFAULT '[]',
|
|
31
|
+
days TEXT NOT NULL DEFAULT '[]',
|
|
32
|
+
last_useful_at INTEGER
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS vectors (
|
|
35
|
+
stable_id TEXT PRIMARY KEY,
|
|
36
|
+
dim INTEGER NOT NULL,
|
|
37
|
+
data BLOB NOT NULL
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
40
|
+
key TEXT PRIMARY KEY,
|
|
41
|
+
value TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
`;
|
|
44
|
+
export class MemoryStore {
|
|
45
|
+
db;
|
|
46
|
+
fts;
|
|
47
|
+
constructor(indexFile) {
|
|
48
|
+
this.db = new DatabaseSync(indexFile);
|
|
49
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
50
|
+
this.db.exec(SCHEMA);
|
|
51
|
+
this.fts = this.ensureFts();
|
|
52
|
+
}
|
|
53
|
+
static async open(indexFile) {
|
|
54
|
+
await mkdir(dirname(indexFile), { mode: 0o700, recursive: true });
|
|
55
|
+
return new MemoryStore(indexFile);
|
|
56
|
+
}
|
|
57
|
+
ensureFts() {
|
|
58
|
+
try {
|
|
59
|
+
this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(stable_id UNINDEXED, text);");
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
hasFts() {
|
|
67
|
+
return this.fts;
|
|
68
|
+
}
|
|
69
|
+
replaceEntries(entries) {
|
|
70
|
+
this.db.exec("BEGIN IMMEDIATE;");
|
|
71
|
+
try {
|
|
72
|
+
this.db.exec("DELETE FROM entries;");
|
|
73
|
+
if (this.fts)
|
|
74
|
+
this.db.exec("DELETE FROM entries_fts;");
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (seen.has(entry.stableId))
|
|
78
|
+
continue;
|
|
79
|
+
seen.add(entry.stableId);
|
|
80
|
+
this.db
|
|
81
|
+
.prepare(`INSERT INTO entries (stable_id, file, line, kind, key, text, trigger_phrases, importance, pinned, project, origin, observed_at, status)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
83
|
+
.run(entry.stableId, entry.file, entry.line, entry.kind, entry.key, entry.text, JSON.stringify(entry.trigger), entry.importance, entry.pinned ? 1 : 0, entry.project, entry.origin, entry.observedAt, entry.status);
|
|
84
|
+
if (this.fts)
|
|
85
|
+
this.db
|
|
86
|
+
.prepare("INSERT INTO entries_fts (stable_id, text) VALUES (?, ?);")
|
|
87
|
+
.run(entry.stableId, entry.text);
|
|
88
|
+
}
|
|
89
|
+
this.db.exec("DELETE FROM usage WHERE stable_id NOT IN (SELECT stable_id FROM entries);");
|
|
90
|
+
this.db.exec("DELETE FROM vectors WHERE stable_id NOT IN (SELECT stable_id FROM entries);");
|
|
91
|
+
this.db.exec("COMMIT;");
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
this.db.exec("ROLLBACK;");
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
allEntries() {
|
|
99
|
+
return this.db
|
|
100
|
+
.prepare("SELECT stable_id, file, line, kind, key, text, trigger_phrases, importance, pinned, project, origin, observed_at, status FROM entries ORDER BY observed_at ASC")
|
|
101
|
+
.all()
|
|
102
|
+
.map((row) => {
|
|
103
|
+
const value = row;
|
|
104
|
+
return {
|
|
105
|
+
stableId: value.stable_id,
|
|
106
|
+
file: value.file,
|
|
107
|
+
line: value.line,
|
|
108
|
+
kind: value.kind,
|
|
109
|
+
key: value.key ?? null,
|
|
110
|
+
text: value.text,
|
|
111
|
+
trigger: JSON.parse(value.trigger_phrases),
|
|
112
|
+
importance: value.importance,
|
|
113
|
+
pinned: value.pinned === 1,
|
|
114
|
+
project: value.project ?? null,
|
|
115
|
+
origin: value.origin ?? null,
|
|
116
|
+
observedAt: value.observed_at ?? null,
|
|
117
|
+
status: value.status ?? null,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
ftsSearch(query, limit) {
|
|
122
|
+
if (!this.fts)
|
|
123
|
+
return new Map();
|
|
124
|
+
const tokens = query
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
127
|
+
.filter((token) => token.length >= 2)
|
|
128
|
+
.slice(0, 12);
|
|
129
|
+
if (!tokens.length)
|
|
130
|
+
return new Map();
|
|
131
|
+
const pattern = tokens.map((token) => `"${token.replace(/"/g, "")}"*`).join(" OR ");
|
|
132
|
+
const rows = this.db
|
|
133
|
+
.prepare(`SELECT stable_id, rank FROM entries_fts WHERE entries_fts MATCH ? ORDER BY rank LIMIT ?;`)
|
|
134
|
+
.all(pattern, limit);
|
|
135
|
+
const result = new Map();
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const quality = -row.rank;
|
|
138
|
+
const score = quality <= 0 ? 0.1 : Math.min(1, 0.5 + quality * 0.25);
|
|
139
|
+
result.set(row.stable_id, score);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
markSurfaced(stableIds, query, now = Date.now()) {
|
|
144
|
+
const queryHash = createHash("sha256").update(query.toLowerCase()).digest("hex").slice(0, 16);
|
|
145
|
+
const day = new Date(now).toISOString().slice(0, 10);
|
|
146
|
+
const read = this.db.prepare("SELECT queries, days FROM usage WHERE stable_id = ?;");
|
|
147
|
+
const upsert = this.db.prepare(`INSERT INTO usage (stable_id, surfaced, useful, queries, days) VALUES (?, 1, 0, ?, ?)
|
|
148
|
+
ON CONFLICT(stable_id) DO UPDATE SET
|
|
149
|
+
surfaced = usage.surfaced + 1,
|
|
150
|
+
queries = excluded.queries,
|
|
151
|
+
days = excluded.days;`);
|
|
152
|
+
for (const stableId of stableIds) {
|
|
153
|
+
const row = read.get(stableId);
|
|
154
|
+
const queries = row ? JSON.parse(row.queries) : [];
|
|
155
|
+
const days = row ? JSON.parse(row.days) : [];
|
|
156
|
+
if (!queries.includes(queryHash) && queries.length < 32)
|
|
157
|
+
queries.push(queryHash);
|
|
158
|
+
if (!days.includes(day))
|
|
159
|
+
days.push(day);
|
|
160
|
+
upsert.run(stableId, JSON.stringify(queries), JSON.stringify(days));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
markUseful(stableIds, now = Date.now()) {
|
|
164
|
+
const statement = this.db.prepare(`INSERT INTO usage (stable_id, useful, last_useful_at) VALUES (?, 1, ?)
|
|
165
|
+
ON CONFLICT(stable_id) DO UPDATE SET useful = useful + 1, last_useful_at = ?;`);
|
|
166
|
+
for (const stableId of stableIds)
|
|
167
|
+
statement.run(stableId, now, now);
|
|
168
|
+
}
|
|
169
|
+
usageFor(stableId) {
|
|
170
|
+
const row = this.db.prepare("SELECT * FROM usage WHERE stable_id = ?").get(stableId);
|
|
171
|
+
if (!row)
|
|
172
|
+
return { surfaced: 0, useful: 0, queries: [], days: [], lastUsefulAt: null };
|
|
173
|
+
return {
|
|
174
|
+
surfaced: row.surfaced,
|
|
175
|
+
useful: row.useful,
|
|
176
|
+
queries: JSON.parse(row.queries),
|
|
177
|
+
days: JSON.parse(row.days),
|
|
178
|
+
lastUsefulAt: row.last_useful_at ?? null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
setVector(stableId, vector) {
|
|
182
|
+
this.db
|
|
183
|
+
.prepare(`INSERT INTO vectors (stable_id, dim, data) VALUES (?, ?, ?)
|
|
184
|
+
ON CONFLICT(stable_id) DO UPDATE SET dim = excluded.dim, data = excluded.data;`)
|
|
185
|
+
.run(stableId, vector.length, Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength));
|
|
186
|
+
}
|
|
187
|
+
vectors() {
|
|
188
|
+
return this.db.prepare("SELECT stable_id, dim, data FROM vectors").all().map((row) => ({
|
|
189
|
+
stableId: row.stable_id,
|
|
190
|
+
dim: row.dim,
|
|
191
|
+
data: new Float32Array(row.data.buffer.slice(row.data.byteOffset, row.data.byteOffset + row.data.byteLength)),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
getMeta(key) {
|
|
195
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
196
|
+
return row?.value ?? null;
|
|
197
|
+
}
|
|
198
|
+
setMeta(key, value) {
|
|
199
|
+
this.db
|
|
200
|
+
.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
|
|
201
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value;`)
|
|
202
|
+
.run(key, value);
|
|
203
|
+
}
|
|
204
|
+
close() {
|
|
205
|
+
this.db.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export function cosineSimilarity(left, right) {
|
|
209
|
+
const length = Math.min(left.length, right.length);
|
|
210
|
+
let dot = 0;
|
|
211
|
+
let leftNorm = 0;
|
|
212
|
+
let rightNorm = 0;
|
|
213
|
+
for (let index = 0; index < length; index += 1) {
|
|
214
|
+
dot += left[index] * right[index];
|
|
215
|
+
leftNorm += left[index] * left[index];
|
|
216
|
+
rightNorm += right[index] * right[index];
|
|
217
|
+
}
|
|
218
|
+
if (!leftNorm || !rightNorm)
|
|
219
|
+
return 0;
|
|
220
|
+
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
|
|
221
|
+
}
|