@bcmyguest/openmem 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/openmem/SKILL.md +34 -0
- package/LICENSE +190 -0
- package/README.md +140 -2
- package/bin/hindsight-project-index +530 -0
- package/cli/coding-agent/access-policy.mjs +38 -0
- package/cli/coding-agent/agents.mjs +34 -0
- package/cli/coding-agent/bank-from-cwd.mjs +140 -0
- package/cli/coding-agent/capabilities.mjs +34 -0
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +897 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +88 -0
- package/cli/coding-agent/hook-runner.mjs +284 -0
- package/cli/coding-agent/install-access.mjs +119 -0
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +201 -0
- package/cli/coding-agent/opencode-config.mjs +107 -0
- package/cli/coding-agent/opencode-plugin.mjs +132 -0
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +40 -0
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +86 -0
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +27 -0
- package/cli/install-coding-agent.mjs +298 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -0
- package/cli/mcp/context.mjs +61 -0
- package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
- package/cli/mcp/export/cognee.mjs +119 -0
- package/cli/mcp/export/hindsight.mjs +48 -0
- package/cli/mcp/export/index.mjs +26 -0
- package/cli/mcp/export/mem0.mjs +31 -0
- package/cli/mcp/knowledge.mjs +333 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +521 -0
- package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
- package/cli/mcp/notebooklm-banks.mjs +21 -0
- package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
- package/cli/mcp/notebooklm-provider.mjs +225 -0
- package/cli/mcp/pmm-records.mjs +23 -0
- package/cli/mcp/portability.mjs +158 -0
- package/cli/mcp/stdio.mjs +65 -0
- package/cli/mcp/unified-mcp-install.mjs +65 -0
- package/cli/mem0-mcp.mjs +108 -0
- package/cli/notebooklm-mcp.mjs +236 -0
- package/cli/openmem-mcp.mjs +38 -0
- package/cli/openmem.mjs +222 -0
- package/cli/roots.mjs +85 -0
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +438 -0
- package/cli/switch-memory-provider.mjs +358 -0
- package/cli/wiring-report.mjs +125 -0
- package/compose.bench.yaml +12 -0
- package/compose.yaml +227 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +26 -0
- package/config/index.yml +54 -0
- package/config/litellm.lemond.yaml +40 -0
- package/config/litellm.ollama.yaml +33 -0
- package/config/notebooklm.yml +33 -0
- package/config/openmem.toml.example +124 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +245 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +75 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +71 -0
- package/docs/cognee.md +37 -0
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +135 -0
- package/docs/context-management.md +255 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +215 -0
- package/docs/context-store.md +277 -0
- package/docs/hindsight.md +77 -0
- package/docs/index-sources-roadmap.md +38 -0
- package/docs/index-sources.md +39 -0
- package/docs/indexer-sinks.md +122 -0
- package/docs/indexing.md +57 -0
- package/docs/manual-stack-setup.md +74 -0
- package/docs/mcp-install-guide.md +4 -0
- package/docs/mcp-setup.md +104 -0
- package/docs/mem0.md +141 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +106 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +172 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +107 -0
- package/docs/setup-wizard.md +117 -0
- package/docs/sources-feeds.md +39 -0
- package/docs/sources-readwise.md +23 -0
- package/docs/sources-zotero.md +22 -0
- package/glama.json +4 -0
- package/indexer/Dockerfile +38 -0
- package/indexer/code_outline.py +146 -0
- package/indexer/feed_sync.py +107 -0
- package/indexer/index_config.py +290 -0
- package/indexer/pytest.ini +5 -0
- package/indexer/sinks/__init__.py +148 -0
- package/indexer/sinks/_http.py +44 -0
- package/indexer/sinks/cognee.py +329 -0
- package/indexer/sinks/hindsight.py +107 -0
- package/indexer/sinks/mem0.py +247 -0
- package/indexer/watch.py +240 -0
- package/package.json +96 -4
- package/providers/_template/compose.yaml +52 -0
- package/providers/cognee/OPERATOR.md +82 -0
- package/providers/cognee/compose.yaml +89 -0
- package/providers/cognee/lib/clack.mjs +157 -0
- package/providers/cognee/lib/coding-agent.mjs +26 -0
- package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
- package/providers/cognee/lib/config.mjs +98 -0
- package/providers/cognee/lib/env-file.mjs +194 -0
- package/providers/cognee/lib/env-setup.mjs +83 -0
- package/providers/cognee/lib/llm-env.mjs +382 -0
- package/providers/cognee/lib/ports.mjs +25 -0
- package/providers/cognee/lib/smoke.mjs +246 -0
- package/providers/cognee/lib/stack-env.mjs +134 -0
- package/providers/cognee/lib/state.mjs +101 -0
- package/providers/cognee/lib/summary.mjs +56 -0
- package/providers/cognee/lib/validate.mjs +97 -0
- package/providers/cognee/manifest.json +56 -0
- package/providers/cognee/wizard.mjs +134 -0
- package/providers/graphiti/manifest.json +15 -0
- package/providers/hindsight/lib/clack.mjs +347 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +79 -0
- package/providers/hindsight/lib/env-setup.mjs +72 -0
- package/providers/hindsight/lib/litellm-routes.mjs +196 -0
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +243 -0
- package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
- package/providers/hindsight/lib/ollama-compose.mjs +51 -0
- package/providers/hindsight/lib/ports.mjs +30 -0
- package/providers/hindsight/lib/probe.mjs +54 -0
- package/providers/hindsight/lib/stack-env.mjs +125 -0
- package/providers/hindsight/lib/state.mjs +80 -0
- package/providers/hindsight/lib/summary.mjs +57 -0
- package/providers/hindsight/lib/validate.mjs +81 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +150 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +143 -0
- package/providers/mem0/compose.yaml +79 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +201 -0
- package/providers/mem0/lib/dotenv.mjs +71 -0
- package/providers/mem0/lib/env-file.mjs +110 -0
- package/providers/mem0/lib/http.mjs +69 -0
- package/providers/mem0/lib/litellm.mjs +457 -0
- package/providers/mem0/lib/mcp-install.mjs +165 -0
- package/providers/mem0/lib/secret.mjs +30 -0
- package/providers/mem0/lib/smoke.mjs +179 -0
- package/providers/mem0/lib/stack-env.mjs +255 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +88 -0
- package/providers/mem0/lib/url.mjs +106 -0
- package/providers/mem0/lib/validate.mjs +158 -0
- package/providers/mem0/manifest.json +47 -0
- package/providers/mem0/wizard.mjs +146 -0
- package/providers/zep/manifest.json +29 -0
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +606 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +219 -0
- package/scripts/gen-masks.mjs +83 -0
- package/scripts/gen-masks.sh +4 -0
- package/scripts/promotion-provider-smoke.mjs +34 -0
- package/scripts/run-setup.sh +30 -0
- package/scripts/setup.mjs +107 -0
- package/scripts/setup.sh +5 -0
- package/scripts/spin-up.sh +45 -0
- package/scripts/wizard.sh +5 -0
- package/server.json +34 -0
- package/sync/Dockerfile +27 -0
- package/sync/memory_backend.py +87 -0
- package/sync/notebooklm_sync.py +664 -0
- package/wizard/bin/pm.mjs +97 -0
- package/wizard/bin/setup.mjs +33 -0
- package/wizard/lib/compose-version.mjs +32 -0
- package/wizard/lib/compose.mjs +70 -0
- package/wizard/lib/context-step.mjs +140 -0
- package/wizard/lib/curl.mjs +103 -0
- package/wizard/lib/docker-build.mjs +148 -0
- package/wizard/lib/exec.mjs +41 -0
- package/wizard/lib/indexer.mjs +6 -0
- package/wizard/lib/litellm-env.mjs +55 -0
- package/wizard/lib/load-machine-env.mjs +4 -0
- package/wizard/lib/log.mjs +32 -0
- package/wizard/lib/notebooklm-agent-access.mjs +92 -0
- package/wizard/lib/notebooklm.mjs +214 -0
- package/wizard/lib/parse-args.mjs +87 -0
- package/wizard/lib/paths.mjs +39 -0
- package/wizard/lib/pm-config.mjs +371 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +85 -0
- package/wizard/lib/provider-wizard/helpers.mjs +98 -0
- package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
- package/wizard/lib/providers/registry.mjs +262 -0
- package/wizard/lib/run-clack.mjs +108 -0
- package/wizard/lib/run-wizard.mjs +750 -0
- package/wizard/lib/validate-setup.mjs +62 -0
- package/wizard/lib/wizard-action.mjs +34 -0
- package/wizard/lib/wizard-conf.mjs +71 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session SQLite store `sessions/<sid>.db` (docs/context-store.md, "Session database"): the JS side of
|
|
3
|
+
* proxy/openmem_context/sessiondb.py, ttl.py and embed.py. The schema and row rules must match the Python
|
|
4
|
+
* byte for byte (scripts/tests/test_context_parity.mjs).
|
|
5
|
+
*
|
|
6
|
+
* node:sqlite is loaded on first use, so modules that import this one (hooks among them) never load it
|
|
7
|
+
* unless they touch a database. Node >= 24 (engines.node; node:sqlite with FTS5 first shipped in 22.16).
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export const SCHEMA_VERSION = 3;
|
|
14
|
+
/** Version 1, executed in order on an empty database. Same strings as SCHEMA in sessiondb.py. */
|
|
15
|
+
export const SCHEMA = [
|
|
16
|
+
"CREATE TABLE messages (seq INTEGER PRIMARY KEY, fingerprint TEXT NOT NULL UNIQUE, thread TEXT NOT NULL, "
|
|
17
|
+
+ "role TEXT NOT NULL, kind TEXT NOT NULL, name TEXT, text TEXT NOT NULL, blob_id TEXT, "
|
|
18
|
+
+ "in_context INTEGER NOT NULL DEFAULT 1, trimmed_at_seq INTEGER, trimmed_at TEXT, created_at TEXT NOT NULL)",
|
|
19
|
+
"CREATE INDEX messages_thread ON messages (thread, seq)",
|
|
20
|
+
"CREATE TABLE lines (id INTEGER PRIMARY KEY, message_seq INTEGER NOT NULL REFERENCES messages (seq), "
|
|
21
|
+
+ "line_no INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, UNIQUE (message_seq, line_no))",
|
|
22
|
+
"CREATE VIRTUAL TABLE messages_fts USING fts5 (text, content='messages', content_rowid='seq')",
|
|
23
|
+
"CREATE VIRTUAL TABLE lines_fts USING fts5 (text, content='lines', content_rowid='id')",
|
|
24
|
+
"CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN "
|
|
25
|
+
+ "INSERT INTO messages_fts (rowid, text) VALUES (new.seq, new.text); END",
|
|
26
|
+
"CREATE TRIGGER lines_fts_insert AFTER INSERT ON lines BEGIN "
|
|
27
|
+
+ "INSERT INTO lines_fts (rowid, text) VALUES (new.id, new.text); END",
|
|
28
|
+
"CREATE TABLE vectors (message_seq INTEGER NOT NULL REFERENCES messages (seq), model TEXT NOT NULL, "
|
|
29
|
+
+ "dim INTEGER NOT NULL, blob BLOB NOT NULL, PRIMARY KEY (message_seq, model))",
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* [version, DDL] run in order after SCHEMA on a new database, or from the stored version up on an older one; then
|
|
33
|
+
* PRAGMA user_version = SCHEMA_VERSION. Same strings as MIGRATIONS in sessiondb.py.
|
|
34
|
+
*/
|
|
35
|
+
export const MIGRATIONS = [
|
|
36
|
+
// Trims and fail-opens: kind 'trim' | 'fail_open'; version names the writer ("proxy:<hash>", "opencode:<v>").
|
|
37
|
+
[2, ["CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL, kind TEXT NOT NULL, version TEXT NOT NULL, "
|
|
38
|
+
+ "location TEXT, error TEXT, tokens_before INTEGER, tokens_after INTEGER, dropped INTEGER)"]],
|
|
39
|
+
// Per-request usage: kind 'usage', with provider-reported token counts, prompt-cache reads and writes, whether
|
|
40
|
+
// the request carried a cut, and its cache_control breakpoints (Anthropic route).
|
|
41
|
+
[3, ["model TEXT", "thread_root TEXT", "trimmed INTEGER", "input_tokens INTEGER", "cache_read_tokens INTEGER",
|
|
42
|
+
"cache_creation_tokens INTEGER", "output_tokens INTEGER", "breakpoints INTEGER", "breakpoint_at TEXT"]
|
|
43
|
+
.map((c) => `ALTER TABLE events ADD COLUMN ${c}`)],
|
|
44
|
+
];
|
|
45
|
+
export const EVENT_COLUMNS = ["at", "kind", "version", "location", "error", "tokens_before", "tokens_after", "dropped", "model",
|
|
46
|
+
"thread_root", "trimmed", "input_tokens", "cache_read_tokens", "cache_creation_tokens", "output_tokens", "breakpoints",
|
|
47
|
+
"breakpoint_at"];
|
|
48
|
+
export const DB_SUFFIX = ".db";
|
|
49
|
+
export const BUSY_TIMEOUT_MS = 1000;
|
|
50
|
+
export const DEFAULT_TTL_DAYS = 30;
|
|
51
|
+
export const DEFAULT_EMBEDDING_URL = "http://127.0.0.1:11434/v1";
|
|
52
|
+
/** Seconds per row of an embeddings call and characters of a row's head sent (settings.py DEFAULT_EMBEDDING_*). */
|
|
53
|
+
export const DEFAULT_EMBEDDING_TIMEOUT_S = 30;
|
|
54
|
+
export const DEFAULT_EMBEDDING_MAX_CHARS = 4000;
|
|
55
|
+
/** Adaptive batches and row skipping (embed.py START_BATCH, MAX_BATCH, GROW_FRACTION, MAX_FAILURES). */
|
|
56
|
+
export const EMBED_START_BATCH = 4;
|
|
57
|
+
const EMBED_MAX_BATCH = 32;
|
|
58
|
+
const EMBED_GROW_FRACTION = 0.25;
|
|
59
|
+
export const EMBED_MAX_FAILURES = 3;
|
|
60
|
+
const EMBED_RETRY_MS = 60_000;
|
|
61
|
+
const MAX_OPEN = 16;
|
|
62
|
+
const DAY_MS = 86_400_000;
|
|
63
|
+
export const TTL_STAMP = ".ttl-stamp";
|
|
64
|
+
/** Longest first, so `x.trim.json` groups under `x` (ttl.py SESSION_SUFFIXES). */
|
|
65
|
+
const SESSION_SUFFIXES = [".log.jsonl", ".trim.json", ".db-wal", ".db-shm", ".force", ".json", ".db"];
|
|
66
|
+
const BLOB_REF_RE = /^\[blob:([0-9a-f]{16})\]/;
|
|
67
|
+
const open = new Map();
|
|
68
|
+
const embedState = new Map();
|
|
69
|
+
/** { batch, ok } per url + model (embed.py Embedder._endpoint). */
|
|
70
|
+
const embedBatch = new Map();
|
|
71
|
+
|
|
72
|
+
/** node:sqlite, loaded on first use. */
|
|
73
|
+
export function sqlite() {
|
|
74
|
+
return process.getBuiltinModule("node:sqlite");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Blob id named by a stored tool-result text ("[blob:<id>]..."), else null. */
|
|
78
|
+
export function blobRef(text) {
|
|
79
|
+
return typeof text === "string" ? BLOB_REF_RE.exec(text)?.[1] ?? null : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Python fingerprint() of {prev, role, kind, name, text}: sorted keys, compact JSON, SHA-256, 32 hex. */
|
|
83
|
+
function rowFingerprint(prev, row) {
|
|
84
|
+
const blob = JSON.stringify({ kind: row.kind, name: row.name ?? null, prev, role: row.role, text: row.text });
|
|
85
|
+
return crypto.createHash("sha256").update(blob, "utf8").digest("hex").slice(0, 32);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Give each row its fingerprint and thread, in order, in place (sessiondb.chain). A row's fingerprint hashes
|
|
90
|
+
* the previous row's fingerprint with its own role, kind, name and text: a resent history yields the same
|
|
91
|
+
* keys, and the same text said twice yields two rows. thread is the first row's fingerprint.
|
|
92
|
+
*/
|
|
93
|
+
export function chain(rows) {
|
|
94
|
+
let prev = "";
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
prev = row.fingerprint = rowFingerprint(prev, row);
|
|
97
|
+
row.thread = rows[0].fingerprint;
|
|
98
|
+
}
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureFile(file) {
|
|
103
|
+
const dir = path.dirname(file);
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* not ours */ }
|
|
106
|
+
if (!fs.existsSync(file)) fs.closeSync(fs.openSync(file, "a", 0o600));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Writer connection in WAL mode with the schema in place; throws on a database of another schema version. */
|
|
110
|
+
export function connect(file) {
|
|
111
|
+
ensureFile(file);
|
|
112
|
+
const { DatabaseSync } = sqlite();
|
|
113
|
+
const db = new DatabaseSync(file);
|
|
114
|
+
try {
|
|
115
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
116
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
117
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
118
|
+
const version = () => db.prepare("PRAGMA user_version").get().user_version;
|
|
119
|
+
if (version() < SCHEMA_VERSION) {
|
|
120
|
+
db.exec("BEGIN IMMEDIATE");
|
|
121
|
+
try {
|
|
122
|
+
const from = version();
|
|
123
|
+
if (from < SCHEMA_VERSION) {
|
|
124
|
+
if (from === 0) for (const ddl of SCHEMA) db.exec(ddl);
|
|
125
|
+
for (const [target, ddls] of MIGRATIONS) {
|
|
126
|
+
if (target > Math.max(from, 1)) for (const ddl of ddls) db.exec(ddl);
|
|
127
|
+
}
|
|
128
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
129
|
+
}
|
|
130
|
+
db.exec("COMMIT");
|
|
131
|
+
} catch (error) {
|
|
132
|
+
db.exec("ROLLBACK");
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (version() !== SCHEMA_VERSION) throw new Error(`unsupported session database user_version ${version()}`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
db.close();
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return db;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function identity(file) {
|
|
145
|
+
try {
|
|
146
|
+
const st = fs.statSync(file);
|
|
147
|
+
return `${st.dev}:${st.ino}`;
|
|
148
|
+
} catch { return null; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One session's database plus an index of its rows (fingerprint -> [seq, in_context]). */
|
|
152
|
+
export class SessionDB {
|
|
153
|
+
constructor(file) {
|
|
154
|
+
this.file = file;
|
|
155
|
+
this.db = connect(file);
|
|
156
|
+
this.identity = identity(file);
|
|
157
|
+
this.known = new Map();
|
|
158
|
+
this.dataVersion = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
close() {
|
|
162
|
+
try { this.db.close(); } catch { /* already closed */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
refresh() {
|
|
166
|
+
const version = this.db.prepare("PRAGMA data_version").get().data_version;
|
|
167
|
+
if (version === this.dataVersion) return;
|
|
168
|
+
this.known = new Map(this.db.prepare("SELECT fingerprint, seq, in_context FROM messages").all()
|
|
169
|
+
.map((r) => [r.fingerprint, [r.seq, r.in_context]]));
|
|
170
|
+
this.dataVersion = version;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Store chained rows and their in_context flags (sessiondb.SessionDB.sync); returns the number of new rows.
|
|
175
|
+
* rows: {fingerprint, thread, role, kind, name, text, blob_id, keep}; cutRow: index of the cut turn's first row or null.
|
|
176
|
+
*/
|
|
177
|
+
sync(rows, cutRow, ts) {
|
|
178
|
+
this.refresh();
|
|
179
|
+
const { known, db } = this;
|
|
180
|
+
const fresh = rows.filter((r) => !known.has(r.fingerprint));
|
|
181
|
+
const changes = rows.some((r) => known.has(r.fingerprint) && known.get(r.fingerprint)[1] !== (r.keep ? 1 : 0));
|
|
182
|
+
if (!fresh.length && !changes) return 0;
|
|
183
|
+
db.exec("BEGIN IMMEDIATE");
|
|
184
|
+
try {
|
|
185
|
+
let added = 0;
|
|
186
|
+
const insert = db.prepare("INSERT INTO messages (fingerprint, thread, role, kind, name, text, blob_id, created_at) "
|
|
187
|
+
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (fingerprint) DO NOTHING");
|
|
188
|
+
const insertLine = db.prepare("INSERT INTO lines (message_seq, line_no, role, text) VALUES (?, ?, ?, ?)");
|
|
189
|
+
for (const r of fresh) {
|
|
190
|
+
const res = insert.run(r.fingerprint, r.thread, r.role, r.kind, r.name ?? null, r.text, r.blob_id ?? null, ts);
|
|
191
|
+
if (res.changes === 1) {
|
|
192
|
+
const seq = Number(res.lastInsertRowid);
|
|
193
|
+
r.text.split("\n").forEach((line, i) => insertLine.run(seq, i + 1, r.role, line));
|
|
194
|
+
known.set(r.fingerprint, [seq, 1]);
|
|
195
|
+
added++;
|
|
196
|
+
} else {
|
|
197
|
+
const row = db.prepare("SELECT seq, in_context FROM messages WHERE fingerprint = ?").get(r.fingerprint);
|
|
198
|
+
known.set(r.fingerprint, [row.seq, row.in_context]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const cutSeq = cutRow === null || cutRow === undefined ? null : known.get(rows[cutRow].fingerprint)[0];
|
|
202
|
+
const back = db.prepare("UPDATE messages SET in_context = 1, trimmed_at_seq = NULL, trimmed_at = NULL WHERE seq = ?");
|
|
203
|
+
const out = db.prepare("UPDATE messages SET in_context = 0, trimmed_at_seq = ?, trimmed_at = ? WHERE seq = ?");
|
|
204
|
+
for (const r of rows) {
|
|
205
|
+
const entry = known.get(r.fingerprint);
|
|
206
|
+
const want = r.keep ? 1 : 0;
|
|
207
|
+
if (entry[1] === want) continue;
|
|
208
|
+
if (want) back.run(entry[0]);
|
|
209
|
+
else out.run(cutSeq, ts, entry[0]);
|
|
210
|
+
entry[1] = want;
|
|
211
|
+
}
|
|
212
|
+
db.exec("COMMIT");
|
|
213
|
+
this.dataVersion = db.prepare("PRAGMA data_version").get().data_version;
|
|
214
|
+
return added;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
try { db.exec("ROLLBACK"); } catch { /* no transaction */ }
|
|
217
|
+
this.dataVersion = null;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Append one events row (SessionDB.add_event); fields are the other EVENT_COLUMNS. */
|
|
223
|
+
addEvent(kind, at, version, fields = {}) {
|
|
224
|
+
const values = { at, kind, version, ...fields };
|
|
225
|
+
this.db.prepare(`INSERT INTO events (${EVENT_COLUMNS.join(", ")}) VALUES (${EVENT_COLUMNS.map(() => "?").join(", ")})`)
|
|
226
|
+
.run(...EVENT_COLUMNS.map((c) => values[c] ?? null));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Cached SessionDB for file; reopened when the file was deleted or replaced (TTL cleanup). */
|
|
231
|
+
export function openSession(file) {
|
|
232
|
+
let entry = open.get(file);
|
|
233
|
+
open.delete(file);
|
|
234
|
+
if (entry && entry.identity !== identity(file)) {
|
|
235
|
+
entry.close();
|
|
236
|
+
entry = null;
|
|
237
|
+
}
|
|
238
|
+
entry ||= new SessionDB(file);
|
|
239
|
+
open.set(file, entry);
|
|
240
|
+
while (open.size > MAX_OPEN) {
|
|
241
|
+
const [key, oldest] = open.entries().next().value;
|
|
242
|
+
open.delete(key);
|
|
243
|
+
oldest.close();
|
|
244
|
+
}
|
|
245
|
+
return entry;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Forget a cached connection after an error, so the next request reopens it. */
|
|
249
|
+
export function dropSession(file) {
|
|
250
|
+
const entry = open.get(file);
|
|
251
|
+
open.delete(file);
|
|
252
|
+
entry?.close();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Hours of events `openmem context status` and the context_status MCP tool summarize by default. */
|
|
256
|
+
export const EVENT_WINDOW_HOURS = 24;
|
|
257
|
+
const LAST_ERRORS = 5;
|
|
258
|
+
// events.trimmed of a usage row whose request made a new cut (core.TRIM_NEW, context-trim.mjs TRIM_NEW).
|
|
259
|
+
const USAGE_NEW_CUT = 2;
|
|
260
|
+
const TOP_BREAKPOINT_LAYOUTS = 5;
|
|
261
|
+
|
|
262
|
+
function emptyUsage() {
|
|
263
|
+
return { requests: 0, withCacheCounts: 0, inputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, outputTokens: 0,
|
|
264
|
+
hitRatio: null, newCut: { requests: 0, withCacheCounts: 0, cacheCreationTokens: 0 },
|
|
265
|
+
other: { requests: 0, withCacheCounts: 0, cacheCreationTokens: 0 }, breakpoints: {}, breakpointLayouts: [] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Add one usage row to u. Only rows reporting input, cache read and cache creation enter the token sums. */
|
|
269
|
+
function addUsage(u, r, layouts) {
|
|
270
|
+
u.requests++;
|
|
271
|
+
const group = r.trimmed === USAGE_NEW_CUT ? u.newCut : u.other;
|
|
272
|
+
group.requests++;
|
|
273
|
+
if (r.breakpoints !== null && r.breakpoints !== undefined) {
|
|
274
|
+
u.breakpoints[r.breakpoints] = (u.breakpoints[r.breakpoints] || 0) + 1;
|
|
275
|
+
const at = r.breakpoint_at || "(none)";
|
|
276
|
+
layouts.set(at, (layouts.get(at) || 0) + 1);
|
|
277
|
+
}
|
|
278
|
+
if ([r.input_tokens, r.cache_read_tokens, r.cache_creation_tokens].some((v) => v === null || v === undefined)) return;
|
|
279
|
+
u.withCacheCounts++;
|
|
280
|
+
group.withCacheCounts++;
|
|
281
|
+
u.inputTokens += r.input_tokens;
|
|
282
|
+
u.cacheReadTokens += r.cache_read_tokens;
|
|
283
|
+
u.cacheCreationTokens += r.cache_creation_tokens;
|
|
284
|
+
u.outputTokens += r.output_tokens ?? 0;
|
|
285
|
+
group.cacheCreationTokens += r.cache_creation_tokens;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Summary of the events rows written in the last `hours` across `sessions/*.db` (only files modified in that
|
|
290
|
+
* window are opened): trims, estimated tokens saved, fail-opens, the newest errors, row counts per version and the
|
|
291
|
+
* newest version per writer (`latest.proxy`, `latest.opencode`).
|
|
292
|
+
* Trim token figures are the writers' calibrated estimates, not provider-billed counts. `usage` sums the usage rows'
|
|
293
|
+
* provider-reported counts: cache hit ratio read / (read + creation + uncached input), cache-creation tokens on
|
|
294
|
+
* requests that made a new cut (`newCut`) and on the rest (`other`), and the cache_control breakpoint counts
|
|
295
|
+
* (`breakpoints`: count -> requests) and most common layouts (Anthropic route only).
|
|
296
|
+
*/
|
|
297
|
+
export function summarizeEvents(sessionsDir, { hours = EVENT_WINDOW_HOURS, now = Date.now() } = {}) {
|
|
298
|
+
const sinceMs = now - hours * 3_600_000;
|
|
299
|
+
const since = new Date(sinceMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
300
|
+
const out = { hours, since, sessions: 0, trims: 0, tokensBefore: 0, tokensAfter: 0, tokensSaved: 0, dropped: 0,
|
|
301
|
+
failOpens: 0, lastErrors: [], versions: {}, latest: {}, unreadable: 0, usage: emptyUsage() };
|
|
302
|
+
const layouts = new Map();
|
|
303
|
+
let names = [];
|
|
304
|
+
try { names = fs.readdirSync(sessionsDir).filter((n) => n.endsWith(DB_SUFFIX)); } catch { return out; }
|
|
305
|
+
const errors = [];
|
|
306
|
+
const latestAt = {};
|
|
307
|
+
for (const name of names) {
|
|
308
|
+
const file = path.join(sessionsDir, name);
|
|
309
|
+
try { if (fs.statSync(file).mtimeMs < sinceMs) continue; } catch { continue; }
|
|
310
|
+
let db;
|
|
311
|
+
try {
|
|
312
|
+
db = new (sqlite().DatabaseSync)(file, { readOnly: true });
|
|
313
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
314
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'events'").get()) continue;
|
|
315
|
+
const rows = db.prepare("SELECT * FROM events WHERE at >= ? ORDER BY id").all(since);
|
|
316
|
+
if (!rows.length) continue;
|
|
317
|
+
out.sessions++;
|
|
318
|
+
const sid = name.slice(0, -DB_SUFFIX.length);
|
|
319
|
+
for (const r of rows) {
|
|
320
|
+
out.versions[r.version] = (out.versions[r.version] || 0) + 1;
|
|
321
|
+
const writer = String(r.version).split(":")[0];
|
|
322
|
+
if (r.at >= (latestAt[writer] || "")) [latestAt[writer], out.latest[writer]] = [r.at, r.version];
|
|
323
|
+
if (r.kind === "trim") {
|
|
324
|
+
out.trims++;
|
|
325
|
+
out.tokensBefore += r.tokens_before ?? 0;
|
|
326
|
+
out.tokensAfter += r.tokens_after ?? 0;
|
|
327
|
+
out.dropped += r.dropped ?? 0;
|
|
328
|
+
} else if (r.kind === "fail_open") {
|
|
329
|
+
out.failOpens++;
|
|
330
|
+
errors.push({ at: r.at, session: sid, error: r.error, location: r.location, version: r.version });
|
|
331
|
+
} else if (r.kind === "usage") addUsage(out.usage, r, layouts);
|
|
332
|
+
}
|
|
333
|
+
} catch { out.unreadable++; } finally { db?.close(); }
|
|
334
|
+
}
|
|
335
|
+
out.tokensSaved = out.tokensBefore - out.tokensAfter;
|
|
336
|
+
const u = out.usage;
|
|
337
|
+
const prompt = u.inputTokens + u.cacheReadTokens + u.cacheCreationTokens;
|
|
338
|
+
u.hitRatio = prompt > 0 ? u.cacheReadTokens / prompt : null;
|
|
339
|
+
u.breakpointLayouts = [...layouts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, TOP_BREAKPOINT_LAYOUTS)
|
|
340
|
+
.map(([at, requests]) => ({ at, requests }));
|
|
341
|
+
out.lastErrors = errors.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)).slice(0, LAST_ERRORS);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** "proxy:" + first 12 hex of SHA-256 over dir's *.py files (name, NUL, bytes, NUL, by name): core.code_hash. */
|
|
346
|
+
export function proxyCodeVersion(dir) {
|
|
347
|
+
const hash = crypto.createHash("sha256");
|
|
348
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith(".py")).sort()) {
|
|
349
|
+
hash.update(Buffer.concat([Buffer.from(`${name}\0`), fs.readFileSync(path.join(dir, name)), Buffer.from("\0")]));
|
|
350
|
+
}
|
|
351
|
+
return `proxy:${hash.digest("hex").slice(0, 12)}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Vectors: float32 little-endian, brute-force cosine (sessions hold thousands of rows).
|
|
355
|
+
export function packVector(values) {
|
|
356
|
+
const buffer = Buffer.alloc(values.length * 4);
|
|
357
|
+
values.forEach((v, i) => buffer.writeFloatLE(v, i * 4));
|
|
358
|
+
return buffer;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function unpackVector(blob, dim) {
|
|
362
|
+
const buffer = Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
363
|
+
return Array.from({ length: dim }, (_, i) => buffer.readFloatLE(i * 4));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function cosine(a, b) {
|
|
367
|
+
let dot = 0;
|
|
368
|
+
let na = 0;
|
|
369
|
+
let nb = 0;
|
|
370
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
371
|
+
dot += a[i] * b[i];
|
|
372
|
+
na += a[i] * a[i];
|
|
373
|
+
nb += b[i] * b[i];
|
|
374
|
+
}
|
|
375
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* [seq, similarity] of the k vectors of `model` closest to query, best first, ties by seq (sessiondb.nearest).
|
|
380
|
+
* `where` / `params` narrow the rows through the joined messages table `m`.
|
|
381
|
+
*/
|
|
382
|
+
export function nearest(db, query, model, k, where = "", params = []) {
|
|
383
|
+
const rows = db.prepare(`SELECT v.message_seq AS seq, v.dim AS dim, v.blob AS blob FROM vectors v `
|
|
384
|
+
+ `JOIN messages m ON m.seq = v.message_seq WHERE v.model = ?${where ? ` AND ${where}` : ""}`).all(model, ...params);
|
|
385
|
+
return rows.filter((r) => r.dim === query.length)
|
|
386
|
+
.map((r) => [r.seq, cosine(query, unpackVector(r.blob, r.dim))])
|
|
387
|
+
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
388
|
+
.slice(0, k);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Vectors for texts from an OpenAI-compatible POST <url>/embeddings, in input order; timeoutMs per call. */
|
|
392
|
+
export async function fetchEmbeddings({ url, model, apiKey }, texts, fetchFn = globalThis.fetch,
|
|
393
|
+
timeoutMs = DEFAULT_EMBEDDING_TIMEOUT_S * 1000) {
|
|
394
|
+
const response = await fetchFn(`${String(url).replace(/\/+$/, "")}/embeddings`, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
|
|
397
|
+
body: JSON.stringify({ model, input: texts }),
|
|
398
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
399
|
+
});
|
|
400
|
+
if (!response.ok) throw new Error(`embeddings HTTP ${response.status}`);
|
|
401
|
+
const payload = await response.json();
|
|
402
|
+
const data = [...(payload?.data || [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
403
|
+
const vectors = data.map((d) => d.embedding.map(Number));
|
|
404
|
+
if (vectors.length !== texts.length) throw new Error("embedding count mismatch");
|
|
405
|
+
return vectors;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Rows without a `model` vector, trimmed first, then newest first; seqs in exclude are left out (embed.py pending_rows). */
|
|
409
|
+
export function pendingRows(db, model, limit, exclude = []) {
|
|
410
|
+
const skip = exclude.length ? ` AND m.seq NOT IN (${exclude.map(() => "?").join(",")})` : "";
|
|
411
|
+
return db.prepare("SELECT m.seq AS seq, m.text AS text FROM messages m LEFT JOIN vectors v "
|
|
412
|
+
+ `ON v.message_seq = m.seq AND v.model = ? WHERE v.message_seq IS NULL AND length(trim(m.text)) > 0${skip} `
|
|
413
|
+
+ "ORDER BY m.in_context, m.seq DESC LIMIT ?").all(model, ...[...exclude].sort((a, b) => a - b), limit);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** { filled, pending, skipped } row counts for `model`; skipped rows carry a dim-0 vector. */
|
|
417
|
+
export function vectorCounts(db, model) {
|
|
418
|
+
const v = db.prepare("SELECT coalesce(sum(dim > 0), 0) AS filled, coalesce(sum(dim = 0), 0) AS skipped FROM vectors WHERE model = ?").get(model);
|
|
419
|
+
const pending = db.prepare("SELECT count(*) AS n FROM messages m LEFT JOIN vectors v ON v.message_seq = m.seq AND v.model = ? "
|
|
420
|
+
+ "WHERE v.message_seq IS NULL AND length(trim(m.text)) > 0").get(model).n;
|
|
421
|
+
return { filled: Number(v.filled), pending: Number(pending), skipped: Number(v.skipped) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function insertVectors(db, model, rows) {
|
|
425
|
+
const insert = db.prepare("INSERT OR IGNORE INTO vectors (message_seq, model, dim, blob) VALUES (?, ?, ?, ?)");
|
|
426
|
+
db.exec("BEGIN IMMEDIATE");
|
|
427
|
+
try {
|
|
428
|
+
for (const [seq, vec] of rows) insert.run(seq, model, vec.length, packVector(vec));
|
|
429
|
+
db.exec("COMMIT");
|
|
430
|
+
} catch (error) {
|
|
431
|
+
db.exec("ROLLBACK");
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const errorName = (error) => error?.name || typeof error;
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Embed every row of an open SessionDB that lacks a `model` vector (embed.py embed_pending); returns rows stored.
|
|
440
|
+
* config: { model, url, apiKey, timeoutS, maxChars }. state carries `batch`, `ok` (the endpoint's last call
|
|
441
|
+
* succeeded) and `failures` (Map seq → single-row failures) between runs. A failed size caps growth for the run. Rethrows the last error once MAX_FAILURES single-row calls fail in a row with nothing
|
|
442
|
+
* stored this run.
|
|
443
|
+
*/
|
|
444
|
+
export async function embedPending(session, config, fetchFn, { state = {}, log = () => {} } = {}) {
|
|
445
|
+
const timeoutS = config.timeoutS > 0 ? config.timeoutS : DEFAULT_EMBEDDING_TIMEOUT_S;
|
|
446
|
+
const maxChars = config.maxChars > 0 ? config.maxChars : DEFAULT_EMBEDDING_MAX_CHARS;
|
|
447
|
+
state.batch ??= EMBED_START_BATCH;
|
|
448
|
+
state.ok ??= false;
|
|
449
|
+
state.failures ??= new Map();
|
|
450
|
+
const { failures } = state;
|
|
451
|
+
const tried = new Set();
|
|
452
|
+
let stored = 0;
|
|
453
|
+
let streak = 0;
|
|
454
|
+
let ceiling = EMBED_MAX_BATCH;
|
|
455
|
+
for (;;) {
|
|
456
|
+
const batch = state.batch;
|
|
457
|
+
// Rows that never failed first; earlier failures get another try once those are done.
|
|
458
|
+
let rows = pendingRows(session.db, config.model, batch, [...new Set([...tried, ...failures.keys()])]);
|
|
459
|
+
if (!rows.length) rows = pendingRows(session.db, config.model, batch, [...tried]);
|
|
460
|
+
if (!rows.length) return stored;
|
|
461
|
+
const timeoutMs = timeoutS * 1000 * rows.length;
|
|
462
|
+
const start = performance.now();
|
|
463
|
+
let vectors;
|
|
464
|
+
try {
|
|
465
|
+
vectors = await fetchEmbeddings(config, rows.map((r) => Array.from(r.text).slice(0, maxChars).join("")), fetchFn, timeoutMs);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
const seconds = ((performance.now() - start) / 1000).toFixed(1);
|
|
468
|
+
if (rows.length > 1) {
|
|
469
|
+
ceiling = Math.max(1, Math.floor(rows.length / 2));
|
|
470
|
+
state.batch = Math.max(1, Math.floor(rows.length / 2));
|
|
471
|
+
log(`embedding batch failed ${errorName(error)} rows=${rows.length} seconds=${seconds} batch=${state.batch}`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const seq = rows[0].seq;
|
|
475
|
+
tried.add(seq);
|
|
476
|
+
failures.set(seq, (failures.get(seq) || 0) + 1);
|
|
477
|
+
streak++;
|
|
478
|
+
log(`embedding row failed ${errorName(error)} seq=${seq} failures=${failures.get(seq)} seconds=${seconds} batch=1`);
|
|
479
|
+
// Blame the row only while the endpoint works for other rows.
|
|
480
|
+
if (failures.get(seq) >= EMBED_MAX_FAILURES && (stored || state.ok)) {
|
|
481
|
+
insertVectors(session.db, config.model, [[seq, []]]);
|
|
482
|
+
failures.delete(seq);
|
|
483
|
+
log(`embedding skipped seq=${seq} after ${EMBED_MAX_FAILURES} failures`);
|
|
484
|
+
}
|
|
485
|
+
// The endpoint looks down: scheduleEmbedding retries the database after EMBED_RETRY_MS.
|
|
486
|
+
if (streak >= EMBED_MAX_FAILURES && !stored) {
|
|
487
|
+
state.ok = false;
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const ms = performance.now() - start;
|
|
493
|
+
insertVectors(session.db, config.model, rows.map((r, i) => [r.seq, vectors[i]]));
|
|
494
|
+
for (const r of rows) failures.delete(r.seq);
|
|
495
|
+
stored += rows.length;
|
|
496
|
+
streak = 0;
|
|
497
|
+
state.ok = true;
|
|
498
|
+
if (rows.length === batch && ms < EMBED_GROW_FRACTION * timeoutMs) state.batch = Math.min(ceiling, batch * 2);
|
|
499
|
+
log(`embedded rows=${rows.length} seconds=${(ms / 1000).toFixed(1)} batch=${state.batch}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Next batch size for an embeddings endpoint and model. */
|
|
504
|
+
export function embeddingBatchSize(url, model) {
|
|
505
|
+
return embedBatch.get(`${url}\u0000${model}`)?.batch ?? EMBED_START_BATCH;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const stderrLog = (message) => process.stderr.write(`openmem-context: ${message}\n`);
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Embed a session's new rows in the background: at most one run per database at a time, retried no sooner
|
|
512
|
+
* than a minute after a failing run. Returns the run's promise (resolves to rows stored, or 0); never rejects.
|
|
513
|
+
* One log line per batch goes to `log` (default: stderr).
|
|
514
|
+
*/
|
|
515
|
+
export function scheduleEmbedding(file, config, { fetchFn, onError, log = stderrLog } = {}) {
|
|
516
|
+
if (!config?.model || !config?.url) return Promise.resolve(0);
|
|
517
|
+
const state = embedState.get(file) || { running: null, again: false, retryAt: 0, failures: new Map() };
|
|
518
|
+
embedState.set(file, state);
|
|
519
|
+
if (Date.now() < state.retryAt) return Promise.resolve(0);
|
|
520
|
+
if (state.running) {
|
|
521
|
+
state.again = true;
|
|
522
|
+
return state.running;
|
|
523
|
+
}
|
|
524
|
+
const batchKey = `${config.url}\u0000${config.model}`;
|
|
525
|
+
state.running = (async () => {
|
|
526
|
+
let total = 0;
|
|
527
|
+
const endpoint = embedBatch.get(batchKey) || { batch: EMBED_START_BATCH, ok: false };
|
|
528
|
+
const run = { ...endpoint, failures: state.failures };
|
|
529
|
+
try {
|
|
530
|
+
do {
|
|
531
|
+
state.again = false;
|
|
532
|
+
total += await embedPending(openSession(file), config, fetchFn, { state: run, log });
|
|
533
|
+
} while (state.again);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
state.retryAt = Date.now() + EMBED_RETRY_MS;
|
|
536
|
+
onError?.(error);
|
|
537
|
+
} finally {
|
|
538
|
+
embedBatch.set(batchKey, { batch: run.batch, ok: run.ok });
|
|
539
|
+
state.running = null;
|
|
540
|
+
}
|
|
541
|
+
return total;
|
|
542
|
+
})();
|
|
543
|
+
return state.running;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** [sid, suffix] for a session file name, or null for anything else (ttl.py session_of). */
|
|
547
|
+
export function sessionOf(name) {
|
|
548
|
+
if (name.startsWith(".")) return null;
|
|
549
|
+
const suffix = SESSION_SUFFIXES.find((s) => name.endsWith(s) && name.length > s.length);
|
|
550
|
+
return suffix ? [name.slice(0, -suffix.length), suffix] : null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Delete every session whose newest file is older than ttlDays (ttl.py sweep); returns the deleted sids, sorted. */
|
|
554
|
+
export function sweepContextStore(root, ttlDays, now = Date.now()) {
|
|
555
|
+
if (!(ttlDays > 0)) return [];
|
|
556
|
+
const dir = path.join(root, "sessions");
|
|
557
|
+
let names;
|
|
558
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
559
|
+
const groups = new Map();
|
|
560
|
+
for (const entry of names) {
|
|
561
|
+
const parsed = sessionOf(entry.name);
|
|
562
|
+
if (!parsed || !entry.isFile()) continue;
|
|
563
|
+
const file = path.join(dir, entry.name);
|
|
564
|
+
let mtime;
|
|
565
|
+
try { mtime = fs.lstatSync(file).mtimeMs; } catch { continue; }
|
|
566
|
+
const group = groups.get(parsed[0]) || { newest: 0, files: [] };
|
|
567
|
+
group.newest = Math.max(group.newest, mtime);
|
|
568
|
+
group.files.push(file);
|
|
569
|
+
groups.set(parsed[0], group);
|
|
570
|
+
}
|
|
571
|
+
const cutoff = now - ttlDays * DAY_MS;
|
|
572
|
+
const deleted = [];
|
|
573
|
+
for (const [sid, group] of groups) {
|
|
574
|
+
if (group.newest >= cutoff) continue;
|
|
575
|
+
for (const file of group.files) {
|
|
576
|
+
try { fs.unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
577
|
+
}
|
|
578
|
+
deleted.push(sid);
|
|
579
|
+
}
|
|
580
|
+
return deleted.sort();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** sweepContextStore at most once a day, recorded by the mtime of <root>/.ttl-stamp (ttl.py maybe_sweep). */
|
|
584
|
+
export function maybeSweepContextStore(root, ttlDays, now = Date.now()) {
|
|
585
|
+
if (!(ttlDays > 0)) return [];
|
|
586
|
+
const stamp = path.join(root, TTL_STAMP);
|
|
587
|
+
try {
|
|
588
|
+
if (now - fs.statSync(stamp).mtimeMs < DAY_MS) return [];
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error?.code !== "ENOENT") return [];
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
if (!fs.statSync(root).isDirectory()) return [];
|
|
594
|
+
} catch { return []; }
|
|
595
|
+
fs.closeSync(fs.openSync(stamp, "a", 0o600));
|
|
596
|
+
fs.utimesSync(stamp, now / 1000, now / 1000);
|
|
597
|
+
return sweepContextStore(root, ttlDays, now);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function positiveNumber(raw, fallback) {
|
|
601
|
+
const text = String(raw ?? "").trim();
|
|
602
|
+
const n = text ? Number(text) : NaN;
|
|
603
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function positiveInt(raw, fallback) {
|
|
607
|
+
const text = String(raw ?? "").trim();
|
|
608
|
+
return /^[+-]?\d+$/.test(text) && Number.parseInt(text, 10) > 0 ? Number.parseInt(text, 10) : fallback;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Store settings from the environment (the proxy's Settings): CONTEXT_STORE_TTL_DAYS (default 30, 0 keeps
|
|
613
|
+
* everything), CONTEXT_EMBEDDING_MODEL (empty turns vectors off), CONTEXT_EMBEDDING_URL, CONTEXT_EMBEDDING_API_KEY,
|
|
614
|
+
* CONTEXT_EMBEDDING_TIMEOUT_S (seconds per row of a call, default 30) and CONTEXT_EMBEDDING_MAX_CHARS (default 4000).
|
|
615
|
+
*/
|
|
616
|
+
export function storeSettings(env = process.env) {
|
|
617
|
+
const ttl = String(env.CONTEXT_STORE_TTL_DAYS ?? "").trim();
|
|
618
|
+
return {
|
|
619
|
+
ttlDays: /^[+-]?\d+$/.test(ttl) ? Number.parseInt(ttl, 10) : DEFAULT_TTL_DAYS,
|
|
620
|
+
embeddingModel: String(env.CONTEXT_EMBEDDING_MODEL ?? "").trim(),
|
|
621
|
+
embeddingUrl: (String(env.CONTEXT_EMBEDDING_URL ?? "").trim() || DEFAULT_EMBEDDING_URL).replace(/\/+$/, ""),
|
|
622
|
+
embeddingApiKey: String(env.CONTEXT_EMBEDDING_API_KEY ?? "").trim(),
|
|
623
|
+
embeddingTimeoutS: positiveNumber(env.CONTEXT_EMBEDDING_TIMEOUT_S, DEFAULT_EMBEDDING_TIMEOUT_S),
|
|
624
|
+
embeddingMaxChars: positiveInt(env.CONTEXT_EMBEDDING_MAX_CHARS, DEFAULT_EMBEDDING_MAX_CHARS),
|
|
625
|
+
};
|
|
626
|
+
}
|