@hera-al/server 1.6.27 → 1.6.29
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/dist/agent/session-db.d.ts +6 -0
- package/dist/agent/session-db.js +1 -1
- package/dist/agent/workspace-files.d.ts +11 -0
- package/dist/agent/workspace-files.js +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -1
- package/dist/gateway/channels/telegram/index.js +1 -1
- package/dist/memory/l0-generator.d.ts +39 -0
- package/dist/memory/l0-generator.js +1 -0
- package/dist/memory/memory-search.d.ts +11 -0
- package/dist/memory/memory-search.js +1 -1
- package/dist/nostromo/ui-html-layout.js +1 -1
- package/dist/nostromo/ui-js-config.js +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -1
- package/dist/tools/memory-tools.js +1 -1
- package/installationPkg/config.example.yaml +45 -13
- package/package.json +2 -2
|
@@ -21,6 +21,12 @@ export declare class SessionDB {
|
|
|
21
21
|
/** Return sessions with an active SDK session whose last_activity is older than `maxAgeMs` milliseconds. */
|
|
22
22
|
listStaleSessions(maxAgeMs: number): SessionRecord[];
|
|
23
23
|
deleteSession(sessionKey: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Check if there has been recent non-cron session activity within the given window.
|
|
26
|
+
* Used by the heartbeat triage layer to decide if an LLM session is needed.
|
|
27
|
+
* (Aurora lesson #1: triage layer)
|
|
28
|
+
*/
|
|
29
|
+
hasRecentActivity(windowMs: number): boolean;
|
|
24
30
|
/** Prune stale cron sessions (sessionKey starting with "cron:") older than maxAgeMs. Returns count of deleted rows. */
|
|
25
31
|
pruneStaleCronSessions(maxAgeMs: number): number;
|
|
26
32
|
close(): void;
|
package/dist/agent/session-db.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import s from"better-sqlite3";import{createLogger as e}from"../utils/logger.js";const
|
|
1
|
+
import s from"better-sqlite3";import{createLogger as e}from"../utils/logger.js";const t=e("SessionDB");function i(s){return{sessionKey:s.session_key,sdkSessionId:s.sdk_session_id,modelOverride:s.model_override,createdAt:s.created_at,lastActivity:s.last_activity}}export class SessionDB{db;constructor(e){this.db=new s(e),this.db.pragma("journal_mode = WAL"),this.initSchema(),this.migrate(),t.info(`Session database opened at ${e}`)}initSchema(){this.db.exec("\n CREATE TABLE IF NOT EXISTS sessions_map (\n session_key TEXT PRIMARY KEY NOT NULL,\n sdk_session_id TEXT,\n model_override TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n last_activity TEXT NOT NULL DEFAULT (datetime('now'))\n );\n ")}migrate(){this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get()&&(t.info("Migrating data from old 'sessions' table to 'sessions_map'..."),this.db.exec("\n INSERT OR IGNORE INTO sessions_map (session_key, sdk_session_id, model_override, created_at, last_activity)\n SELECT session_key, sdk_session_id, model_override, created_at, last_activity\n FROM sessions;\n "),this.db.exec("DROP TABLE sessions;"),t.info("Migration complete — old 'sessions' table dropped."))}getOrCreate(s){return this.db.prepare("INSERT INTO sessions_map (session_key)\n VALUES (?)\n ON CONFLICT(session_key) DO UPDATE SET\n last_activity = datetime('now')").run(s),this.getBySessionKey(s)}updateSdkSessionId(s,e){this.db.prepare("UPDATE sessions_map SET sdk_session_id = ?, last_activity = datetime('now') WHERE session_key = ?").run(e,s),t.info(`SDK session updated: ${s} -> ${e}`)}updateModelOverride(s,e){this.db.prepare("UPDATE sessions_map SET model_override = ?, last_activity = datetime('now') WHERE session_key = ?").run(e,s)}getBySessionKey(s){const e=this.db.prepare("SELECT * FROM sessions_map WHERE session_key = ?").get(s);return e?i(e):null}listSessions(){return this.db.prepare("SELECT * FROM sessions_map ORDER BY last_activity DESC").all().map(i)}clearSdkSessionId(s){this.db.prepare("UPDATE sessions_map SET sdk_session_id = NULL, last_activity = datetime('now') WHERE session_key = ?").run(s),t.info(`SDK session cleared (model preserved): ${s}`)}listStaleSessions(s){const e=new Date(Date.now()-s).toISOString().replace("T"," ").replace("Z","");return this.db.prepare("SELECT * FROM sessions_map WHERE sdk_session_id IS NOT NULL AND last_activity < ?").all(e).map(i)}deleteSession(s){this.db.prepare("DELETE FROM sessions_map WHERE session_key = ?").run(s),t.info(`Session deleted: ${s}`)}hasRecentActivity(s){const e=new Date(Date.now()-s).toISOString().replace("T"," ").replace("Z","");return!!this.db.prepare("SELECT 1 FROM sessions_map WHERE session_key NOT LIKE 'cron:%' AND last_activity > ? LIMIT 1").get(e)}pruneStaleCronSessions(s){const e=new Date(Date.now()-s).toISOString().replace("T"," ").replace("Z",""),i=this.db.prepare("DELETE FROM sessions_map WHERE session_key LIKE 'cron:%' AND last_activity < ?").run(e).changes;return i>0&&t.info(`Pruned ${i} stale cron session(s) (older than ${Math.round(s/36e5)}h)`),i}close(){this.db.close()}}
|
|
@@ -31,8 +31,19 @@ export declare function truncateContent(content: string, fileName: string, maxCh
|
|
|
31
31
|
* Format workspace files into a single string for the {{WORKSPACE_FILES}} placeholder.
|
|
32
32
|
* File contents are concatenated directly — each file already contains its own heading.
|
|
33
33
|
* HEARTBEAT.md and BOOTSTRAP.md are excluded (handled separately).
|
|
34
|
+
*
|
|
35
|
+
* MEMORY.md gets special treatment (Aurora lesson #4 — memory budget):
|
|
36
|
+
* if it exceeds MEMORY_HOT_BUDGET, only sections marked with <!-- hot --> are loaded.
|
|
37
|
+
* Cold sections remain accessible via memory_search.
|
|
34
38
|
*/
|
|
35
39
|
export declare function formatWorkspaceFiles(files: WorkspaceFile[]): string;
|
|
40
|
+
/**
|
|
41
|
+
* Extract only <!-- hot --> ... <!-- /hot --> sections from a file.
|
|
42
|
+
* If no hot markers exist, returns the full content (backward compatible).
|
|
43
|
+
* Appends a note about cold content available via memory_search.
|
|
44
|
+
* (Aurora lesson #4: memory budget awareness)
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractHotSections(content: string, fileName: string): string;
|
|
36
47
|
/**
|
|
37
48
|
* Load built-in tools from CBINT.json and format for the system prompt.
|
|
38
49
|
* Returns a formatted list of tools for the given mode (full = main agent, minimal = sub agent).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFileSync as t,copyFileSync as
|
|
1
|
+
import{readFileSync as t,copyFileSync as e,existsSync as n,mkdirSync as o}from"node:fs";import{join as r}from"node:path";import{createLogger as s}from"../utils/logger.js";import{resolveInstallPkgDir as c}from"../utils/package-paths.js";const a=s("WorkspaceFiles"),i=["AGENTS.md","SOUL.md","TOOLS.md","IDENTITY.md","USER.md","HEARTBEAT.md","BOOTSTRAP.md"],m=["SYSTEM_PROMPT.md","SYSTEM_PROMPT_SUBAGENT.md","CBINT.json"],f=["MEMORY.md","memory.md"],l=new Set(["AGENTS.md","TOOLS.md","SOUL.md","IDENTITY.md","USER.md"]);export function ensureWorkspaceFiles(t){o(t,{recursive:!0});const s=d(),c=r(t,".templates");o(c,{recursive:!0});for(const o of i){const c=r(t,o);if(!n(c)){const t=r(s,o);n(t)&&(e(t,c),a.info(`Seeded ${o} → ${c}`))}}for(const t of m){const o=r(c,t);if(!n(o)){const c=r(s,t);n(c)&&(e(c,o),a.info(`Seeded template ${t} → ${o}`))}}}export function loadWorkspaceFiles(e){const o=[];for(const s of i){const c=r(e,s);if(n(c))try{const e=t(c,"utf-8");o.push({name:s,path:c,content:e,missing:!1})}catch(t){a.warn(`Failed to read ${c}: ${t}`),o.push({name:s,path:c,content:"",missing:!0})}}for(const s of f){const c=r(e,s);if(n(c)){try{const e=t(c,"utf-8");o.push({name:s,path:c,content:e,missing:!1})}catch(t){a.warn(`Failed to read ${c}: ${t}`)}break}}return o}export function filterForSubagent(t){return t.filter(t=>l.has(t.name))}export function truncateContent(t,e,n=2e4){if(t.length<=n)return t;const o=Math.floor(.7*n),r=Math.floor(.2*n),s=t.slice(0,o),c=t.slice(-r),i=t.length-o-r;return a.debug(`Truncated ${e}: ${t.length} → ${o+r} chars (skipped ${i})`),`${s}\n\n[... ${i} characters omitted from ${e} ...]\n\n${c}`}const u=new Set(["HEARTBEAT.md","BOOTSTRAP.md"]);export function formatWorkspaceFiles(t){const e=t.filter(t=>!t.missing&&t.content.trim().length>0&&!u.has(t.name));return 0===e.length?"(No workspace files found)":e.map(t=>{let e=t.content;var n;return n=t.name,f.includes(n)&&e.length>8e3&&(e=extractHotSections(e,t.name)),truncateContent(e,t.name)}).join("\n\n")}export function extractHotSections(t,e){const n="\x3c!-- hot --\x3e",o="\x3c!-- /hot --\x3e",r=[];let s=0;for(;s<t.length;){const e=t.indexOf(n,s);if(-1===e)break;const c=e+12,a=t.indexOf(o,c);if(-1===a){r.push(t.slice(c).trim());break}r.push(t.slice(c,a).trim()),s=a+13}if(0===r.length)return t;const c=r.join("\n\n"),i=t.length-c.length;return a.info(`Memory budget: ${e} ${t.length} chars → ${c.length} hot, ${i} cold (via memory_search)`),`${c}\n\n[... ${i} characters of cold memory omitted from ${e} — available via memory_search ...]`}export function loadBuiltInTools(t,e){const n=loadTemplateOrEmpty(t,"CBINT.json");if(!n.trim())return"";try{const t=JSON.parse(n),o="full"===e?{...t.tools_main_agent||{},...t.tools_sub_agent||{}}:t.tools_sub_agent||{},r=Object.entries(o);if(0===r.length)return"";const s=[];for(const[t,e]of r)s.push(`- **${t}**: ${e}`);return s.join("\n")}catch(t){return a.warn(`Failed to parse CBINT.json: ${t}`),""}}export function loadTemplateOrEmpty(e,o){const s=r(e,".templates",o);if(n(s))try{return t(s,"utf-8")}catch{return""}const c=r(d(),o);if(n(c))try{return t(c,"utf-8")}catch{return""}return""}export function loadTemplate(e,o){const s=r(e,".templates",o);if(n(s))return t(s,"utf-8");const c=r(d(),o);if(n(c))return a.warn(`Template ${o} not found in ${e}/.templates/, using bundled seed`),t(c,"utf-8");throw new Error(`Template ${o} not found in ${e}/.templates/ or bundled seeds`)}function d(){return c()}
|
package/dist/config.d.ts
CHANGED
|
@@ -353,6 +353,10 @@ declare const AppConfigSchema: z.ZodObject<{
|
|
|
353
353
|
maxInjectedChars: z.ZodDefault<z.ZodNumber>;
|
|
354
354
|
rrfK: z.ZodDefault<z.ZodNumber>;
|
|
355
355
|
}, z.core.$strip>>>;
|
|
356
|
+
l0: z.ZodDefault<z.ZodOptional<z.ZodObject<{
|
|
357
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
358
|
+
model: z.ZodDefault<z.ZodString>;
|
|
359
|
+
}, z.core.$strip>>>;
|
|
356
360
|
}, z.core.$strip>>>;
|
|
357
361
|
agent: z.ZodDefault<z.ZodOptional<z.ZodObject<{
|
|
358
362
|
model: z.ZodDefault<z.ZodString>;
|
package/dist/config.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFileSync as e,writeFileSync as t,existsSync as o,mkdirSync as a,renameSync as n,unlinkSync as l}from"node:fs";import{resolve as r,join as s}from"node:path";import{homedir as i}from"node:os";import{config as u}from"dotenv";import{parse as d,stringify as c}from"yaml";import{z as f}from"zod";import{BrowserConfigSchema as p}from"@hera-al/browser-server/config";import{createLogger as m}from"./utils/logger.js";const b=m("Config");function g(e){return"~"===e||e.startsWith("~/")?e.replace("~",i()):e}let h=g(process.env.GMAB_PATH??"~/gmab"),y=s(h,"data");export function getGmabPath(){return h}export function getDataDir(){return y}export function getNostromoKeyPath(){return s(y,".nostromo-key")}export function backupConfig(a){if(o(a))try{const r=e=>`${a}.backup${e}`;o(r(1))&&l(r(1));for(let e=2;e<=5;e++)o(r(e))&&n(r(e),r(e-1));t(r(5),e(a)),b.debug(`Config backup created: ${r(5)}`)}catch(e){b.warn(`Failed to create config backup: ${e}`)}}const v=f.record(f.string(),f.any()),x=f.object({reactions:f.boolean().default(!0),sendMessage:f.boolean().default(!0),editMessage:f.boolean().default(!0),deleteMessage:f.boolean().default(!0),sticker:f.boolean().default(!1),createForumTopic:f.boolean().default(!1)}),w=f.object({maxAttempts:f.number().default(3),baseDelayMs:f.number().default(1e3),maxDelayMs:f.number().default(3e4)}),M=f.enum(["off","dm","group","all","allowlist"]),k=f.object({botToken:f.string(),dmPolicy:f.enum(["open","token","allowlist"]).default("allowlist"),allowFrom:f.array(f.union([f.string(),f.number()])).default([]),name:f.string().optional(),reactionLevel:f.enum(["off","ack","minimal","extensive"]).default("ack"),reactionNotifications:f.enum(["off","own","all"]).default("off"),inlineButtonsScope:M.optional(),textChunkLimit:f.number().default(4e3),streamMode:f.enum(["off","partial","block"]).default("partial"),linkPreview:f.boolean().default(!0),actions:x.optional(),retry:w.optional(),timeoutSeconds:f.number().optional(),proxy:f.string().optional()}),P=f.object({enabled:f.boolean().default(!1),accounts:f.record(f.string(),k).default({})}),j=f.object({enabled:f.boolean().default(!1),accounts:v.default({})}),R=f.object({enabled:f.boolean().default(!0),port:f.number().default(3004)}),C=f.object({telegram:P.optional().default({enabled:!1,accounts:{}}),whatsapp:j.optional().default({enabled:!1,accounts:{}}),discord:j.optional().default({enabled:!1,accounts:{}}),slack:j.optional().default({enabled:!1,accounts:{}}),signal:j.optional().default({enabled:!1,accounts:{}}),msteams:j.optional().default({enabled:!1,accounts:{}}),googlechat:j.optional().default({enabled:!1,accounts:{}}),line:j.optional().default({enabled:!1,accounts:{}}),matrix:j.optional().default({enabled:!1,accounts:{}}),responses:R.optional().default({enabled:!0,port:3e3})}),S=f.object({modelRef:f.string().default(""),model:f.string().default("whisper-1"),language:f.string().default("")}),T=f.object({binaryPath:f.string().default("whisper"),model:f.string().default("base")}),A=f.object({enabled:f.boolean().default(!1),provider:f.string().default("openai-whisper"),"openai-whisper":S.optional().default({modelRef:"",model:"whisper-1",language:""}),"local-whisper":T.optional().default({binaryPath:"whisper",model:"base"})}),D=f.object({voice:f.string().default("en-US-MichelleNeural"),lang:f.string().default("en-US"),outputFormat:f.string().default("audio-24khz-48kbitrate-mono-mp3")}),I=f.object({modelRef:f.string().default(""),model:f.string().default("gpt-4o-mini-tts"),voice:f.string().default("alloy")}),U=f.object({modelRef:f.string().default(""),voiceId:f.string().default("pMsXgVXv3BLzUgSXRplE"),modelId:f.string().default("eleven_multilingual_v2")}),z=f.object({enabled:f.boolean().default(!1),provider:f.enum(["edge","openai","elevenlabs"]).default("openai"),maxTextLength:f.number().default(4096),timeoutMs:f.number().default(3e4),edge:D.optional().default({voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"}),openai:I.optional().default({modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"}),elevenlabs:U.optional().default({modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"})}),L=f.object({enabled:f.boolean().default(!1),embeddingModel:f.string().default("text-embedding-3-small"),embeddingDimensions:f.number().default(1536),modelRef:f.string().default(""),prefixQuery:f.string().default(""),prefixDocument:f.string().default(""),updateDebounceMs:f.number().default(3e3),embedIntervalMs:f.number().default(3e5),maxResults:f.number().default(6),maxSnippetChars:f.number().default(700),maxInjectedChars:f.number().default(4e3),rrfK:f.number().default(60)}),W={enabled:!1,embeddingModel:"text-embedding-3-small",embeddingDimensions:1536,modelRef:"",prefixQuery:"",prefixDocument:"",updateDebounceMs:3e3,embedIntervalMs:3e5,maxResults:6,maxSnippetChars:700,maxInjectedChars:4e3,rrfK:60},E=f.object({enabled:f.boolean().default(!0),recallStrategy:f.enum(["builtin-only","search"]).default("builtin-only"),search:L.optional().default(W)}),F=f.object({command:f.string(),args:f.array(f.string()).optional(),env:f.record(f.string(),f.string()).optional()}).passthrough(),K=f.object({id:f.string(),name:f.string(),types:f.array(f.enum(["internal","external","env-var"])).optional().default(["external"]),proxy:f.enum(["not-used","direct","proxied"]).optional().default("not-used"),fastUrl:f.string().optional().default(""),fastProxyApiKey:f.string().optional().default(""),apiKey:f.string().optional().default(""),baseURL:f.string().optional().default(""),useEnvVar:f.string().optional().default(""),contextWindow:f.number().optional().default(2e5),costInput:f.number().optional().default(0),costOutput:f.number().optional().default(0),costCacheRead:f.number().optional().default(0),costCacheWrite:f.number().optional().default(0)}),O=[{id:"claude-opus-4-6",name:"Claude Opus",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-sonnet-4-6",name:"Claude Sonnet",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-haiku-3-5-20241022",name:"Claude Haiku",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0}],_=f.array(K).default(O),q=f.object({name:f.string(),path:f.string(),description:f.string().default(""),enabled:f.boolean().default(!1)}),B=f.object({name:f.string(),description:f.string(),prompt:f.string(),model:f.enum(["sonnet","opus","haiku","inherit"]).default("inherit"),tools:f.array(f.string()).default(["Read","Write","Edit","Glob","Grep","WebSearch","WebFetch"]),expandContext:f.boolean().default(!1),enabled:f.boolean().default(!1)}),G=f.object({type:f.enum(["claudecode","pi"]).default("claudecode"),piModelRef:f.string().default("")}).passthrough(),N={type:"claudecode",piModelRef:""},X=f.object({enabled:f.boolean().default(!1),modelRefs:f.array(f.string()).default([]),rollingMemoryModel:f.string().default("")}),$={enabled:!1,modelRefs:[],rollingMemoryModel:""},V=f.object({maxAttempts:f.number().default(5),baseDelayMs:f.number().default(2e3),maxDelayMs:f.number().default(3e4)}),H={maxAttempts:5,baseDelayMs:2e3,maxDelayMs:3e4},Q=f.object({model:f.string().default("claude-opus-4-6"),mainFallback:f.string().default(""),engine:G.optional().default(N),picoAgent:X.optional().default($),maxTurns:f.number().default(50),permissionMode:f.string().default("bypassPermissions"),sessionTTL:f.number().default(3600),queueMode:f.enum(["queue","collect","steer"]).default("steer"),queueDebounceMs:f.number().default(1500),queueCap:f.number().default(20),queueDropPolicy:f.enum(["old","new","summarize"]).default("summarize"),allowedTools:f.array(f.string()).default([]),disallowedTools:f.array(f.string()).default([]),mcpServers:f.record(f.string(),F).default({}),workspacePath:f.string().default("./workspace"),builtinCoderSkill:f.boolean().default(!1),settingSources:f.enum(["nothing","user","project","both"]).default("project"),customSubAgents:f.array(B).default([]),plugins:f.array(q).default([]),inflightTyping:f.boolean().default(!0),autoApproveTools:f.boolean().default(!0),autoRenew:f.number().default(0),apiRetry:V.optional().default(H),skillNudge:f.object({enabled:f.boolean().default(!0),threshold:f.number().default(10)}).default({enabled:!0,threshold:10})}),Z={enabled:!1,provider:"openai",maxTextLength:4096,timeoutMs:3e4,edge:{voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"},openai:{modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"},elevenlabs:{modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"}},J={enabled:!0,recallStrategy:"builtin-only",dir:"",search:W},Y={model:"claude-opus-4-6",mainFallback:"",engine:N,picoAgent:$,maxTurns:50,permissionMode:"bypassPermissions",sessionTTL:3600,queueMode:"steer",queueDebounceMs:1500,queueCap:20,queueDropPolicy:"summarize",allowedTools:[],disallowedTools:[],mcpServers:{},workspacePath:"./workspace",builtinCoderSkill:!1,settingSources:"project",customSubAgents:[],plugins:[],inflightTyping:!0,autoApproveTools:!0,autoRenew:0,apiRetry:H,skillNudge:{enabled:!0,threshold:10}},ee=f.object({enabled:f.boolean().default(!1),every:f.number().default(18e5),channel:f.string().default(""),chatId:f.string().default(""),message:f.string().default(""),ackMaxChars:f.number().default(300)}),te={enabled:!1,every:18e5,channel:"",chatId:"",message:"",ackMaxChars:300},oe=f.object({enabled:f.boolean().default(!0),isolated:f.boolean().default(!0),broadcastEvents:f.boolean().default(!1),storePath:f.string().default(""),heartbeat:ee.optional().default(te)}),ae={enabled:!0,isolated:!0,broadcastEvents:!1,storePath:"",heartbeat:te},ne=f.object({enabled:f.boolean().default(!0),port:f.number().default(3001),basePath:f.string().default("/nostromo"),configCheckInterval:f.number().default(5),autoRestart:f.boolean().default(!0)}),le={enabled:!0,port:3001,basePath:"/nostromo",configCheckInterval:5,autoRestart:!0},re=f.object({gmabPath:f.string().optional().default("~/gmab"),host:f.string().optional().default("127.0.0.1"),logLevel:f.enum(["debug","info","warn","error"]).optional().default("info"),verboseDebugLogs:f.boolean().optional().default(!0),timezone:f.string().optional().default(""),fastProxyUrl:f.string().optional().default("http://localhost:4181"),channels:C.optional().default({telegram:{enabled:!1,accounts:{}},whatsapp:{enabled:!1,accounts:{}},discord:{enabled:!1,accounts:{}},slack:{enabled:!1,accounts:{}},signal:{enabled:!1,accounts:{}},msteams:{enabled:!1,accounts:{}},googlechat:{enabled:!1,accounts:{}},line:{enabled:!1,accounts:{}},matrix:{enabled:!1,accounts:{}},responses:{enabled:!0,port:3004}}),models:_.optional().default(O),stt:A.optional().default({enabled:!1,provider:"openai-whisper","openai-whisper":{modelRef:"",model:"whisper-1",language:""},"local-whisper":{binaryPath:"whisper",model:"base"}}),tts:z.optional().default(Z),memory:E.optional().default(J),agent:Q.optional().default(Y),cron:oe.optional().default(ae),nostromo:ne.optional().default(le),browser:p.optional().default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,remoteCdpTimeoutMs:1500,profiles:{default:{cdpPort:9222,color:"#FF4500"}}})});function se(e){const t=function(e){const t=new Set,o=/\$\{([^}]+)\}/g;let a;for(;null!==(a=o.exec(e));)t.add(a[1]);return Array.from(t)}(e),o=t.filter(e=>!process.env[e]);o.length>0&&b.warn(`Missing environment variables referenced in config: ${o.join(", ")}. Add them to .env or export them before starting.`)}function ie(e){if("string"==typeof e)return e.replace(/\$\{([^}]+)\}/g,(e,t)=>process.env[t]??"");if(Array.isArray(e))return e.map(ie);if(null!==e&&"object"==typeof e){const t={};for(const[o,a]of Object.entries(e))t[o]=ie(a);return t}return e}const ue={channels:{responses:{enabled:!0,port:3004}},stt:{enabled:!1},tts:Z,memory:J,agent:{...Y,permissionMode:"bypassPermissions",allowedTools:["Read","Grep","Bash","WebSearch","Glob","Write","Edit","WebFetch","Task","Skill"]},cron:ae,nostromo:le};export function loadConfig(n){const l=n??r(process.cwd(),"config.yaml"),i=r(process.cwd(),".env");if(o(i)&&(u({path:i}),b.info(`Loaded .env from ${i}`)),!o(l)){const e="# GrabMeABeer Configuration\n# Configure channels and settings via Nostromo: http://localhost:3001\n\n"+c({gmabPath:"~/gmab",...ue});t(l,e,"utf-8")}const f=e(l,"utf-8");se(f);const p=ie(d(f)),m=re.parse(p);if(h=process.env.GMAB_PATH?g(process.env.GMAB_PATH):g(m.gmabPath),y=s(h,"data"),a(y,{recursive:!0}),!m.timezone){m.timezone=Intl.DateTimeFormat().resolvedOptions().timeZone;try{const o=d(e(l,"utf-8"))??{};o.timezone=m.timezone,t(l,c(o),"utf-8"),b.info(`Timezone auto-detected and saved: ${m.timezone}`)}catch(e){}}return function(e){a(y,{recursive:!0});const t=process.env.WORKSPACE_PATH??e.agent.workspacePath;e.agent.workspacePath=r(g(t)),a(e.agent.workspacePath,{recursive:!0});const o=s(y,"cron");a(o,{recursive:!0});const n=e.cron.storePath.trim()?r(g(e.cron.storePath)):s(o,"jobs.json");return{...e,gmabPath:h,dataDir:y,dbPath:s(y,"core.db"),memoryDir:s(y,"memory"),cronStorePath:n}}(m)}export function loadRawConfig(t){const a=t??r(process.cwd(),"config.yaml");if(!o(a))return{};const n=e(a,"utf-8");return d(n)??{}}export function resolveModelEntry(e,t){if(!t)return;const o=e.models;if(!o?.length)return;const a=t.indexOf(":");if(a>=0){const e=t.substring(0,a),n=t.substring(a+1);return o.find(t=>t.name===e&&t.id===n)}return o.find(e=>e.name===t)??o.find(e=>e.id===t)}export function modelRefName(e){if(!e)return e;const t=e.indexOf(":");return t>=0?e.substring(0,t):e}export function resolveModelId(e,t){if(!t)return t;const o=resolveModelEntry(e,t);return o?o.id:t}
|
|
1
|
+
import{readFileSync as e,writeFileSync as t,existsSync as o,mkdirSync as a,renameSync as n,unlinkSync as l}from"node:fs";import{resolve as r,join as s}from"node:path";import{homedir as i}from"node:os";import{config as u}from"dotenv";import{parse as d,stringify as c}from"yaml";import{z as f}from"zod";import{BrowserConfigSchema as p}from"@hera-al/browser-server/config";import{createLogger as m}from"./utils/logger.js";const b=m("Config");function g(e){return"~"===e||e.startsWith("~/")?e.replace("~",i()):e}let h=g(process.env.GMAB_PATH??"~/gmab"),y=s(h,"data");export function getGmabPath(){return h}export function getDataDir(){return y}export function getNostromoKeyPath(){return s(y,".nostromo-key")}export function backupConfig(a){if(o(a))try{const r=e=>`${a}.backup${e}`;o(r(1))&&l(r(1));for(let e=2;e<=5;e++)o(r(e))&&n(r(e),r(e-1));t(r(5),e(a)),b.debug(`Config backup created: ${r(5)}`)}catch(e){b.warn(`Failed to create config backup: ${e}`)}}const v=f.record(f.string(),f.any()),x=f.object({reactions:f.boolean().default(!0),sendMessage:f.boolean().default(!0),editMessage:f.boolean().default(!0),deleteMessage:f.boolean().default(!0),sticker:f.boolean().default(!1),createForumTopic:f.boolean().default(!1)}),w=f.object({maxAttempts:f.number().default(3),baseDelayMs:f.number().default(1e3),maxDelayMs:f.number().default(3e4)}),M=f.enum(["off","dm","group","all","allowlist"]),k=f.object({botToken:f.string(),dmPolicy:f.enum(["open","token","allowlist"]).default("allowlist"),allowFrom:f.array(f.union([f.string(),f.number()])).default([]),name:f.string().optional(),reactionLevel:f.enum(["off","ack","minimal","extensive"]).default("ack"),reactionNotifications:f.enum(["off","own","all"]).default("off"),inlineButtonsScope:M.optional(),textChunkLimit:f.number().default(4e3),streamMode:f.enum(["off","partial","block"]).default("partial"),linkPreview:f.boolean().default(!0),actions:x.optional(),retry:w.optional(),timeoutSeconds:f.number().optional(),proxy:f.string().optional()}),j=f.object({enabled:f.boolean().default(!1),accounts:f.record(f.string(),k).default({})}),P=f.object({enabled:f.boolean().default(!1),accounts:v.default({})}),R=f.object({enabled:f.boolean().default(!0),port:f.number().default(3004)}),C=f.object({telegram:j.optional().default({enabled:!1,accounts:{}}),whatsapp:P.optional().default({enabled:!1,accounts:{}}),discord:P.optional().default({enabled:!1,accounts:{}}),slack:P.optional().default({enabled:!1,accounts:{}}),signal:P.optional().default({enabled:!1,accounts:{}}),msteams:P.optional().default({enabled:!1,accounts:{}}),googlechat:P.optional().default({enabled:!1,accounts:{}}),line:P.optional().default({enabled:!1,accounts:{}}),matrix:P.optional().default({enabled:!1,accounts:{}}),responses:R.optional().default({enabled:!0,port:3e3})}),S=f.object({modelRef:f.string().default(""),model:f.string().default("whisper-1"),language:f.string().default("")}),T=f.object({binaryPath:f.string().default("whisper"),model:f.string().default("base")}),A=f.object({enabled:f.boolean().default(!1),provider:f.string().default("openai-whisper"),"openai-whisper":S.optional().default({modelRef:"",model:"whisper-1",language:""}),"local-whisper":T.optional().default({binaryPath:"whisper",model:"base"})}),D=f.object({voice:f.string().default("en-US-MichelleNeural"),lang:f.string().default("en-US"),outputFormat:f.string().default("audio-24khz-48kbitrate-mono-mp3")}),I=f.object({modelRef:f.string().default(""),model:f.string().default("gpt-4o-mini-tts"),voice:f.string().default("alloy")}),U=f.object({modelRef:f.string().default(""),voiceId:f.string().default("pMsXgVXv3BLzUgSXRplE"),modelId:f.string().default("eleven_multilingual_v2")}),z=f.object({enabled:f.boolean().default(!1),provider:f.enum(["edge","openai","elevenlabs"]).default("openai"),maxTextLength:f.number().default(4096),timeoutMs:f.number().default(3e4),edge:D.optional().default({voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"}),openai:I.optional().default({modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"}),elevenlabs:U.optional().default({modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"})}),L=f.object({enabled:f.boolean().default(!1),embeddingModel:f.string().default("text-embedding-3-small"),embeddingDimensions:f.number().default(1536),modelRef:f.string().default(""),prefixQuery:f.string().default(""),prefixDocument:f.string().default(""),updateDebounceMs:f.number().default(3e3),embedIntervalMs:f.number().default(3e5),maxResults:f.number().default(6),maxSnippetChars:f.number().default(700),maxInjectedChars:f.number().default(4e3),rrfK:f.number().default(60)}),W={enabled:!1,embeddingModel:"text-embedding-3-small",embeddingDimensions:1536,modelRef:"",prefixQuery:"",prefixDocument:"",updateDebounceMs:3e3,embedIntervalMs:3e5,maxResults:6,maxSnippetChars:700,maxInjectedChars:4e3,rrfK:60},E=f.object({enabled:f.boolean().default(!0),model:f.string().default("")}),F={enabled:!0,model:""},K=f.object({enabled:f.boolean().default(!0),recallStrategy:f.enum(["builtin-only","search"]).default("builtin-only"),search:L.optional().default(W),l0:E.optional().default(F)}),O=f.object({command:f.string(),args:f.array(f.string()).optional(),env:f.record(f.string(),f.string()).optional()}).passthrough(),_=f.object({id:f.string(),name:f.string(),types:f.array(f.enum(["internal","external","env-var"])).optional().default(["external"]),proxy:f.enum(["not-used","direct","proxied"]).optional().default("not-used"),fastUrl:f.string().optional().default(""),fastProxyApiKey:f.string().optional().default(""),apiKey:f.string().optional().default(""),baseURL:f.string().optional().default(""),useEnvVar:f.string().optional().default(""),contextWindow:f.number().optional().default(2e5),costInput:f.number().optional().default(0),costOutput:f.number().optional().default(0),costCacheRead:f.number().optional().default(0),costCacheWrite:f.number().optional().default(0)}),q=[{id:"claude-opus-4-6",name:"Claude Opus",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-sonnet-4-6",name:"Claude Sonnet",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0},{id:"claude-haiku-3-5-20241022",name:"Claude Haiku",types:["internal"],proxy:"not-used",fastUrl:"",fastProxyApiKey:"",apiKey:"",baseURL:"",useEnvVar:"",contextWindow:2e5,costInput:0,costOutput:0,costCacheRead:0,costCacheWrite:0}],B=f.array(_).default(q),G=f.object({name:f.string(),path:f.string(),description:f.string().default(""),enabled:f.boolean().default(!1)}),N=f.object({name:f.string(),description:f.string(),prompt:f.string(),model:f.enum(["sonnet","opus","haiku","inherit"]).default("inherit"),tools:f.array(f.string()).default(["Read","Write","Edit","Glob","Grep","WebSearch","WebFetch"]),expandContext:f.boolean().default(!1),enabled:f.boolean().default(!1)}),X=f.object({type:f.enum(["claudecode","pi"]).default("claudecode"),piModelRef:f.string().default("")}).passthrough(),$={type:"claudecode",piModelRef:""},V=f.object({enabled:f.boolean().default(!1),modelRefs:f.array(f.string()).default([]),rollingMemoryModel:f.string().default("")}),H={enabled:!1,modelRefs:[],rollingMemoryModel:""},Q=f.object({maxAttempts:f.number().default(5),baseDelayMs:f.number().default(2e3),maxDelayMs:f.number().default(3e4)}),Z={maxAttempts:5,baseDelayMs:2e3,maxDelayMs:3e4},J=f.object({model:f.string().default("claude-opus-4-6"),mainFallback:f.string().default(""),engine:X.optional().default($),picoAgent:V.optional().default(H),maxTurns:f.number().default(50),permissionMode:f.string().default("bypassPermissions"),sessionTTL:f.number().default(3600),queueMode:f.enum(["queue","collect","steer"]).default("steer"),queueDebounceMs:f.number().default(1500),queueCap:f.number().default(20),queueDropPolicy:f.enum(["old","new","summarize"]).default("summarize"),allowedTools:f.array(f.string()).default([]),disallowedTools:f.array(f.string()).default([]),mcpServers:f.record(f.string(),O).default({}),workspacePath:f.string().default("./workspace"),builtinCoderSkill:f.boolean().default(!1),settingSources:f.enum(["nothing","user","project","both"]).default("project"),customSubAgents:f.array(N).default([]),plugins:f.array(G).default([]),inflightTyping:f.boolean().default(!0),autoApproveTools:f.boolean().default(!0),autoRenew:f.number().default(0),apiRetry:Q.optional().default(Z),skillNudge:f.object({enabled:f.boolean().default(!0),threshold:f.number().default(10)}).default({enabled:!0,threshold:10})}),Y={enabled:!1,provider:"openai",maxTextLength:4096,timeoutMs:3e4,edge:{voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"},openai:{modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"},elevenlabs:{modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"}},ee={enabled:!0,recallStrategy:"builtin-only",dir:"",search:W,l0:F},te={model:"claude-opus-4-6",mainFallback:"",engine:$,picoAgent:H,maxTurns:50,permissionMode:"bypassPermissions",sessionTTL:3600,queueMode:"steer",queueDebounceMs:1500,queueCap:20,queueDropPolicy:"summarize",allowedTools:[],disallowedTools:[],mcpServers:{},workspacePath:"./workspace",builtinCoderSkill:!1,settingSources:"project",customSubAgents:[],plugins:[],inflightTyping:!0,autoApproveTools:!0,autoRenew:0,apiRetry:Z,skillNudge:{enabled:!0,threshold:10}},oe=f.object({enabled:f.boolean().default(!1),every:f.number().default(18e5),channel:f.string().default(""),chatId:f.string().default(""),message:f.string().default(""),ackMaxChars:f.number().default(300)}),ae={enabled:!1,every:18e5,channel:"",chatId:"",message:"",ackMaxChars:300},ne=f.object({enabled:f.boolean().default(!0),isolated:f.boolean().default(!0),broadcastEvents:f.boolean().default(!1),storePath:f.string().default(""),heartbeat:oe.optional().default(ae)}),le={enabled:!0,isolated:!0,broadcastEvents:!1,storePath:"",heartbeat:ae},re=f.object({enabled:f.boolean().default(!0),port:f.number().default(3001),basePath:f.string().default("/nostromo"),configCheckInterval:f.number().default(5),autoRestart:f.boolean().default(!0)}),se={enabled:!0,port:3001,basePath:"/nostromo",configCheckInterval:5,autoRestart:!0},ie=f.object({gmabPath:f.string().optional().default("~/gmab"),host:f.string().optional().default("127.0.0.1"),logLevel:f.enum(["debug","info","warn","error"]).optional().default("info"),verboseDebugLogs:f.boolean().optional().default(!0),timezone:f.string().optional().default(""),fastProxyUrl:f.string().optional().default("http://localhost:4181"),channels:C.optional().default({telegram:{enabled:!1,accounts:{}},whatsapp:{enabled:!1,accounts:{}},discord:{enabled:!1,accounts:{}},slack:{enabled:!1,accounts:{}},signal:{enabled:!1,accounts:{}},msteams:{enabled:!1,accounts:{}},googlechat:{enabled:!1,accounts:{}},line:{enabled:!1,accounts:{}},matrix:{enabled:!1,accounts:{}},responses:{enabled:!0,port:3004}}),models:B.optional().default(q),stt:A.optional().default({enabled:!1,provider:"openai-whisper","openai-whisper":{modelRef:"",model:"whisper-1",language:""},"local-whisper":{binaryPath:"whisper",model:"base"}}),tts:z.optional().default(Y),memory:K.optional().default(ee),agent:J.optional().default(te),cron:ne.optional().default(le),nostromo:re.optional().default(se),browser:p.optional().default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,remoteCdpTimeoutMs:1500,profiles:{default:{cdpPort:9222,color:"#FF4500"}}})});function ue(e){const t=function(e){const t=new Set,o=/\$\{([^}]+)\}/g;let a;for(;null!==(a=o.exec(e));)t.add(a[1]);return Array.from(t)}(e),o=t.filter(e=>!process.env[e]);o.length>0&&b.warn(`Missing environment variables referenced in config: ${o.join(", ")}. Add them to .env or export them before starting.`)}function de(e){if("string"==typeof e)return e.replace(/\$\{([^}]+)\}/g,(e,t)=>process.env[t]??"");if(Array.isArray(e))return e.map(de);if(null!==e&&"object"==typeof e){const t={};for(const[o,a]of Object.entries(e))t[o]=de(a);return t}return e}const ce={channels:{responses:{enabled:!0,port:3004}},stt:{enabled:!1},tts:Y,memory:ee,agent:{...te,permissionMode:"bypassPermissions",allowedTools:["Read","Grep","Bash","WebSearch","Glob","Write","Edit","WebFetch","Task","Skill"]},cron:le,nostromo:se};export function loadConfig(n){const l=n??r(process.cwd(),"config.yaml"),i=r(process.cwd(),".env");if(o(i)&&(u({path:i}),b.info(`Loaded .env from ${i}`)),!o(l)){const e="# GrabMeABeer Configuration\n# Configure channels and settings via Nostromo: http://localhost:3001\n\n"+c({gmabPath:"~/gmab",...ce});t(l,e,"utf-8")}const f=e(l,"utf-8");ue(f);const p=de(d(f)),m=ie.parse(p);if(h=process.env.GMAB_PATH?g(process.env.GMAB_PATH):g(m.gmabPath),y=s(h,"data"),a(y,{recursive:!0}),!m.timezone){m.timezone=Intl.DateTimeFormat().resolvedOptions().timeZone;try{const o=d(e(l,"utf-8"))??{};o.timezone=m.timezone,t(l,c(o),"utf-8"),b.info(`Timezone auto-detected and saved: ${m.timezone}`)}catch(e){}}return function(e){a(y,{recursive:!0});const t=process.env.WORKSPACE_PATH??e.agent.workspacePath;e.agent.workspacePath=r(g(t)),a(e.agent.workspacePath,{recursive:!0});const o=s(y,"cron");a(o,{recursive:!0});const n=e.cron.storePath.trim()?r(g(e.cron.storePath)):s(o,"jobs.json");return{...e,gmabPath:h,dataDir:y,dbPath:s(y,"core.db"),memoryDir:s(y,"memory"),cronStorePath:n}}(m)}export function loadRawConfig(t){const a=t??r(process.cwd(),"config.yaml");if(!o(a))return{};const n=e(a,"utf-8");return d(n)??{}}export function resolveModelEntry(e,t){if(!t)return;const o=e.models;if(!o?.length)return;const a=t.indexOf(":");if(a>=0){const e=t.substring(0,a),n=t.substring(a+1);return o.find(t=>t.name===e&&t.id===n)}return o.find(e=>e.name===t)??o.find(e=>e.id===t)}export function modelRefName(e){if(!e)return e;const t=e.indexOf(":");return t>=0?e.substring(0,t):e}export function resolveModelId(e,t){if(!t)return t;const o=resolveModelEntry(e,t);return o?o.id:t}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Bot as t,InputFile as e}from"grammy";import{validateChannelUser as i}from"../../../auth/auth-middleware.js";import{parseMediaLines as n}from"../../../utils/media-response.js";import{markdownToTelegramHtmlChunks as a}from"../../../utils/telegram-format.js";import{createLogger as o}from"../../../utils/logger.js";import{sendMessageTelegram as s}from"./send.js";import{reactMessageTelegram as r,resolveReactionLevel as c}from"./reactions.js";import{editMessageTelegram as l,deleteMessageTelegram as h}from"./edit-delete.js";import{sendStickerTelegram as d,cacheSticker as g}from"./stickers.js";import{validateButtonsForChatId as f}from"./inline-buttons.js";const m=o("Telegram");export class TelegramChannel{name="telegram";bot;config;accountId;tokenDb;typingIntervals=new Map;inflightTyping;inflightCount=new Map;constructor(e,i,n,a=!0){this.config=e,this.accountId=i,this.tokenDb=n,this.inflightTyping=a,this.bot=new t(e.botToken)}async start(t){this.bot.on("message",e=>{this.handleIncoming(e,t)}),this.bot.on("callback_query:data",e=>{const i=e.callbackQuery.data,n=String(e.from?.id??"unknown"),a=String(e.chat?.id??e.callbackQuery.message?.chat?.id??"unknown"),o=e.from?.username;e.answerCallbackQuery().catch(()=>{}),this.startTypingInterval(a);t({chatId:a,userId:n,channelName:"telegram",text:i,attachments:[],username:o,rawContext:e}).then(async t=>{t&&t.trim()&&await this.sendText(a,t)}).catch(t=>{m.error(`Error handling callback from ${n}: ${t}`),this.stopTypingInterval(a)})}),m.info("Starting Telegram bot..."),this.bot.start({onStart:t=>{m.info(`Telegram bot started: @${t.username}`)}}).catch(t=>{String(t).includes("Aborted delay")?m.debug("Telegram polling stopped"):m.error(`Telegram bot polling error: ${t}`)})}async sendText(t,e){try{await s(t,e,{token:this.config.botToken,accountId:this.accountId,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}catch(t){throw m.error(`Failed to send message: ${t}`),t}await this.resendTypingIfActive(t)}async setTyping(t){this.typingIntervals.has(t)?await this.bot.api.sendChatAction(t,"typing").catch(()=>{}):this.startTypingInterval(t)}async resendTypingIfActive(t){this.typingIntervals.has(t)&&await this.bot.api.sendChatAction(t,"typing").catch(()=>{})}async clearTyping(t){this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async releaseTyping(t){this.stopTypingInterval(t)}startTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??0)+1;if(this.inflightCount.set(t,e),e>1)return}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.bot.api.sendChatAction(t,"typing").catch(()=>{});const i=setInterval(()=>{const e=this.inflightCount.get(t)??0;!this.inflightTyping||e>0?this.bot.api.sendChatAction(t,"typing").catch(()=>{}):(clearInterval(i),this.typingIntervals.delete(t))},4e3);this.typingIntervals.set(t,i)}stopTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??1)-1;return void(e>0?this.inflightCount.set(t,e):this.inflightCount.delete(t))}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async sendButtons(t,e,i){const n=i.map(t=>t.map(t=>({text:t.text,callback_data:t.callbackData??t.text})));try{f(n,this.config,t)}catch(i){return m.warn(`Button validation failed: ${i}, sending without buttons`),void await this.sendText(t,e)}await s(t,e,{token:this.config.botToken,accountId:this.accountId,buttons:n,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry}),await this.resendTypingIfActive(t)}async sendAudio(t,i,n){const a=new e(i);n?await this.bot.api.sendVoice(t,a):await this.bot.api.sendAudio(t,a),await this.resendTypingIfActive(t)}async reactMessage(t,e,i,n){const{level:a}=c(this.config);"off"!==a?await r(t,e,i,this.config.botToken,{remove:n}):m.debug("Reactions disabled for this account")}async editMessage(t,e,i,n){const a=n?.map(t=>({text:t.text,callback_data:t.callbackData??t.text}));await l(t,e,i,this.config.botToken,{buttons:a?[a]:void 0,linkPreview:this.config.linkPreview})}async deleteMessage(t,e){await h(t,e,this.config.botToken,this.config.retry)}async sendSticker(t,e){await d(t,e,this.config.botToken,{retry:this.config.retry}),await this.resendTypingIfActive(t)}async stop(){for(const[,t]of this.typingIntervals)clearInterval(t);this.typingIntervals.clear(),this.inflightCount.clear();try{await this.bot.stop(),m.info("Telegram bot stopped"),await new Promise(t=>setTimeout(t,100))}catch(t){m.warn(`Error stopping Telegram bot: ${t}`)}}async handleIncoming(t,a){const o=String(t.from?.id??"unknown"),s=String(t.chat?.id??"unknown"),r=t.from?.username,c=i(this.tokenDb,o,"telegram",this.config.dmPolicy,this.config.allowFrom);if(!c.authorized)return m.warn(`Unauthorized message from ${o} (@${r})`),void await t.reply(c.reason??"Not authorized.");this.startTypingInterval(s);try{const i=await this.buildIncomingMessage(t,s,o,r),c=await a(i),{textParts:l,mediaEntries:h}=n(c);for(const i of h)try{const n=new e(i.path);i.asVoice?await t.replyWithVoice(n):await t.replyWithAudio(n)}catch(t){m.error(`Failed to send audio: ${t}`)}const d=l.join("\n").trim();d&&await this.sendChunked(t,d),
|
|
1
|
+
import{Bot as t,InputFile as e}from"grammy";import{validateChannelUser as i}from"../../../auth/auth-middleware.js";import{parseMediaLines as n}from"../../../utils/media-response.js";import{markdownToTelegramHtmlChunks as a}from"../../../utils/telegram-format.js";import{createLogger as o}from"../../../utils/logger.js";import{sendMessageTelegram as s}from"./send.js";import{reactMessageTelegram as r,resolveReactionLevel as c}from"./reactions.js";import{editMessageTelegram as l,deleteMessageTelegram as h}from"./edit-delete.js";import{sendStickerTelegram as d,cacheSticker as g}from"./stickers.js";import{validateButtonsForChatId as f}from"./inline-buttons.js";const m=o("Telegram");export class TelegramChannel{name="telegram";bot;config;accountId;tokenDb;typingIntervals=new Map;inflightTyping;inflightCount=new Map;constructor(e,i,n,a=!0){this.config=e,this.accountId=i,this.tokenDb=n,this.inflightTyping=a,this.bot=new t(e.botToken)}async start(t){this.bot.on("message",e=>{this.handleIncoming(e,t)}),this.bot.on("callback_query:data",e=>{const i=e.callbackQuery.data,n=String(e.from?.id??"unknown"),a=String(e.chat?.id??e.callbackQuery.message?.chat?.id??"unknown"),o=e.from?.username;e.answerCallbackQuery().catch(()=>{}),this.startTypingInterval(a);t({chatId:a,userId:n,channelName:"telegram",text:i,attachments:[],username:o,rawContext:e}).then(async t=>{t&&t.trim()&&await this.sendText(a,t)}).catch(t=>{m.error(`Error handling callback from ${n}: ${t}`),this.stopTypingInterval(a)})}),m.info("Starting Telegram bot..."),this.bot.start({onStart:t=>{m.info(`Telegram bot started: @${t.username}`)}}).catch(t=>{String(t).includes("Aborted delay")?m.debug("Telegram polling stopped"):m.error(`Telegram bot polling error: ${t}`)})}async sendText(t,e){try{await s(t,e,{token:this.config.botToken,accountId:this.accountId,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}catch(t){throw m.error(`Failed to send message: ${t}`),t}await this.resendTypingIfActive(t)}async setTyping(t){this.typingIntervals.has(t)?await this.bot.api.sendChatAction(t,"typing").catch(()=>{}):this.startTypingInterval(t)}async resendTypingIfActive(t){this.typingIntervals.has(t)&&await this.bot.api.sendChatAction(t,"typing").catch(()=>{})}async clearTyping(t){this.inflightCount.delete(t);const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async releaseTyping(t){this.stopTypingInterval(t)}startTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??0)+1;if(this.inflightCount.set(t,e),e>1)return}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t)),this.bot.api.sendChatAction(t,"typing").catch(()=>{});const i=setInterval(()=>{const e=this.inflightCount.get(t)??0;!this.inflightTyping||e>0?this.bot.api.sendChatAction(t,"typing").catch(()=>{}):(clearInterval(i),this.typingIntervals.delete(t))},4e3);this.typingIntervals.set(t,i)}stopTypingInterval(t){if(this.inflightTyping){const e=(this.inflightCount.get(t)??1)-1;return void(e>0?this.inflightCount.set(t,e):this.inflightCount.delete(t))}const e=this.typingIntervals.get(t);e&&(clearInterval(e),this.typingIntervals.delete(t))}async sendButtons(t,e,i){const n=i.map(t=>t.map(t=>({text:t.text,callback_data:t.callbackData??t.text})));try{f(n,this.config,t)}catch(i){return m.warn(`Button validation failed: ${i}, sending without buttons`),void await this.sendText(t,e)}await s(t,e,{token:this.config.botToken,accountId:this.accountId,buttons:n,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry}),await this.resendTypingIfActive(t)}async sendAudio(t,i,n){const a=new e(i);n?await this.bot.api.sendVoice(t,a):await this.bot.api.sendAudio(t,a),await this.resendTypingIfActive(t)}async reactMessage(t,e,i,n){const{level:a}=c(this.config);"off"!==a?await r(t,e,i,this.config.botToken,{remove:n}):m.debug("Reactions disabled for this account")}async editMessage(t,e,i,n){const a=n?.map(t=>({text:t.text,callback_data:t.callbackData??t.text}));await l(t,e,i,this.config.botToken,{buttons:a?[a]:void 0,linkPreview:this.config.linkPreview})}async deleteMessage(t,e){await h(t,e,this.config.botToken,this.config.retry)}async sendSticker(t,e){await d(t,e,this.config.botToken,{retry:this.config.retry}),await this.resendTypingIfActive(t)}async stop(){for(const[,t]of this.typingIntervals)clearInterval(t);this.typingIntervals.clear(),this.inflightCount.clear();try{await this.bot.stop(),m.info("Telegram bot stopped"),await new Promise(t=>setTimeout(t,100))}catch(t){m.warn(`Error stopping Telegram bot: ${t}`)}}async handleIncoming(t,a){const o=String(t.from?.id??"unknown"),s=String(t.chat?.id??"unknown"),r=t.from?.username,c=i(this.tokenDb,o,"telegram",this.config.dmPolicy,this.config.allowFrom);if(!c.authorized)return m.warn(`Unauthorized message from ${o} (@${r})`),void await t.reply(c.reason??"Not authorized.");this.startTypingInterval(s);try{const i=await this.buildIncomingMessage(t,s,o,r),c=await a(i),{textParts:l,mediaEntries:h}=n(c);for(const i of h)try{const n=new e(i.path);i.asVoice?await t.replyWithVoice(n):await t.replyWithAudio(n)}catch(t){m.error(`Failed to send audio: ${t}`)}const d=l.join("\n").trim();d&&await this.sendChunked(t,d),this.stopTypingInterval(s)}catch(e){m.error(`Error handling message from ${o}: ${e}`),this.stopTypingInterval(s),await t.reply("An error occurred while processing your message.").catch(()=>{})}}async buildIncomingMessage(t,e,i,n){const a=t.message,o=[];let s=a.text??a.caption??void 0;if(a.photo&&a.photo.length>0){const e=a.photo[a.photo.length-1];o.push({type:"image",mimeType:"image/jpeg",fileSize:e.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,e.file_id)})}if(a.voice&&o.push({type:"voice",mimeType:a.voice.mime_type??"audio/ogg",duration:a.voice.duration,fileSize:a.voice.file_size,getBuffer:()=>this.downloadFile(t,a.voice.file_id)}),a.audio&&o.push({type:"audio",mimeType:a.audio.mime_type??"audio/mpeg",fileName:a.audio.file_name,duration:a.audio.duration,fileSize:a.audio.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.audio.file_id)}),a.document&&o.push({type:"document",mimeType:a.document.mime_type,fileName:a.document.file_name,fileSize:a.document.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.document.file_id)}),a.video&&o.push({type:"video",mimeType:a.video.mime_type??"video/mp4",fileName:a.video.file_name,duration:a.video.duration,fileSize:a.video.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(t,a.video.file_id)}),a.video_note&&o.push({type:"video_note",mimeType:"video/mp4",duration:a.video_note.duration,fileSize:a.video_note.file_size,getBuffer:()=>this.downloadFile(t,a.video_note.file_id)}),a.sticker){if(this.config.actions?.sticker)try{g({fileId:a.sticker.file_id,fileUniqueId:a.sticker.file_unique_id,emoji:a.sticker.emoji,setName:a.sticker.set_name,description:a.sticker.emoji?`${a.sticker.emoji} sticker${a.sticker.set_name?` from ${a.sticker.set_name}`:""}`:a.sticker.set_name??"sticker"})}catch(t){m.warn(`Failed to cache sticker: ${t}`)}o.push({type:"sticker",mimeType:a.sticker.is_animated?"application/x-tgsticker":a.sticker.is_video?"video/webm":"image/webp",metadata:{emoji:a.sticker.emoji,setName:a.sticker.set_name},getBuffer:()=>this.downloadFile(t,a.sticker.file_id)})}return a.location&&o.push({type:"location",metadata:{latitude:a.location.latitude,longitude:a.location.longitude},getBuffer:async()=>Buffer.alloc(0)}),a.contact&&o.push({type:"contact",metadata:{phoneNumber:a.contact.phone_number,firstName:a.contact.first_name,lastName:a.contact.last_name,userId:a.contact.user_id},getBuffer:async()=>Buffer.alloc(0)}),{chatId:e,userId:i,channelName:"telegram",text:s,attachments:o,username:n,rawContext:t}}async downloadFile(t,e){const i=await t.api.getFile(e),n=`https://api.telegram.org/file/bot${this.config.botToken}/${i.file_path}`,a=await fetch(n);if(!a.ok)throw new Error(`Failed to download file: ${a.statusText}`);return Buffer.from(await a.arrayBuffer())}async sendChunked(t,e){const i=a(e,4096);for(const n of i)try{await t.reply(n,{parse_mode:"HTML"})}catch{const i=p(e,4096);for(const e of i)await t.reply(e);break}}}function p(t,e){if(t.length<=e)return[t];const i=[];let n=t;for(;n.length>0;){if(n.length<=e){i.push(n);break}let t=n.lastIndexOf("\n",e);t<=0&&(t=e),i.push(n.slice(0,t)),n=n.slice(t).trimStart()}return i}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L0 Abstract Generator — generates short (≤80 char) summaries for memory chunks.
|
|
3
|
+
*
|
|
4
|
+
* Two backends:
|
|
5
|
+
* 1. Haiku (default): via Claude Agent SDK query() — no API key needed, uses subscription
|
|
6
|
+
* 2. OpenAI-compatible: via REST API — requires OPENAI_API_KEY env var
|
|
7
|
+
* Optionally set OPENAI_BASE_URL for OpenAI-compatible providers (e.g. local LLMs).
|
|
8
|
+
*
|
|
9
|
+
* Config (config.yaml → memory.l0):
|
|
10
|
+
* enabled: true # enable/disable L0 generation + retrieval
|
|
11
|
+
* model: "" # if empty → Haiku; if set → OpenAI model (e.g. "gpt-4o-mini")
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const gen = new L0Generator({ enabled: true });
|
|
15
|
+
* await gen.start(indexDb); // starts backfill of chunks with l0 IS NULL
|
|
16
|
+
* await gen.generate(indexDb); // process pending chunks (called after indexing)
|
|
17
|
+
* gen.stop();
|
|
18
|
+
*/
|
|
19
|
+
import type Database from "better-sqlite3";
|
|
20
|
+
export interface L0Config {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
model: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const L0_DEFAULT: L0Config;
|
|
25
|
+
export declare class L0Generator {
|
|
26
|
+
private config;
|
|
27
|
+
private backfillTimer;
|
|
28
|
+
private processing;
|
|
29
|
+
private stopped;
|
|
30
|
+
constructor(config: L0Config);
|
|
31
|
+
get enabled(): boolean;
|
|
32
|
+
start(db: Database.Database): Promise<void>;
|
|
33
|
+
stop(): void;
|
|
34
|
+
private migrateSchema;
|
|
35
|
+
generate(db: Database.Database): Promise<number>;
|
|
36
|
+
private callHaiku;
|
|
37
|
+
private callOpenAI;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=l0-generator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createLogger as e}from"../utils/logger.js";const t=e("L0Generator");export const L0_DEFAULT={enabled:!0,model:""};export class L0Generator{config;backfillTimer=null;processing=!1;stopped=!0;constructor(e){this.config=e}get enabled(){return this.config.enabled}async start(e){if(!this.config.enabled)return;this.stopped=!1,this.migrateSchema(e);const n=e.prepare("SELECT COUNT(*) as c FROM chunks WHERE l0 IS NULL").get().c;n>0?(t.info(`L0 backfill: ${n} chunks pending`),await this.generate(e),this.stopped||(this.backfillTimer=setInterval(async()=>{if(0===e.prepare("SELECT COUNT(*) as c FROM chunks WHERE l0 IS NULL").get().c)return t.info("L0 backfill complete"),void(this.backfillTimer&&(clearInterval(this.backfillTimer),this.backfillTimer=null));await this.generate(e)},3e4))):t.info("L0: all chunks have abstracts")}stop(){this.stopped=!0,this.backfillTimer&&(clearInterval(this.backfillTimer),this.backfillTimer=null)}migrateSchema(e){e.prepare("PRAGMA table_info(chunks)").all().some(e=>"l0"===e.name)||(e.exec("ALTER TABLE chunks ADD COLUMN l0 TEXT"),t.info("Migrated: added l0 column to chunks table"))}async generate(e){if(!this.config.enabled||this.processing||this.stopped)return 0;this.processing=!0;const n=e.prepare("SELECT id, content FROM chunks WHERE l0 IS NULL ORDER BY id LIMIT ?").all(20);if(0===n.length)return this.processing=!1,0;try{const s=e.prepare("UPDATE chunks SET l0 = ? WHERE id = ?"),r=[];for(const e of n){const n=e.content.trim();n.length<50?(s.run("",e.id),t.debug(`L0: chunk ${e.id} too short (${n.length} chars), skipped`)):r.push(e)}if(0===r.length)return t.info(`L0: batch of ${n.length} chunks all too short, skipped`),0;const i=`You are a memory indexer. For each chunk, write ONE line (max 80 chars) summarizing the key topic. Be specific — include names, numbers, decisions. No filler words. Output ONLY the lines, one per chunk, format: "ID: summary"\n\n${r.map(e=>{return`CHUNK ${e.id}:\n"${t=e.content.slice(0,800),t.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,"�")}"`;var t}).join("\n\n")}`;let o;if(o=this.config.model?await this.callOpenAI(i):await this.callHaiku(i),this.stopped)return 0;const a=function(e){const t=new Map;for(const n of e.split("\n")){const e=n.match(/^(\d+):\s*(.+)/);if(e){const n=parseInt(e[1],10);let s=e[2].trim();s.length>100&&(s=s.slice(0,100)),t.set(n,s)}}return t}(o),c=e.prepare("UPDATE chunks SET l0 = ? WHERE id = ?"),l=e.transaction(()=>{let e=0;for(const n of r){const s=a.get(n.id);s?(c.run(s,n.id),e++):(t.debug(`L0: no abstract returned for chunk ${n.id}, marking as skipped`),c.run("",n.id))}return e}),u=l();return t.info(`L0: generated ${u}/${r.length} abstracts`),u}catch(s){const r=s;t.error(`L0 generation error: ${r?.message??s}`);try{const s=e.prepare("UPDATE chunks SET l0 = '' WHERE id = ?");for(const e of n)s.run(e.id);t.warn(`L0: skipped ${n.length} chunks due to error`)}catch{}return 0}finally{this.processing=!1}}async callHaiku(e){const{query:n}=await import("@anthropic-ai/claude-agent-sdk"),s={...process.env};delete s.ANTHROPIC_BETAS,delete s.CLAUDE_CODE_EXTRA_BODY,delete s.CLAUDECODE;let r="";for await(const i of n({prompt:e,options:{model:"haiku",env:s,maxTurns:1,tools:[],persistSession:!1}}))if("result"===i.type){const e=i;("error_during_execution"===e.subtype||e.is_error)&&t.error(`L0 Haiku result error: subtype=${e.subtype}, errors=${JSON.stringify(e.errors)}, result=${e.result}`),r=e.result??""}else"system"===i.type&&t.debug(`L0 Haiku system: ${JSON.stringify(i).slice(0,300)}`);return r}async callOpenAI(e){const n=process.env.OPENAI_API_KEY;if(!n)throw new Error("L0: OPENAI_API_KEY not set but memory.l0.model requires it");const s=(process.env.OPENAI_BASE_URL||"https://api.openai.com/v1").replace(/\/+$/,""),r=await fetch(`${s}/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n}`},body:JSON.stringify({model:this.config.model,max_tokens:500,temperature:.3,messages:[{role:"user",content:e}]})});if(!r.ok){const e=await r.text().catch(()=>"(no body)");throw new Error(`OpenAI API ${r.status}: ${e.slice(0,300)}`)}const i=await r.json();return i.usage&&t.debug(`L0 OpenAI usage: in=${i.usage.prompt_tokens} out=${i.usage.completion_tokens}`),i.choices?.[0]?.message?.content??""}}
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
* 3. Dense: embed query → HNSW searchKnn → top 20
|
|
22
22
|
* 4. RRF fusion (k=60) → return top N
|
|
23
23
|
*/
|
|
24
|
+
import { type L0Config } from "./l0-generator.js";
|
|
24
25
|
export interface MemorySearchResult {
|
|
26
|
+
chunkId: number;
|
|
25
27
|
path: string;
|
|
26
28
|
sessionKey: string;
|
|
27
29
|
snippet: string;
|
|
@@ -42,6 +44,7 @@ export interface MemorySearchOptions {
|
|
|
42
44
|
maxSnippetChars: number;
|
|
43
45
|
maxInjectedChars: number;
|
|
44
46
|
rrfK: number;
|
|
47
|
+
l0: L0Config;
|
|
45
48
|
}
|
|
46
49
|
export declare class MemorySearch {
|
|
47
50
|
private memoryDir;
|
|
@@ -55,6 +58,7 @@ export declare class MemorySearch {
|
|
|
55
58
|
private indexing;
|
|
56
59
|
private embedding;
|
|
57
60
|
private stopped;
|
|
61
|
+
private l0Generator;
|
|
58
62
|
private searchDb;
|
|
59
63
|
private searchHnsw;
|
|
60
64
|
private openai;
|
|
@@ -83,6 +87,13 @@ export declare class MemorySearch {
|
|
|
83
87
|
path: string;
|
|
84
88
|
content: string;
|
|
85
89
|
};
|
|
90
|
+
expandChunks(ids: number[]): Array<{
|
|
91
|
+
chunkId: number;
|
|
92
|
+
content: string;
|
|
93
|
+
path: string;
|
|
94
|
+
role: string;
|
|
95
|
+
timestamp: string;
|
|
96
|
+
}>;
|
|
86
97
|
private bm25Search;
|
|
87
98
|
private denseSearch;
|
|
88
99
|
private startWatcher;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync as e,readdirSync as t,readFileSync as n,statSync as s,copyFileSync as i,renameSync as r,unlinkSync as h,watch as o}from"node:fs";import{join as a,relative as c,basename as d,dirname as l}from"node:path";import m from"better-sqlite3";import p from"openai";import u from"hnswlib-node";const{HierarchicalNSW:b}=u;import{createLogger as E}from"../utils/logger.js";const f=E("MemorySearch"),g="cosine";export class MemorySearch{memoryDir;dataDir;opts;indexDb=null;indexHnsw=null;watcher=null;debounceTimer=null;embedTimer=null;indexing=!1;embedding=!1;stopped=!0;searchDb=null;searchHnsw=null;openai=null;indexDbPath;searchDbPath;searchNextDbPath;indexHnswPath;searchHnswPath;searchNextHnswPath;constructor(e,t,n){this.memoryDir=e,this.dataDir=t,this.opts=n,this.indexDbPath=a(t,"memory-index.db"),this.searchDbPath=a(t,"memory-search.db"),this.searchNextDbPath=a(t,"memory-search-next.db"),this.indexHnswPath=a(t,"memory-vectors.hnsw"),this.searchHnswPath=a(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=a(t,"memory-vectors-search-next.hnsw"),this.isOpenAI()&&(this.openai=new p({apiKey:n.apiKey,...n.baseURL?{baseURL:n.baseURL}:{}}))}isOpenAI(){return(this.opts.baseURL||"https://api.openai.com/v1").includes("openai.com")}getMaxInjectedChars(){return this.opts.maxInjectedChars}async start(){f.info("Starting memory search engine..."),this.stopped=!1,this.indexDb=new m(this.indexDbPath),this.indexDb.pragma("journal_mode = WAL"),this.migrateEmbeddingsTable(),this.indexDb.exec("\n CREATE TABLE IF NOT EXISTS documents (\n path TEXT PRIMARY KEY,\n mtime_ms INTEGER NOT NULL,\n size INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS chunks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n doc_path TEXT NOT NULL,\n chunk_idx INTEGER NOT NULL,\n role TEXT NOT NULL DEFAULT '',\n timestamp TEXT NOT NULL DEFAULT '',\n session_key TEXT NOT NULL DEFAULT '',\n content TEXT NOT NULL,\n UNIQUE(doc_path, chunk_idx)\n );\n\n CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(\n content,\n content='chunks',\n content_rowid='id',\n tokenize='porter unicode61'\n );\n\n CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n -- Lookup table only (no vector BLOB) — vectors live in HNSW index\n CREATE TABLE IF NOT EXISTS embeddings (\n chunk_id INTEGER PRIMARY KEY\n );\n\n -- Metadata for detecting config changes (model, dimensions)\n CREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n"),this.checkEmbeddingConfigChange(),this.initIndexHnsw(),await this.indexFiles(),await this.embedPending(),this.publishSnapshot(),this.maybeSwap(),this.startWatcher(),this.opts.embedIntervalMs>0&&(this.embedTimer=setInterval(()=>{this.embedPending().catch(e=>f.error(`Embed cycle error: ${e}`))},this.opts.embedIntervalMs)),f.info("Memory search engine started")}stop(){this.stopped=!0,this.watcher&&(this.watcher.close(),this.watcher=null),this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.embedTimer&&(clearInterval(this.embedTimer),this.embedTimer=null),this.indexDb&&(this.indexDb.close(),this.indexDb=null),this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.indexHnsw=null,this.searchHnsw=null,f.info("Memory search engine stopped")}migrateEmbeddingsTable(){if(!this.indexDb)return;if(this.indexDb.prepare("PRAGMA table_info(embeddings)").all().some(e=>"vector"===e.name)){f.info("Migrating: dropping old embeddings table (had vector BLOB). All embeddings will be re-created via HNSW."),this.indexDb.exec("DROP TABLE IF EXISTS embeddings");try{h(this.indexHnswPath)}catch{}}}checkEmbeddingConfigChange(){if(!this.indexDb)return;const e=this.indexDb.prepare("SELECT value FROM meta WHERE key = ?"),t=this.indexDb.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"),n=e.get("embedding_model")?.value,s=e.get("embedding_dimensions")?.value,i=this.opts.embeddingModel,r=String(this.opts.embeddingDimensions),o=void 0!==n&&n!==i,a=void 0!==s&&s!==r;if(o||a){const e=[];o&&e.push(`model: ${n} → ${i}`),a&&e.push(`dimensions: ${s} → ${r}`),f.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{h(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",r)}initIndexHnsw(){const t=this.opts.embeddingDimensions;if(this.indexHnsw=new b(g,t),e(this.indexHnswPath))try{this.indexHnsw.readIndexSync(this.indexHnswPath,!0),f.info(`Loaded HNSW index: ${this.indexHnsw.getCurrentCount()} points`)}catch(e){f.warn(`Failed to load HNSW index, creating new: ${e}`),this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0})}else this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0}),f.info("Created new HNSW index")}ensureHnswCapacity(e){if(!this.indexHnsw)return;const t=this.indexHnsw.getMaxElements();if(this.indexHnsw.getCurrentCount()+e>t){const n=Math.max(2*t,this.indexHnsw.getCurrentCount()+e+1e3);this.indexHnsw.resizeIndex(n),f.info(`Resized HNSW index: ${t} → ${n}`)}}async search(e,t){const n=t??this.opts.maxResults;if(this.maybeSwap(),!this.searchDb)return f.warn("Search DB not available"),[];const s=this.bm25Search(e,20);let i=[];try{const t=await this.embedText(e);t&&this.searchHnsw&&this.searchHnsw.getCurrentCount()>0&&(i=this.denseSearch(t,20))}catch(e){f.warn(`Dense search failed, using BM25 only: ${e}`)}const r=function(e,t,n){const s=new Map;for(let t=0;t<e.length;t++){const{id:i}=e[t];s.set(i,(s.get(i)??0)+1/(n+t+1))}for(let e=0;e<t.length;e++){const{id:i}=t[e];s.set(i,(s.get(i)??0)+1/(n+e+1))}const i=Array.from(s.entries()).map(([e,t])=>({id:e,score:t})).sort((e,t)=>t.score-e.score);return i}(s,i,this.opts.rrfK),h=Math.min(r.length,40),o=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content FROM chunks WHERE id = ?"),a=[];for(let e=0;e<h;e++){const{id:t,score:n}=r[e],s=o.get(t);s&&a.push({id:t,score:n,row:s})}const c=function(e,t,n){if(e.length<=t)return e;const s=e.map(e=>function(e){const t=new Set;for(const n of e.toLowerCase().matchAll(/\b\w{2,}\b/g))t.add(n[0]);return t}(e.row.content)),i=e[0]?.score??1,r=e[e.length-1]?.score??0,h=i-r||1,o=[],a=new Set(e.map((e,t)=>t));o.push(0),a.delete(0);for(;o.length<t&&a.size>0;){let t=-1,i=-1/0;for(const c of a){const a=(e[c].score-r)/h;let d=0;for(const e of o){const t=w(s[c],s[e]);t>d&&(d=t)}const l=n*a-(1-n)*d;l>i&&(i=l,t=c)}if(t<0)break;o.push(t),a.delete(t)}return o.map(t=>e[t])}(a,n,.7),d=[];for(const{score:e,row:t}of c){const n=t.content.length>this.opts.maxSnippetChars?t.content.slice(0,this.opts.maxSnippetChars)+"...":t.content;d.push({path:t.doc_path,sessionKey:t.session_key,snippet:n,score:e,role:t.role,timestamp:t.timestamp})}return f.info(`Search "${e.slice(0,60)}": ${d.length} results (sparse=${s.length}, dense=${i.length}, candidates=${a.length}, mmr=${c.length})`),d}readFile(t,s,i){const r=a(this.memoryDir,t);if(!e(r))return{path:t,content:`[File not found: ${t}]`};const h=n(r,"utf-8"),o=h.split("\n");if(void 0!==s||void 0!==i){const e=Math.max(0,(s??1)-1),n=i??o.length;return{path:t,content:o.slice(e,e+n).join("\n")}}return{path:t,content:h}}bm25Search(e,t){if(!this.searchDb)return[];try{const n=function(e){const t=e.replace(/[^\w\s]/g," ").split(/\s+/).filter(e=>e.length>0);return 0===t.length?"":t.map(e=>`"${e}"`).join(" OR ")}(e);if(!n)return[];return this.searchDb.prepare("\n SELECT chunks.id, bm25(chunks_fts) as rank\n FROM chunks_fts\n JOIN chunks ON chunks.id = chunks_fts.rowid\n WHERE chunks_fts MATCH ?\n ORDER BY rank\n LIMIT ?\n ").all(n,t).map(e=>({id:e.id,score:-e.rank}))}catch(e){return f.warn(`BM25 search error: ${e}`),[]}}denseSearch(e,t){if(!this.searchHnsw||0===this.searchHnsw.getCurrentCount())return[];const n=Math.min(t,this.searchHnsw.getCurrentCount()),s=this.searchHnsw.searchKnn(Array.from(e),n);return s.neighbors.map((e,t)=>({id:e,score:1-s.distances[t]}))}startWatcher(){if(e(this.memoryDir))try{this.watcher=o(this.memoryDir,{recursive:!0},(e,t)=>{this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.runIndexCycle()},this.opts.updateDebounceMs)})}catch(e){f.warn(`Could not start file watcher: ${e}`)}}async runIndexCycle(){if(!this.indexing){this.indexing=!0;try{await this.indexFiles(),this.publishSnapshot()}catch(e){f.error(`Index cycle error: ${e}`)}finally{this.indexing=!1}}}async indexFiles(){if(!this.indexDb||!e(this.memoryDir))return;const i=function(e){const n=[];function i(r){let h;try{h=t(r,{withFileTypes:!0})}catch{return}for(const t of h){const h=a(r,t.name);if(t.isDirectory())i(h);else if(t.name.endsWith(".md"))try{const t=s(h),i=c(e,h),r=d(l(h));n.push({fullPath:h,relPath:i,sessionKey:r===d(e)?"":r,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}return i(e),n}(this.memoryDir);let r=0;const h=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),o=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),m=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),p=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),u=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),b=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),E=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),g=new Set(i.map(e=>e.relPath));for(const e of E)if(!g.has(e)){const t=p.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e),m.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),f.debug(`Removed deleted file from index: ${e}`)}for(const e of i){const t=o.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){r+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const s=x(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),i=p.all(e.relPath);for(const{id:e}of i)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e.relPath),m.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<s.length;t++){const n=s[t];b.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content)}h.run(e.relPath,e.mtimeMs,e.size)})(),r+=s.length,f.debug(`Indexed ${e.relPath}: ${s.length} chunks`)}f.info(`Indexed ${r} chunks from ${i.length} files`)}async embedPending(){if(!this.stopped&&!this.embedding&&this.indexDb&&this.indexHnsw){this.embedding=!0;try{const e=this.indexDb.prepare("\n SELECT c.id, c.content FROM chunks c\n LEFT JOIN embeddings e ON e.chunk_id = c.id\n WHERE e.chunk_id IS NULL\n ").all();if(0===e.length)return;f.info(`Embedding ${e.length} pending chunks...`),this.ensureHnswCapacity(e.length);const t=this.indexDb.prepare("INSERT OR REPLACE INTO embeddings (chunk_id) VALUES (?)");for(let n=0;n<e.length;n+=100){if(this.stopped)return void f.warn("embedPending aborted: engine stopped");const s=e.slice(n,n+100),i=s.map(e=>this.applyPrefix(this.opts.prefixDocument,e.content).slice(0,8e3)),r=this.opts.prefixDocument.trim();r&&f.debug(`Using prefixDocument (template: ${r}) → result sample: [${i[0].slice(0,80)}]`);try{let e;if(this.openai){const t=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:i,dimensions:this.opts.embeddingDimensions});e=t.data.sort((e,t)=>e.index-t.index).map(e=>e.embedding)}else e=await this.fetchEmbeddings(i);if(this.stopped||!this.indexDb||!this.indexHnsw)return void f.warn("embedPending aborted: engine stopped during embedding");this.indexDb.transaction(()=>{for(let n=0;n<e.length;n++)this.indexHnsw.addPoint(e[n],s[n].id,!0),t.run(s[n].id)})(),f.debug(`Embedded batch ${n/100+1}: ${s.length} chunks`)}catch(e){if(this.stopped)return;f.error(`Embedding batch failed: ${e}`)}}if(this.stopped||!this.indexHnsw)return;this.indexHnsw.writeIndexSync(this.indexHnswPath),this.publishSnapshot(),f.info(`Embedded ${e.length} chunks (HNSW: ${this.indexHnsw.getCurrentCount()} total points)`)}finally{this.embedding=!1}}}publishSnapshot(){if(!this.indexDb)return;const t=a(this.dataDir,".memory-search-next.tmp"),n=a(this.dataDir,".memory-vectors-search-next.tmp");try{this.indexDb.pragma("wal_checkpoint(TRUNCATE)"),i(this.indexDbPath,t),r(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),r(n,this.searchNextHnswPath)),f.debug("Published search snapshot (DB + HNSW)")}catch(e){f.error(`Failed to publish snapshot: ${e}`);try{h(t)}catch{}try{h(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,r(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&r(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new m(this.searchDbPath,{readonly:!0}),e(this.searchHnswPath)?(this.searchHnsw=new b(g,this.opts.embeddingDimensions),this.searchHnsw.readIndexSync(this.searchHnswPath),this.searchHnsw.setEf(50),f.debug(`Swapped to new search DB + HNSW (${this.searchHnsw.getCurrentCount()} points)`)):f.debug("Swapped to new search DB (no HNSW index yet)")}catch(t){f.error(`Failed to swap search DB: ${t}`);try{e(this.searchDbPath)&&(this.searchDb=new m(this.searchDbPath,{readonly:!0}))}catch{}}}applyPrefix(e,t){const n=e.trim();return n?n.replace(/\{content\}/g,()=>t):t}async fetchEmbeddings(e){const t=`${(this.opts.baseURL||"").replace(/\/+$/,"")}/embed`,n={"Content-Type":"application/json"};this.opts.apiKey&&(n.Authorization=`Bearer ${this.opts.apiKey}`);const s=await fetch(t,{method:"POST",headers:n,body:JSON.stringify({model:this.opts.embeddingModel,input:e})});if(!s.ok){const e=await s.text().catch(()=>"(no body)");throw new Error(`Embedding API ${s.status}: ${e.slice(0,300)}`)}const i=await s.json();if(Array.isArray(i.embeddings))return i.embeddings;throw new Error(`Unknown embedding response format. Keys: ${Object.keys(i).join(", ")}`)}async embedText(e){try{const t=this.applyPrefix(this.opts.prefixQuery,e),n=this.opts.prefixQuery.trim();if(n&&f.debug(`Using prefixQuery (template: ${n}) → result sample: [${t.slice(0,80)}]`),this.openai){const e=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:t.slice(0,8e3),dimensions:this.opts.embeddingDimensions});return new Float32Array(e.data[0].embedding)}const s=await this.fetchEmbeddings([t.slice(0,8e3)]);return new Float32Array(s[0])}catch(e){return f.error(`Failed to embed query: ${e}`),null}}}function x(e,t,n){const s=[],i=e.split(/^### /m);for(const e of i){if(!e.trim())continue;const t=e.match(/^(user|assistant)\s*\(([^)]+)\)\s*\n/),i=t?t[1]:"",r=t?t[2]:"",h=t?e.slice(t[0].length).trim():e.trim();if(!h)continue;const o=1500,a=100;if(h.length<=o)s.push({role:i,timestamp:r,sessionKey:n,content:h});else{let e=0;for(;e<h.length;){const t=Math.min(e+o,h.length),c=h.slice(e,t);if(s.push({role:i,timestamp:r,sessionKey:n,content:c}),e=t-a,e+a>=h.length)break}}}return s}function w(e,t){if(0===e.size&&0===t.size)return 0;let n=0;const s=e.size<=t.size?e:t,i=e.size<=t.size?t:e;for(const e of s)i.has(e)&&n++;const r=e.size+t.size-n;return 0===r?0:n/r}
|
|
1
|
+
import{existsSync as e,readdirSync as t,readFileSync as n,statSync as s,copyFileSync as i,renameSync as r,unlinkSync as h,watch as o}from"node:fs";import{join as a,relative as c,basename as d,dirname as l}from"node:path";import m from"better-sqlite3";import p from"openai";import u from"hnswlib-node";const{HierarchicalNSW:b}=u;import{createLogger as E}from"../utils/logger.js";import{L0Generator as f,L0_DEFAULT as g}from"./l0-generator.js";const x=E("MemorySearch"),w="cosine";export class MemorySearch{memoryDir;dataDir;opts;indexDb=null;indexHnsw=null;watcher=null;debounceTimer=null;embedTimer=null;indexing=!1;embedding=!1;stopped=!0;l0Generator;searchDb=null;searchHnsw=null;openai=null;indexDbPath;searchDbPath;searchNextDbPath;indexHnswPath;searchHnswPath;searchNextHnswPath;constructor(e,t,n){this.memoryDir=e,this.dataDir=t,this.opts=n,this.indexDbPath=a(t,"memory-index.db"),this.searchDbPath=a(t,"memory-search.db"),this.searchNextDbPath=a(t,"memory-search-next.db"),this.indexHnswPath=a(t,"memory-vectors.hnsw"),this.searchHnswPath=a(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=a(t,"memory-vectors-search-next.hnsw"),this.isOpenAI()&&(this.openai=new p({apiKey:n.apiKey,...n.baseURL?{baseURL:n.baseURL}:{}})),this.l0Generator=new f(n.l0??g)}isOpenAI(){return(this.opts.baseURL||"https://api.openai.com/v1").includes("openai.com")}getMaxInjectedChars(){return this.opts.maxInjectedChars}async start(){x.info("Starting memory search engine..."),this.stopped=!1,this.indexDb=new m(this.indexDbPath),this.indexDb.pragma("journal_mode = WAL"),this.migrateEmbeddingsTable(),this.indexDb.exec("\n CREATE TABLE IF NOT EXISTS documents (\n path TEXT PRIMARY KEY,\n mtime_ms INTEGER NOT NULL,\n size INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS chunks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n doc_path TEXT NOT NULL,\n chunk_idx INTEGER NOT NULL,\n role TEXT NOT NULL DEFAULT '',\n timestamp TEXT NOT NULL DEFAULT '',\n session_key TEXT NOT NULL DEFAULT '',\n content TEXT NOT NULL,\n UNIQUE(doc_path, chunk_idx)\n );\n\n CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(\n content,\n content='chunks',\n content_rowid='id',\n tokenize='porter unicode61'\n );\n\n CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n -- Lookup table only (no vector BLOB) — vectors live in HNSW index\n CREATE TABLE IF NOT EXISTS embeddings (\n chunk_id INTEGER PRIMARY KEY\n );\n\n -- Metadata for detecting config changes (model, dimensions)\n CREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n"),this.checkEmbeddingConfigChange(),this.initIndexHnsw(),await this.indexFiles(),await this.embedPending(),this.indexDb&&await this.l0Generator.start(this.indexDb),this.publishSnapshot(),this.maybeSwap(),this.startWatcher(),this.opts.embedIntervalMs>0&&(this.embedTimer=setInterval(()=>{this.embedPending().catch(e=>x.error(`Embed cycle error: ${e}`))},this.opts.embedIntervalMs)),x.info("Memory search engine started")}stop(){this.stopped=!0,this.watcher&&(this.watcher.close(),this.watcher=null),this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.embedTimer&&(clearInterval(this.embedTimer),this.embedTimer=null),this.indexDb&&(this.indexDb.close(),this.indexDb=null),this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.indexHnsw=null,this.searchHnsw=null,this.l0Generator.stop(),x.info("Memory search engine stopped")}migrateEmbeddingsTable(){if(!this.indexDb)return;if(this.indexDb.prepare("PRAGMA table_info(embeddings)").all().some(e=>"vector"===e.name)){x.info("Migrating: dropping old embeddings table (had vector BLOB). All embeddings will be re-created via HNSW."),this.indexDb.exec("DROP TABLE IF EXISTS embeddings");try{h(this.indexHnswPath)}catch{}}}checkEmbeddingConfigChange(){if(!this.indexDb)return;const e=this.indexDb.prepare("SELECT value FROM meta WHERE key = ?"),t=this.indexDb.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"),n=e.get("embedding_model")?.value,s=e.get("embedding_dimensions")?.value,i=this.opts.embeddingModel,r=String(this.opts.embeddingDimensions),o=void 0!==n&&n!==i,a=void 0!==s&&s!==r;if(o||a){const e=[];o&&e.push(`model: ${n} → ${i}`),a&&e.push(`dimensions: ${s} → ${r}`),x.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{h(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",r)}initIndexHnsw(){const t=this.opts.embeddingDimensions;if(this.indexHnsw=new b(w,t),e(this.indexHnswPath))try{this.indexHnsw.readIndexSync(this.indexHnswPath,!0),x.info(`Loaded HNSW index: ${this.indexHnsw.getCurrentCount()} points`)}catch(e){x.warn(`Failed to load HNSW index, creating new: ${e}`),this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0})}else this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0}),x.info("Created new HNSW index")}ensureHnswCapacity(e){if(!this.indexHnsw)return;const t=this.indexHnsw.getMaxElements();if(this.indexHnsw.getCurrentCount()+e>t){const n=Math.max(2*t,this.indexHnsw.getCurrentCount()+e+1e3);this.indexHnsw.resizeIndex(n),x.info(`Resized HNSW index: ${t} → ${n}`)}}async search(e,t){const n=t??this.opts.maxResults;if(this.maybeSwap(),!this.searchDb)return x.warn("Search DB not available"),[];const s=this.bm25Search(e,20);let i=[];try{const t=await this.embedText(e);t&&this.searchHnsw&&this.searchHnsw.getCurrentCount()>0&&(i=this.denseSearch(t,20))}catch(e){x.warn(`Dense search failed, using BM25 only: ${e}`)}const r=function(e,t,n){const s=new Map;for(let t=0;t<e.length;t++){const{id:i}=e[t];s.set(i,(s.get(i)??0)+1/(n+t+1))}for(let e=0;e<t.length;e++){const{id:i}=t[e];s.set(i,(s.get(i)??0)+1/(n+e+1))}const i=Array.from(s.entries()).map(([e,t])=>({id:e,score:t})).sort((e,t)=>t.score-e.score);return i}(s,i,this.opts.rrfK),h=Math.min(r.length,40),o=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),a=[];for(let e=0;e<h;e++){const{id:t,score:n}=r[e],s=o.get(t);s&&a.push({id:t,score:n,row:s})}const c=function(e,t,n){if(e.length<=t)return e;const s=e.map(e=>function(e){const t=new Set;for(const n of e.toLowerCase().matchAll(/\b\w{2,}\b/g))t.add(n[0]);return t}(e.row.content)),i=e[0]?.score??1,r=e[e.length-1]?.score??0,h=i-r||1,o=[],a=new Set(e.map((e,t)=>t));o.push(0),a.delete(0);for(;o.length<t&&a.size>0;){let t=-1,i=-1/0;for(const c of a){const a=(e[c].score-r)/h;let d=0;for(const e of o){const t=D(s[c],s[e]);t>d&&(d=t)}const l=n*a-(1-n)*d;l>i&&(i=l,t=c)}if(t<0)break;o.push(t),a.delete(t)}return o.map(t=>e[t])}(a,n,.7),d=[];for(const{id:e,score:t,row:n}of c){const s=n.l0&&this.l0Generator.enabled?n.l0:n.content.length>this.opts.maxSnippetChars?n.content.slice(0,this.opts.maxSnippetChars)+"...":n.content;d.push({chunkId:e,path:n.doc_path,sessionKey:n.session_key,snippet:s,score:t,role:n.role,timestamp:n.timestamp})}return x.info(`Search "${e.slice(0,60)}": ${d.length} results (sparse=${s.length}, dense=${i.length}, candidates=${a.length}, mmr=${c.length})`),d}readFile(t,s,i){const r=a(this.memoryDir,t);if(!e(r))return{path:t,content:`[File not found: ${t}]`};const h=n(r,"utf-8"),o=h.split("\n");if(void 0!==s||void 0!==i){const e=Math.max(0,(s??1)-1),n=i??o.length;return{path:t,content:o.slice(e,e+n).join("\n")}}return{path:t,content:h}}expandChunks(e){if(!this.searchDb||0===e.length)return[];const t=e.map(()=>"?").join(",");return this.searchDb.prepare(`SELECT id, content, doc_path, role, timestamp FROM chunks WHERE id IN (${t})`).all(...e).map(e=>({chunkId:e.id,content:e.content,path:e.doc_path,role:e.role,timestamp:e.timestamp}))}bm25Search(e,t){if(!this.searchDb)return[];try{const n=function(e){const t=e.replace(/[^\w\s]/g," ").split(/\s+/).filter(e=>e.length>0);return 0===t.length?"":t.map(e=>`"${e}"`).join(" OR ")}(e);if(!n)return[];return this.searchDb.prepare("\n SELECT chunks.id, bm25(chunks_fts) as rank\n FROM chunks_fts\n JOIN chunks ON chunks.id = chunks_fts.rowid\n WHERE chunks_fts MATCH ?\n ORDER BY rank\n LIMIT ?\n ").all(n,t).map(e=>({id:e.id,score:-e.rank}))}catch(e){return x.warn(`BM25 search error: ${e}`),[]}}denseSearch(e,t){if(!this.searchHnsw||0===this.searchHnsw.getCurrentCount())return[];const n=Math.min(t,this.searchHnsw.getCurrentCount()),s=this.searchHnsw.searchKnn(Array.from(e),n);return s.neighbors.map((e,t)=>({id:e,score:1-s.distances[t]}))}startWatcher(){if(e(this.memoryDir))try{this.watcher=o(this.memoryDir,{recursive:!0},(e,t)=>{this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.runIndexCycle()},this.opts.updateDebounceMs)})}catch(e){x.warn(`Could not start file watcher: ${e}`)}}async runIndexCycle(){if(!this.indexing){this.indexing=!0;try{await this.indexFiles(),this.indexDb&&await this.l0Generator.generate(this.indexDb),this.publishSnapshot()}catch(e){x.error(`Index cycle error: ${e}`)}finally{this.indexing=!1}}}async indexFiles(){if(!this.indexDb||!e(this.memoryDir))return;const i=function(e){const n=[];function i(r){let h;try{h=t(r,{withFileTypes:!0})}catch{return}for(const t of h){const h=a(r,t.name);if(t.isDirectory())i(h);else if(t.name.endsWith(".md"))try{const t=s(h),i=c(e,h),r=d(l(h));n.push({fullPath:h,relPath:i,sessionKey:r===d(e)?"":r,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}return i(e),n}(this.memoryDir);let r=0;const h=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),o=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),m=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),p=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),u=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),b=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),E=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),f=new Set(i.map(e=>e.relPath));for(const e of E)if(!f.has(e)){const t=p.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e),m.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),x.debug(`Removed deleted file from index: ${e}`)}for(const e of i){const t=o.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){r+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const s=T(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),i=p.all(e.relPath);for(const{id:e}of i)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e.relPath),m.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<s.length;t++){const n=s[t];b.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content)}h.run(e.relPath,e.mtimeMs,e.size)})(),r+=s.length,x.debug(`Indexed ${e.relPath}: ${s.length} chunks`)}x.info(`Indexed ${r} chunks from ${i.length} files`)}async embedPending(){if(!this.stopped&&!this.embedding&&this.indexDb&&this.indexHnsw){this.embedding=!0;try{const e=this.indexDb.prepare("\n SELECT c.id, c.content FROM chunks c\n LEFT JOIN embeddings e ON e.chunk_id = c.id\n WHERE e.chunk_id IS NULL\n ").all();if(0===e.length)return;x.info(`Embedding ${e.length} pending chunks...`),this.ensureHnswCapacity(e.length);const t=this.indexDb.prepare("INSERT OR REPLACE INTO embeddings (chunk_id) VALUES (?)");for(let n=0;n<e.length;n+=100){if(this.stopped)return void x.warn("embedPending aborted: engine stopped");const s=e.slice(n,n+100),i=s.map(e=>this.applyPrefix(this.opts.prefixDocument,e.content).slice(0,8e3)),r=this.opts.prefixDocument.trim();r&&x.debug(`Using prefixDocument (template: ${r}) → result sample: [${i[0].slice(0,80)}]`);try{let e;if(this.openai){const t=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:i,dimensions:this.opts.embeddingDimensions});e=t.data.sort((e,t)=>e.index-t.index).map(e=>e.embedding)}else e=await this.fetchEmbeddings(i);if(this.stopped||!this.indexDb||!this.indexHnsw)return void x.warn("embedPending aborted: engine stopped during embedding");this.indexDb.transaction(()=>{for(let n=0;n<e.length;n++)this.indexHnsw.addPoint(e[n],s[n].id,!0),t.run(s[n].id)})(),x.debug(`Embedded batch ${n/100+1}: ${s.length} chunks`)}catch(e){if(this.stopped)return;x.error(`Embedding batch failed: ${e}`)}}if(this.stopped||!this.indexHnsw)return;this.indexHnsw.writeIndexSync(this.indexHnswPath),this.publishSnapshot(),x.info(`Embedded ${e.length} chunks (HNSW: ${this.indexHnsw.getCurrentCount()} total points)`)}finally{this.embedding=!1}}}publishSnapshot(){if(!this.indexDb)return;const t=a(this.dataDir,".memory-search-next.tmp"),n=a(this.dataDir,".memory-vectors-search-next.tmp");try{this.indexDb.pragma("wal_checkpoint(TRUNCATE)"),i(this.indexDbPath,t),r(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),r(n,this.searchNextHnswPath)),x.debug("Published search snapshot (DB + HNSW)")}catch(e){x.error(`Failed to publish snapshot: ${e}`);try{h(t)}catch{}try{h(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,r(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&r(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new m(this.searchDbPath,{readonly:!0}),e(this.searchHnswPath)?(this.searchHnsw=new b(w,this.opts.embeddingDimensions),this.searchHnsw.readIndexSync(this.searchHnswPath),this.searchHnsw.setEf(50),x.debug(`Swapped to new search DB + HNSW (${this.searchHnsw.getCurrentCount()} points)`)):x.debug("Swapped to new search DB (no HNSW index yet)")}catch(t){x.error(`Failed to swap search DB: ${t}`);try{e(this.searchDbPath)&&(this.searchDb=new m(this.searchDbPath,{readonly:!0}))}catch{}}}applyPrefix(e,t){const n=e.trim();return n?n.replace(/\{content\}/g,()=>t):t}async fetchEmbeddings(e){const t=`${(this.opts.baseURL||"").replace(/\/+$/,"")}/embed`,n={"Content-Type":"application/json"};this.opts.apiKey&&(n.Authorization=`Bearer ${this.opts.apiKey}`);const s=await fetch(t,{method:"POST",headers:n,body:JSON.stringify({model:this.opts.embeddingModel,input:e})});if(!s.ok){const e=await s.text().catch(()=>"(no body)");throw new Error(`Embedding API ${s.status}: ${e.slice(0,300)}`)}const i=await s.json();if(Array.isArray(i.embeddings))return i.embeddings;throw new Error(`Unknown embedding response format. Keys: ${Object.keys(i).join(", ")}`)}async embedText(e){try{const t=this.applyPrefix(this.opts.prefixQuery,e),n=this.opts.prefixQuery.trim();if(n&&x.debug(`Using prefixQuery (template: ${n}) → result sample: [${t.slice(0,80)}]`),this.openai){const e=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:t.slice(0,8e3),dimensions:this.opts.embeddingDimensions});return new Float32Array(e.data[0].embedding)}const s=await this.fetchEmbeddings([t.slice(0,8e3)]);return new Float32Array(s[0])}catch(e){return x.error(`Failed to embed query: ${e}`),null}}}function T(e,t,n){const s=[],i=e.split(/^### /m);for(const e of i){if(!e.trim())continue;const t=e.match(/^(user|assistant)\s*\(([^)]+)\)\s*\n/),i=t?t[1]:"",r=t?t[2]:"",h=t?e.slice(t[0].length).trim():e.trim();if(!h)continue;const o=1500,a=100;if(h.length<=o)s.push({role:i,timestamp:r,sessionKey:n,content:h});else{let e=0;for(;e<h.length;){const t=Math.min(e+o,h.length),c=h.slice(e,t);if(s.push({role:i,timestamp:r,sessionKey:n,content:c}),e=t-a,e+a>=h.length)break}}}return s}function D(e,t){if(0===e.size&&0===t.size)return 0;let n=0;const s=e.size<=t.size?e:t,i=e.size<=t.size?t:e;for(const e of s)i.has(e)&&n++;const r=e.size+t.size-n;return 0===r?0:n/r}
|