alvin-bot 4.19.0 โ 4.20.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/CHANGELOG.md +54 -0
- package/dist/handlers/commands.js +15 -0
- package/dist/handlers/message.js +42 -2
- package/dist/handlers/platform-message.js +28 -4
- package/dist/index.js +11 -0
- package/dist/paths.js +4 -1
- package/dist/providers/claude-sdk-provider.js +31 -0
- package/dist/services/embeddings-migration.js +114 -0
- package/dist/services/embeddings.js +207 -166
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.20.0] โ 2026-05-03
|
|
6
|
+
|
|
7
|
+
### ๐ Embeddings: JSON โ SQLite
|
|
8
|
+
|
|
9
|
+
**Why.** The vector index `~/.alvin-bot/memory/.embeddings.json` had grown to **146 MB**. Every bot start parsed the whole file (slow boot, large heap), and every reindex iteration rewrote the entire 146 MB blob to disk. With ~3 800 entries the corpus is still small enough that linear-scan cosine similarity is fine, but the JSON serialisation overhead and per-write full-file rewrite were the real cost.
|
|
10
|
+
|
|
11
|
+
**Change.** New SQLite-backed store at `~/.alvin-bot/memory/.embeddings.db` (table `entries(id, source, text, vector BLOB, indexed_at)` + index on `source`). Vectors live as raw `Float32Array` BLOBs (4 B ร 3072 dims = 12 KB each) instead of JSON-encoded Float64 arrays (โ 24 KB each). Reindexing is per-chunk INSERT/UPDATE inside a single transaction โ no full-file rewrite. WAL mode + 256 MB mmap, `synchronous = NORMAL`.
|
|
12
|
+
|
|
13
|
+
**Migration.** `src/services/embeddings-migration.ts` runs once on boot if `.embeddings.json` exists but `.embeddings.db` does not. Source JSON is renamed to `.embeddings.json.bak-pre-sqlite` after a successful entry-count match (idempotent, safe to re-run). On the maintainer's instance: 146 MB โ 49 MB, 3 799 entries copied in 660 ms.
|
|
14
|
+
|
|
15
|
+
**Files touched.** `src/paths.ts` (new `EMBEDDINGS_DB`), `src/services/embeddings.ts` (full rewrite, drop-in same public surface), `src/services/embeddings-migration.ts` (new), `src/index.ts` (boot hook), `package.json` (deps `better-sqlite3@^12`, `@types/better-sqlite3` dev). Public API unchanged: `searchMemory`, `reindexMemory`, `initEmbeddings`, `getIndexStats` keep their signatures so callers in `engine.ts`, `web-server.ts` etc. don't change.
|
|
16
|
+
|
|
17
|
+
**Wins.** ~66 % smaller on disk. Bot boot no longer parses a 146 MB JSON. Reindex of a single file is O(log n) DELETE-by-source + transactional INSERTs instead of `JSON.stringify` + `writeFileSync` of the whole index.
|
|
18
|
+
|
|
19
|
+
## [4.19.2] โ 2026-04-24
|
|
20
|
+
|
|
21
|
+
### ๐ Fix: workspace switch produced "(no response)" format-kaskade; added empty-stream diagnostics
|
|
22
|
+
|
|
23
|
+
**Symptom.** After v4.19.1 shipped, a workspace/dir switch still produced a broken response โ but this time NOT an empty stream. Claude replied with literal text like `"(no response)\n\nUser: Hallo"`, then the next turn `"(no response)\n\nUser: wie viele tools hast duโฆ"` โ a format-kaskade where every response got worse.
|
|
24
|
+
|
|
25
|
+
**Root cause.** v4.19.1's cwd-change reset set `session.lastSdkHistoryIndex = -1`. That value is consumed by `buildBridgeMessage()` in `handlers/message.ts`, which is designed for the Ollama-fallback path โ its preamble frames past turns as *"the following N message(s) were exchanged with a fallback model"*. When the reset runs on a workspace switch, the ENTIRE conversation history (dozens of turns in a long-running session) gets packaged under that framing and prepended to the next prompt. If the history contains Telegram fallback artifacts (`(Keine Antwort)`, `(no response)`), Claude reads those as the "fallback model's response format" and imitates it. Each imitation lands back in history, poisoning the next bridge. Cascade.
|
|
26
|
+
|
|
27
|
+
Workspace switch is not a fallback event โ it's *"new persona, new task"*. The old conversation belongs to the old workspace and must not be reframed and re-injected.
|
|
28
|
+
|
|
29
|
+
**Fix.** `handlers/message.ts`, `handlers/platform-message.ts`, and `handlers/commands.ts` (`/dir`) now set `session.lastSdkHistoryIndex = session.history.length - 1` on cwd change. `buildBridgeMessage()` returns empty for the next turn, Claude starts the new workspace with a clean slate โ persona, cwd, system prompt, but no inherited conversation.
|
|
30
|
+
|
|
31
|
+
**Additionally โ empty-stream diagnostics.** `src/providers/claude-sdk-provider.ts` now logs a structured JSON dump on empty-stream detection: SDK result `subtype`/`is_error`/`num_turns`/`duration_ms`, the `usage` object, the `session_id` Claude returned vs. the one we passed, model override, cwd, effort, prompt/systemPrompt/history sizes, allowedTools count, and MCP state. Lets future empty-stream events be triaged in one log line instead of guessing.
|
|
32
|
+
|
|
33
|
+
**Net effect.** `/workspace <name>` โ message โ clean response (no Fallback-framed preamble, no format-kaskade). `/dir <path>` โ same. Next empty-stream event will come with actionable diagnostic output instead of a silent symptom.
|
|
34
|
+
|
|
35
|
+
## [4.19.1] โ 2026-04-24
|
|
36
|
+
|
|
37
|
+
### ๐ Critical fix: workspace/dir switch no longer produces empty-stream loop
|
|
38
|
+
|
|
39
|
+
**Problem:** After `/workspace <name>` (or `/dir <path>`), every subsequent SDK message returned `โ ๏ธ Claude antwortete mit leerem Stream โฆ` โ and even switching back to the previous workspace did not recover. The v4.18.5 auto-reset only masked the symptom; the underlying cause survived the recovery attempt.
|
|
40
|
+
|
|
41
|
+
**Root cause โ a two-part bug:**
|
|
42
|
+
|
|
43
|
+
1. **Prevention layer missing.** The Claude Agent SDK's `resume: <sessionId>` is bound to the cwd the session was created in: session files live under `~/.claude/projects/<cwd-hash>/<session-id>.jsonl`. When a workspace switch changes `session.workingDir`, the stored `session.sessionId` points at a file that no longer exists in the new project folder. The CLI silently returns an empty stream.
|
|
44
|
+
2. **Recovery layer broken.** v4.18.5's empty-stream detector correctly cleared `session.sessionId = null` on the `text` chunk โ but the very next `done` chunk of the same stream carried `sessionId: resultMsg.session_id || capturedSessionId`, and the handler's `if (chunk.sessionId) session.sessionId = chunk.sessionId;` restored it. The "reset" was immediately undone by the trailing done chunk, so the next turn resumed the same dead session. Loop.
|
|
45
|
+
|
|
46
|
+
**Fix (defense in depth, three layers):**
|
|
47
|
+
|
|
48
|
+
- **Prevention** (root cause): `handlers/message.ts`, `handlers/platform-message.ts`, and `handlers/commands.ts` (`/dir`) now detect `session.workingDir !== workspace.cwd` (resp. new dir) BEFORE the query and clear `session.sessionId = null` + `session.lastSdkHistoryIndex = -1`. The next SDK turn starts fresh in the new project folder. `markSessionDirty()` is called so the clear persists across restarts.
|
|
49
|
+
- **Recovery**: both handlers now track a local `sessionResetInStream` flag. When the provider signals `sessionResetRequested` on a text chunk, the flag is set, and the subsequent `done` chunk's sessionId is ignored (the original resume token or the CLI's fresh-but-wrong-project fallback โ neither is safe).
|
|
50
|
+
- **Hygiene**: `markSessionDirty()` is also called from the empty-stream reset path so the cleared sessionId is persisted immediately rather than waiting for the next trackProviderUsage debounce.
|
|
51
|
+
|
|
52
|
+
**Net effect:** `/workspace <name>` โ message โ works. `/workspace default` โ message โ works. `/dir ~/Projects/foo` โ message โ works. No manual `/new` needed, no credit burn, no recovery retry.
|
|
53
|
+
|
|
54
|
+
**Files:**
|
|
55
|
+
- `src/handlers/message.ts` โ cwd-change detection, sessionResetInStream flag, done-chunk guard, markSessionDirty import
|
|
56
|
+
- `src/handlers/platform-message.ts` โ same set of changes for non-Telegram platforms (Slack, Discord, WhatsApp)
|
|
57
|
+
- `src/handlers/commands.ts` โ `/dir` now invalidates SDK resume anchor on cwd change
|
|
58
|
+
|
|
5
59
|
## [4.19.0] โ 2026-04-24
|
|
6
60
|
|
|
7
61
|
### ๐งญ Feature: per-workspace runtime overrides (effort ยท provider ยท voice ยท temperature ยท toolset)
|
|
@@ -227,7 +227,22 @@ export function registerCommands(bot) {
|
|
|
227
227
|
? path.join(os.homedir(), newDir.slice(1))
|
|
228
228
|
: path.resolve(newDir);
|
|
229
229
|
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
230
|
+
// v4.19.1 โ Claude Agent SDK's `resume` is bound to the cwd. Changing
|
|
231
|
+
// the working dir without clearing the resume anchor would make the
|
|
232
|
+
// next SDK turn look up the session file in the wrong project folder
|
|
233
|
+
// โ silent empty stream. Null out sessionId + history-anchor so the
|
|
234
|
+
// next turn starts a fresh SDK session in the new cwd.
|
|
235
|
+
const cwdChanged = session.workingDir !== resolved;
|
|
230
236
|
session.workingDir = resolved;
|
|
237
|
+
if (cwdChanged) {
|
|
238
|
+
session.sessionId = null;
|
|
239
|
+
// v4.19.2 โ Anchor at current last turn so no catch-up bridge is
|
|
240
|
+
// generated for the next turn. /dir semantically means "switch
|
|
241
|
+
// project context" just like /workspace โ starting fresh is the
|
|
242
|
+
// sane default.
|
|
243
|
+
session.lastSdkHistoryIndex = session.history.length - 1;
|
|
244
|
+
}
|
|
245
|
+
markSessionDirty(userId);
|
|
231
246
|
await ctx.reply(`Working directory: ${session.workingDir}`);
|
|
232
247
|
}
|
|
233
248
|
else {
|
package/dist/handlers/message.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { InputFile } from "grammy";
|
|
2
2
|
import fs from "fs";
|
|
3
|
-
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace } from "../services/session.js";
|
|
3
|
+
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace, markSessionDirty } from "../services/session.js";
|
|
4
4
|
import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
|
|
5
5
|
import { TelegramStreamer } from "../services/telegram.js";
|
|
6
6
|
import { getRegistry } from "../engine.js";
|
|
@@ -313,9 +313,32 @@ export async function handleMessage(ctx) {
|
|
|
313
313
|
const workspace = activeWsName
|
|
314
314
|
? (getWorkspace(activeWsName) ?? resolveWorkspaceOrDefault("telegram", String(userId), undefined))
|
|
315
315
|
: resolveWorkspaceOrDefault("telegram", String(userId), undefined);
|
|
316
|
+
// v4.19.1 โ Workspace switch detection. Claude Agent SDK's `resume` is
|
|
317
|
+
// bound to the cwd (session files live under
|
|
318
|
+
// ~/.claude/projects/<cwd-hash>/<session-id>.jsonl). If cwd changes as
|
|
319
|
+
// part of this switch, the stored sessionId points at a file the CLI
|
|
320
|
+
// cannot find in the new project folder โ silent empty stream. Guard
|
|
321
|
+
// with a workspaceName change (not cwd comparison) so /dir-initiated
|
|
322
|
+
// custom cwds are preserved across turns where no workspace actually
|
|
323
|
+
// switched.
|
|
316
324
|
if (session.workspaceName !== workspace.name) {
|
|
325
|
+
const cwdChanged = session.workingDir !== workspace.cwd;
|
|
317
326
|
session.workspaceName = workspace.name;
|
|
318
327
|
session.workingDir = workspace.cwd;
|
|
328
|
+
if (cwdChanged) {
|
|
329
|
+
console.log(`[session] workspace switch changed cwd (โ ${workspace.cwd}) โ ` +
|
|
330
|
+
`invalidating SDK resume anchor and skipping bridge`);
|
|
331
|
+
session.sessionId = null;
|
|
332
|
+
// v4.19.2 โ Anchor at the last turn BEFORE the new user message so
|
|
333
|
+
// buildBridgeMessage() produces no catch-up preamble. A workspace
|
|
334
|
+
// switch means "new persona, new task" โ the previous conversation
|
|
335
|
+
// (often from a different workspace) should NOT be reframed as
|
|
336
|
+
// "Fallback model turns" and fed back into Claude. That framing
|
|
337
|
+
// was producing format-kaskaden where Claude imitated Telegram
|
|
338
|
+
// "(Keine Antwort)" fallback artifacts from history.
|
|
339
|
+
session.lastSdkHistoryIndex = session.history.length - 1;
|
|
340
|
+
markSessionDirty(userId);
|
|
341
|
+
}
|
|
319
342
|
}
|
|
320
343
|
const chatIdStr = String(ctx.chat.id);
|
|
321
344
|
const skillContext = buildSkillContext(text);
|
|
@@ -429,6 +452,12 @@ export async function handleMessage(ctx) {
|
|
|
429
452
|
// readable description (which only appears in the tool_use input,
|
|
430
453
|
// not in the tool_result text). See Fix #17 Stage 2.
|
|
431
454
|
let lastAgentToolUseInput;
|
|
455
|
+
// v4.19.1 โ Track whether the provider requested a session reset during
|
|
456
|
+
// this stream. If it did, the trailing `done` chunk's sessionId MUST be
|
|
457
|
+
// ignored โ otherwise it restores the exact sessionId we just cleared
|
|
458
|
+
// (the empty-stream capturedSessionId) and the next turn loops again.
|
|
459
|
+
// This is the second half of the empty-stream-loop fix.
|
|
460
|
+
let sessionResetInStream = false;
|
|
432
461
|
for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
|
|
433
462
|
// v4.12.1 โ Update pending-sync-task state FIRST so the timer's
|
|
434
463
|
// next reset picks up the new state. This ordering is load-bearing:
|
|
@@ -464,6 +493,8 @@ export async function handleMessage(ctx) {
|
|
|
464
493
|
console.warn(`[session] provider requested reset for ${sessionKey} โ clearing sessionId + SDK anchor`);
|
|
465
494
|
session.sessionId = null;
|
|
466
495
|
session.lastSdkHistoryIndex = -1;
|
|
496
|
+
sessionResetInStream = true;
|
|
497
|
+
markSessionDirty(userId);
|
|
467
498
|
}
|
|
468
499
|
// Emit the new delta for observers โ accumulated text minus what
|
|
469
500
|
// we already broadcast.
|
|
@@ -544,7 +575,16 @@ export async function handleMessage(ctx) {
|
|
|
544
575
|
lastAgentToolUseInput = undefined;
|
|
545
576
|
break;
|
|
546
577
|
case "done":
|
|
547
|
-
|
|
578
|
+
// v4.19.1 โ Respect the in-stream session reset. If the provider
|
|
579
|
+
// already signalled `sessionResetRequested` on the preceding text
|
|
580
|
+
// chunk (empty-stream detection), do NOT let the trailing done
|
|
581
|
+
// chunk restore the sessionId we just nulled โ that was the
|
|
582
|
+
// silent bug behind the empty-stream loop across workspace
|
|
583
|
+
// switches. The `done` chunk's sessionId on an empty stream is
|
|
584
|
+
// either the stale resume token we tried to use or a brand-new
|
|
585
|
+
// session file the CLI created in the wrong project folder;
|
|
586
|
+
// neither is safe to resume from.
|
|
587
|
+
if (chunk.sessionId && !sessionResetInStream)
|
|
548
588
|
session.sessionId = chunk.sessionId;
|
|
549
589
|
if (chunk.costUsd)
|
|
550
590
|
session.totalCost += chunk.costUsd;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* This is the platform-agnostic equivalent of message.ts (which is Telegram-specific).
|
|
8
8
|
*/
|
|
9
9
|
import fs from "fs";
|
|
10
|
-
import { getSession, addToHistory, trackProviderUsage, buildSessionKey } from "../services/session.js";
|
|
10
|
+
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, markSessionDirty } from "../services/session.js";
|
|
11
11
|
import { resolveWorkspaceOrDefault } from "../services/workspaces.js";
|
|
12
12
|
import { getRegistry } from "../engine.js";
|
|
13
13
|
import { buildSmartSystemPrompt } from "../services/personality.js";
|
|
@@ -114,11 +114,25 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
const workspace = resolveWorkspaceOrDefault(msg.platform, msg.chatId, channelName);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
117
|
+
// v4.19.1 โ Workspace switch detection. If cwd changes as part of the
|
|
118
|
+
// switch, null out session.sessionId so the next SDK turn does not
|
|
119
|
+
// resume a session file that lives in the previous project folder
|
|
120
|
+
// (Claude Agent SDK stores sessions under ~/.claude/projects/<cwd-hash>/).
|
|
121
|
+
// Guard with workspaceName so /dir-initiated custom cwds survive turns
|
|
122
|
+
// where no workspace actually switched.
|
|
119
123
|
if (session.workspaceName !== workspace.name) {
|
|
124
|
+
const cwdChanged = session.workingDir !== workspace.cwd;
|
|
120
125
|
session.workspaceName = workspace.name;
|
|
121
126
|
session.workingDir = workspace.cwd;
|
|
127
|
+
if (cwdChanged) {
|
|
128
|
+
console.log(`[session] workspace switch changed cwd (โ ${workspace.cwd}) โ ` +
|
|
129
|
+
`invalidating SDK resume anchor and skipping bridge`);
|
|
130
|
+
session.sessionId = null;
|
|
131
|
+
// v4.19.2 โ Anchor at current last turn so no catch-up bridge is
|
|
132
|
+
// generated for the next turn (see message.ts for full rationale).
|
|
133
|
+
session.lastSdkHistoryIndex = session.history.length - 1;
|
|
134
|
+
markSessionDirty(sessionKey);
|
|
135
|
+
}
|
|
122
136
|
}
|
|
123
137
|
// Skip if already processing (queue up to 3)
|
|
124
138
|
if (session.isProcessing) {
|
|
@@ -195,6 +209,11 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
195
209
|
if (!isSDK) {
|
|
196
210
|
addToHistory(sessionKey, { role: "user", content: fullText });
|
|
197
211
|
}
|
|
212
|
+
// v4.19.1 โ Track whether the provider requested a session reset during
|
|
213
|
+
// this stream. If it did, the trailing `done` chunk's sessionId MUST be
|
|
214
|
+
// ignored โ otherwise it restores the exact sessionId we just cleared
|
|
215
|
+
// and the next turn loops again. Mirror of message.ts.
|
|
216
|
+
let sessionResetInStream = false;
|
|
198
217
|
for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
|
|
199
218
|
switch (chunk.type) {
|
|
200
219
|
case "text":
|
|
@@ -205,10 +224,15 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
205
224
|
console.warn(`[session] provider requested reset for ${sessionKey} โ clearing sessionId + SDK anchor`);
|
|
206
225
|
session.sessionId = null;
|
|
207
226
|
session.lastSdkHistoryIndex = -1;
|
|
227
|
+
sessionResetInStream = true;
|
|
228
|
+
markSessionDirty(sessionKey);
|
|
208
229
|
}
|
|
209
230
|
break;
|
|
210
231
|
case "done":
|
|
211
|
-
|
|
232
|
+
// v4.19.1 โ Respect in-stream reset: don't let done.sessionId undo
|
|
233
|
+
// the clear from the empty-stream text chunk. See message.ts for
|
|
234
|
+
// full rationale.
|
|
235
|
+
if (chunk.sessionId && !sessionResetInStream)
|
|
212
236
|
session.sessionId = chunk.sessionId;
|
|
213
237
|
if (chunk.costUsd)
|
|
214
238
|
session.totalCost += chunk.costUsd;
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,17 @@ if (hasLegacyData()) {
|
|
|
20
20
|
}
|
|
21
21
|
// 3. Seed defaults for any files that don't exist yet (fresh install)
|
|
22
22
|
seedDefaults();
|
|
23
|
+
// 3b. v4.20 โ One-shot migration of legacy .embeddings.json โ SQLite (.embeddings.db).
|
|
24
|
+
// Idempotent and safe: source JSON is renamed to .bak-pre-sqlite after success.
|
|
25
|
+
import { shouldMigrateEmbeddingsToSqlite, migrateEmbeddingsToSqlite } from "./services/embeddings-migration.js";
|
|
26
|
+
if (shouldMigrateEmbeddingsToSqlite()) {
|
|
27
|
+
try {
|
|
28
|
+
migrateEmbeddingsToSqlite();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error("โ Embeddings migration failed โ bot will continue with empty SQLite store, JSON kept:", err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
23
34
|
// 3a. v4.12.2 โ Audit + repair permissions on sensitive files. On multi-user
|
|
24
35
|
// systems, files written pre-v4.12.2 may have 0o644 / 0o666 mode โ i.e.
|
|
25
36
|
// readable by other users on the same machine. This routine chmod-repairs
|
package/dist/paths.js
CHANGED
|
@@ -55,8 +55,11 @@ export const PROJECTS_MEMORY_DIR = resolve(DATA_DIR, "memory", "projects");
|
|
|
55
55
|
* name, purpose, cwd, color, emoji, and an optional system prompt body.
|
|
56
56
|
* See src/services/workspaces.ts for the loader and matcher. */
|
|
57
57
|
export const WORKSPACES_DIR = resolve(DATA_DIR, "workspaces");
|
|
58
|
-
/** memory/.embeddings.json โ
|
|
58
|
+
/** memory/.embeddings.json โ Legacy JSON vector index. Read on first SQLite migration only;
|
|
59
|
+
* active code path is EMBEDDINGS_DB. */
|
|
59
60
|
export const EMBEDDINGS_IDX = resolve(DATA_DIR, "memory", ".embeddings.json");
|
|
61
|
+
/** memory/.embeddings.db โ SQLite vector store (replaces .embeddings.json since v4.20). */
|
|
62
|
+
export const EMBEDDINGS_DB = resolve(DATA_DIR, "memory", ".embeddings.db");
|
|
60
63
|
/** users/ โ User profiles and per-user memory */
|
|
61
64
|
export const USERS_DIR = resolve(DATA_DIR, "users");
|
|
62
65
|
/** data/ โ Runtime control data */
|
|
@@ -380,6 +380,37 @@ export class ClaudeSDKProvider {
|
|
|
380
380
|
// and knows to resend โ without tripping the failover.
|
|
381
381
|
if (accumulatedText === "" && outputTok === 0) {
|
|
382
382
|
this.invalidateAvailabilityCache();
|
|
383
|
+
// v4.19.2 โ Diagnostic logging: when the Agent SDK returns an
|
|
384
|
+
// empty stream, we need enough detail to tell apart the possible
|
|
385
|
+
// causes (auth, quota, context overflow, model rejection, MCP
|
|
386
|
+
// init failure). The message handler-level reset was fine for
|
|
387
|
+
// stale-session recovery but gave us no signal on WHY it went
|
|
388
|
+
// empty in the first place.
|
|
389
|
+
try {
|
|
390
|
+
const diag = {
|
|
391
|
+
subtype: resultMsg.subtype,
|
|
392
|
+
is_error: resultMsg.is_error,
|
|
393
|
+
num_turns: resultMsg.num_turns,
|
|
394
|
+
duration_ms: resultMsg.duration_ms,
|
|
395
|
+
duration_api_ms: resultMsg.duration_api_ms,
|
|
396
|
+
total_cost_usd: resultMsg.total_cost_usd,
|
|
397
|
+
session_id: resultMsg.session_id,
|
|
398
|
+
passed_session_id: options.sessionId ?? null,
|
|
399
|
+
usage,
|
|
400
|
+
modelOverride,
|
|
401
|
+
cwd: options.workingDir,
|
|
402
|
+
effort: options.effort,
|
|
403
|
+
systemPromptLen: systemPrompt.length,
|
|
404
|
+
promptLen: prompt.length,
|
|
405
|
+
historyLen: options.history?.length ?? 0,
|
|
406
|
+
allowedToolsCount: (options.allowedTools ?? defaultAllowed).length,
|
|
407
|
+
hasMcp: Object.keys(mcpServers).length > 0,
|
|
408
|
+
};
|
|
409
|
+
console.warn(`[empty-stream] SDK returned 0 output tokens โ diagnostic dump:`, JSON.stringify(diag));
|
|
410
|
+
}
|
|
411
|
+
catch (diagErr) {
|
|
412
|
+
console.warn(`[empty-stream] SDK returned 0 output tokens (diagnostic serialisation failed: ${diagErr})`);
|
|
413
|
+
}
|
|
383
414
|
const hint = "โ ๏ธ Claude antwortete mit leerem Stream. " +
|
|
384
415
|
"Meist Folge einer stale SDK-Session nach /extra-usage, /login oder Token-Refresh. " +
|
|
385
416
|
"Ich starte die Session automatisch neu โ bitte schick die Nachricht einfach nochmal.";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot migration from legacy .embeddings.json โ SQLite .embeddings.db.
|
|
3
|
+
*
|
|
4
|
+
* Triggered on startup if .embeddings.json exists but .embeddings.db does not.
|
|
5
|
+
* Idempotent: skips silently if the DB is already populated.
|
|
6
|
+
*
|
|
7
|
+
* Safety:
|
|
8
|
+
* - Source JSON is renamed to .embeddings.json.bak-pre-sqlite (kept on disk).
|
|
9
|
+
* - Entry counts are compared after import; mismatch โ throw, leaving the bak
|
|
10
|
+
* file in place for manual recovery.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import Database from "better-sqlite3";
|
|
15
|
+
import { EMBEDDINGS_IDX, EMBEDDINGS_DB } from "../paths.js";
|
|
16
|
+
function vectorToBlob(v) {
|
|
17
|
+
const f32 = new Float32Array(v);
|
|
18
|
+
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength);
|
|
19
|
+
}
|
|
20
|
+
export function shouldMigrateEmbeddingsToSqlite() {
|
|
21
|
+
return fs.existsSync(EMBEDDINGS_IDX) && !fs.existsSync(EMBEDDINGS_DB);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run the migration. Returns the entry count migrated, or null if skipped.
|
|
25
|
+
*/
|
|
26
|
+
export function migrateEmbeddingsToSqlite() {
|
|
27
|
+
if (!shouldMigrateEmbeddingsToSqlite())
|
|
28
|
+
return null;
|
|
29
|
+
const t0 = Date.now();
|
|
30
|
+
const sourceSize = fs.statSync(EMBEDDINGS_IDX).size;
|
|
31
|
+
console.log(`๐ฆ Migrating embeddings JSON (${(sourceSize / 1024 / 1024).toFixed(0)} MB) โ SQLite...`);
|
|
32
|
+
const raw = fs.readFileSync(EMBEDDINGS_IDX, "utf-8");
|
|
33
|
+
let legacy;
|
|
34
|
+
try {
|
|
35
|
+
legacy = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
console.error("โ ๏ธ Embeddings migration: source JSON is corrupt โ skipping.", err);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
fs.mkdirSync(path.dirname(EMBEDDINGS_DB), { recursive: true });
|
|
42
|
+
const db = new Database(EMBEDDINGS_DB);
|
|
43
|
+
try {
|
|
44
|
+
db.pragma("journal_mode = WAL");
|
|
45
|
+
db.pragma("synchronous = NORMAL");
|
|
46
|
+
db.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
48
|
+
key TEXT PRIMARY KEY,
|
|
49
|
+
value TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS file_mtimes (
|
|
52
|
+
source TEXT PRIMARY KEY,
|
|
53
|
+
mtime_ms REAL NOT NULL
|
|
54
|
+
);
|
|
55
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
source TEXT NOT NULL,
|
|
58
|
+
text TEXT NOT NULL,
|
|
59
|
+
vector BLOB NOT NULL,
|
|
60
|
+
indexed_at INTEGER NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_entries_source ON entries(source);
|
|
63
|
+
`);
|
|
64
|
+
const setMeta = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
|
65
|
+
setMeta.run("model", legacy.model);
|
|
66
|
+
setMeta.run("schemaVersion", "1");
|
|
67
|
+
setMeta.run("lastReindex", String(legacy.lastReindex));
|
|
68
|
+
setMeta.run("migratedFromJson", String(Date.now()));
|
|
69
|
+
const insMtime = db.prepare("INSERT INTO file_mtimes (source, mtime_ms) VALUES (?, ?) ON CONFLICT(source) DO UPDATE SET mtime_ms = excluded.mtime_ms");
|
|
70
|
+
const writeMtimes = db.transaction((rows) => {
|
|
71
|
+
for (const [s, m] of rows)
|
|
72
|
+
insMtime.run(s, m);
|
|
73
|
+
});
|
|
74
|
+
writeMtimes(Object.entries(legacy.fileMtimes ?? {}));
|
|
75
|
+
const insEntry = db.prepare("INSERT INTO entries (id, source, text, vector, indexed_at) VALUES (?, ?, ?, ?, ?)");
|
|
76
|
+
const writeEntries = db.transaction((rows) => {
|
|
77
|
+
for (const e of rows) {
|
|
78
|
+
if (!Array.isArray(e.vector) || e.vector.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
insEntry.run(e.id, e.source, e.text, vectorToBlob(e.vector), e.indexedAt);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
writeEntries(legacy.entries ?? []);
|
|
84
|
+
const written = db.prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
85
|
+
const expected = (legacy.entries ?? []).filter(e => Array.isArray(e.vector) && e.vector.length > 0).length;
|
|
86
|
+
if (written !== expected) {
|
|
87
|
+
throw new Error(`Entry-count mismatch after migration: expected ${expected}, got ${written}`);
|
|
88
|
+
}
|
|
89
|
+
db.close();
|
|
90
|
+
// Move source JSON aside so we never re-migrate.
|
|
91
|
+
const bak = `${EMBEDDINGS_IDX}.bak-pre-sqlite`;
|
|
92
|
+
try {
|
|
93
|
+
fs.renameSync(EMBEDDINGS_IDX, bak);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.warn("โ ๏ธ Could not rename source JSON:", err);
|
|
97
|
+
}
|
|
98
|
+
const targetSize = fs.statSync(EMBEDDINGS_DB).size;
|
|
99
|
+
const dt = Date.now() - t0;
|
|
100
|
+
console.log(`โ
Embeddings migrated: ${written} entries, ${(sourceSize / 1024 / 1024).toFixed(0)} MB JSON โ ${(targetSize / 1024 / 1024).toFixed(0)} MB SQLite in ${dt} ms`);
|
|
101
|
+
return { entries: written, sourceMb: sourceSize / 1024 / 1024, targetMb: targetSize / 1024 / 1024 };
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
db.close();
|
|
105
|
+
// Remove half-written DB so the next boot retries cleanly.
|
|
106
|
+
try {
|
|
107
|
+
fs.unlinkSync(EMBEDDINGS_DB);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* nothing to clean */
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -1,31 +1,116 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Embeddings Service โ Vector-based semantic memory search.
|
|
3
3
|
*
|
|
4
|
-
* Uses Google's
|
|
5
|
-
* Stores embeddings in a
|
|
4
|
+
* Uses Google's gemini-embedding-001 model for generating embeddings.
|
|
5
|
+
* Stores embeddings in a SQLite database (.embeddings.db) โ replaces the
|
|
6
|
+
* older .embeddings.json index since v4.20. The migration runs once
|
|
7
|
+
* automatically on startup (see src/migrate.ts).
|
|
6
8
|
*
|
|
7
9
|
* Architecture:
|
|
8
|
-
* - Each memory entry (paragraph/section) gets
|
|
9
|
-
* - Vectors are stored
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
10
|
+
* - Each memory entry (paragraph/section) gets a 3072-dim Float32 vector.
|
|
11
|
+
* - Vectors are stored as raw BLOB (4 bytes ร 3072 = 12 KB each) instead of
|
|
12
|
+
* JSON-encoded Float64 arrays (~24 KB each) โ halves disk footprint.
|
|
13
|
+
* - Cosine similarity runs in-memory: SQLite has no native vector ops, but
|
|
14
|
+
* reading the BLOBs is mmap-cheap and JS does the dot product fast enough
|
|
15
|
+
* for the current corpus (a few thousand entries).
|
|
16
|
+
* - Reindexing is per-chunk INSERT/UPDATE โ no full-file rewrite.
|
|
12
17
|
*/
|
|
13
18
|
import fs from "fs";
|
|
14
19
|
import path from "path";
|
|
15
20
|
import { resolve } from "path";
|
|
16
|
-
import { config } from "../config.js";
|
|
17
21
|
import os from "os";
|
|
18
|
-
import
|
|
22
|
+
import Database from "better-sqlite3";
|
|
23
|
+
import { config } from "../config.js";
|
|
24
|
+
import { MEMORY_DIR, MEMORY_FILE, EMBEDDINGS_DB } from "../paths.js";
|
|
19
25
|
import { ASSETS_DIR, ASSETS_INDEX_MD } from "../paths.js";
|
|
20
26
|
// Hub memory directory (Claude Hub โ read-only, additional context)
|
|
21
27
|
const HUB_MEMORY_DIR = resolve(os.homedir(), ".claude", "hub", "MEMORY");
|
|
22
|
-
// โโ
|
|
28
|
+
// โโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
23
29
|
const EMBEDDING_MODEL = "gemini-embedding-001";
|
|
24
30
|
const EMBEDDING_DIMENSION = 3072;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const SCHEMA_VERSION = "1";
|
|
32
|
+
// โโ Vector encoding (Float32Array โ Buffer) โโโโโโโโโโโโโ
|
|
33
|
+
function vectorToBlob(v) {
|
|
34
|
+
const f32 = new Float32Array(v);
|
|
35
|
+
// Buffer.from(arrayBuffer, byteOffset, length) preserves the underlying memory.
|
|
36
|
+
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength);
|
|
37
|
+
}
|
|
38
|
+
function blobToVector(b) {
|
|
39
|
+
// Buffers from better-sqlite3 own their memory and may not be aligned to 4 bytes.
|
|
40
|
+
// Copying into a fresh Float32Array guarantees alignment.
|
|
41
|
+
const f32 = new Float32Array(b.byteLength / 4);
|
|
42
|
+
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
43
|
+
for (let i = 0; i < f32.length; i++) {
|
|
44
|
+
f32[i] = dv.getFloat32(i * 4, true /* little-endian */);
|
|
45
|
+
}
|
|
46
|
+
return f32;
|
|
47
|
+
}
|
|
48
|
+
// โโ DB lifecycle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
49
|
+
let dbInstance = null;
|
|
50
|
+
function db() {
|
|
51
|
+
if (dbInstance)
|
|
52
|
+
return dbInstance;
|
|
53
|
+
// Ensure directory exists (handles fresh installs).
|
|
54
|
+
fs.mkdirSync(path.dirname(EMBEDDINGS_DB), { recursive: true });
|
|
55
|
+
dbInstance = new Database(EMBEDDINGS_DB);
|
|
56
|
+
dbInstance.pragma("journal_mode = WAL");
|
|
57
|
+
dbInstance.pragma("synchronous = NORMAL");
|
|
58
|
+
dbInstance.pragma("temp_store = MEMORY");
|
|
59
|
+
dbInstance.pragma("mmap_size = 268435456"); // 256 MB
|
|
60
|
+
dbInstance.exec(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
62
|
+
key TEXT PRIMARY KEY,
|
|
63
|
+
value TEXT NOT NULL
|
|
64
|
+
);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS file_mtimes (
|
|
66
|
+
source TEXT PRIMARY KEY,
|
|
67
|
+
mtime_ms REAL NOT NULL
|
|
68
|
+
);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
70
|
+
id TEXT PRIMARY KEY,
|
|
71
|
+
source TEXT NOT NULL,
|
|
72
|
+
text TEXT NOT NULL,
|
|
73
|
+
vector BLOB NOT NULL,
|
|
74
|
+
indexed_at INTEGER NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_entries_source ON entries(source);
|
|
77
|
+
`);
|
|
78
|
+
// Initialise meta if absent.
|
|
79
|
+
const set = dbInstance.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING");
|
|
80
|
+
set.run("model", EMBEDDING_MODEL);
|
|
81
|
+
set.run("schemaVersion", SCHEMA_VERSION);
|
|
82
|
+
return dbInstance;
|
|
83
|
+
}
|
|
84
|
+
/** Close handle (used by tests / shutdown). */
|
|
85
|
+
export function closeEmbeddingsDb() {
|
|
86
|
+
if (dbInstance) {
|
|
87
|
+
dbInstance.close();
|
|
88
|
+
dbInstance = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// โโ Meta helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
92
|
+
function getMeta(key) {
|
|
93
|
+
const row = db().prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
94
|
+
return row?.value ?? null;
|
|
95
|
+
}
|
|
96
|
+
function setMeta(key, value) {
|
|
97
|
+
db()
|
|
98
|
+
.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
99
|
+
.run(key, value);
|
|
100
|
+
}
|
|
101
|
+
function getFileMtimes() {
|
|
102
|
+
const rows = db().prepare("SELECT source, mtime_ms FROM file_mtimes").all();
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const r of rows)
|
|
105
|
+
out[r.source] = r.mtime_ms;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function setFileMtime(source, mtimeMs) {
|
|
109
|
+
db()
|
|
110
|
+
.prepare("INSERT INTO file_mtimes (source, mtime_ms) VALUES (?, ?) ON CONFLICT(source) DO UPDATE SET mtime_ms = excluded.mtime_ms")
|
|
111
|
+
.run(source, mtimeMs);
|
|
112
|
+
}
|
|
113
|
+
// โโ Google Embeddings API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
29
114
|
async function getEmbeddings(texts) {
|
|
30
115
|
const apiKey = config.apiKeys.google;
|
|
31
116
|
if (!apiKey) {
|
|
@@ -50,16 +135,13 @@ async function getEmbeddings(texts) {
|
|
|
50
135
|
const err = await response.text();
|
|
51
136
|
throw new Error(`Embedding API error: ${response.status} โ ${err}`);
|
|
52
137
|
}
|
|
53
|
-
const data = await response.json();
|
|
138
|
+
const data = (await response.json());
|
|
54
139
|
for (const emb of data.embeddings) {
|
|
55
140
|
results.push(emb.values);
|
|
56
141
|
}
|
|
57
142
|
}
|
|
58
143
|
return results;
|
|
59
144
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Get embedding for a single query text.
|
|
62
|
-
*/
|
|
63
145
|
async function getQueryEmbedding(text) {
|
|
64
146
|
const apiKey = config.apiKeys.google;
|
|
65
147
|
if (!apiKey) {
|
|
@@ -78,11 +160,11 @@ async function getQueryEmbedding(text) {
|
|
|
78
160
|
const err = await response.text();
|
|
79
161
|
throw new Error(`Embedding API error: ${response.status} โ ${err}`);
|
|
80
162
|
}
|
|
81
|
-
const data = await response.json();
|
|
163
|
+
const data = (await response.json());
|
|
82
164
|
return data.embedding.values;
|
|
83
165
|
}
|
|
84
166
|
// โโ Vector Math โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
85
|
-
function
|
|
167
|
+
function cosineSimilarityF32(a, b) {
|
|
86
168
|
if (a.length !== b.length)
|
|
87
169
|
return 0;
|
|
88
170
|
let dotProduct = 0;
|
|
@@ -97,20 +179,13 @@ function cosineSimilarity(a, b) {
|
|
|
97
179
|
return denom === 0 ? 0 : dotProduct / denom;
|
|
98
180
|
}
|
|
99
181
|
// โโ Text Chunking โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
100
|
-
/**
|
|
101
|
-
* Split a markdown file into meaningful chunks.
|
|
102
|
-
* Splits on ## headers, keeping each section as a chunk.
|
|
103
|
-
* Falls back to paragraph splitting for files without headers.
|
|
104
|
-
*/
|
|
105
182
|
function chunkMarkdown(content, source) {
|
|
106
183
|
const chunks = [];
|
|
107
|
-
// Split on ## headers
|
|
108
184
|
const sections = content.split(/^(?=## )/gm);
|
|
109
185
|
for (let i = 0; i < sections.length; i++) {
|
|
110
186
|
const section = sections[i].trim();
|
|
111
187
|
if (!section || section.length < 20)
|
|
112
|
-
continue;
|
|
113
|
-
// If section is too long (>1000 chars), split into paragraphs
|
|
188
|
+
continue;
|
|
114
189
|
if (section.length > 1000) {
|
|
115
190
|
const paragraphs = section.split(/\n\n+/);
|
|
116
191
|
let currentChunk = "";
|
|
@@ -142,51 +217,7 @@ function chunkMarkdown(content, source) {
|
|
|
142
217
|
}
|
|
143
218
|
return chunks;
|
|
144
219
|
}
|
|
145
|
-
// โโ
|
|
146
|
-
// In-memory cache for the embedding index. Without this, every query would
|
|
147
|
-
// re-read and re-parse the on-disk index (can be 100+ MB, making searchMemory
|
|
148
|
-
// the slowest step in a message turn). We keep the parsed object and invalidate
|
|
149
|
-
// via mtime check โ so external reindexers are still picked up.
|
|
150
|
-
let indexCache = null;
|
|
151
|
-
let indexCacheMtime = 0;
|
|
152
|
-
function loadIndex() {
|
|
153
|
-
try {
|
|
154
|
-
const st = fs.statSync(INDEX_FILE);
|
|
155
|
-
if (indexCache && st.mtimeMs === indexCacheMtime) {
|
|
156
|
-
return indexCache;
|
|
157
|
-
}
|
|
158
|
-
const raw = fs.readFileSync(INDEX_FILE, "utf-8");
|
|
159
|
-
indexCache = JSON.parse(raw);
|
|
160
|
-
indexCacheMtime = st.mtimeMs;
|
|
161
|
-
return indexCache;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
// File missing or unparseable โ return an empty index and don't cache it
|
|
165
|
-
// (next call will retry, so a freshly-written index gets picked up).
|
|
166
|
-
return {
|
|
167
|
-
model: EMBEDDING_MODEL,
|
|
168
|
-
lastReindex: 0,
|
|
169
|
-
fileMtimes: {},
|
|
170
|
-
entries: [],
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
function saveIndex(index) {
|
|
175
|
-
fs.writeFileSync(INDEX_FILE, JSON.stringify(index));
|
|
176
|
-
// Refresh cache immediately so the next loadIndex() sees the new state
|
|
177
|
-
// without a disk round-trip.
|
|
178
|
-
indexCache = index;
|
|
179
|
-
try {
|
|
180
|
-
indexCacheMtime = fs.statSync(INDEX_FILE).mtimeMs;
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
indexCacheMtime = Date.now();
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Recursively walk a directory, returning file paths.
|
|
188
|
-
* Skips INDEX.json and INDEX.md at the directory root.
|
|
189
|
-
*/
|
|
220
|
+
// โโ Indexable file discovery โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
190
221
|
function walkAssetDir(dir) {
|
|
191
222
|
const results = [];
|
|
192
223
|
function walk(currentDir) {
|
|
@@ -213,17 +244,11 @@ function walkAssetDir(dir) {
|
|
|
213
244
|
return results;
|
|
214
245
|
}
|
|
215
246
|
const TEXT_EXTENSIONS = new Set([".md", ".html", ".txt", ".css", ".ts"]);
|
|
216
|
-
/**
|
|
217
|
-
* Get all files that should be indexed โ memories + text-based assets.
|
|
218
|
-
*/
|
|
219
247
|
function getIndexableFiles() {
|
|
220
248
|
const files = [];
|
|
221
|
-
// โโ Memories (existing) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
222
|
-
// Alvin-Bot MEMORY.md
|
|
223
249
|
if (fs.existsSync(MEMORY_FILE)) {
|
|
224
250
|
files.push({ path: MEMORY_FILE, relativePath: "MEMORY.md" });
|
|
225
251
|
}
|
|
226
|
-
// Alvin-Bot daily logs
|
|
227
252
|
if (fs.existsSync(MEMORY_DIR)) {
|
|
228
253
|
const entries = fs.readdirSync(MEMORY_DIR);
|
|
229
254
|
for (const entry of entries) {
|
|
@@ -235,7 +260,6 @@ function getIndexableFiles() {
|
|
|
235
260
|
}
|
|
236
261
|
}
|
|
237
262
|
}
|
|
238
|
-
// Hub memories (~/.claude/hub/MEMORY/) โ Claude Hub knowledge base
|
|
239
263
|
if (fs.existsSync(HUB_MEMORY_DIR)) {
|
|
240
264
|
try {
|
|
241
265
|
const entries = fs.readdirSync(HUB_MEMORY_DIR);
|
|
@@ -248,14 +272,13 @@ function getIndexableFiles() {
|
|
|
248
272
|
}
|
|
249
273
|
}
|
|
250
274
|
}
|
|
251
|
-
catch {
|
|
275
|
+
catch {
|
|
276
|
+
/* Hub not available โ skip */
|
|
277
|
+
}
|
|
252
278
|
}
|
|
253
|
-
// โโ Assets (new) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
254
|
-
// Asset INDEX.md โ compact summary of all assets
|
|
255
279
|
if (fs.existsSync(ASSETS_INDEX_MD)) {
|
|
256
280
|
files.push({ path: ASSETS_INDEX_MD, relativePath: "assets/INDEX.md" });
|
|
257
281
|
}
|
|
258
|
-
// Text-based asset files (HTML, MD, TXT, CSS, TS)
|
|
259
282
|
if (fs.existsSync(ASSETS_DIR)) {
|
|
260
283
|
for (const entry of walkAssetDir(ASSETS_DIR)) {
|
|
261
284
|
if (TEXT_EXTENSIONS.has(path.extname(entry.name))) {
|
|
@@ -268,120 +291,133 @@ function getIndexableFiles() {
|
|
|
268
291
|
}
|
|
269
292
|
return files;
|
|
270
293
|
}
|
|
271
|
-
|
|
272
|
-
* Check which files need reindexing (new or modified).
|
|
273
|
-
*/
|
|
274
|
-
function getStaleFiles(index) {
|
|
294
|
+
function getStaleFiles() {
|
|
275
295
|
const allFiles = getIndexableFiles();
|
|
296
|
+
const known = getFileMtimes();
|
|
276
297
|
const stale = [];
|
|
277
298
|
for (const file of allFiles) {
|
|
278
299
|
try {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
if (!index.fileMtimes[file.relativePath] || index.fileMtimes[file.relativePath] < mtime) {
|
|
300
|
+
const mtime = fs.statSync(file.path).mtimeMs;
|
|
301
|
+
if (!known[file.relativePath] || known[file.relativePath] < mtime) {
|
|
282
302
|
stale.push(file);
|
|
283
303
|
}
|
|
284
304
|
}
|
|
285
305
|
catch {
|
|
286
|
-
|
|
306
|
+
/* file disappeared */
|
|
287
307
|
}
|
|
288
308
|
}
|
|
289
309
|
return stale;
|
|
290
310
|
}
|
|
291
311
|
// โโ Public API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
292
|
-
/**
|
|
293
|
-
* Reindex all memory files (or just stale ones).
|
|
294
|
-
* Returns number of chunks indexed.
|
|
295
|
-
*/
|
|
296
312
|
export async function reindexMemory(force = false) {
|
|
297
|
-
const
|
|
298
|
-
const filesToIndex = force ? getIndexableFiles() : getStaleFiles(index);
|
|
313
|
+
const filesToIndex = force ? getIndexableFiles() : getStaleFiles();
|
|
299
314
|
if (filesToIndex.length === 0) {
|
|
300
|
-
|
|
315
|
+
const total = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
316
|
+
return { indexed: 0, total };
|
|
301
317
|
}
|
|
302
|
-
//
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
// Drop existing entries for files being reindexed (per-source DELETE is O(log n) thanks to idx).
|
|
319
|
+
const delStmt = db().prepare("DELETE FROM entries WHERE source = ?");
|
|
320
|
+
const dropOld = db().transaction((sources) => {
|
|
321
|
+
for (const s of sources)
|
|
322
|
+
delStmt.run(s);
|
|
323
|
+
});
|
|
324
|
+
dropOld(filesToIndex.map(f => f.relativePath));
|
|
325
|
+
// Chunk all files.
|
|
306
326
|
const allChunks = [];
|
|
307
327
|
for (const file of filesToIndex) {
|
|
308
328
|
try {
|
|
309
329
|
const content = fs.readFileSync(file.path, "utf-8");
|
|
310
330
|
const chunks = chunkMarkdown(content, file.relativePath);
|
|
331
|
+
const mtime = fs.statSync(file.path).mtimeMs;
|
|
311
332
|
for (const chunk of chunks) {
|
|
312
|
-
allChunks.push({ ...chunk, source: file.relativePath });
|
|
333
|
+
allChunks.push({ ...chunk, source: file.relativePath, mtime });
|
|
313
334
|
}
|
|
314
|
-
// Update mtime
|
|
315
|
-
const stat = fs.statSync(file.path);
|
|
316
|
-
index.fileMtimes[file.relativePath] = stat.mtimeMs;
|
|
317
335
|
}
|
|
318
336
|
catch (err) {
|
|
319
337
|
console.error(`Failed to chunk ${file.relativePath}:`, err);
|
|
320
338
|
}
|
|
321
339
|
}
|
|
322
340
|
if (allChunks.length === 0) {
|
|
323
|
-
|
|
324
|
-
|
|
341
|
+
// Even with zero chunks, keep mtimes in sync so we don't re-walk on next run.
|
|
342
|
+
const updMtime = db().transaction((files) => {
|
|
343
|
+
for (const f of files) {
|
|
344
|
+
try {
|
|
345
|
+
setFileMtime(f.relativePath, fs.statSync(f.path).mtimeMs);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
/* file disappeared */
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
updMtime(filesToIndex);
|
|
353
|
+
const total = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
354
|
+
return { indexed: 0, total };
|
|
325
355
|
}
|
|
326
|
-
// Get embeddings for all chunks
|
|
356
|
+
// Get embeddings for all chunks (network).
|
|
327
357
|
const texts = allChunks.map(c => c.text);
|
|
328
358
|
const vectors = await getEmbeddings(texts);
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
359
|
+
// Single transaction for all writes.
|
|
360
|
+
const insertStmt = db().prepare("INSERT INTO entries (id, source, text, vector, indexed_at) VALUES (?, ?, ?, ?, ?) " +
|
|
361
|
+
"ON CONFLICT(id) DO UPDATE SET source=excluded.source, text=excluded.text, vector=excluded.vector, indexed_at=excluded.indexed_at");
|
|
362
|
+
const writeAll = db().transaction((rows) => {
|
|
363
|
+
for (const r of rows) {
|
|
364
|
+
insertStmt.run(r.id, r.source, r.text, r.vector, r.indexedAt);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
writeAll(allChunks.map((c, i) => ({
|
|
369
|
+
id: c.id,
|
|
370
|
+
source: c.source,
|
|
371
|
+
text: c.text,
|
|
372
|
+
vector: vectorToBlob(vectors[i]),
|
|
373
|
+
indexedAt: now,
|
|
374
|
+
})));
|
|
375
|
+
// Update mtimes for the files we just (re-)indexed.
|
|
376
|
+
const updMtime = db().transaction((files) => {
|
|
377
|
+
for (const f of files) {
|
|
378
|
+
try {
|
|
379
|
+
setFileMtime(f.relativePath, fs.statSync(f.path).mtimeMs);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
/* file disappeared */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
updMtime(filesToIndex);
|
|
387
|
+
setMeta("lastReindex", String(now));
|
|
388
|
+
const total = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
389
|
+
return { indexed: allChunks.length, total };
|
|
342
390
|
}
|
|
343
|
-
/**
|
|
344
|
-
* Semantic search across all indexed memory.
|
|
345
|
-
* Returns top-K results sorted by similarity.
|
|
346
|
-
*/
|
|
347
391
|
export async function searchMemory(query, topK = 5, minScore = 0.3) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
392
|
+
// Auto-index if empty.
|
|
393
|
+
const total = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
394
|
+
if (total === 0) {
|
|
351
395
|
await reindexMemory();
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (reloaded.entries.length === 0)
|
|
396
|
+
const after = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
397
|
+
if (after === 0)
|
|
355
398
|
return [];
|
|
356
399
|
}
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
.slice(0, topK);
|
|
400
|
+
const queryVector = Float32Array.from(await getQueryEmbedding(query));
|
|
401
|
+
const rows = db().prepare("SELECT id, source, text, vector FROM entries").all();
|
|
402
|
+
const scored = [];
|
|
403
|
+
for (const row of rows) {
|
|
404
|
+
const v = blobToVector(row.vector);
|
|
405
|
+
const score = cosineSimilarityF32(queryVector, v);
|
|
406
|
+
if (score >= minScore) {
|
|
407
|
+
scored.push({ text: row.text, source: row.source, score });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
scored.sort((a, b) => b.score - a.score);
|
|
411
|
+
return scored.slice(0, topK);
|
|
370
412
|
}
|
|
371
|
-
/**
|
|
372
|
-
* Get index stats for /status.
|
|
373
|
-
*/
|
|
374
|
-
/**
|
|
375
|
-
* Auto-reindex on startup. Indexes only stale/new files (incremental).
|
|
376
|
-
* Runs in background โ does not block bot startup.
|
|
377
|
-
*/
|
|
378
413
|
export async function initEmbeddings() {
|
|
379
414
|
try {
|
|
380
|
-
|
|
415
|
+
db(); // Open & migrate schema.
|
|
416
|
+
const stale = getStaleFiles();
|
|
381
417
|
if (stale.length === 0) {
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
384
|
-
return;
|
|
418
|
+
const total = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
419
|
+
if (total > 0)
|
|
420
|
+
return;
|
|
385
421
|
}
|
|
386
422
|
const result = await reindexMemory();
|
|
387
423
|
if (result.indexed > 0) {
|
|
@@ -389,21 +425,26 @@ export async function initEmbeddings() {
|
|
|
389
425
|
}
|
|
390
426
|
}
|
|
391
427
|
catch (err) {
|
|
392
|
-
// Non-fatal โ bot works without embeddings
|
|
393
428
|
console.warn("โ ๏ธ Embeddings init failed:", err instanceof Error ? err.message : err);
|
|
394
429
|
}
|
|
395
430
|
}
|
|
396
431
|
export function getIndexStats() {
|
|
397
|
-
|
|
432
|
+
let entries = 0;
|
|
433
|
+
let files = 0;
|
|
434
|
+
let lastReindex = 0;
|
|
398
435
|
let sizeBytes = 0;
|
|
399
436
|
try {
|
|
400
|
-
|
|
437
|
+
entries = db().prepare("SELECT COUNT(*) AS c FROM entries").get().c;
|
|
438
|
+
files = db().prepare("SELECT COUNT(*) AS c FROM file_mtimes").get().c;
|
|
439
|
+
const meta = getMeta("lastReindex");
|
|
440
|
+
if (meta)
|
|
441
|
+
lastReindex = Number(meta);
|
|
442
|
+
sizeBytes = fs.statSync(EMBEDDINGS_DB).size;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
/* DB not yet initialised */
|
|
401
446
|
}
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
entries: index.entries.length,
|
|
405
|
-
files: Object.keys(index.fileMtimes).length,
|
|
406
|
-
lastReindex: index.lastReindex,
|
|
407
|
-
sizeBytes,
|
|
408
|
-
};
|
|
447
|
+
return { entries, files, lastReindex, sizeBytes };
|
|
409
448
|
}
|
|
449
|
+
// โโ Re-export embedding dim for tests / debugging โโโโโโ
|
|
450
|
+
export { EMBEDDING_DIMENSION, EMBEDDING_MODEL };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alvin-bot",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "Alvin Bot
|
|
3
|
+
"version": "4.20.0",
|
|
4
|
+
"description": "Alvin Bot โ Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -170,6 +170,7 @@
|
|
|
170
170
|
"@types/node": "^22.0.0",
|
|
171
171
|
"@types/ws": "^8.18.1",
|
|
172
172
|
"@whiskeysockets/baileys": "^6.7.21",
|
|
173
|
+
"better-sqlite3": "^12.9.0",
|
|
173
174
|
"dotenv": "^16.4.0",
|
|
174
175
|
"electron-updater": "^6.8.3",
|
|
175
176
|
"grammy": "^1.30.0",
|
|
@@ -181,6 +182,7 @@
|
|
|
181
182
|
"ws": "^8.19.0"
|
|
182
183
|
},
|
|
183
184
|
"devDependencies": {
|
|
185
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
184
186
|
"@vitest/ui": "^4.1.4",
|
|
185
187
|
"electron": "^35.7.5",
|
|
186
188
|
"electron-builder": "^26.8.1",
|