@hera-al/server 1.6.43 → 1.6.45

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.
@@ -114,6 +114,33 @@ export declare class ConceptStore {
114
114
  concepts: number;
115
115
  triples: number;
116
116
  };
117
+ /**
118
+ * Get all concepts with fan counts and per-entity S_max for the
119
+ * spreading activation cache in MemorySearch.
120
+ */
121
+ getConceptCacheData(): Array<{
122
+ id: string;
123
+ label: string;
124
+ fan: number;
125
+ sMax: number | null;
126
+ }>;
127
+ /**
128
+ * Get 1-hop neighbors for a set of entity IDs (Phase 2: graph-distance propagation).
129
+ * Returns a map: entityId → [{ neighbor, predicate }]
130
+ */
131
+ getNeighbors(entityIds: string[]): Map<string, Array<{
132
+ neighbor: string;
133
+ predicate: string;
134
+ }>>;
135
+ /**
136
+ * Update per-entity S_max (Phase 4: adaptive tuning via dreaming).
137
+ * Dreaming analyzes which associative boosts led to useful retrievals
138
+ * and adjusts S_max per entity accordingly.
139
+ */
140
+ updateSMax(updates: Array<{
141
+ conceptId: string;
142
+ sMax: number;
143
+ }>): number;
117
144
  close(): void;
118
145
  }
119
146
  //# sourceMappingURL=concept-store.d.ts.map
@@ -1 +1 @@
1
- import{existsSync as e,readFileSync as t}from"node:fs";import{join as s}from"node:path";import r from"better-sqlite3";import{createLogger as c}from"../utils/logger.js";const n=c("ConceptStore");export class ConceptStore{db;constructor(e){const t=s(e,"concepts.db");this.db=new r(t),this.db.pragma("journal_mode = WAL"),this.db.pragma("foreign_keys = ON"),this.migrate(),n.info(`ConceptStore opened: ${t}`)}migrate(){this.db.exec("\n CREATE TABLE IF NOT EXISTS concepts (\n id TEXT PRIMARY KEY,\n label TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n last_accessed TEXT,\n access_count INTEGER DEFAULT 0,\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS triples (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n subject TEXT NOT NULL,\n predicate TEXT NOT NULL,\n object TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS concept_drafts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n context TEXT,\n session_key TEXT,\n created_at TEXT DEFAULT (datetime('now')),\n processed INTEGER DEFAULT 0\n );\n\n CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);\n CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);\n CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);\n CREATE UNIQUE INDEX IF NOT EXISTS idx_triples_unique ON triples(subject, predicate, object);\n CREATE INDEX IF NOT EXISTS idx_concepts_access ON concepts(access_count, last_accessed);\n CREATE INDEX IF NOT EXISTS idx_drafts_pending ON concept_drafts(processed);\n ")}query(e,t=1){const s=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!s){const s=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return s?this.query(s.id,t):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const r=new Set([e]);let c=[e];const n=[];for(let e=0;e<t&&0!==c.length;e++){const e=c.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})`).all(...c),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})`).all(...c),o=[];for(const e of[...t,...s]){n.push(e);for(const t of[e.subject,e.object])r.has(t)||(r.add(t),o.push(t))}c=o}const o=[...r],E=[];for(let e=0;e<o.length;e+=500){const t=o.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);E.push(...r)}const p=new Set;return{center:s,concepts:E,triples:n.filter(e=>!p.has(e.id)&&(p.add(e.id),!0))}}findPath(e,t,s=6){if(e===t)return{found:!0,path:[e],triples:[]};const r=new Map,c=new Set([e]);let n=[e];for(let o=0;o<s&&0!==n.length;o++){const s=[],o=n.map(()=>"?").join(","),E=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${o})`).all(...n),p=this.db.prepare(`SELECT * FROM triples WHERE object IN (${o})`).all(...n);for(const n of[...E,...p]){const o=[{node:n.object,from:n.subject},{node:n.subject,from:n.object}];for(const{node:E,from:p}of o)if(!c.has(E)&&c.has(p)&&(c.add(E),r.set(E,{parent:p,triple:n}),s.push(E),E===t)){const s=[t],c=[];let n=t;for(;n!==e;){const e=r.get(n);c.unshift(e.triple),s.unshift(e.parent),n=e.parent}return{found:!0,path:s,triples:c}}}n=s}return{found:!1,path:[],triples:[]}}queryTemporal(e,t=1,s){const r=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!r){const r=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return r?this.queryTemporal(r.id,t,s):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const c=[],n=[];s?.since&&(c.push("created_at >= ?"),n.push(s.since)),s?.until&&(c.push("created_at <= ?"),n.push(s.until));const o=c.length>0?` AND ${c.join(" AND ")}`:"",E=s?.recentFirst?" ORDER BY created_at DESC":"",p=new Set([e]);let i=[e];const a=[];for(let e=0;e<t&&0!==i.length;e++){const e=i.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})${o}${E}`).all(...i,...n),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})${o}${E}`).all(...i,...n),r=[];for(const e of[...t,...s]){a.push(e);for(const t of[e.subject,e.object])p.has(t)||(p.add(t),r.push(t))}i=r}const d=[...p],T=[];for(let e=0;e<d.length;e+=500){const t=d.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);T.push(...r)}const l=new Set;return{center:r,concepts:T,triples:a.filter(e=>!l.has(e.id)&&(l.add(e.id),!0))}}search(e,t=10){return this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? ORDER BY access_count DESC LIMIT ?").all(`%${e}%`,t)}stats(){return{totalConcepts:this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c,totalTriples:this.db.prepare("SELECT COUNT(*) as c FROM triples").get().c,totalDraftsPending:this.db.prepare("SELECT COUNT(*) as c FROM concept_drafts WHERE processed = 0").get().c,orphanConcepts:this.db.prepare("\n SELECT COUNT(*) as c FROM concepts\n WHERE id NOT IN (SELECT subject FROM triples)\n AND id NOT IN (SELECT object FROM triples)\n ").get().c,neverAccessed:this.db.prepare("SELECT COUNT(*) as c FROM concepts WHERE access_count = 0").get().c,topAccessed:this.db.prepare("SELECT id, label, access_count FROM concepts ORDER BY access_count DESC LIMIT 10").all()}}addDraft(e,t,s){const r=this.db.prepare("INSERT INTO concept_drafts (text, context, session_key) VALUES (?, ?, ?)").run(e,t??null,s??null);return n.info(`Draft added: "${e.slice(0,60)}..." (id=${r.lastInsertRowid})`),r.lastInsertRowid}addConcept(e,t,s="dreaming"){this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, ?)").run(e,t,s)}addTriple(e,t,s,r="dreaming"){try{return this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, ?)").run(e,t,s,r),!0}catch{return!1}}removeConcept(e){this.db.prepare("DELETE FROM triples WHERE subject = ? OR object = ?").run(e,e),this.db.prepare("DELETE FROM concepts WHERE id = ?").run(e),n.info(`Concept removed: ${e}`)}removeTriple(e,t,s){this.db.prepare("DELETE FROM triples WHERE subject = ? AND predicate = ? AND object = ?").run(e,t,s)}updateConceptLabel(e,t){this.db.prepare("UPDATE concepts SET label = ? WHERE id = ?").run(t,e)}getPendingDrafts(){return this.db.prepare("SELECT * FROM concept_drafts WHERE processed = 0 ORDER BY created_at ASC").all()}markDraftProcessed(e){this.db.prepare("UPDATE concept_drafts SET processed = 1 WHERE id = ?").run(e)}importFromTurtleIfEmpty(s){if(this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c>0)return;if(!e(s))return void n.info("No CONCEPTS.md found, starting with empty concept graph");n.info(`Importing concepts from ${s}...`);const r=t(s,"utf-8");this.importTurtle(r)}importTurtle(e){let t=0,s=0;const r=this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, 'migration')"),c=this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, 'migration')");return this.db.transaction(()=>{for(const n of e.split("\n")){const e=n.trim();if(!e||e.startsWith("#")||e.startsWith("@prefix")||e.startsWith("```"))continue;const o=e.match(/^:(\S+)\s+rdfs:label\s+"([^"]+)"\s*\.\s*$/);if(o){const[,e,s]=o;r.run(e,s),t++;continue}const E=e.match(/^:(\S+)\s+rel:(\S+)\s+:(\S+)\s*\.\s*$/);if(E){const[,e,t,n]=E;r.run(e,e),r.run(n,n),c.run(e,t,n),s++;continue}const p=e.match(/^:(\S+)\s+rel:(\S+)\s+"([^"]+)"\s*\.\s*$/);if(p){const[,e,t,n]=p;r.run(e,e),c.run(e,t,n),s++;continue}const i=e.match(/^:(\S+)\s+rel:(\S+)\s+(\S+)\s*\.\s*$/);if(i){const[,e,t,n]=i;"."===n||n.startsWith("rel:")||n.startsWith("rdfs:")||(r.run(e,e),c.run(e,t,n),s++)}}})(),n.info(`Imported ${t} concepts and ${s} triples from Turtle`),{concepts:t,triples:s}}close(){this.db.close(),n.info("ConceptStore closed")}}
1
+ import{existsSync as e,readFileSync as t}from"node:fs";import{join as s}from"node:path";import r from"better-sqlite3";import{createLogger as c}from"../utils/logger.js";const n=c("ConceptStore");export class ConceptStore{db;constructor(e){const t=s(e,"concepts.db");this.db=new r(t),this.db.pragma("journal_mode = WAL"),this.db.pragma("foreign_keys = ON"),this.migrate(),n.info(`ConceptStore opened: ${t}`)}migrate(){this.db.exec("\n CREATE TABLE IF NOT EXISTS concepts (\n id TEXT PRIMARY KEY,\n label TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n last_accessed TEXT,\n access_count INTEGER DEFAULT 0,\n source TEXT DEFAULT 'migration',\n s_max REAL\n );\n\n CREATE TABLE IF NOT EXISTS triples (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n subject TEXT NOT NULL,\n predicate TEXT NOT NULL,\n object TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS concept_drafts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n context TEXT,\n session_key TEXT,\n created_at TEXT DEFAULT (datetime('now')),\n processed INTEGER DEFAULT 0\n );\n\n CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);\n CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);\n CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);\n CREATE UNIQUE INDEX IF NOT EXISTS idx_triples_unique ON triples(subject, predicate, object);\n CREATE INDEX IF NOT EXISTS idx_concepts_access ON concepts(access_count, last_accessed);\n CREATE INDEX IF NOT EXISTS idx_drafts_pending ON concept_drafts(processed);\n ");this.db.prepare("PRAGMA table_info(concepts)").all().some(e=>"s_max"===e.name)||(this.db.exec("ALTER TABLE concepts ADD COLUMN s_max REAL"),n.info("Migrated concepts: added s_max column"))}query(e,t=1){const s=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!s){const s=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return s?this.query(s.id,t):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const r=new Set([e]);let c=[e];const n=[];for(let e=0;e<t&&0!==c.length;e++){const e=c.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})`).all(...c),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})`).all(...c),o=[];for(const e of[...t,...s]){n.push(e);for(const t of[e.subject,e.object])r.has(t)||(r.add(t),o.push(t))}c=o}const o=[...r],p=[];for(let e=0;e<o.length;e+=500){const t=o.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);p.push(...r)}const i=new Set;return{center:s,concepts:p,triples:n.filter(e=>!i.has(e.id)&&(i.add(e.id),!0))}}findPath(e,t,s=6){if(e===t)return{found:!0,path:[e],triples:[]};const r=new Map,c=new Set([e]);let n=[e];for(let o=0;o<s&&0!==n.length;o++){const s=[],o=n.map(()=>"?").join(","),p=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${o})`).all(...n),i=this.db.prepare(`SELECT * FROM triples WHERE object IN (${o})`).all(...n);for(const n of[...p,...i]){const o=[{node:n.object,from:n.subject},{node:n.subject,from:n.object}];for(const{node:p,from:i}of o)if(!c.has(p)&&c.has(i)&&(c.add(p),r.set(p,{parent:i,triple:n}),s.push(p),p===t)){const s=[t],c=[];let n=t;for(;n!==e;){const e=r.get(n);c.unshift(e.triple),s.unshift(e.parent),n=e.parent}return{found:!0,path:s,triples:c}}}n=s}return{found:!1,path:[],triples:[]}}queryTemporal(e,t=1,s){const r=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!r){const r=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return r?this.queryTemporal(r.id,t,s):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const c=[],n=[];s?.since&&(c.push("created_at >= ?"),n.push(s.since)),s?.until&&(c.push("created_at <= ?"),n.push(s.until));const o=c.length>0?` AND ${c.join(" AND ")}`:"",p=s?.recentFirst?" ORDER BY created_at DESC":"",i=new Set([e]);let a=[e];const E=[];for(let e=0;e<t&&0!==a.length;e++){const e=a.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})${o}${p}`).all(...a,...n),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})${o}${p}`).all(...a,...n),r=[];for(const e of[...t,...s]){E.push(e);for(const t of[e.subject,e.object])i.has(t)||(i.add(t),r.push(t))}a=r}const d=[...i],l=[];for(let e=0;e<d.length;e+=500){const t=d.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);l.push(...r)}const T=new Set;return{center:r,concepts:l,triples:E.filter(e=>!T.has(e.id)&&(T.add(e.id),!0))}}search(e,t=10){return this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? ORDER BY access_count DESC LIMIT ?").all(`%${e}%`,t)}stats(){return{totalConcepts:this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c,totalTriples:this.db.prepare("SELECT COUNT(*) as c FROM triples").get().c,totalDraftsPending:this.db.prepare("SELECT COUNT(*) as c FROM concept_drafts WHERE processed = 0").get().c,orphanConcepts:this.db.prepare("\n SELECT COUNT(*) as c FROM concepts\n WHERE id NOT IN (SELECT subject FROM triples)\n AND id NOT IN (SELECT object FROM triples)\n ").get().c,neverAccessed:this.db.prepare("SELECT COUNT(*) as c FROM concepts WHERE access_count = 0").get().c,topAccessed:this.db.prepare("SELECT id, label, access_count FROM concepts ORDER BY access_count DESC LIMIT 10").all()}}addDraft(e,t,s){const r=this.db.prepare("INSERT INTO concept_drafts (text, context, session_key) VALUES (?, ?, ?)").run(e,t??null,s??null);return n.info(`Draft added: "${e.slice(0,60)}..." (id=${r.lastInsertRowid})`),r.lastInsertRowid}addConcept(e,t,s="dreaming"){this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, ?)").run(e,t,s)}addTriple(e,t,s,r="dreaming"){try{return this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, ?)").run(e,t,s,r),!0}catch{return!1}}removeConcept(e){this.db.prepare("DELETE FROM triples WHERE subject = ? OR object = ?").run(e,e),this.db.prepare("DELETE FROM concepts WHERE id = ?").run(e),n.info(`Concept removed: ${e}`)}removeTriple(e,t,s){this.db.prepare("DELETE FROM triples WHERE subject = ? AND predicate = ? AND object = ?").run(e,t,s)}updateConceptLabel(e,t){this.db.prepare("UPDATE concepts SET label = ? WHERE id = ?").run(t,e)}getPendingDrafts(){return this.db.prepare("SELECT * FROM concept_drafts WHERE processed = 0 ORDER BY created_at ASC").all()}markDraftProcessed(e){this.db.prepare("UPDATE concept_drafts SET processed = 1 WHERE id = ?").run(e)}importFromTurtleIfEmpty(s){if(this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c>0)return;if(!e(s))return void n.info("No CONCEPTS.md found, starting with empty concept graph");n.info(`Importing concepts from ${s}...`);const r=t(s,"utf-8");this.importTurtle(r)}importTurtle(e){let t=0,s=0;const r=this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, 'migration')"),c=this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, 'migration')");return this.db.transaction(()=>{for(const n of e.split("\n")){const e=n.trim();if(!e||e.startsWith("#")||e.startsWith("@prefix")||e.startsWith("```"))continue;const o=e.match(/^:(\S+)\s+rdfs:label\s+"([^"]+)"\s*\.\s*$/);if(o){const[,e,s]=o;r.run(e,s),t++;continue}const p=e.match(/^:(\S+)\s+rel:(\S+)\s+:(\S+)\s*\.\s*$/);if(p){const[,e,t,n]=p;r.run(e,e),r.run(n,n),c.run(e,t,n),s++;continue}const i=e.match(/^:(\S+)\s+rel:(\S+)\s+"([^"]+)"\s*\.\s*$/);if(i){const[,e,t,n]=i;r.run(e,e),c.run(e,t,n),s++;continue}const a=e.match(/^:(\S+)\s+rel:(\S+)\s+(\S+)\s*\.\s*$/);if(a){const[,e,t,n]=a;"."===n||n.startsWith("rel:")||n.startsWith("rdfs:")||(r.run(e,e),c.run(e,t,n),s++)}}})(),n.info(`Imported ${t} concepts and ${s} triples from Turtle`),{concepts:t,triples:s}}getConceptCacheData(){return this.db.prepare("\n SELECT c.id, c.label, c.s_max as sMax,\n (SELECT COUNT(*) FROM triples t WHERE t.subject = c.id OR t.object = c.id) as fan\n FROM concepts c\n ").all()}getNeighbors(e){const t=new Map;if(0===e.length)return t;const s=e.map(()=>"?").join(","),r=this.db.prepare(`SELECT subject, predicate, object FROM triples WHERE subject IN (${s})`).all(...e),c=this.db.prepare(`SELECT subject, predicate, object FROM triples WHERE object IN (${s})`).all(...e);for(const e of r)t.has(e.subject)||t.set(e.subject,[]),t.get(e.subject).push({neighbor:e.object,predicate:e.predicate});for(const e of c)t.has(e.object)||t.set(e.object,[]),t.get(e.object).push({neighbor:e.subject,predicate:e.predicate});return t}updateSMax(e){const t=this.db.prepare("UPDATE concepts SET s_max = ? WHERE id = ?");let s=0;return this.db.transaction(()=>{for(const{conceptId:r,sMax:c}of e){const e=Math.max(0,Math.min(5,c));t.run(e,r).changes>0&&s++}})(),n.info(`Updated S_max for ${s}/${e.length} concepts`),s}close(){this.db.close(),n.info("ConceptStore closed")}}
@@ -6,7 +6,11 @@ export declare class MemoryManager implements MemoryProvider {
6
6
  private currentStem;
7
7
  /** sessionKeys that have been cleared — next append creates a new file */
8
8
  private cleared;
9
- constructor(baseDir: string, timezone?: string | undefined);
9
+ /** CKR L2 prefix to strip from memory logs (loaded from config.ckr.l2Prefix) */
10
+ private stripPrefix?;
11
+ /** Whether to actually strip the prefix (loaded from config.ckr.removeL2PrefixFromMemory) */
12
+ private stripEnabled;
13
+ constructor(baseDir: string, timezone?: string | undefined, stripPrefix?: string, stripEnabled?: boolean);
10
14
  private getChatDir;
11
15
  /** Returns the stem for the current session (e.g. "2026-02-06-2324") */
12
16
  private getCurrentStem;
@@ -14,6 +18,7 @@ export declare class MemoryManager implements MemoryProvider {
14
18
  getAttachmentsDir(sessionKey: string): string;
15
19
  private ensureConversationFile;
16
20
  append(sessionKey: string, role: "user" | "assistant", content: string, attachments?: string[]): Promise<void>;
21
+ appendReaction(sessionKey: string, emoji: string, username?: string, removed?: boolean): Promise<void>;
17
22
  saveFile(sessionKey: string, buffer: Buffer, fileName: string): Promise<string>;
18
23
  getConversationMessages(sessionKey: string): ConversationMessage[];
19
24
  retrieveFromMemory(queryText: string): MemorySearchResult[];
@@ -1 +1 @@
1
- import{mkdirSync as t,appendFileSync as e,readFileSync as n,writeFileSync as i,existsSync as r,readdirSync as o}from"node:fs";import{join as s}from"node:path";import{createLogger as a}from"../utils/logger.js";const c=a("MemoryManager");export class MemoryManager{baseDir;timezone;currentStem=new Map;cleared=new Set;constructor(e,n){this.baseDir=e,this.timezone=n,t(e,{recursive:!0})}getChatDir(e){const n=e.replace(/:/g,"_"),i=s(this.baseDir,n);return t(i,{recursive:!0}),i}getCurrentStem(t){const e=this.currentStem.get(t);if(e)return e;if(!this.cleared.has(t)){const e=this.getChatDir(t),n=o(e).filter(t=>t.endsWith(".md")).sort();if(n.length>0){const e=n[n.length-1].replace(".md","");return this.currentStem.set(t,e),e}}const n=function(t){const e=new Date;if(t){return`${e.toLocaleString("en-US",{timeZone:t,year:"numeric"})}-${e.toLocaleString("en-US",{timeZone:t,month:"2-digit"})}-${e.toLocaleString("en-US",{timeZone:t,day:"2-digit"})}-${e.toLocaleString("en-GB",{timeZone:t,hour:"2-digit",hour12:!1})}${e.toLocaleString("en-GB",{timeZone:t,minute:"2-digit"}).padStart(2,"0")}`}const n=t=>String(t).padStart(2,"0");return`${e.getFullYear()}-${n(e.getMonth()+1)}-${n(e.getDate())}-${n(e.getHours())}${n(e.getMinutes())}`}(this.timezone);return this.currentStem.set(t,n),this.cleared.delete(t),n}getConversationFile(t){const e=this.getCurrentStem(t);return s(this.getChatDir(t),`${e}.md`)}getAttachmentsDir(e){const n=this.getCurrentStem(e),i=s(this.getChatDir(e),n);return t(i,{recursive:!0}),i}ensureConversationFile(t){const n=this.getConversationFile(t);if(!r(n)){const i=[`# Memory: ${t}`,`- Started: ${(new Date).toISOString()}`,"","---",""].join("\n");e(n,i,"utf-8"),c.info(`Memory file created: ${n}`)}return n}async append(t,n,i,r){const o=this.ensureConversationFile(t);let s=`### ${n} (${function(t,e){if(!e)return t.toISOString();const n=t.toLocaleString("en-US",{timeZone:e,year:"numeric"}),i=t.toLocaleString("en-US",{timeZone:e,month:"2-digit"}),r=t.toLocaleString("en-US",{timeZone:e,day:"2-digit"}),o=t.toLocaleString("en-GB",{timeZone:e,hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1});return`${n}-${i}-${r}T${o}`}(new Date,this.timezone)})\n`;r&&r.length>0&&(s+=`[files: ${r.join(", ")}]\n`),s+=`\n${i}\n\n`,e(o,s,"utf-8")}async saveFile(t,e,n){const r=this.getAttachmentsDir(t),o=(new Date).toISOString().replace(/[-:]/g,"").replace("T","_").slice(0,15),a=n.replace(/[^a-zA-Z0-9._-]/g,"_").slice(0,100),m=s(r,`${o}_${a}`);return i(m,e),c.info(`File saved to memory: ${m} (${e.length} bytes)`),m}getConversationMessages(t){const e=this.getConversationFile(t);if(!r(e))return[];return m(n(e,"utf-8"))}retrieveFromMemory(t){const e=[];if(!r(this.baseDir))return e;const i=t.toLowerCase(),a=o(this.baseDir,{withFileTypes:!0});for(const t of a){if(!t.isDirectory())continue;const r=s(this.baseDir,t.name),a=o(r).filter(t=>t.endsWith(".md"));for(const o of a){const a=s(r,o),c=m(n(a,"utf-8")),g=[],h=new Set;let u="",f="";for(const t of c)if(t.content.toLowerCase().includes(i)){if(g.push(l(t.content,i)),!u&&t.timestamp){const e=new Date(t.timestamp);isNaN(e.getTime())||(u=e.toISOString().slice(0,10),f=e.toISOString().slice(11,19))}for(const e of t.attachments)h.add(e)}g.length>0&&e.push({memoryFile:a,sessionKey:t.name,date:u,time:f,snippets:g,files:Array.from(h)})}}return e}clearSession(t){this.currentStem.delete(t),this.cleared.add(t),c.info(`Memory session cleared for ${t} — next message starts a new file`)}}function m(t){const e=[],n=t.split(/^### /m).slice(1);for(const t of n){const n=t.match(/^(user|assistant)\s*\(([^)]+)\)\s*\n/);if(!n)continue;const i=n[1],r=n[2],o=t.slice(n[0].length);let s,a=[];const c=o.match(/^\[files:\s*(.+?)\]\s*\n/);c?(a=c[1].split(",").map(t=>t.trim()),s=o.slice(c[0].length).trim()):s=o.trim(),e.push({role:i,content:s,timestamp:r,attachments:a})}return e}function l(t,e,n=150){const i=t.toLowerCase().indexOf(e);if(-1===i)return t.slice(0,2*n);const r=Math.max(0,i-n),o=Math.min(t.length,i+e.length+n);let s=t.slice(r,o);return r>0&&(s="..."+s),o<t.length&&(s+="..."),s}
1
+ import{mkdirSync as t,appendFileSync as e,readFileSync as n,writeFileSync as i,existsSync as r,readdirSync as s}from"node:fs";import{join as o}from"node:path";import{createLogger as a}from"../utils/logger.js";const c=a("MemoryManager");export class MemoryManager{baseDir;timezone;currentStem=new Map;cleared=new Set;stripPrefix;stripEnabled;constructor(e,n,i,r=!0){this.baseDir=e,this.timezone=n,t(e,{recursive:!0}),this.stripPrefix=i,this.stripEnabled=r}getChatDir(e){const n=e.replace(/:/g,"_"),i=o(this.baseDir,n);return t(i,{recursive:!0}),i}getCurrentStem(t){const e=this.currentStem.get(t);if(e)return e;if(!this.cleared.has(t)){const e=this.getChatDir(t),n=s(e).filter(t=>t.endsWith(".md")).sort();if(n.length>0){const e=n[n.length-1].replace(".md","");return this.currentStem.set(t,e),e}}const n=function(t){const e=new Date;if(t){return`${e.toLocaleString("en-US",{timeZone:t,year:"numeric"})}-${e.toLocaleString("en-US",{timeZone:t,month:"2-digit"})}-${e.toLocaleString("en-US",{timeZone:t,day:"2-digit"})}-${e.toLocaleString("en-GB",{timeZone:t,hour:"2-digit",hour12:!1})}${e.toLocaleString("en-GB",{timeZone:t,minute:"2-digit"}).padStart(2,"0")}`}const n=t=>String(t).padStart(2,"0");return`${e.getFullYear()}-${n(e.getMonth()+1)}-${n(e.getDate())}-${n(e.getHours())}${n(e.getMinutes())}`}(this.timezone);return this.currentStem.set(t,n),this.cleared.delete(t),n}getConversationFile(t){const e=this.getCurrentStem(t);return o(this.getChatDir(t),`${e}.md`)}getAttachmentsDir(e){const n=this.getCurrentStem(e),i=o(this.getChatDir(e),n);return t(i,{recursive:!0}),i}ensureConversationFile(t){const n=this.getConversationFile(t);if(!r(n)){const i=[`# Memory: ${t}`,`- Started: ${(new Date).toISOString()}`,"","---",""].join("\n");e(n,i,"utf-8"),c.info(`Memory file created: ${n}`)}return n}async append(t,n,i,r){const s=this.ensureConversationFile(t),o=l(new Date,this.timezone);let a=i;if(this.stripEnabled&&this.stripPrefix&&"user"===n){const t=this.stripPrefix+"\n";a.startsWith(t)?a=a.slice(t.length):a.includes(t)&&(a=a.replace(t,""))}let c=`### ${n} (${o})\n`;r&&r.length>0&&(c+=`[files: ${r.join(", ")}]\n`),c+=`\n${a}\n\n`,e(s,c,"utf-8")}async appendReaction(t,n,i,s){const o=this.getConversationFile(t);if(!r(o))return;const a=l(new Date,this.timezone),m=s?"unreacted":"reacted";e(o,`← ${n} ${i??"user"} ${m} (${a})\n\n`,"utf-8"),c.debug(`Reaction ${m}: ${n} by ${i??"user"} on ${t}`)}async saveFile(t,e,n){const r=this.getAttachmentsDir(t),s=(new Date).toISOString().replace(/[-:]/g,"").replace("T","_").slice(0,15),a=n.replace(/[^a-zA-Z0-9._-]/g,"_").slice(0,100),l=o(r,`${s}_${a}`);return i(l,e),c.info(`File saved to memory: ${l} (${e.length} bytes)`),l}getConversationMessages(t){const e=this.getConversationFile(t);if(!r(e))return[];return m(n(e,"utf-8"))}retrieveFromMemory(t){const e=[];if(!r(this.baseDir))return e;const i=t.toLowerCase(),a=s(this.baseDir,{withFileTypes:!0});for(const t of a){if(!t.isDirectory())continue;const r=o(this.baseDir,t.name),a=s(r).filter(t=>t.endsWith(".md"));for(const s of a){const a=o(r,s),c=m(n(a,"utf-8")),l=[],u=new Set;let g="",f="";for(const t of c)if(t.content.toLowerCase().includes(i)){if(l.push(h(t.content,i)),!g&&t.timestamp){const e=new Date(t.timestamp);isNaN(e.getTime())||(g=e.toISOString().slice(0,10),f=e.toISOString().slice(11,19))}for(const e of t.attachments)u.add(e)}l.length>0&&e.push({memoryFile:a,sessionKey:t.name,date:g,time:f,snippets:l,files:Array.from(u)})}}return e}clearSession(t){this.currentStem.delete(t),this.cleared.add(t),c.info(`Memory session cleared for ${t} — next message starts a new file`)}}function l(t,e){if(!e)return t.toISOString();return`${t.toLocaleString("en-US",{timeZone:e,year:"numeric"})}-${t.toLocaleString("en-US",{timeZone:e,month:"2-digit"})}-${t.toLocaleString("en-US",{timeZone:e,day:"2-digit"})}T${t.toLocaleString("en-GB",{timeZone:e,hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}`}function m(t){const e=[],n=t.split(/^### /m).slice(1);for(const t of n){const n=t.match(/^(user|assistant)\s*\(([^)]+)\)\s*\n/);if(!n)continue;const i=n[1],r=n[2],s=t.slice(n[0].length);let o,a=[];const c=s.match(/^\[files:\s*(.+?)\]\s*\n/);c?(a=c[1].split(",").map(t=>t.trim()),o=s.slice(c[0].length).trim()):o=s.trim(),e.push({role:i,content:o,timestamp:r,attachments:a})}return e}function h(t,e,n=150){const i=t.toLowerCase().indexOf(e);if(-1===i)return t.slice(0,2*n);const r=Math.max(0,i-n),s=Math.min(t.length,i+e.length+n);let o=t.slice(r,s);return r>0&&(o="..."+o),s<t.length&&(o+="..."),o}
@@ -22,6 +22,7 @@
22
22
  * 4. RRF fusion (k=60) → return top N
23
23
  */
24
24
  import { type L0Config } from "./l0-generator.js";
25
+ import type { ConceptStore } from "./concept-store.js";
25
26
  export interface MemorySearchResult {
26
27
  chunkId: number;
27
28
  path: string;
@@ -61,6 +62,12 @@ export declare class MemorySearch {
61
62
  private l0Generator;
62
63
  private searchDb;
63
64
  private searchHnsw;
65
+ private conceptStore;
66
+ private conceptCache;
67
+ private conceptCacheMap;
68
+ private conceptCacheTime;
69
+ private queryCache;
70
+ private dbVersion;
64
71
  private openai;
65
72
  private indexDbPath;
66
73
  private searchDbPath;
@@ -69,11 +76,50 @@ export declare class MemorySearch {
69
76
  private searchHnswPath;
70
77
  private searchNextHnswPath;
71
78
  constructor(memoryDir: string, dataDir: string, opts: MemorySearchOptions);
79
+ /**
80
+ * Wire the concept graph for spreading activation.
81
+ * Called by Server after both MemorySearch and ConceptStore are created.
82
+ * Triggers Phase 3 entity index population if needed.
83
+ */
84
+ setConceptStore(store: ConceptStore): void;
85
+ /**
86
+ * Get or refresh the concept cache. Concepts change only during dreaming,
87
+ * so a 1-hour TTL is more than adequate.
88
+ */
89
+ private getConceptCacheEntries;
90
+ /**
91
+ * Extract entity IDs mentioned in text via substring matching.
92
+ * Uses the closed vocabulary from the concept graph — no NER model needed.
93
+ */
94
+ private extractEntities;
95
+ /**
96
+ * Compute spreading activation S_i for a chunk, given query entities.
97
+ *
98
+ * Phase 1: Direct overlap — query entities that appear in the chunk get a boost
99
+ * weighted by the fan effect (S_max - ln(fan)).
100
+ * Phase 2: Graph-distance propagation — entities in the chunk that are 1-hop
101
+ * connected to query entities get an attenuated boost.
102
+ */
103
+ private computeSpreadingActivation;
104
+ /**
105
+ * Phase 3: Populate chunk_entities table for all existing chunks.
106
+ * One-time backfill when ConceptStore is first connected.
107
+ */
108
+ private populateChunkEntities;
109
+ /**
110
+ * Phase 3: Get entities for chunks using the pre-computed index.
111
+ * Falls back to substring matching if index is empty.
112
+ */
113
+ private getChunkEntities;
72
114
  private isOpenAI;
73
115
  getMaxInjectedChars(): number;
74
116
  start(): Promise<void>;
75
117
  stop(): void;
76
118
  private migrateEmbeddingsTable;
119
+ /**
120
+ * Migrate chunk_utility: add importance + access_times columns if missing.
121
+ */
122
+ private migrateChunkUtility;
77
123
  /**
78
124
  * Check if embedding model or dimensions changed since last run.
79
125
  * If so, wipe embeddings + HNSW and re-embed everything.
@@ -85,12 +131,32 @@ export declare class MemorySearch {
85
131
  search(query: string, maxResults?: number): Promise<MemorySearchResult[]>;
86
132
  /**
87
133
  * Record that chunks were accessed (expanded/used in a response).
88
- * Writes to indexDb will propagate to searchDb on next snapshot.
134
+ * Writes to indexDb so data survives snapshot cycles (indexDb searchDb copy).
89
135
  */
90
136
  recordAccess(chunkIds: number[]): void;
137
+ /**
138
+ * Get recently accessed chunks for importance re-scoring (used by dreaming).
139
+ * Returns chunks accessed in the last N days, with their content snippet.
140
+ */
141
+ getRecentlyAccessedChunks(days?: number, limit?: number): Array<{
142
+ chunkId: number;
143
+ content: string;
144
+ path: string;
145
+ importance: number;
146
+ accessCount: number;
147
+ lastAccessed: string;
148
+ }>;
149
+ /**
150
+ * Update importance scores for specific chunks (used by dreaming LLM re-scoring).
151
+ * Writes to indexDb so data survives snapshot cycles.
152
+ */
153
+ updateImportance(updates: Array<{
154
+ chunkId: number;
155
+ importance: number;
156
+ }>): number;
91
157
  /**
92
158
  * Fetch utility data for a set of chunk IDs from the search DB.
93
- * Returns a Map of chunkId → { access_count, last_accessed, first_accessed }.
159
+ * Returns a Map of chunkId → { access_count, last_accessed, first_accessed, importance, access_times, maturity }.
94
160
  */
95
161
  private getUtilityScores;
96
162
  readFile(path: string, from?: number, lines?: number): {
@@ -131,5 +197,15 @@ export declare class MemorySearch {
131
197
  private fetchEmbeddings;
132
198
  /** Embed a single query text, returns null on failure. */
133
199
  private embedText;
200
+ /**
201
+ * T0/T1 cache lookup. Returns cached results or null.
202
+ * T0: exact query string match (same dbVersion + within TTL).
203
+ * T1: Jaccard similarity ≥ threshold on query tokens.
204
+ */
205
+ private cacheLookup;
206
+ /**
207
+ * Store search results in cache. Evicts oldest entries if over capacity.
208
+ */
209
+ private cacheStore;
134
210
  }
135
211
  //# sourceMappingURL=memory-search.d.ts.map
@@ -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 a}from"node:fs";import{join as o,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:E}=u;import{createLogger as b}from"../utils/logger.js";import{L0Generator as f,L0_DEFAULT as g}from"./l0-generator.js";const T=b("MemorySearch"),x="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=o(t,"memory-index.db"),this.searchDbPath=o(t,"memory-search.db"),this.searchNextDbPath=o(t,"memory-search-next.db"),this.indexHnswPath=o(t,"memory-vectors.hnsw"),this.searchHnswPath=o(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=o(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(){T.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\n -- Utility scoring: tracks how often each chunk is retrieved and used\n CREATE TABLE IF NOT EXISTS chunk_utility (\n chunk_id INTEGER PRIMARY KEY,\n access_count INTEGER DEFAULT 0,\n last_accessed TEXT,\n first_accessed TEXT\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=>T.error(`Embed cycle error: ${e}`))},this.opts.embedIntervalMs)),T.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(),T.info("Memory search engine stopped")}migrateEmbeddingsTable(){if(!this.indexDb)return;if(this.indexDb.prepare("PRAGMA table_info(embeddings)").all().some(e=>"vector"===e.name)){T.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),a=void 0!==n&&n!==i,o=void 0!==s&&s!==r;if(a||o){const e=[];a&&e.push(`model: ${n} → ${i}`),o&&e.push(`dimensions: ${s} → ${r}`),T.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 E(x,t),e(this.indexHnswPath))try{this.indexHnsw.readIndexSync(this.indexHnswPath,!0),T.info(`Loaded HNSW index: ${this.indexHnsw.getCurrentCount()} points`)}catch(e){T.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}),T.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),T.info(`Resized HNSW index: ${t} → ${n}`)}}async search(e,t){const n=t??this.opts.maxResults;if(this.maybeSwap(),!this.searchDb)return T.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){T.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),a=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),o=[];for(let e=0;e<h;e++){const{id:t,score:n}=r[e],s=a.get(t);s&&o.push({id:t,score:n,row:s})}const c=this.getUtilityScores(o.map(e=>e.id)),d=Date.now();for(const e of o){const t=c.get(e.id),n=t?.access_count??0;if(n>0&&(e.score+=.05*Math.log(n+1)),0===n&&e.row.timestamp){const t=new Date(e.row.timestamp).getTime();if(!isNaN(t)){const n=(d-t)/864e5;n>30&&(e.score-=Math.min(.001*(n-30),.05))}}}o.sort((e,t)=>t.score-e.score);const l=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,a=[],o=new Set(e.map((e,t)=>t));a.push(0),o.delete(0);for(;a.length<t&&o.size>0;){let t=-1,i=-1/0;for(const c of o){const o=(e[c].score-r)/h;let d=0;for(const e of a){const t=y(s[c],s[e]);t>d&&(d=t)}const l=n*o-(1-n)*d;l>i&&(i=l,t=c)}if(t<0)break;a.push(t),o.delete(t)}return a.map(t=>e[t])}(o,n,.7),m=[];for(const{id:e,score:t,row:n}of l){const s=n.l0&&this.l0Generator.enabled?n.l0:n.content.length>this.opts.maxSnippetChars?n.content.slice(0,this.opts.maxSnippetChars)+"...":n.content;m.push({chunkId:e,path:n.doc_path,sessionKey:n.session_key,snippet:s,score:t,role:n.role,timestamp:n.timestamp})}return T.info(`Search "${e.slice(0,60)}": ${m.length} results (sparse=${s.length}, dense=${i.length}, candidates=${o.length}, mmr=${l.length})`),m}recordAccess(e){if(!this.indexDb||0===e.length)return;const t=(new Date).toISOString(),n=this.indexDb.prepare("\n INSERT INTO chunk_utility (chunk_id, access_count, last_accessed, first_accessed)\n VALUES (?, 1, ?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n access_count = access_count + 1,\n last_accessed = excluded.last_accessed\n ");this.indexDb.transaction(()=>{for(const s of e)n.run(s,t,t)})(),T.debug(`Recorded access for ${e.length} chunks`)}getUtilityScores(e){const t=new Map;if(!this.searchDb||0===e.length)return t;if(!this.searchDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())return t;const n=e.map(()=>"?").join(","),s=this.searchDb.prepare(`SELECT chunk_id, access_count, last_accessed, first_accessed FROM chunk_utility WHERE chunk_id IN (${n})`).all(...e);for(const e of s)t.set(e.chunk_id,{access_count:e.access_count,last_accessed:e.last_accessed,first_accessed:e.first_accessed});return t}readFile(t,s,i){const r=o(this.memoryDir,t);if(!e(r))return{path:t,content:`[File not found: ${t}]`};const h=n(r,"utf-8"),a=h.split("\n");if(void 0!==s||void 0!==i){const e=Math.max(0,(s??1)-1),n=i??a.length;return{path:t,content:a.slice(e,e+n).join("\n")}}return{path:t,content:h}}listFiles(e,t){const n=D(this.memoryDir),s=[];for(const i of n){const n=d(i.relPath).match(/^(\d{4}-\d{2}-\d{2})/);if(!n)continue;const r=n[1];r>=e&&r<=t&&s.push({path:i.relPath,sessionKey:i.sessionKey,size:i.size})}return s.sort((e,t)=>e.path.localeCompare(t.path)),s}expandChunks(e){if(!this.searchDb||0===e.length)return[];const t=e.map(()=>"?").join(","),n=this.searchDb.prepare(`SELECT id, content, doc_path, role, timestamp FROM chunks WHERE id IN (${t})`).all(...e);return n.length>0&&this.recordAccess(n.map(e=>e.id)),n.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 T.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=a(this.memoryDir,{recursive:!0},(e,t)=>{this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.runIndexCycle()},this.opts.updateDebounceMs)})}catch(e){T.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){T.error(`Index cycle error: ${e}`)}finally{this.indexing=!1}}}async indexFiles(){if(!this.indexDb||!e(this.memoryDir))return;const t=D(this.memoryDir);let s=0;const i=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),r=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),h=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),a=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),o=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),c=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),d=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),l=new Set(t.map(e=>e.relPath));for(const e of d)if(!l.has(e)){const t=a.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}o.run(e),h.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),T.debug(`Removed deleted file from index: ${e}`)}for(const e of t){const t=r.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){s+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const d=w(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),l=a.all(e.relPath);for(const{id:e}of l)try{this.indexHnsw?.markDelete(e)}catch{}o.run(e.relPath),h.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<d.length;t++){const n=d[t];c.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content)}i.run(e.relPath,e.mtimeMs,e.size)})(),s+=d.length,T.debug(`Indexed ${e.relPath}: ${d.length} chunks`)}T.info(`Indexed ${s} chunks from ${t.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;T.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 T.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&&T.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 T.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)})(),T.debug(`Embedded batch ${n/100+1}: ${s.length} chunks`)}catch(e){if(this.stopped)return;T.error(`Embedding batch failed: ${e}`)}}if(this.stopped||!this.indexHnsw)return;this.indexHnsw.writeIndexSync(this.indexHnswPath),this.publishSnapshot(),T.info(`Embedded ${e.length} chunks (HNSW: ${this.indexHnsw.getCurrentCount()} total points)`)}finally{this.embedding=!1}}}publishSnapshot(){if(!this.indexDb)return;const t=o(this.dataDir,".memory-search-next.tmp"),n=o(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)),T.debug("Published search snapshot (DB + HNSW)")}catch(e){T.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 E(x,this.opts.embeddingDimensions),this.searchHnsw.readIndexSync(this.searchHnswPath),this.searchHnsw.setEf(50),T.debug(`Swapped to new search DB + HNSW (${this.searchHnsw.getCurrentCount()} points)`)):T.debug("Swapped to new search DB (no HNSW index yet)")}catch(t){T.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&&T.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 T.error(`Failed to embed query: ${e}`),null}}}function w(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 a=1500,o=100;if(h.length<=a)s.push({role:i,timestamp:r,sessionKey:n,content:h});else{let e=0;for(;e<h.length;){const t=Math.min(e+a,h.length),c=h.slice(e,t);if(s.push({role:i,timestamp:r,sessionKey:n,content:c}),e=t-o,e+o>=h.length)break}}}return s}function D(e){const n=[];return function i(r){let h;try{h=t(r,{withFileTypes:!0})}catch{return}for(const t of h){const h=o(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{}}}(e),n}function y(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 c,unlinkSync as r,watch as a}from"node:fs";import{join as o,relative as h,basename as d,dirname as u}from"node:path";import l from"better-sqlite3";import p from"openai";import m from"hnswlib-node";const{HierarchicalNSW:E}=m;import{createLogger as f}from"../utils/logger.js";import{L0Generator as b,L0_DEFAULT as g}from"./l0-generator.js";const T=f("MemorySearch"),x="cosine",D={draft:1,validated:1.03,core:1.07};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;conceptStore=null;conceptCache=null;conceptCacheMap=null;conceptCacheTime=0;queryCache=[];dbVersion=0;openai=null;indexDbPath;searchDbPath;searchNextDbPath;indexHnswPath;searchHnswPath;searchNextHnswPath;constructor(e,t,n){this.memoryDir=e,this.dataDir=t,this.opts=n,this.indexDbPath=o(t,"memory-index.db"),this.searchDbPath=o(t,"memory-search.db"),this.searchNextDbPath=o(t,"memory-search-next.db"),this.indexHnswPath=o(t,"memory-vectors.hnsw"),this.searchHnswPath=o(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=o(t,"memory-vectors-search-next.hnsw"),this.isOpenAI()&&(this.openai=new p({apiKey:n.apiKey,...n.baseURL?{baseURL:n.baseURL}:{}})),this.l0Generator=new b(n.l0??g)}setConceptStore(e){this.conceptStore=e,T.info("ConceptStore connected — spreading activation enabled"),this.populateChunkEntities()}getConceptCacheEntries(){if(this.conceptCache&&Date.now()-this.conceptCacheTime<36e5)return this.conceptCache;if(!this.conceptStore)return[];const e=this.conceptStore.getConceptCacheData();this.conceptCache=e.filter(e=>e.label.length>=3).map(e=>({id:e.id,label:e.label,labelLower:e.label.toLowerCase(),fan:e.fan,sMax:e.sMax??1.6})),this.conceptCacheMap=new Map;for(const e of this.conceptCache)this.conceptCacheMap.set(e.id,e);return this.conceptCacheTime=Date.now(),T.info(`Concept cache refreshed: ${this.conceptCache.length} entries`),this.conceptCache}extractEntities(e){const t=this.getConceptCacheEntries();if(0===t.length)return[];const n=e.toLowerCase(),s=[];for(const e of t)e.fan>30||n.includes(e.labelLower)&&s.push(e.id);return s}computeSpreadingActivation(e,t,n){if(0===e.length||0===t.length)return 0;const s=this.conceptCacheMap;if(!s)return 0;const i=1/e.length,c=new Set(t);let r=0;for(const t of e){const e=s.get(t);if(!e)continue;const a=Math.max(e.sMax-Math.log(e.fan+1),0);c.has(t)&&(r+=i*a);const o=n.get(t);if(o)for(const{neighbor:e}of o)if(e!==t&&c.has(e)){const t=s.get(e);if(!t)continue;r+=i*Math.max(t.sMax-Math.log(t.fan+1),0)*.5}}return r}populateChunkEntities(){if(!this.indexDb||!this.conceptStore)return;const e=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunk_entities").get().c;if(e>0)return void T.debug(`chunk_entities already populated (${e} rows)`);if(0===this.getConceptCacheEntries().length)return;const t=this.indexDb.prepare("SELECT id, content FROM chunks").all();if(0===t.length)return;const n=this.indexDb.prepare("INSERT OR IGNORE INTO chunk_entities (chunk_id, concept_id) VALUES (?, ?)");let s=0;this.indexDb.transaction(()=>{for(const e of t){const t=this.extractEntities(e.content);for(const i of t)n.run(e.id,i),s++}})(),this.publishSnapshot(),T.info(`Phase 3: Populated chunk_entities — ${s} mappings for ${t.length} chunks`)}getChunkEntities(e,t){if(this.searchDb){const t=this.searchDb.prepare("SELECT concept_id FROM chunk_entities WHERE chunk_id = ?").all(e);if(t.length>0)return t.map(e=>e.concept_id)}return this.extractEntities(t)}isOpenAI(){return(this.opts.baseURL||"https://api.openai.com/v1").includes("openai.com")}getMaxInjectedChars(){return this.opts.maxInjectedChars}async start(){T.info("Starting memory search engine..."),this.stopped=!1,this.indexDb=new l(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\n -- Phase 3: Pre-computed entity→chunk mapping for spreading activation\n CREATE TABLE IF NOT EXISTS chunk_entities (\n chunk_id INTEGER NOT NULL,\n concept_id TEXT NOT NULL,\n PRIMARY KEY (chunk_id, concept_id)\n );\n CREATE INDEX IF NOT EXISTS idx_chunk_entities_concept ON chunk_entities(concept_id);\n\n -- Utility scoring: tracks how often each chunk is retrieved and used\n CREATE TABLE IF NOT EXISTS chunk_utility (\n chunk_id INTEGER PRIMARY KEY,\n access_count INTEGER DEFAULT 0,\n last_accessed TEXT,\n first_accessed TEXT,\n importance INTEGER DEFAULT 5,\n access_times TEXT DEFAULT '[]',\n maturity TEXT DEFAULT 'draft'\n );\n"),this.migrateChunkUtility(),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=>T.error(`Embed cycle error: ${e}`))},this.opts.embedIntervalMs)),this.conceptStore&&this.populateChunkEntities(),T.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(),T.info("Memory search engine stopped")}migrateEmbeddingsTable(){if(!this.indexDb)return;if(this.indexDb.prepare("PRAGMA table_info(embeddings)").all().some(e=>"vector"===e.name)){T.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{r(this.indexHnswPath)}catch{}}}migrateChunkUtility(){for(const e of[this.indexDb,this.searchDb]){if(!e)continue;if(!e.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())continue;const t=e.prepare("PRAGMA table_info(chunk_utility)").all(),n=new Set(t.map(e=>e.name));n.has("importance")||(T.info("Migrating chunk_utility: adding importance column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN importance INTEGER DEFAULT 5")),n.has("access_times")||(T.info("Migrating chunk_utility: adding access_times column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN access_times TEXT DEFAULT '[]'")),n.has("maturity")||(T.info("Migrating chunk_utility: adding maturity column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN maturity TEXT DEFAULT 'draft'"))}}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,c=String(this.opts.embeddingDimensions),a=void 0!==n&&n!==i,o=void 0!==s&&s!==c;if(a||o){const e=[];a&&e.push(`model: ${n} → ${i}`),o&&e.push(`dimensions: ${s} → ${c}`),T.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{r(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",c)}initIndexHnsw(){const t=this.opts.embeddingDimensions;if(this.indexHnsw=new E(x,t),e(this.indexHnswPath))try{this.indexHnsw.readIndexSync(this.indexHnswPath,!0),T.info(`Loaded HNSW index: ${this.indexHnsw.getCurrentCount()} points`)}catch(e){T.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}),T.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),T.info(`Resized HNSW index: ${t} → ${n}`)}}async search(e,t){const n=t??this.opts.maxResults;if(this.maybeSwap(),!this.searchDb)return T.warn("Search DB not available"),[];const s=this.cacheLookup(e,n);if(s)return s;const i=this.bm25Search(e,20);let c=[];try{const t=await this.embedText(e);t&&this.searchHnsw&&this.searchHnsw.getCurrentCount()>0&&(c=this.denseSearch(t,20))}catch(e){T.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}(i,c,this.opts.rrfK),a=Math.min(r.length,40),o=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),h=[];for(let e=0;e<a;e++){const{id:t,score:n}=r[e],s=o.get(t);s&&h.push({id:t,score:n,row:s})}const d=36e5,u=this.getUtilityScores(h.map(e=>e.id)),l=Date.now();for(const e of h){const t=u.get(e.id),n=t?.access_times??[],s=t?.importance??5;let i=0;if(n.length>0)for(const e of n){const t=Math.max((l-e)/d,.1);i+=Math.pow(t,-.5)}else if(e.row.timestamp){const t=new Date(e.row.timestamp).getTime();if(!isNaN(t)){const e=Math.max((l-t)/d,.1);i=Math.pow(e,-.5)}}if(i>0){const t=Math.log(i),n=.02*(s-5);e.score+=.01*t+n}}if(this.conceptStore){const t=this.extractEntities(e);if(t.length>0){const e=this.conceptStore.getNeighbors(t);let n=0;for(const s of h){const i=this.getChunkEntities(s.id,s.row.content),c=this.computeSpreadingActivation(t,i,e);c>0&&(s.score+=.015*c,n++)}n>0&&T.debug(`Spreading activation: boosted ${n}/${h.length} candidates (query entities: [${t.join(", ")}])`)}}for(const e of h){const t=u.get(e.id),n=D[t?.maturity??"draft"];1!==n&&(e.score*=n)}h.sort((e,t)=>t.score-e.score);const p=function(e,t,n){if(e.length<=t)return e;const s=e.map(e=>S(e.row.content)),i=e[0]?.score??1,c=e[e.length-1]?.score??0,r=i-c||1,a=[],o=new Set(e.map((e,t)=>t));a.push(0),o.delete(0);for(;a.length<t&&o.size>0;){let t=-1,i=-1/0;for(const h of o){const o=(e[h].score-c)/r;let d=0;for(const e of a){const t=R(s[h],s[e]);t>d&&(d=t)}const u=n*o-(1-n)*d;u>i&&(i=u,t=h)}if(t<0)break;a.push(t),o.delete(t)}return a.map(t=>e[t])}(h,n,.7),m=[];for(const{id:e,score:t,row:n}of p){const s=n.l0&&this.l0Generator.enabled?n.l0:n.content.length>this.opts.maxSnippetChars?n.content.slice(0,this.opts.maxSnippetChars)+"...":n.content;m.push({chunkId:e,path:n.doc_path,sessionKey:n.session_key,snippet:s,score:t,role:n.role,timestamp:n.timestamp})}return this.cacheStore(e,m),T.info(`Search "${e.slice(0,60)}": ${m.length} results (sparse=${i.length}, dense=${c.length}, candidates=${h.length}, mmr=${p.length})`),m}recordAccess(e){const t=this.indexDb??this.searchDb;if(!t||0===e.length)return;const n=(new Date).toISOString(),s=Date.now(),i=t.prepare("\n INSERT INTO chunk_utility (chunk_id, access_count, last_accessed, first_accessed, access_times)\n VALUES (?, 1, ?, ?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n access_count = access_count + 1,\n last_accessed = excluded.last_accessed,\n access_times = excluded.access_times\n "),c=t.prepare("SELECT access_count, access_times, maturity FROM chunk_utility WHERE chunk_id = ?"),r=t.prepare("UPDATE chunk_utility SET maturity = ? WHERE chunk_id = ?");t.transaction(()=>{for(const t of e){const e=c.get(t);let a=[];if(e?.access_times)try{a=JSON.parse(e.access_times)}catch{}a.push(s),a.length>50&&(a=a.slice(-50)),i.run(t,n,n,JSON.stringify(a));const o=(e?.access_count??0)+1,h=e?.maturity??"draft",d=y(h,o);d!==h&&(r.run(d,t),T.debug(`Chunk ${t} maturity: ${h} → ${d} (access_count=${o})`))}})(),T.debug(`Recorded access for ${e.length} chunks`)}getRecentlyAccessedChunks(e=7,t=100){if(!this.searchDb)return[];if(!this.searchDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())return[];const n=new Date(Date.now()-864e5*e).toISOString();return this.searchDb.prepare("\n SELECT cu.chunk_id, c.content, c.doc_path, cu.importance, cu.access_count, cu.last_accessed\n FROM chunk_utility cu\n JOIN chunks c ON c.id = cu.chunk_id\n WHERE cu.last_accessed >= ? AND cu.access_count > 0\n ORDER BY cu.access_count DESC, cu.last_accessed DESC\n LIMIT ?\n ").all(n,t).map(e=>({chunkId:e.chunk_id,content:e.content.slice(0,500),path:e.doc_path,importance:e.importance??5,accessCount:e.access_count,lastAccessed:e.last_accessed}))}updateImportance(e){const t=this.indexDb??this.searchDb;if(!t||0===e.length)return 0;const n=t.prepare("UPDATE chunk_utility SET importance = ? WHERE chunk_id = ?");let s=0;return t.transaction(()=>{for(const{chunkId:t,importance:i}of e){const e=Math.max(1,Math.min(10,Math.round(i)));n.run(e,t).changes>0&&s++}})(),T.info(`Updated importance for ${s}/${e.length} chunks`),s}getUtilityScores(e){const t=new Map;if(!this.searchDb||0===e.length)return t;if(!this.searchDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())return t;const n=e.map(()=>"?").join(","),s=this.searchDb.prepare(`SELECT chunk_id, access_count, last_accessed, first_accessed, importance, access_times, maturity FROM chunk_utility WHERE chunk_id IN (${n})`).all(...e);for(const e of s){let n=[];if(e.access_times)try{n=JSON.parse(e.access_times)}catch{}const s="validated"===e.maturity||"core"===e.maturity?e.maturity:"draft";t.set(e.chunk_id,{access_count:e.access_count,last_accessed:e.last_accessed,first_accessed:e.first_accessed,importance:e.importance??5,access_times:n,maturity:s})}return t}readFile(t,s,i){const c=o(this.memoryDir,t);if(!e(c))return{path:t,content:`[File not found: ${t}]`};const r=n(c,"utf-8"),a=r.split("\n");if(void 0!==s||void 0!==i){const e=Math.max(0,(s??1)-1),n=i??a.length;return{path:t,content:a.slice(e,e+n).join("\n")}}return{path:t,content:r}}listFiles(e,t){const n=k(this.memoryDir),s=[];for(const i of n){const n=d(i.relPath).match(/^(\d{4}-\d{2}-\d{2})/);if(!n)continue;const c=n[1];c>=e&&c<=t&&s.push({path:i.relPath,sessionKey:i.sessionKey,size:i.size})}return s.sort((e,t)=>e.path.localeCompare(t.path)),s}expandChunks(e){if(!this.searchDb||0===e.length)return[];const t=e.map(()=>"?").join(","),n=this.searchDb.prepare(`SELECT id, content, doc_path, role, timestamp FROM chunks WHERE id IN (${t})`).all(...e);return n.length>0&&this.recordAccess(n.map(e=>e.id)),n.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 T.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=a(this.memoryDir,{recursive:!0},(e,t)=>{this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.runIndexCycle()},this.opts.updateDebounceMs)})}catch(e){T.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){T.error(`Index cycle error: ${e}`)}finally{this.indexing=!1}}}async indexFiles(){if(!this.indexDb||!e(this.memoryDir))return;const t=k(this.memoryDir);let s=0;const i=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),c=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),r=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),a=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),o=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),h=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),d=this.indexDb.prepare("\n INSERT INTO chunk_utility (chunk_id, importance)\n VALUES (?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n importance = excluded.importance\n "),u=this.indexDb.prepare("INSERT OR IGNORE INTO chunk_entities (chunk_id, concept_id) VALUES (?, ?)"),l=this.indexDb.prepare("DELETE FROM chunk_entities WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),p=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),m=new Set(t.map(e=>e.relPath));for(const e of p)if(!m.has(e)){const t=a.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}o.run(e),r.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),T.debug(`Removed deleted file from index: ${e}`)}for(const e of t){const t=c.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){s+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const p=_(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),m=a.all(e.relPath);for(const{id:e}of m)try{this.indexHnsw?.markDelete(e)}catch{}l.run(e.relPath),o.run(e.relPath),r.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<p.length;t++){const n=p[t],s=h.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content),i=Number(s.lastInsertRowid),c=w(n.content,e.relPath);if(5!==c&&d.run(i,c),this.conceptStore){const e=this.extractEntities(n.content);for(const t of e)u.run(i,t)}}i.run(e.relPath,e.mtimeMs,e.size)})(),s+=p.length,T.debug(`Indexed ${e.relPath}: ${p.length} chunks`)}T.info(`Indexed ${s} chunks from ${t.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;T.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 T.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)),c=this.opts.prefixDocument.trim();c&&T.debug(`Using prefixDocument (template: ${c}) → 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 T.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)})(),T.debug(`Embedded batch ${n/100+1}: ${s.length} chunks`)}catch(e){if(this.stopped)return;T.error(`Embedding batch failed: ${e}`)}}if(this.stopped||!this.indexHnsw)return;this.indexHnsw.writeIndexSync(this.indexHnswPath),this.publishSnapshot(),T.info(`Embedded ${e.length} chunks (HNSW: ${this.indexHnsw.getCurrentCount()} total points)`)}finally{this.embedding=!1}}}publishSnapshot(){if(!this.indexDb)return;const t=o(this.dataDir,".memory-search-next.tmp"),n=o(this.dataDir,".memory-vectors-search-next.tmp");try{this.indexDb.pragma("wal_checkpoint(TRUNCATE)"),i(this.indexDbPath,t),c(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),c(n,this.searchNextHnswPath)),T.debug("Published search snapshot (DB + HNSW)")}catch(e){T.error(`Failed to publish snapshot: ${e}`);try{r(t)}catch{}try{r(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,c(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&c(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new l(this.searchDbPath),this.searchDb.pragma("journal_mode = WAL"),this.migrateChunkUtility(),this.dbVersion++,this.queryCache=[],e(this.searchHnswPath)?(this.searchHnsw=new E(x,this.opts.embeddingDimensions),this.searchHnsw.readIndexSync(this.searchHnswPath),this.searchHnsw.setEf(50),T.debug(`Swapped to new search DB + HNSW (${this.searchHnsw.getCurrentCount()} points)`)):T.debug("Swapped to new search DB (no HNSW index yet)")}catch(t){T.error(`Failed to swap search DB: ${t}`);try{e(this.searchDbPath)&&(this.searchDb=new l(this.searchDbPath))}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&&T.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 T.error(`Failed to embed query: ${e}`),null}}cacheLookup(e,t){const n=Date.now();this.queryCache=this.queryCache.filter(e=>e.dbVersion===this.dbVersion&&n-e.timestamp<6e5);const s=this.queryCache.find(t=>t.query===e);if(s)return T.info(`Cache T0 (exact) hit for "${e.slice(0,60)}"`),s.results.slice(0,t);const i=S(e);if(0===i.size)return null;let c=null,r=0;for(const e of this.queryCache){const t=R(i,e.tokens);t>r&&(r=t,c=e)}return c&&r>=.75?(T.info(`Cache T1 (fuzzy, sim=${r.toFixed(2)}) hit for "${e.slice(0,60)}" ≈ "${c.query.slice(0,60)}"`),c.results.slice(0,t)):null}cacheStore(e,t){this.queryCache.length>=200&&this.queryCache.shift(),this.queryCache.push({query:e,tokens:S(e),results:t,timestamp:Date.now(),dbVersion:this.dbVersion})}}function y(e,t){switch(e){case"draft":return t>=3?"validated":"draft";case"validated":return t>=10?"core":t<1?"draft":"validated";case"core":return t<7?"validated":"core";default:return"draft"}}function w(e,t){let n=5;return"MEMORY.md"===t&&(n=9),t.startsWith("knowledge/")&&(n=8),/\b(decid|commit|important|critical|must|priority|never|always)\b/i.test(e)&&(n=Math.max(n,7)),/\b(password|key|token|secret|wallet|address)\b/i.test(e)&&(n=Math.max(n,8)),/\b(ok|thanks|got it|sure|yes|no|grazie|va bene)\b/i.test(e)&&e.length<80&&(n=Math.min(n,2)),/\d{4}-\d{2}-\d{2}/.test(e)&&(n=Math.max(n,6)),/https?:\/\//.test(e)&&(n=Math.max(n,6)),/\b(TODO|FIXME|HACK|BUG)\b/.test(e)&&(n=Math.max(n,7)),Math.min(10,Math.max(1,n))}function _(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]:"",c=t?t[2]:"",r=t?e.slice(t[0].length).trim():e.trim();if(!r)continue;const a=1500,o=100;if(r.length<=a)s.push({role:i,timestamp:c,sessionKey:n,content:r});else{let e=0;for(;e<r.length;){const t=Math.min(e+a,r.length),h=r.slice(e,t);if(s.push({role:i,timestamp:c,sessionKey:n,content:h}),e=t-o,e+o>=r.length)break}}}return s}function k(e){const n=[];return function i(c){let r;try{r=t(c,{withFileTypes:!0})}catch{return}for(const t of r){const r=o(c,t.name);if(t.isDirectory())i(r);else if(t.name.endsWith(".md"))try{const t=s(r),i=h(e,r),c=d(u(r));n.push({fullPath:r,relPath:i,sessionKey:c===d(e)?"":c,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}(e),n}function S(e){const t=new Set;for(const n of e.toLowerCase().matchAll(/\b\w{2,}\b/g))t.add(n[0]);return t}function R(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 c=e.size+t.size-n;return 0===c?0:n/c}
package/dist/server.d.ts CHANGED
@@ -50,6 +50,8 @@ export declare class Server {
50
50
  private triageHeartbeat;
51
51
  private setupCommands;
52
52
  private registerChannels;
53
+ /** Handle an incoming user reaction — annotate in memory file (no API call). */
54
+ private handleReaction;
53
55
  handleMessage(msg: IncomingMessage, isRetry?: boolean): Promise<string>;
54
56
  start(): Promise<void>;
55
57
  private initCronAndHeartbeat;
package/dist/server.js CHANGED
@@ -1 +1 @@
1
- import{readFileSync as e,writeFileSync as t,mkdirSync as s,existsSync as n}from"node:fs";import{join as o,resolve as i}from"node:path";import{TokenDB as r}from"./auth/token-db.js";import{NodeSignatureDB as a}from"./auth/node-signature-db.js";import{SessionDB as h}from"./agent/session-db.js";import{ChannelManager as c}from"./gateway/channel-manager.js";import{TypingCoordinator as g}from"./gateway/typing-coordinator.js";import{TelegramChannel as m}from"./gateway/channels/telegram/index.js";import{WhatsAppChannel as l}from"./gateway/channels/whatsapp.js";import{WebChatChannel as d}from"./gateway/channels/webchat.js";import{MeshChannel as f}from"./gateway/channels/mesh.js";import{ResponsesChannel as p}from"./channels/responses.js";import{AgentService as u}from"./agent/agent-service.js";import{SessionManager as y}from"./agent/session-manager.js";import{buildPrompt as b,buildSystemPrompt as S}from"./agent/prompt-builder.js";import{ensureWorkspaceFiles as w,loadWorkspaceFiles as v}from"./agent/workspace-files.js";import{NodeRegistry as M}from"./gateway/node-registry.js";import{MemoryManager as R}from"./memory/memory-manager.js";import{MessageProcessor as C}from"./media/message-processor.js";import{loadSTTProvider as A}from"./stt/stt-loader.js";import{CommandRegistry as $}from"./commands/command-registry.js";import{NewCommand as T}from"./commands/new.js";import{CompactCommand as k}from"./commands/compact.js";import{ModelCommand as j,DefaultModelCommand as D}from"./commands/model.js";import{StopCommand as I}from"./commands/stop.js";import{HelpCommand as x}from"./commands/help.js";import{McpCommand as E}from"./commands/mcp.js";import{ModelsCommand as _}from"./commands/models.js";import{CoderCommand as N}from"./commands/coder.js";import{SandboxCommand as P}from"./commands/sandbox.js";import{SubAgentsCommand as F}from"./commands/subagents.js";import{CustomSubAgentsCommand as U}from"./commands/customsubagents.js";import{StatusCommand as K}from"./commands/status.js";import{ShowToolCommand as O}from"./commands/showtool.js";import{UsageCommand as H}from"./commands/usage.js";import{DebugA2UICommand as B}from"./commands/debuga2ui.js";import{DebugDynamicCommand as L}from"./commands/debugdynamic.js";import{CronService as z}from"./cron/cron-service.js";import{stripHeartbeatToken as Q,isHeartbeatContentEffectivelyEmpty as W}from"./cron/heartbeat-token.js";import{createServerToolsServer as q}from"./tools/server-tools.js";import{createCronToolsServer as G}from"./tools/cron-tools.js";import{createTTSToolsServer as V}from"./tools/tts-tools.js";import{createMemoryToolsServer as J}from"./tools/memory-tools.js";import{createBrowserToolsServer as X}from"./tools/browser-tools.js";import{createPicoToolsServer as Y}from"./tools/pico-tools.js";import{createPlasmaClientToolsServer as Z}from"./tools/plasma-client-tools.js";import{createConceptToolsServer as ee}from"./tools/concept-tools.js";import{BrowserService as te}from"./browser/browser-service.js";import{MemorySearch as se}from"./memory/memory-search.js";import{ConceptStore as ne}from"./memory/concept-store.js";import{stripMediaLines as oe}from"./utils/media-response.js";import{loadConfig as ie,loadRawConfig as re,backupConfig as ae,resolveModelEntry as he,modelRefName as ce}from"./config.js";import{stringify as ge}from"yaml";import{createLogger as me}from"./utils/logger.js";import{SessionErrorHandler as le}from"./agent/session-error-handler.js";import{initStickerCache as de}from"./gateway/channels/telegram/stickers.js";const fe=me("Server");export class Server{config;tokenDb;sessionDb;nodeSignatureDb;channelManager;typingCoordinator;agentService;sessionManager;memoryManager=null;nodeRegistry;messageProcessor;commandRegistry;serverToolsFactory;coderSkill;showToolUse=!1;subagentsEnabled=!0;customSubAgentsEnabled=!1;chatSettings=new Map;cronService=null;browserService;memorySearch=null;conceptStore=null;whatsappQr=null;whatsappConnected=!1;whatsappError;webChatChannel=null;meshChannel=null;autoRenewTimer=null;constructor(e){this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.tokenDb=new r(e.dbPath),this.sessionDb=new h(e.dbPath),this.nodeSignatureDb=new a(e.dbPath),this.nodeRegistry=new M,this.sessionManager=new y(this.sessionDb),e.memory.enabled&&(this.memoryManager=new R(e.memoryDir,e.timezone));const t=A(e),n=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new C(t,n),de(e.dataDir),this.commandRegistry=new $,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.typingCoordinator=new g(this.channelManager),this.serverToolsFactory=()=>q({restartFn:()=>this.triggerRestart(),timezone:this.config.timezone,memoryDir:this.config.memoryDir,warmRestart:this.config.warmRestart}),this.cronService=this.createCronService(),this.createMemorySearch(e),this.browserService=new te,this.conceptStore=new ne(e.dataDir);const i=o(e.dataDir,"CONCEPTS.md");this.conceptStore.importFromTurtleIfEmpty(i);const m=o(e.agent.workspacePath,".plasma"),l=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new u(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>V(()=>this.config):void 0,this.memorySearch?()=>J(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>X({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,l?()=>Y({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=v(this.config.dataDir);return S({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Z({plasmaRootDir:m,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>ee(this.conceptStore):void 0),w(e.dataDir),s(o(e.agent.workspacePath,".claude","skills"),{recursive:!0}),s(o(e.agent.workspacePath,".claude","commands"),{recursive:!0}),s(o(e.agent.workspacePath,".plugins"),{recursive:!0})}getChatSetting(e,t){return this.chatSettings.get(e)?.[t]}setChatSetting(e,t,s){const n=this.chatSettings.get(e)??{};n[t]=s,this.chatSettings.set(e,n)}createCronService(){return new z({storePath:this.config.cronStorePath,enabled:this.config.cron.enabled,defaultTimezone:this.config.timezone,onExecute:e=>this.executeCronJob(e),sessionReaper:{pruneStaleSessions:e=>this.sessionDb.pruneStaleCronSessions(e)}})}createMemorySearch(e){if(!e.memory.enabled||!e.memory.search.enabled)return;const t=e.memory.search,s=he(e,t.modelRef),n=(s?.useEnvVar?process.env[s.useEnvVar]:s?.apiKey)||"",o=s?.baseURL||"";if(n)return this.memorySearch=new se(e.memoryDir,e.dataDir,{apiKey:n,baseURL:o||void 0,embeddingModel:t.embeddingModel,embeddingDimensions:t.embeddingDimensions,prefixQuery:t.prefixQuery,prefixDocument:t.prefixDocument,updateDebounceMs:t.updateDebounceMs,embedIntervalMs:t.embedIntervalMs,maxResults:t.maxResults,maxSnippetChars:t.maxSnippetChars,maxInjectedChars:t.maxInjectedChars,rrfK:t.rrfK,l0:e.memory.l0??{enabled:!0,model:""}}),J(this.memorySearch);fe.warn(`Memory search enabled but no API key found for modelRef "${t.modelRef}". Search will not start.`)}stopMemorySearch(){this.memorySearch&&(this.memorySearch.stop(),this.memorySearch=null)}collectBroadcastTargets(){const e=new Set,t=[],s=(s,n)=>{const o=`${s}:${n}`;e.has(o)||(e.add(o),t.push({channel:s,chatId:n}))},n=this.config.channels;for(const[e,t]of Object.entries(n)){if("responses"===e)continue;if(!t?.enabled||!this.channelManager.getAdapter(e))continue;const n=t.accounts;if(n)for(const t of Object.values(n)){const n=t?.allowFrom;if(Array.isArray(n))for(const t of n){const n=String(t).trim();n&&s(e,n)}}}for(const e of this.sessionDb.listSessions()){const t=e.sessionKey.indexOf(":");if(t<0)continue;const n=e.sessionKey.substring(0,t),o=e.sessionKey.substring(t+1);"cron"!==n&&o&&(this.channelManager.getAdapter(n)&&s(n,o))}return t}async executeCronJob(e){const t=this.config.cron.broadcastEvents;if(!t&&!this.channelManager.getAdapter(e.channel))return fe.warn(`Cron job "${e.name}": skipped (channel "${e.channel}" is not active)`),{response:"",delivered:!1};if(e.suppressToken&&"__heartbeat"===e.name){const t=this.triageHeartbeat();if(!t.shouldRun)return fe.info(`Cron job "${e.name}": skipped by triage (${t.reason})`),{response:"",delivered:!1};fe.info(`Cron job "${e.name}": triage passed (${t.reason})`)}const s="boolean"==typeof e.isolated?e.isolated:this.config.cron.isolated,n=s?"cron":e.channel,o=s?e.name:e.chatId;fe.info(`Cron job "${e.name}": session=${n}:${o}, delivery=${e.channel}:${e.chatId}${t?" (broadcast)":""}`);const i={chatId:o,userId:"cron",channelName:n,text:e.message,attachments:[]},r=`${n}:${o}`;try{const s=await this.handleMessage(i);let n=s;if(e.suppressToken){const t=this.config.cron.heartbeat.ackMaxChars,{shouldSkip:o,text:i}=Q(s,t);if(o)return fe.info(`Cron job "${e.name}": response suppressed (HEARTBEAT_OK)`),{response:s,delivered:!1};n=i}if(t){const t=this.collectBroadcastTargets();fe.info(`Cron job "${e.name}": broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(e=>this.channelManager.sendResponse(e.channel,e.chatId,n)));for(const e of t)this.typingCoordinator.release(e.channel,e.chatId)}else await this.channelManager.sendResponse(e.channel,e.chatId,n),this.typingCoordinator.release(e.channel,e.chatId);return{response:n,delivered:!0}}finally{this.agentService.destroySession(r),this.sessionManager.resetSession(r),this.memoryManager&&this.memoryManager.clearSession(r),fe.info(`Cron job "${e.name}": ephemeral session destroyed (${r})`)}}triageHeartbeat(){const t=(new Date).getHours(),s=o(this.config.agent.workspacePath,"attention","pending_signals.md");if(n(s))try{const t=e(s,"utf-8").trim().split("\n").filter(e=>e.trim()&&!e.startsWith("#"));if(t.length>0)return{shouldRun:!0,reason:`${t.length} pending signal(s)`}}catch{}const i=o(this.config.dataDir,"HEARTBEAT.md");let r=!1;if(n(i))try{const t=e(i,"utf-8");r=!W(t)}catch{r=!0}const a=this.sessionDb.hasRecentActivity(3e5);return t>=23||t<7?a?{shouldRun:!0,reason:"night mode but recent messages"}:{shouldRun:!1,reason:"night mode, no activity"}:r||a?{shouldRun:!0,reason:a?"recent messages":"actionable heartbeat"}:{shouldRun:!1,reason:"no signals, no messages, empty heartbeat"}}setupCommands(){this.commandRegistry.register(new T),this.commandRegistry.register(new k);const e=()=>this.config.agent.picoAgent??{enabled:!1,modelRefs:[]};this.commandRegistry.register(new j(()=>this.config.models??[],async(e,t)=>{const s=this.config.models?.find(e=>e.id===t),n=this.config.agent.picoAgent,o=e=>!(!n?.enabled||!Array.isArray(n.modelRefs))&&n.modelRefs.some(t=>t.split(":")[0]===e),i=this.sessionManager.getModel(e)||this.config.agent.model,r=he(this.config,i),a=o(r?.name??ce(i)),h=o(s?.name??t);this.sessionManager.setModel(e,t),this.agentService.destroySession(e);const c=a||h;return c&&this.sessionManager.resetSession(e),c},e)),this.commandRegistry.register(new D(()=>this.config.models??[],async e=>{const s=this.config.models?.find(t=>t.id===e),n=s?`${s.name}:${s.id}`:e;this.config.agent.model=n;const o=this.config.agent.picoAgent;if(o?.enabled&&Array.isArray(o.modelRefs)){const t=s?.name??e,n=o.modelRefs.findIndex(e=>e.split(":")[0]===t);if(n>0){const[e]=o.modelRefs.splice(n,1);o.modelRefs.unshift(e)}}try{const e=i(process.cwd(),"config.yaml"),s=re(e);s.agent||(s.agent={}),s.agent.model=n,o?.enabled&&Array.isArray(o.modelRefs)&&(s.agent.picoAgent||(s.agent.picoAgent={}),s.agent.picoAgent.modelRefs=[...o.modelRefs]),ae(e),t(e,ge(s),"utf-8")}catch{}},e)),this.commandRegistry.register(new _(()=>this.config.models??[],e)),this.commandRegistry.register(new N(e=>this.getChatSetting(e,"coderSkill")??this.coderSkill,(e,t)=>this.setChatSetting(e,"coderSkill",t))),this.commandRegistry.register(new P(e=>this.getChatSetting(e,"sandboxEnabled")??!1,(e,t)=>this.setChatSetting(e,"sandboxEnabled",t))),this.commandRegistry.register(new O(e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,(e,t)=>this.setChatSetting(e,"showToolUse",t))),this.commandRegistry.register(new F(e=>this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,(e,t)=>this.setChatSetting(e,"subagentsEnabled",t))),this.commandRegistry.register(new U(e=>this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,(e,t)=>this.setChatSetting(e,"customSubAgentsEnabled",t),()=>this.config)),this.commandRegistry.register(new K(e=>{const t=this.sessionManager.getModel(e)||this.config.agent.model,s=this.config.agent.mainFallback,n=he(this.config,t),o=s?he(this.config,s):void 0;return{configDefaultModel:ce(this.config.agent.model),agentModel:n?.id??t,agentModelName:n?.name??ce(t),fallbackModel:o?.id??s,fallbackModelName:o?.name??(s?ce(s):void 0),fallbackActive:this.agentService.isFallbackActive(e),coderSkill:this.getChatSetting(e,"coderSkill")??this.coderSkill,showToolUse:this.getChatSetting(e,"showToolUse")??this.showToolUse,subagentsEnabled:this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,customSubAgentsEnabled:this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,sandboxEnabled:this.getChatSetting(e,"sandboxEnabled")??!1,connectedNodes:this.nodeRegistry.listNodes().map(e=>({nodeId:e.nodeId,displayName:e.displayName,hostname:e.hostname}))}})),this.commandRegistry.register(new I(e=>this.agentService.interrupt(e))),this.commandRegistry.register(new E(()=>this.agentService.getToolServers())),this.commandRegistry.register(new x(()=>this.agentService.getSdkSlashCommands())),this.commandRegistry.register(new H(e=>this.agentService.getUsage(e))),this.commandRegistry.register(new B(this.nodeRegistry)),this.commandRegistry.register(new L(this.nodeRegistry))}registerChannels(){if(this.config.channels.telegram.enabled){const e=this.config.channels.telegram.accounts;for(const[t,s]of Object.entries(e)){if(!s.botToken){fe.error(`Telegram account "${t}" has no botToken configured — skipping. Check your config.yaml.`);continue}const e=new m(s,t,this.tokenDb);this.channelManager.registerAdapter(e)}}if(this.config.channels.whatsapp.enabled){const e=this.config.channels.whatsapp.accounts;for(const[t,s]of Object.entries(e)){const e=new l(s);e.setQrCallback((e,t,s)=>{this.whatsappQr=e?{dataUrl:e,timestamp:Date.now()}:null,this.whatsappConnected=t,this.whatsappError=s}),this.channelManager.registerAdapter(e)}}if(this.config.channels.responses.enabled){const e=new p({host:this.config.host,port:this.config.channels.responses.port},this.tokenDb);this.channelManager.registerAdapter(e)}if(this.config.channels.mesh.enabled){const e=this.config.channels.mesh;e.agentId&&e.privateKey?(this.meshChannel||(this.meshChannel=new f({brokerUrl:e.brokerUrl,agentId:e.agentId,privateKey:e.privateKey,reconnectDelayMs:e.reconnectDelayMs}),this.meshChannel.onReply((e,t,s)=>{fe.info(`[Mesh] Routing reply from ${t} to origin session ${e}`);const n=e.indexOf(":"),o=n>0?e.substring(0,n):"mesh",i=n>0?e.substring(n+1):t,r={chatId:i,userId:t,channelName:o,text:`[Mesh reply from ${t}] ${s}`,attachments:[],username:t};this.handleMessage(r).then(t=>{t&&t.trim()&&this.channelManager.sendResponse(o,i,t).catch(t=>{fe.error(`[Mesh] Failed to deliver reply-response to ${e}: ${t}`)})}).catch(s=>{fe.error(`[Mesh] Failed to process reply from ${t} in session ${e}: ${s}`)})})),this.channelManager.registerAdapter(this.meshChannel)):fe.warn("Mesh channel enabled but agentId or privateKey not configured")}this.webChatChannel||(this.webChatChannel=new d),this.channelManager.registerAdapter(this.webChatChannel)}async handleMessage(e,t=!1){const s=`${e.channelName}:${e.chatId}`,n=e.text?e.text.length>15?e.text.slice(0,15)+"...":e.text:"[no text]";fe.info(`Message from ${s} (user=${e.userId}, ${e.username??"?"}): ${n}`),this.config.verboseDebugLogs&&fe.debug(`Message from ${s} full text: ${e.text??"[no text]"}`);try{if(this.typingCoordinator.acquire(e.channelName,e.chatId),e.text){if(e.text.startsWith("__ask:")){const t=e.text.substring(6);return this.agentService.resolveQuestion(s,t),""}if(this.agentService.hasPendingQuestion(s)){const t=e.text.trim();return this.agentService.resolveQuestion(s,t),`Selected: ${t}`}if(e.text.startsWith("__tool_perm:")){const t="__tool_perm:approve"===e.text;return this.agentService.resolvePermission(s,t),t?"Tool approved.":"Tool denied."}if(this.agentService.hasPendingPermission(s)){const t=e.text.trim().toLowerCase();if("approve"===t||"approva"===t)return this.agentService.resolvePermission(s,!0),"Tool approved.";if("deny"===t||"vieta"===t||"blocca"===t)return this.agentService.resolvePermission(s,!1),"Tool denied."}}const n=!0===e.__passthrough;if(!n&&e.text&&this.commandRegistry.isCommand(e.text)){const t=await this.commandRegistry.dispatch(e.text,{sessionKey:s,chatId:e.chatId,channelName:e.channelName,userId:e.userId});if(t)return t.passthrough?this.handleMessage({...e,text:t.passthrough,__passthrough:!0}):(t.resetSession?(this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s)):t.resetAgent&&this.agentService.destroySession(s),t.buttons&&t.buttons.length>0?(await this.channelManager.sendButtons(e.channelName,e.chatId,t.text,t.buttons),""):t.text)}if(!n&&e.text?.startsWith("/"))return this.agentService.isBusy(s)?"I'm busy right now. Please resend this request later.":this.handleMessage({...e,text:e.text,__passthrough:!0});const o=this.sessionManager.getOrCreate(s),i=await this.messageProcessor.process(e),r=b(i,void 0,{sessionKey:s,channel:e.channelName,chatId:e.chatId},this.config.timezone);fe.debug(`[${s}] Prompt to agent (${r.text.length} chars): ${this.config.verboseDebugLogs?r.text:r.text.slice(0,15)+"..."}${r.images.length>0?` [+${r.images.length} image(s)]`:""}`);const a=o.model,h={sessionKey:s,channel:e.channelName,chatId:e.chatId,sessionId:o.sessionId??"",memoryFile:this.memoryManager?this.memoryManager.getConversationFile(s):"",attachmentsDir:this.memoryManager?this.memoryManager.getAttachmentsDir(s):""},c=v(this.config.dataDir),g={config:this.config,sessionContext:h,workspaceFiles:c,mode:"full",hasNodeTools:this.agentService.hasNodeTools(),hasMessageTools:this.agentService.hasMessageTools(),coderSkill:this.getChatSetting(s,"coderSkill")??this.coderSkill,toolServers:this.agentService.getToolServers()},m=S(g),l=S({...g,mode:"minimal"});fe.debug(`[${s}] System prompt (${m.length} chars): ${this.config.verboseDebugLogs?m:m.slice(0,15)+"..."}`);try{const n=await this.agentService.sendMessage(s,r,o.sessionId,m,l,a,this.getChatSetting(s,"coderSkill")??this.coderSkill,this.getChatSetting(s,"subagentsEnabled")??this.subagentsEnabled,this.getChatSetting(s,"customSubAgentsEnabled")??this.customSubAgentsEnabled,this.getChatSetting(s,"sandboxEnabled")??!1);if(n.sessionReset){if("[AGENT_CLOSED]"===n.response)return fe.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),"";{const i=n.response||"Session corruption detected",r={sessionKey:s,sessionId:o.sessionId,error:new Error(i),timestamp:new Date},a=le.analyzeError(r.error,r),h=le.getRecoveryStrategy(a);return fe.warn(`[${s}] ${h.message} (error: ${i})`),this.sessionManager.updateSessionId(s,""),h.clearSession&&(this.agentService.destroySession(s),this.memoryManager&&this.memoryManager.clearSession(s)),"clear_and_retry"!==h.action||t?"[SESSION_CORRUPTED] The previous session is no longer valid. Use /new to start a new one.":(fe.info(`[${s}] Retrying with fresh session after: ${i}`),this.handleMessage(e,!0))}}fe.debug(`[${s}] Response from agent (session=${n.sessionId}, len=${n.response.length}): ${this.config.verboseDebugLogs?n.response:n.response.slice(0,15)+"..."}`),n.sessionId&&this.sessionManager.updateSessionId(s,n.sessionId);const h="cron"===e.userId,c=!0===this.config.cron.trackMemory;if(this.memoryManager&&(!h||c)){const e=(r.text||"[media]").replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\n?/,"").trim();await this.memoryManager.append(s,"user",e,i.savedFiles.length>0?i.savedFiles:void 0),await this.memoryManager.append(s,"assistant",oe(n.fullResponse??n.response))}if("max_turns"===n.errorType){const e="\n\n[MAX_TURNS] The agent reached the maximum number of turns. You can continue the conversation by sending another message.";return n.response?n.response+e:e.trim()}if("max_budget"===n.errorType){const e="\n\n[MAX_BUDGET] The agent reached the maximum budget for this request.";return n.response?n.response+e:e.trim()}if("refusal"===n.stopReason){const e="\n\n[REFUSAL] The model declined to fulfill this request.";return n.response?n.response+e:e.trim()}if("max_tokens"===n.stopReason){const e="\n\n[TRUNCATED] The response was cut short because it exceeded the output token limit. You can ask me to continue.";return n.response?n.response+e:e.trim()}return n.response}catch(e){const t=e instanceof Error?e.message:String(e);return t.includes("SessionAgent closed")||t.includes("agent closed")?(fe.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),""):(fe.error(`Agent error for ${s}: ${e}`),`Error: ${t}`)}}finally{this.typingCoordinator.release(e.channelName,e.chatId)}}async start(){fe.info("Starting GrabMeABeer server...");const e=[];this.config.channels.telegram.enabled&&e.push("telegram"),this.config.channels.responses.enabled&&e.push("responses"),this.config.channels.whatsapp.enabled&&e.push("whatsapp"),this.config.channels.discord.enabled&&e.push("discord"),this.config.channels.slack.enabled&&e.push("slack"),this.config.channels.mesh.enabled&&e.push("mesh"),fe.info(`Enabled channels: ${e.join(", ")||"none"}`),await this.channelManager.startAll(),this.notifyAllChannels("Gateway started. Agent is alive!").catch(()=>{}),await this.browserService.start(this.config.browser).catch(e=>{fe.warn(`Browser service failed to start: ${e}`)}),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.nodeRegistry.startPingLoop(),this.startAutoRenewTimer(),fe.info("Server started successfully")}async initCronAndHeartbeat(){if(!this.cronService)return;await this.cronService.start();const e=this.config.cron.heartbeat,t=(await this.cronService.list({includeDisabled:!0})).find(e=>"__heartbeat"===e.name),s=!!e.channel&&!!this.channelManager.getAdapter(e.channel),n=!!e.message&&e.message.trim().length>=15;if(e.enabled&&e.channel&&e.chatId&&s&&n){const s={schedule:{kind:"every",everyMs:e.every},channel:e.channel,chatId:e.chatId,message:e.message};if(t){const e=t.schedule;(t.channel!==s.channel||t.chatId!==s.chatId||t.message!==s.message||t.isolated!==this.config.cron.isolated||"every"!==e.kind||"every"===e.kind&&e.everyMs!==s.schedule.everyMs||!t.enabled)&&(await this.cronService.update(t.id,{...s,isolated:this.config.cron.isolated,enabled:!0}),fe.info("Heartbeat job updated from config"))}else await this.cronService.add({name:"__heartbeat",description:"Auto-generated heartbeat job",enabled:!0,isolated:this.config.cron.isolated,suppressToken:!0,...s}),fe.info("Heartbeat job auto-added")}else t&&t.enabled&&(await this.cronService.update(t.id,{enabled:!1}),e.enabled&&!n?fe.warn("Heartbeat job disabled: message is empty or too short (minimum 15 characters)"):e.enabled&&!s?fe.warn(`Heartbeat job disabled: channel "${e.channel}" is not active`):fe.info("Heartbeat job disabled (config changed)"))}getConfig(){return this.config}getTokenDb(){return this.tokenDb}getSessionDb(){return this.sessionDb}getMemoryManager(){return this.memoryManager}getNodeRegistry(){return this.nodeRegistry}getNodeSignatureDb(){return this.nodeSignatureDb}getChannelManager(){return this.channelManager}getAgentService(){return this.agentService}getCronService(){return this.cronService}getCoderSkill(){return this.coderSkill}getWhatsAppQrState(){return{dataUrl:this.whatsappQr?.dataUrl??null,connected:this.whatsappConnected,error:this.whatsappError}}getWebChatChannel(){return this.webChatChannel}getMeshChannel(){return this.meshChannel}async triggerRestart(){fe.info("Trigger restart requested");const e=ie();await this.reconfigure(e),this.notifyAllChannels("Gateway restarted. Agent is alive!").catch(()=>{})}async notifyAllChannels(e){const t=this.collectBroadcastTargets();fe.info(`Broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(async t=>{try{await this.channelManager.sendSystemMessage(t.channel,t.chatId,e),this.typingCoordinator.reinforceIfActive(t.channel,t.chatId),fe.debug(`Notified ${t.channel}:${t.chatId}`)}catch(e){fe.warn(`Failed to notify ${t.channel}:${t.chatId}: ${e}`)}}))}static AUTO_RENEW_CHECK_INTERVAL_MS=9e5;startAutoRenewTimer(){this.stopAutoRenewTimer();const e=this.config.agent.autoRenew;e&&(fe.info(`AutoRenew enabled — resetting sessions inactive for ${e}h (checking every 15 min)`),this.autoRenewTimer=setInterval(()=>{this.autoRenewStaleSessions().catch(e=>fe.error(`AutoRenew error: ${e}`))},Server.AUTO_RENEW_CHECK_INTERVAL_MS))}stopAutoRenewTimer(){this.autoRenewTimer&&(clearInterval(this.autoRenewTimer),this.autoRenewTimer=null)}async autoRenewStaleSessions(){const e=this.config.agent.autoRenew;if(!e)return;const t=60*e*60*1e3,s=this.sessionDb.listStaleSessions(t);if(0!==s.length){fe.info(`AutoRenew: found ${s.length} stale session(s)`);for(const t of s){const s=t.sessionKey;if(s.startsWith("cron:"))continue;if(this.agentService.isBusy(s))continue;this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s);const n=s.indexOf(":");if(n>0){const t=s.substring(0,n),o=s.substring(n+1),i=this.channelManager.getAdapter(t);if(i)try{await i.sendText(o,`Session renewed automatically after ${e}h of inactivity. Starting fresh!`)}catch(e){fe.warn(`AutoRenew: failed to send courtesy message to ${s}: ${e}`)}}fe.info(`AutoRenew: session reset — ${s}`)}}}async reconfigure(e){fe.info("Reconfiguring server..."),this.cronService&&this.cronService.stop(),await this.channelManager.stopAll(),this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.sessionManager=new y(this.sessionDb),e.memory.enabled?this.memoryManager=new R(e.memoryDir,e.timezone):this.memoryManager=null;const t=A(e),s=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new C(t,s),this.commandRegistry=new $,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.typingCoordinator=new g(this.channelManager),this.agentService.destroyAll(),this.serverToolsFactory=()=>q({restartFn:()=>this.triggerRestart(),timezone:this.config.timezone,memoryDir:this.config.memoryDir,warmRestart:this.config.warmRestart}),this.cronService=this.createCronService(),this.stopMemorySearch(),this.createMemorySearch(e),await this.browserService.reconfigure(e.browser);const n=o(e.agent.workspacePath,".plasma"),i=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new u(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>V(()=>this.config):void 0,this.memorySearch?()=>J(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>X({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,i?()=>Y({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=v(this.config.dataDir);return S({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Z({plasmaRootDir:n,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>ee(this.conceptStore):void 0),w(e.dataDir),await this.channelManager.startAll(),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.startAutoRenewTimer(),fe.info("Server reconfigured successfully")}async stop(){fe.info("Shutting down..."),this.stopAutoRenewTimer(),this.cronService&&this.cronService.stop(),this.memorySearch&&this.memorySearch.stop(),this.conceptStore&&this.conceptStore.close(),await this.browserService.stop(),this.typingCoordinator.stopAll(),this.agentService.destroyAll(),await this.channelManager.stopAll(),this.sessionManager.destroy(),this.sessionDb.close(),this.tokenDb.close(),this.nodeSignatureDb.close(),fe.info("Server stopped")}}
1
+ import{readFileSync as e,writeFileSync as t,mkdirSync as s,existsSync as n}from"node:fs";import{join as o,resolve as r}from"node:path";import{TokenDB as i}from"./auth/token-db.js";import{NodeSignatureDB as a}from"./auth/node-signature-db.js";import{SessionDB as h}from"./agent/session-db.js";import{ChannelManager as c}from"./gateway/channel-manager.js";import{TypingCoordinator as m}from"./gateway/typing-coordinator.js";import{TelegramChannel as g}from"./gateway/channels/telegram/index.js";import{WhatsAppChannel as l}from"./gateway/channels/whatsapp.js";import{WebChatChannel as d}from"./gateway/channels/webchat.js";import{MeshChannel as p}from"./gateway/channels/mesh.js";import{ResponsesChannel as f}from"./channels/responses.js";import{AgentService as u}from"./agent/agent-service.js";import{SessionManager as y}from"./agent/session-manager.js";import{buildPrompt as b,buildSystemPrompt as S}from"./agent/prompt-builder.js";import{ensureWorkspaceFiles as w,loadWorkspaceFiles as v}from"./agent/workspace-files.js";import{NodeRegistry as M}from"./gateway/node-registry.js";import{MemoryManager as R}from"./memory/memory-manager.js";import{MessageProcessor as C}from"./media/message-processor.js";import{loadSTTProvider as A}from"./stt/stt-loader.js";import{CommandRegistry as $}from"./commands/command-registry.js";import{NewCommand as T}from"./commands/new.js";import{CompactCommand as k}from"./commands/compact.js";import{ModelCommand as j,DefaultModelCommand as x}from"./commands/model.js";import{StopCommand as I}from"./commands/stop.js";import{HelpCommand as D}from"./commands/help.js";import{McpCommand as E}from"./commands/mcp.js";import{ModelsCommand as _}from"./commands/models.js";import{CoderCommand as P}from"./commands/coder.js";import{SandboxCommand as N}from"./commands/sandbox.js";import{SubAgentsCommand as F}from"./commands/subagents.js";import{CustomSubAgentsCommand as U}from"./commands/customsubagents.js";import{StatusCommand as K}from"./commands/status.js";import{ShowToolCommand as O}from"./commands/showtool.js";import{UsageCommand as H}from"./commands/usage.js";import{DebugA2UICommand as L}from"./commands/debuga2ui.js";import{DebugDynamicCommand as B}from"./commands/debugdynamic.js";import{CronService as z}from"./cron/cron-service.js";import{stripHeartbeatToken as Q,isHeartbeatContentEffectivelyEmpty as W}from"./cron/heartbeat-token.js";import{createServerToolsServer as q}from"./tools/server-tools.js";import{createCronToolsServer as G}from"./tools/cron-tools.js";import{createTTSToolsServer as V}from"./tools/tts-tools.js";import{createMemoryToolsServer as J}from"./tools/memory-tools.js";import{createBrowserToolsServer as X}from"./tools/browser-tools.js";import{createPicoToolsServer as Y}from"./tools/pico-tools.js";import{createPlasmaClientToolsServer as Z}from"./tools/plasma-client-tools.js";import{createConceptToolsServer as ee}from"./tools/concept-tools.js";import{BrowserService as te}from"./browser/browser-service.js";import{MemorySearch as se}from"./memory/memory-search.js";import{ConceptStore as ne}from"./memory/concept-store.js";import{stripMediaLines as oe}from"./utils/media-response.js";import{loadConfig as re,loadRawConfig as ie,backupConfig as ae,resolveModelEntry as he,modelRefName as ce}from"./config.js";import{stringify as me}from"yaml";import{createLogger as ge}from"./utils/logger.js";import{SessionErrorHandler as le}from"./agent/session-error-handler.js";import{initStickerCache as de}from"./gateway/channels/telegram/stickers.js";const pe=ge("Server");export class Server{config;tokenDb;sessionDb;nodeSignatureDb;channelManager;typingCoordinator;agentService;sessionManager;memoryManager=null;nodeRegistry;messageProcessor;commandRegistry;serverToolsFactory;coderSkill;showToolUse=!1;subagentsEnabled=!0;customSubAgentsEnabled=!1;chatSettings=new Map;cronService=null;browserService;memorySearch=null;conceptStore=null;whatsappQr=null;whatsappConnected=!1;whatsappError;webChatChannel=null;meshChannel=null;autoRenewTimer=null;constructor(e){this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.tokenDb=new i(e.dbPath),this.sessionDb=new h(e.dbPath),this.nodeSignatureDb=new a(e.dbPath),this.nodeRegistry=new M,this.sessionManager=new y(this.sessionDb),e.memory.enabled&&(this.memoryManager=new R(e.memoryDir,e.timezone,e.ckr.l2Prefix,e.ckr.removeL2PrefixFromMemory));const t=A(e),n=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new C(t,n),de(e.dataDir),this.commandRegistry=new $,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e),e=>this.handleReaction(e)),this.registerChannels(),this.typingCoordinator=new m(this.channelManager),this.serverToolsFactory=()=>q({restartFn:()=>this.triggerRestart(),timezone:this.config.timezone,memoryDir:this.config.memoryDir,warmRestart:this.config.warmRestart}),this.cronService=this.createCronService(),this.createMemorySearch(e),this.browserService=new te,this.conceptStore=new ne(e.dataDir);const r=o(e.dataDir,"CONCEPTS.md");this.conceptStore.importFromTurtleIfEmpty(r),this.memorySearch&&this.conceptStore&&this.memorySearch.setConceptStore(this.conceptStore);const g=o(e.agent.workspacePath,".plasma"),l=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new u(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>V(()=>this.config):void 0,this.memorySearch?()=>J(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>X({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,l?()=>Y({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=v(this.config.dataDir);return S({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Z({plasmaRootDir:g,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>ee(this.conceptStore):void 0),w(e.dataDir),s(o(e.agent.workspacePath,".claude","skills"),{recursive:!0}),s(o(e.agent.workspacePath,".claude","commands"),{recursive:!0}),s(o(e.agent.workspacePath,".plugins"),{recursive:!0})}getChatSetting(e,t){return this.chatSettings.get(e)?.[t]}setChatSetting(e,t,s){const n=this.chatSettings.get(e)??{};n[t]=s,this.chatSettings.set(e,n)}createCronService(){return new z({storePath:this.config.cronStorePath,enabled:this.config.cron.enabled,defaultTimezone:this.config.timezone,onExecute:e=>this.executeCronJob(e),sessionReaper:{pruneStaleSessions:e=>this.sessionDb.pruneStaleCronSessions(e)}})}createMemorySearch(e){if(!e.memory.enabled||!e.memory.search.enabled)return;const t=e.memory.search,s=he(e,t.modelRef),n=(s?.useEnvVar?process.env[s.useEnvVar]:s?.apiKey)||"",o=s?.baseURL||"";if(n)return this.memorySearch=new se(e.memoryDir,e.dataDir,{apiKey:n,baseURL:o||void 0,embeddingModel:t.embeddingModel,embeddingDimensions:t.embeddingDimensions,prefixQuery:t.prefixQuery,prefixDocument:t.prefixDocument,updateDebounceMs:t.updateDebounceMs,embedIntervalMs:t.embedIntervalMs,maxResults:t.maxResults,maxSnippetChars:t.maxSnippetChars,maxInjectedChars:t.maxInjectedChars,rrfK:t.rrfK,l0:e.memory.l0??{enabled:!0,model:""}}),J(this.memorySearch);pe.warn(`Memory search enabled but no API key found for modelRef "${t.modelRef}". Search will not start.`)}stopMemorySearch(){this.memorySearch&&(this.memorySearch.stop(),this.memorySearch=null)}collectBroadcastTargets(){const e=new Set,t=[],s=(s,n)=>{const o=`${s}:${n}`;e.has(o)||(e.add(o),t.push({channel:s,chatId:n}))},n=this.config.channels;for(const[e,t]of Object.entries(n)){if("responses"===e)continue;if(!t?.enabled||!this.channelManager.getAdapter(e))continue;const n=t.accounts;if(n)for(const t of Object.values(n)){const n=t?.allowFrom;if(Array.isArray(n))for(const t of n){const n=String(t).trim();n&&s(e,n)}}}for(const e of this.sessionDb.listSessions()){const t=e.sessionKey.indexOf(":");if(t<0)continue;const n=e.sessionKey.substring(0,t),o=e.sessionKey.substring(t+1);"cron"!==n&&o&&(this.channelManager.getAdapter(n)&&s(n,o))}return t}async executeCronJob(e){const t=this.config.cron.broadcastEvents;if(!t&&!this.channelManager.getAdapter(e.channel))return pe.warn(`Cron job "${e.name}": skipped (channel "${e.channel}" is not active)`),{response:"",delivered:!1};if(e.suppressToken&&"__heartbeat"===e.name){const t=this.triageHeartbeat();if(!t.shouldRun)return pe.info(`Cron job "${e.name}": skipped by triage (${t.reason})`),{response:"",delivered:!1};pe.info(`Cron job "${e.name}": triage passed (${t.reason})`)}const s="boolean"==typeof e.isolated?e.isolated:this.config.cron.isolated,n=s?"cron":e.channel,o=s?e.name:e.chatId;pe.info(`Cron job "${e.name}": session=${n}:${o}, delivery=${e.channel}:${e.chatId}${t?" (broadcast)":""}`);const r={chatId:o,userId:"cron",channelName:n,text:e.message,attachments:[]},i=`${n}:${o}`;try{const s=await this.handleMessage(r);let n=s;if(e.suppressToken){const t=this.config.cron.heartbeat.ackMaxChars,{shouldSkip:o,text:r}=Q(s,t);if(o)return pe.info(`Cron job "${e.name}": response suppressed (HEARTBEAT_OK)`),{response:s,delivered:!1};n=r}if(t){const t=this.collectBroadcastTargets();pe.info(`Cron job "${e.name}": broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(e=>this.channelManager.sendResponse(e.channel,e.chatId,n)));for(const e of t)this.typingCoordinator.release(e.channel,e.chatId)}else await this.channelManager.sendResponse(e.channel,e.chatId,n),this.typingCoordinator.release(e.channel,e.chatId);return{response:n,delivered:!0}}finally{this.agentService.destroySession(i),this.sessionManager.resetSession(i),this.memoryManager&&this.memoryManager.clearSession(i),pe.info(`Cron job "${e.name}": ephemeral session destroyed (${i})`)}}triageHeartbeat(){const t=(new Date).getHours(),s=o(this.config.agent.workspacePath,"attention","pending_signals.md");if(n(s))try{const t=e(s,"utf-8").trim().split("\n").filter(e=>e.trim()&&!e.startsWith("#"));if(t.length>0)return{shouldRun:!0,reason:`${t.length} pending signal(s)`}}catch{}const r=o(this.config.dataDir,"HEARTBEAT.md");let i=!1;if(n(r))try{const t=e(r,"utf-8");i=!W(t)}catch{i=!0}const a=this.sessionDb.hasRecentActivity(3e5);return t>=23||t<7?a?{shouldRun:!0,reason:"night mode but recent messages"}:{shouldRun:!1,reason:"night mode, no activity"}:i||a?{shouldRun:!0,reason:a?"recent messages":"actionable heartbeat"}:{shouldRun:!1,reason:"no signals, no messages, empty heartbeat"}}setupCommands(){this.commandRegistry.register(new T),this.commandRegistry.register(new k);const e=()=>this.config.agent.picoAgent??{enabled:!1,modelRefs:[]};this.commandRegistry.register(new j(()=>this.config.models??[],async(e,t)=>{const s=this.config.models?.find(e=>e.id===t),n=this.config.agent.picoAgent,o=e=>!(!n?.enabled||!Array.isArray(n.modelRefs))&&n.modelRefs.some(t=>t.split(":")[0]===e),r=this.sessionManager.getModel(e)||this.config.agent.model,i=he(this.config,r),a=o(i?.name??ce(r)),h=o(s?.name??t);this.sessionManager.setModel(e,t),this.agentService.destroySession(e);const c=a||h;return c&&this.sessionManager.resetSession(e),c},e)),this.commandRegistry.register(new x(()=>this.config.models??[],async e=>{const s=this.config.models?.find(t=>t.id===e),n=s?`${s.name}:${s.id}`:e;this.config.agent.model=n;const o=this.config.agent.picoAgent;if(o?.enabled&&Array.isArray(o.modelRefs)){const t=s?.name??e,n=o.modelRefs.findIndex(e=>e.split(":")[0]===t);if(n>0){const[e]=o.modelRefs.splice(n,1);o.modelRefs.unshift(e)}}try{const e=r(process.cwd(),"config.yaml"),s=ie(e);s.agent||(s.agent={}),s.agent.model=n,o?.enabled&&Array.isArray(o.modelRefs)&&(s.agent.picoAgent||(s.agent.picoAgent={}),s.agent.picoAgent.modelRefs=[...o.modelRefs]),ae(e),t(e,me(s),"utf-8")}catch{}},e)),this.commandRegistry.register(new _(()=>this.config.models??[],e)),this.commandRegistry.register(new P(e=>this.getChatSetting(e,"coderSkill")??this.coderSkill,(e,t)=>this.setChatSetting(e,"coderSkill",t))),this.commandRegistry.register(new N(e=>this.getChatSetting(e,"sandboxEnabled")??!1,(e,t)=>this.setChatSetting(e,"sandboxEnabled",t))),this.commandRegistry.register(new O(e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,(e,t)=>this.setChatSetting(e,"showToolUse",t))),this.commandRegistry.register(new F(e=>this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,(e,t)=>this.setChatSetting(e,"subagentsEnabled",t))),this.commandRegistry.register(new U(e=>this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,(e,t)=>this.setChatSetting(e,"customSubAgentsEnabled",t),()=>this.config)),this.commandRegistry.register(new K(e=>{const t=this.sessionManager.getModel(e)||this.config.agent.model,s=this.config.agent.mainFallback,n=he(this.config,t),o=s?he(this.config,s):void 0;return{configDefaultModel:ce(this.config.agent.model),agentModel:n?.id??t,agentModelName:n?.name??ce(t),fallbackModel:o?.id??s,fallbackModelName:o?.name??(s?ce(s):void 0),fallbackActive:this.agentService.isFallbackActive(e),coderSkill:this.getChatSetting(e,"coderSkill")??this.coderSkill,showToolUse:this.getChatSetting(e,"showToolUse")??this.showToolUse,subagentsEnabled:this.getChatSetting(e,"subagentsEnabled")??this.subagentsEnabled,customSubAgentsEnabled:this.getChatSetting(e,"customSubAgentsEnabled")??this.customSubAgentsEnabled,sandboxEnabled:this.getChatSetting(e,"sandboxEnabled")??!1,connectedNodes:this.nodeRegistry.listNodes().map(e=>({nodeId:e.nodeId,displayName:e.displayName,hostname:e.hostname}))}})),this.commandRegistry.register(new I(e=>this.agentService.interrupt(e))),this.commandRegistry.register(new E(()=>this.agentService.getToolServers())),this.commandRegistry.register(new D(()=>this.agentService.getSdkSlashCommands())),this.commandRegistry.register(new H(e=>this.agentService.getUsage(e))),this.commandRegistry.register(new L(this.nodeRegistry)),this.commandRegistry.register(new B(this.nodeRegistry))}registerChannels(){if(this.config.channels.telegram.enabled){const e=this.config.channels.telegram.accounts;for(const[t,s]of Object.entries(e)){if(!s.botToken){pe.error(`Telegram account "${t}" has no botToken configured — skipping. Check your config.yaml.`);continue}const e=new g(s,t,this.tokenDb);this.channelManager.registerAdapter(e)}}if(this.config.channels.whatsapp.enabled){const e=this.config.channels.whatsapp.accounts;for(const[t,s]of Object.entries(e)){const e=new l(s);e.setQrCallback((e,t,s)=>{this.whatsappQr=e?{dataUrl:e,timestamp:Date.now()}:null,this.whatsappConnected=t,this.whatsappError=s}),this.channelManager.registerAdapter(e)}}if(this.config.channels.responses.enabled){const e=new f({host:this.config.host,port:this.config.channels.responses.port},this.tokenDb);this.channelManager.registerAdapter(e)}if(this.config.channels.mesh.enabled){const e=this.config.channels.mesh;e.agentId&&e.privateKey?(this.meshChannel||(this.meshChannel=new p({brokerUrl:e.brokerUrl,agentId:e.agentId,privateKey:e.privateKey,reconnectDelayMs:e.reconnectDelayMs}),this.meshChannel.onReply((e,t,s)=>{pe.info(`[Mesh] Routing reply from ${t} to origin session ${e}`);const n=e.indexOf(":"),o=n>0?e.substring(0,n):"mesh",r=n>0?e.substring(n+1):t,i={chatId:r,userId:t,channelName:o,text:`[Mesh reply from ${t}] ${s}`,attachments:[],username:t};this.handleMessage(i).then(t=>{t&&t.trim()&&this.channelManager.sendResponse(o,r,t).catch(t=>{pe.error(`[Mesh] Failed to deliver reply-response to ${e}: ${t}`)})}).catch(s=>{pe.error(`[Mesh] Failed to process reply from ${t} in session ${e}: ${s}`)})})),this.channelManager.registerAdapter(this.meshChannel)):pe.warn("Mesh channel enabled but agentId or privateKey not configured")}this.webChatChannel||(this.webChatChannel=new d),this.channelManager.registerAdapter(this.webChatChannel)}handleReaction(e){if(!this.memoryManager)return;const t=`${e.channelName}:${e.chatId}`;this.memoryManager.appendReaction(t,e.emoji,e.username??e.userId,e.removed).catch(e=>{pe.error(`Failed to append reaction to memory: ${e}`)})}async handleMessage(e,t=!1){const s=`${e.channelName}:${e.chatId}`,n=e.text?e.text.length>15?e.text.slice(0,15)+"...":e.text:"[no text]";pe.info(`Message from ${s} (user=${e.userId}, ${e.username??"?"}): ${n}`),this.config.verboseDebugLogs&&pe.debug(`Message from ${s} full text: ${e.text??"[no text]"}`);try{if(this.typingCoordinator.acquire(e.channelName,e.chatId),e.text){if(e.text.startsWith("__ask:")){const t=e.text.substring(6);return this.agentService.resolveQuestion(s,t),""}if(this.agentService.hasPendingQuestion(s)){const t=e.text.trim();return this.agentService.resolveQuestion(s,t),`Selected: ${t}`}if(e.text.startsWith("__tool_perm:")){const t="__tool_perm:approve"===e.text;return this.agentService.resolvePermission(s,t),t?"Tool approved.":"Tool denied."}if(this.agentService.hasPendingPermission(s)){const t=e.text.trim().toLowerCase();if("approve"===t||"approva"===t)return this.agentService.resolvePermission(s,!0),"Tool approved.";if("deny"===t||"vieta"===t||"blocca"===t)return this.agentService.resolvePermission(s,!1),"Tool denied."}}const n=!0===e.__passthrough;if(!n&&e.text&&this.commandRegistry.isCommand(e.text)){const t=await this.commandRegistry.dispatch(e.text,{sessionKey:s,chatId:e.chatId,channelName:e.channelName,userId:e.userId});if(t)return t.passthrough?this.handleMessage({...e,text:t.passthrough,__passthrough:!0}):(t.resetSession?(this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s)):t.resetAgent&&this.agentService.destroySession(s),t.buttons&&t.buttons.length>0?(await this.channelManager.sendButtons(e.channelName,e.chatId,t.text,t.buttons),""):t.text)}if(!n&&e.text?.startsWith("/"))return this.agentService.isBusy(s)?"I'm busy right now. Please resend this request later.":this.handleMessage({...e,text:e.text,__passthrough:!0});const o=this.sessionManager.getOrCreate(s),r=await this.messageProcessor.process(e),i=b(r,void 0,{sessionKey:s,channel:e.channelName,chatId:e.chatId},this.config.timezone,this.config.ckr.applyL2PrefixOnEachUserMessage?this.config.ckr.l2Prefix:void 0);pe.debug(`[${s}] Prompt to agent (${i.text.length} chars): ${this.config.verboseDebugLogs?i.text:i.text.slice(0,15)+"..."}${i.images.length>0?` [+${i.images.length} image(s)]`:""}`);const a=o.model,h={sessionKey:s,channel:e.channelName,chatId:e.chatId,sessionId:o.sessionId??"",memoryFile:this.memoryManager?this.memoryManager.getConversationFile(s):"",attachmentsDir:this.memoryManager?this.memoryManager.getAttachmentsDir(s):""},c=v(this.config.dataDir),m={config:this.config,sessionContext:h,workspaceFiles:c,mode:"full",hasNodeTools:this.agentService.hasNodeTools(),hasMessageTools:this.agentService.hasMessageTools(),coderSkill:this.getChatSetting(s,"coderSkill")??this.coderSkill,toolServers:this.agentService.getToolServers()},g=S(m),l=S({...m,mode:"minimal"});pe.debug(`[${s}] System prompt (${g.length} chars): ${this.config.verboseDebugLogs?g:g.slice(0,15)+"..."}`);try{const n=await this.agentService.sendMessage(s,i,o.sessionId,g,l,a,this.getChatSetting(s,"coderSkill")??this.coderSkill,this.getChatSetting(s,"subagentsEnabled")??this.subagentsEnabled,this.getChatSetting(s,"customSubAgentsEnabled")??this.customSubAgentsEnabled,this.getChatSetting(s,"sandboxEnabled")??!1);if(n.sessionReset){if("[AGENT_CLOSED]"===n.response)return pe.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),"";{const r=n.response||"Session corruption detected",i={sessionKey:s,sessionId:o.sessionId,error:new Error(r),timestamp:new Date},a=le.analyzeError(i.error,i),h=le.getRecoveryStrategy(a);return pe.warn(`[${s}] ${h.message} (error: ${r})`),this.sessionManager.updateSessionId(s,""),h.clearSession&&(this.agentService.destroySession(s),this.memoryManager&&this.memoryManager.clearSession(s)),"clear_and_retry"!==h.action||t?"[SESSION_CORRUPTED] The previous session is no longer valid. Use /new to start a new one.":(pe.info(`[${s}] Retrying with fresh session after: ${r}`),this.handleMessage(e,!0))}}pe.debug(`[${s}] Response from agent (session=${n.sessionId}, len=${n.response.length}): ${this.config.verboseDebugLogs?n.response:n.response.slice(0,15)+"..."}`),n.sessionId&&this.sessionManager.updateSessionId(s,n.sessionId);const h="cron"===e.userId,c=!0===this.config.cron.trackMemory;if(this.memoryManager&&(!h||c)){const e=(i.text||"[media]").replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\n?/,"").trim();await this.memoryManager.append(s,"user",e,r.savedFiles.length>0?r.savedFiles:void 0),await this.memoryManager.append(s,"assistant",oe(n.fullResponse??n.response))}if("max_turns"===n.errorType){const e="\n\n[MAX_TURNS] The agent reached the maximum number of turns. You can continue the conversation by sending another message.";return n.response?n.response+e:e.trim()}if("max_budget"===n.errorType){const e="\n\n[MAX_BUDGET] The agent reached the maximum budget for this request.";return n.response?n.response+e:e.trim()}if("refusal"===n.stopReason){const e="\n\n[REFUSAL] The model declined to fulfill this request.";return n.response?n.response+e:e.trim()}if("max_tokens"===n.stopReason){const e="\n\n[TRUNCATED] The response was cut short because it exceeded the output token limit. You can ask me to continue.";return n.response?n.response+e:e.trim()}return n.response}catch(e){const t=e instanceof Error?e.message:String(e);return t.includes("SessionAgent closed")||t.includes("agent closed")?(pe.info(`[${s}] Agent closed during restart, keeping session ID for resume on next message`),""):(pe.error(`Agent error for ${s}: ${e}`),`Error: ${t}`)}}finally{this.typingCoordinator.release(e.channelName,e.chatId)}}async start(){pe.info("Starting GrabMeABeer server...");const e=[];this.config.channels.telegram.enabled&&e.push("telegram"),this.config.channels.responses.enabled&&e.push("responses"),this.config.channels.whatsapp.enabled&&e.push("whatsapp"),this.config.channels.discord.enabled&&e.push("discord"),this.config.channels.slack.enabled&&e.push("slack"),this.config.channels.mesh.enabled&&e.push("mesh"),pe.info(`Enabled channels: ${e.join(", ")||"none"}`),await this.channelManager.startAll(),this.notifyAllChannels("Gateway started. Agent is alive!").catch(()=>{}),await this.browserService.start(this.config.browser).catch(e=>{pe.warn(`Browser service failed to start: ${e}`)}),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.nodeRegistry.startPingLoop(),this.startAutoRenewTimer(),pe.info("Server started successfully")}async initCronAndHeartbeat(){if(!this.cronService)return;await this.cronService.start();const e=this.config.cron.heartbeat,t=(await this.cronService.list({includeDisabled:!0})).find(e=>"__heartbeat"===e.name),s=!!e.channel&&!!this.channelManager.getAdapter(e.channel),n=!!e.message&&e.message.trim().length>=15;if(e.enabled&&e.channel&&e.chatId&&s&&n){const s={schedule:{kind:"every",everyMs:e.every},channel:e.channel,chatId:e.chatId,message:e.message};if(t){const e=t.schedule;(t.channel!==s.channel||t.chatId!==s.chatId||t.message!==s.message||t.isolated!==this.config.cron.isolated||"every"!==e.kind||"every"===e.kind&&e.everyMs!==s.schedule.everyMs||!t.enabled)&&(await this.cronService.update(t.id,{...s,isolated:this.config.cron.isolated,enabled:!0}),pe.info("Heartbeat job updated from config"))}else await this.cronService.add({name:"__heartbeat",description:"Auto-generated heartbeat job",enabled:!0,isolated:this.config.cron.isolated,suppressToken:!0,...s}),pe.info("Heartbeat job auto-added")}else t&&t.enabled&&(await this.cronService.update(t.id,{enabled:!1}),e.enabled&&!n?pe.warn("Heartbeat job disabled: message is empty or too short (minimum 15 characters)"):e.enabled&&!s?pe.warn(`Heartbeat job disabled: channel "${e.channel}" is not active`):pe.info("Heartbeat job disabled (config changed)"))}getConfig(){return this.config}getTokenDb(){return this.tokenDb}getSessionDb(){return this.sessionDb}getMemoryManager(){return this.memoryManager}getNodeRegistry(){return this.nodeRegistry}getNodeSignatureDb(){return this.nodeSignatureDb}getChannelManager(){return this.channelManager}getAgentService(){return this.agentService}getCronService(){return this.cronService}getCoderSkill(){return this.coderSkill}getWhatsAppQrState(){return{dataUrl:this.whatsappQr?.dataUrl??null,connected:this.whatsappConnected,error:this.whatsappError}}getWebChatChannel(){return this.webChatChannel}getMeshChannel(){return this.meshChannel}async triggerRestart(){pe.info("Trigger restart requested");const e=re();await this.reconfigure(e),this.notifyAllChannels("Gateway restarted. Agent is alive!").catch(()=>{})}async notifyAllChannels(e){const t=this.collectBroadcastTargets();pe.info(`Broadcasting to ${t.length} target(s)`),await Promise.allSettled(t.map(async t=>{try{await this.channelManager.sendSystemMessage(t.channel,t.chatId,e),this.typingCoordinator.reinforceIfActive(t.channel,t.chatId),pe.debug(`Notified ${t.channel}:${t.chatId}`)}catch(e){pe.warn(`Failed to notify ${t.channel}:${t.chatId}: ${e}`)}}))}static AUTO_RENEW_CHECK_INTERVAL_MS=9e5;startAutoRenewTimer(){this.stopAutoRenewTimer();const e=this.config.agent.autoRenew;e&&(pe.info(`AutoRenew enabled — resetting sessions inactive for ${e}h (checking every 15 min)`),this.autoRenewTimer=setInterval(()=>{this.autoRenewStaleSessions().catch(e=>pe.error(`AutoRenew error: ${e}`))},Server.AUTO_RENEW_CHECK_INTERVAL_MS))}stopAutoRenewTimer(){this.autoRenewTimer&&(clearInterval(this.autoRenewTimer),this.autoRenewTimer=null)}async autoRenewStaleSessions(){const e=this.config.agent.autoRenew;if(!e)return;const t=60*e*60*1e3,s=this.sessionDb.listStaleSessions(t);if(0!==s.length){pe.info(`AutoRenew: found ${s.length} stale session(s)`);for(const t of s){const s=t.sessionKey;if(s.startsWith("cron:"))continue;if(this.agentService.isBusy(s))continue;this.agentService.destroySession(s),this.sessionManager.resetSession(s),this.memoryManager&&this.memoryManager.clearSession(s);const n=s.indexOf(":");if(n>0){const t=s.substring(0,n),o=s.substring(n+1),r=this.channelManager.getAdapter(t);if(r)try{await r.sendText(o,`Session renewed automatically after ${e}h of inactivity. Starting fresh!`)}catch(e){pe.warn(`AutoRenew: failed to send courtesy message to ${s}: ${e}`)}}pe.info(`AutoRenew: session reset — ${s}`)}}}async reconfigure(e){pe.info("Reconfiguring server..."),this.cronService&&this.cronService.stop(),await this.channelManager.stopAll(),this.config=e,this.coderSkill=e.agent.builtinCoderSkill,this.sessionManager=new y(this.sessionDb),e.memory.enabled?this.memoryManager=new R(e.memoryDir,e.timezone,e.ckr.l2Prefix):this.memoryManager=null;const t=A(e),s=this.memoryManager?this.memoryManager.saveFile.bind(this.memoryManager):null;this.messageProcessor=new C(t,s),this.commandRegistry=new $,this.setupCommands(),this.channelManager=new c(e,this.tokenDb,e=>this.handleMessage(e)),this.registerChannels(),this.typingCoordinator=new m(this.channelManager),this.agentService.destroyAll(),this.serverToolsFactory=()=>q({restartFn:()=>this.triggerRestart(),timezone:this.config.timezone,memoryDir:this.config.memoryDir,warmRestart:this.config.warmRestart}),this.cronService=this.createCronService(),this.stopMemorySearch(),this.createMemorySearch(e),this.memorySearch&&this.conceptStore&&this.memorySearch.setConceptStore(this.conceptStore),await this.browserService.reconfigure(e.browser);const n=o(e.agent.workspacePath,".plasma"),r=e.agent.picoAgent?.enabled&&Array.isArray(e.agent.picoAgent.modelRefs)&&e.agent.picoAgent.modelRefs.length>0;this.agentService=new u(e,this.nodeRegistry,this.channelManager,this.serverToolsFactory,this.cronService?()=>G(this.cronService,()=>this.config):void 0,this.sessionDb,e.tts.enabled?()=>V(()=>this.config):void 0,this.memorySearch?()=>J(this.memorySearch):void 0,e=>this.getChatSetting(e,"showToolUse")??this.showToolUse,e.browser?.enabled?()=>X({nodeRegistry:this.nodeRegistry,config:this.config}):void 0,r?()=>Y({getConfig:()=>this.config,getSubagentSystemPrompt:()=>{const e=v(this.config.dataDir);return S({config:this.config,sessionContext:{sessionKey:"pico-subagent",channel:"internal",chatId:"subagent",sessionId:"",memoryFile:"",attachmentsDir:""},workspaceFiles:e,mode:"minimal",hasNodeTools:!1,hasMessageTools:!1})},getCallerMcpOptions:()=>{const e={};for(const t of this.agentService.getToolServers())"pico-tools"!==t.name&&(e[t.name]=t);return e}}):void 0,(e,t)=>Z({plasmaRootDir:n,nodeRegistry:this.nodeRegistry,channel:e,chatId:t}),this.conceptStore?()=>ee(this.conceptStore):void 0),w(e.dataDir),await this.channelManager.startAll(),this.memorySearch&&await this.memorySearch.start(),this.cronService&&await this.initCronAndHeartbeat(),this.startAutoRenewTimer(),pe.info("Server reconfigured successfully")}async stop(){pe.info("Shutting down..."),this.stopAutoRenewTimer(),this.cronService&&this.cronService.stop(),this.memorySearch&&this.memorySearch.stop(),this.conceptStore&&this.conceptStore.close(),await this.browserService.stop(),this.typingCoordinator.stopAll(),this.agentService.destroyAll(),await this.channelManager.stopAll(),this.sessionManager.destroy(),this.sessionDb.close(),this.tokenDb.close(),this.nodeSignatureDb.close(),pe.info("Server stopped")}}
@@ -1 +1 @@
1
- import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{z as n}from"zod";import{createLogger as o}from"../utils/logger.js";const r=o("CronTools");export function createCronToolsServer(o,s){return e({name:"cron-tools",version:"1.0.0",tools:[t("cron_add","Create a new cron job. The job will send the specified message to the agent on the given schedule. Channel and chatId are taken from the current session (from <session_info>). The isolated setting is inherited from the global cron config.",{name:n.string().describe("Unique name for the job (e.g. 'daily-reminder')"),description:n.string().optional().describe("Optional description of what the job does"),message:n.string().describe("The message text to send to the agent when the job fires"),channel:n.string().describe("The channel to deliver responses to (from <session_info>)"),chatId:n.string().describe("The chat ID to deliver responses to (from <session_info>)"),scheduleKind:n.enum(["every","cron","at"]).describe("Schedule type: 'every' for interval, 'cron' for cron expression, 'at' for one-shot"),everyMs:n.number().optional().describe("Interval in milliseconds (required when scheduleKind is 'every')"),cronExpr:n.string().optional().describe("Cron expression like '0 9 * * *' (required when scheduleKind is 'cron')"),at:n.string().optional().describe("ISO datetime for one-shot execution (required when scheduleKind is 'at')"),suppressToken:n.boolean().optional().describe("If true, suppress HEARTBEAT_OK responses (default: false)"),timeoutSeconds:n.number().optional().describe("Per-job execution timeout in seconds. 0 = no timeout. Default: 600 (10 min)")},async e=>{try{const t=s();let n;if("every"===e.scheduleKind){if(!e.everyMs)return{content:[{type:"text",text:"everyMs is required for 'every' schedule"}],isError:!0};n={kind:"every",everyMs:e.everyMs}}else if("cron"===e.scheduleKind){if(!e.cronExpr)return{content:[{type:"text",text:"cronExpr is required for 'cron' schedule"}],isError:!0};n={kind:"cron",expr:e.cronExpr}}else{if(!e.at)return{content:[{type:"text",text:"at is required for 'at' schedule"}],isError:!0};n={kind:"at",at:e.at}}const i=await o.add({name:e.name,description:e.description,channel:e.channel,chatId:e.chatId,message:e.message,schedule:n,isolated:t.cron.isolated,suppressToken:e.suppressToken??!1,timeoutSeconds:e.timeoutSeconds,enabled:!0});return r.info(`Cron job created by agent: "${i.name}" (${i.id})`),{content:[{type:"text",text:`Job "${i.name}" created (id: ${i.id}). Schedule: ${JSON.stringify(n)}. Isolated: ${t.cron.isolated}. Next run: ${i.state.nextRunAtMs?new Date(i.state.nextRunAtMs).toISOString():"none"}.`}]}}catch(e){const t=e instanceof Error?e.message:String(e);return r.error(`cron_add failed: ${t}`),{content:[{type:"text",text:`Failed to create job: ${t}`}],isError:!0}}}),t("cron_list","List all cron jobs with their status, schedule, and next run time.",{includeDisabled:n.boolean().optional().describe("Include disabled jobs (default: false)")},async e=>{try{const t=await o.list({includeDisabled:e.includeDisabled??!1});if(0===t.length)return{content:[{type:"text",text:"No cron jobs found."}]};const n=t.map(e=>({id:e.id,name:e.name,enabled:e.enabled,isolated:e.isolated,schedule:e.schedule,channel:e.channel,chatId:e.chatId,nextRun:e.state.nextRunAtMs?new Date(e.state.nextRunAtMs).toISOString():null,lastStatus:e.state.lastStatus??null}));return{content:[{type:"text",text:JSON.stringify(n,null,2)}]}}catch(e){return{content:[{type:"text",text:`Failed to list jobs: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}}),t("cron_remove","Delete a cron job by its ID.",{id:n.string().describe("The job ID to delete")},async e=>{try{return(await o.remove(e.id)).removed?(r.info(`Cron job removed by agent: ${e.id}`),{content:[{type:"text",text:`Job ${e.id} deleted.`}]}):{content:[{type:"text",text:`Job ${e.id} not found.`}],isError:!0}}catch(e){return{content:[{type:"text",text:`Failed to delete job: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}}),t("cron_update","Update an existing cron job. Only provide the fields you want to change.",{id:n.string().describe("The job ID to update"),name:n.string().optional().describe("New name for the job"),description:n.string().optional().describe("New description"),message:n.string().optional().describe("New message text"),enabled:n.boolean().optional().describe("Enable or disable the job"),suppressToken:n.boolean().optional().describe("Enable or disable HEARTBEAT_OK suppression"),timeoutSeconds:n.number().optional().describe("Per-job execution timeout in seconds. 0 = no timeout. Default: 600 (10 min)")},async e=>{try{const t={};void 0!==e.name&&(t.name=e.name),void 0!==e.description&&(t.description=e.description),void 0!==e.message&&(t.message=e.message),void 0!==e.enabled&&(t.enabled=e.enabled),void 0!==e.suppressToken&&(t.suppressToken=e.suppressToken),void 0!==e.timeoutSeconds&&(t.timeoutSeconds=e.timeoutSeconds);const n=await o.update(e.id,t);return r.info(`Cron job updated by agent: "${n.name}" (${n.id})`),{content:[{type:"text",text:`Job "${n.name}" updated. Enabled: ${n.enabled}. Next run: ${n.state.nextRunAtMs?new Date(n.state.nextRunAtMs).toISOString():"none"}.`}]}}catch(e){return{content:[{type:"text",text:`Failed to update job: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}})]})}
1
+ import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{z as n}from"zod";import{createLogger as o}from"../utils/logger.js";const r=o("CronTools");export function createCronToolsServer(o,s){return e({name:"cron-tools",version:"1.0.0",tools:[t("cron_add","Create a new cron job. The job will send the specified message to the agent on the given schedule. Channel and chatId are taken from the current session (from <session_info>). The isolated setting is inherited from the global cron config.",{name:n.string().describe("Unique name for the job (e.g. 'daily-reminder')"),description:n.string().optional().describe("Optional description of what the job does"),message:n.string().describe("The message text to send to the agent when the job fires"),channel:n.string().describe("The channel to deliver responses to (from <session_info>)"),chatId:n.string().describe("The chat ID to deliver responses to (from <session_info>)"),scheduleKind:n.enum(["every","cron","at"]).describe("Schedule type: 'every' for interval, 'cron' for cron expression, 'at' for one-shot"),everyMs:n.coerce.number().optional().describe("Interval in milliseconds (required when scheduleKind is 'every')"),cronExpr:n.string().optional().describe("Cron expression like '0 9 * * *' (required when scheduleKind is 'cron')"),at:n.string().optional().describe("ISO datetime for one-shot execution (required when scheduleKind is 'at')"),suppressToken:n.boolean().optional().describe("If true, suppress HEARTBEAT_OK responses (default: false)"),timeoutSeconds:n.coerce.number().optional().describe("Per-job execution timeout in seconds. 0 = no timeout. Default: 600 (10 min)")},async e=>{try{const t=s();let n;if("every"===e.scheduleKind){if(!e.everyMs)return{content:[{type:"text",text:"everyMs is required for 'every' schedule"}],isError:!0};n={kind:"every",everyMs:e.everyMs}}else if("cron"===e.scheduleKind){if(!e.cronExpr)return{content:[{type:"text",text:"cronExpr is required for 'cron' schedule"}],isError:!0};n={kind:"cron",expr:e.cronExpr}}else{if(!e.at)return{content:[{type:"text",text:"at is required for 'at' schedule"}],isError:!0};n={kind:"at",at:e.at}}const i=await o.add({name:e.name,description:e.description,channel:e.channel,chatId:e.chatId,message:e.message,schedule:n,isolated:t.cron.isolated,suppressToken:e.suppressToken??!1,timeoutSeconds:e.timeoutSeconds,enabled:!0});return r.info(`Cron job created by agent: "${i.name}" (${i.id})`),{content:[{type:"text",text:`Job "${i.name}" created (id: ${i.id}). Schedule: ${JSON.stringify(n)}. Isolated: ${t.cron.isolated}. Next run: ${i.state.nextRunAtMs?new Date(i.state.nextRunAtMs).toISOString():"none"}.`}]}}catch(e){const t=e instanceof Error?e.message:String(e);return r.error(`cron_add failed: ${t}`),{content:[{type:"text",text:`Failed to create job: ${t}`}],isError:!0}}}),t("cron_list","List all cron jobs with their status, schedule, and next run time.",{includeDisabled:n.boolean().optional().describe("Include disabled jobs (default: false)")},async e=>{try{const t=await o.list({includeDisabled:e.includeDisabled??!1});if(0===t.length)return{content:[{type:"text",text:"No cron jobs found."}]};const n=t.map(e=>({id:e.id,name:e.name,enabled:e.enabled,isolated:e.isolated,schedule:e.schedule,channel:e.channel,chatId:e.chatId,nextRun:e.state.nextRunAtMs?new Date(e.state.nextRunAtMs).toISOString():null,lastStatus:e.state.lastStatus??null}));return{content:[{type:"text",text:JSON.stringify(n,null,2)}]}}catch(e){return{content:[{type:"text",text:`Failed to list jobs: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}}),t("cron_remove","Delete a cron job by its ID.",{id:n.string().describe("The job ID to delete")},async e=>{try{return(await o.remove(e.id)).removed?(r.info(`Cron job removed by agent: ${e.id}`),{content:[{type:"text",text:`Job ${e.id} deleted.`}]}):{content:[{type:"text",text:`Job ${e.id} not found.`}],isError:!0}}catch(e){return{content:[{type:"text",text:`Failed to delete job: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}}),t("cron_update","Update an existing cron job. Only provide the fields you want to change.",{id:n.string().describe("The job ID to update"),name:n.string().optional().describe("New name for the job"),description:n.string().optional().describe("New description"),message:n.string().optional().describe("New message text"),enabled:n.boolean().optional().describe("Enable or disable the job"),suppressToken:n.boolean().optional().describe("Enable or disable HEARTBEAT_OK suppression"),timeoutSeconds:n.coerce.number().optional().describe("Per-job execution timeout in seconds. 0 = no timeout. Default: 600 (10 min)")},async e=>{try{const t={};void 0!==e.name&&(t.name=e.name),void 0!==e.description&&(t.description=e.description),void 0!==e.message&&(t.message=e.message),void 0!==e.enabled&&(t.enabled=e.enabled),void 0!==e.suppressToken&&(t.suppressToken=e.suppressToken),void 0!==e.timeoutSeconds&&(t.timeoutSeconds=e.timeoutSeconds);const n=await o.update(e.id,t);return r.info(`Cron job updated by agent: "${n.name}" (${n.id})`),{content:[{type:"text",text:`Job "${n.name}" updated. Enabled: ${n.enabled}. Next run: ${n.state.nextRunAtMs?new Date(n.state.nextRunAtMs).toISOString():"none"}.`}]}}catch(e){return{content:[{type:"text",text:`Failed to update job: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}})]})}
@@ -1 +1 @@
1
- import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{z as r}from"zod";import{createLogger as n}from"../utils/logger.js";const s=n("MemoryTools");export function createMemoryToolsServer(n){return e({name:"memory-tools",version:"1.0.0",tools:[t("memory_search","Search conversation memory using hybrid keyword + semantic search. Returns matching chunks from past conversations with relevance scores. Results include chunkId — use memory_expand to get full chunk content when snippets are insufficient. Formulate clear, specific queries. For broad searches, call multiple times with different angles or phrasings.",{query:r.string().describe("The search query — be specific and descriptive for best results"),maxResults:r.number().optional().describe("Maximum number of results to return (default 6)")},async e=>{try{const t=await n.search(e.query,e.maxResults);if(0===t.length)return{content:[{type:"text",text:"No matching memories found."}]};const r=n.getMaxInjectedChars();let o=JSON.stringify(t,null,2);if(r>0&&o.length>r){const e=[...t];for(;e.length>1&&(e.pop(),o=JSON.stringify(e,null,2),!(o.length<=r)););o.length>r&&(o=o.slice(0,r)+"\n... [truncated — result exceeded maxInjectedChars limit]"),s.info(`memory_search: trimmed from ${t.length} to ${e.length} results to fit maxInjectedChars=${r}`)}return{content:[{type:"text",text:o}]}}catch(e){const t=e instanceof Error?e.message:String(e);return s.error(`memory_search error: ${t}`),{content:[{type:"text",text:`Search error: ${t}`}],isError:!0}}}),t("memory_expand","Get the full content of one or more memory chunks by their IDs (from memory_search results). Use this when a search snippet or L0 abstract is too short and you need the complete text.",{ids:r.array(r.number()).describe("Array of chunk IDs from memory_search results (chunkId field)")},async e=>{try{const t=n.expandChunks(e.ids);return 0===t.length?{content:[{type:"text",text:"No chunks found for the given IDs."}]}:{content:[{type:"text",text:JSON.stringify(t,null,2)}]}}catch(e){const t=e instanceof Error?e.message:String(e);return s.error(`memory_expand error: ${t}`),{content:[{type:"text",text:`Expand error: ${t}`}],isError:!0}}}),t("memory_get","Read a memory file (full or a specific slice) to get surrounding context after a search hit. Use the path from memory_search results.",{path:r.string().describe("Relative path to the memory file (from memory_search results)"),from:r.number().optional().describe("Starting line number (1-based). Omit to read from the beginning."),lines:r.number().optional().describe("Number of lines to read. Omit to read the entire file.")},async e=>{try{const t=n.readFile(e.path,e.from,e.lines);return{content:[{type:"text",text:JSON.stringify(t,null,2)}]}}catch(e){const t=e instanceof Error?e.message:String(e);return s.error(`memory_get error: ${t}`),{content:[{type:"text",text:`Read error: ${t}`}],isError:!0}}}),t("memory_list","List memory files within a date range. Returns relative paths (compatible with memory_get) for all .md files whose filename starts with a date between `from` and `to` (inclusive). Searches across all channels and subfolders.",{from:r.string().describe("Start date (inclusive), format YYYY-MM-DD"),to:r.string().describe("End date (inclusive), format YYYY-MM-DD")},async e=>{try{const t=n.listFiles(e.from,e.to);return 0===t.length?{content:[{type:"text",text:"No memory files found in the given date range."}]}:{content:[{type:"text",text:JSON.stringify(t,null,2)}]}}catch(e){const t=e instanceof Error?e.message:String(e);return s.error(`memory_list error: ${t}`),{content:[{type:"text",text:`List error: ${t}`}],isError:!0}}}),t("memory_record_access","Record that memory chunks were accessed/used in a response. This improves future retrieval by boosting frequently-used chunks. Automatically called by memory_expand, but can also be called manually for chunks from memory_search that were used without expanding.",{ids:r.array(r.number()).describe("Array of chunk IDs that were used")},async e=>{try{return n.recordAccess(e.ids),{content:[{type:"text",text:`Recorded access for ${e.ids.length} chunks.`}]}}catch(e){const t=e instanceof Error?e.message:String(e);return s.error(`memory_record_access error: ${t}`),{content:[{type:"text",text:`Error: ${t}`}],isError:!0}}})]})}
1
+ import{createSdkMcpServer as e,tool as r}from"@anthropic-ai/claude-agent-sdk";import{z as t}from"zod";import{createLogger as n}from"../utils/logger.js";const s=n("MemoryTools"),o=t.preprocess(e=>"string"==typeof e?Number(e):e,t.number()),c=t.preprocess(e=>null==e?void 0:"string"==typeof e?Number(e):e,t.number().optional()),i=t.preprocess(e=>{if("string"==typeof e)try{return JSON.parse(e)}catch{return e}return e},t.array(t.preprocess(e=>"string"==typeof e?Number(e):e,t.number())));export function createMemoryToolsServer(n){return e({name:"memory-tools",version:"1.0.0",tools:[r("memory_search","Search conversation memory using hybrid keyword + semantic search. Returns matching chunks from past conversations with relevance scores. Results include chunkId — use memory_expand to get full chunk content when snippets are insufficient. Formulate clear, specific queries. For broad searches, call multiple times with different angles or phrasings.",{query:t.string().describe("The search query — be specific and descriptive for best results"),maxResults:c.describe("Maximum number of results to return (default 6)")},async e=>{try{const r=await n.search(e.query,e.maxResults);if(0===r.length)return{content:[{type:"text",text:"No matching memories found."}]};const t=n.getMaxInjectedChars();let o=JSON.stringify(r,null,2);if(t>0&&o.length>t){const e=[...r];for(;e.length>1&&(e.pop(),o=JSON.stringify(e,null,2),!(o.length<=t)););o.length>t&&(o=o.slice(0,t)+"\n... [truncated — result exceeded maxInjectedChars limit]"),s.info(`memory_search: trimmed from ${r.length} to ${e.length} results to fit maxInjectedChars=${t}`)}return{content:[{type:"text",text:o}]}}catch(e){const r=e instanceof Error?e.message:String(e);return s.error(`memory_search error: ${r}`),{content:[{type:"text",text:`Search error: ${r}`}],isError:!0}}}),r("memory_expand","Get the full content of one or more memory chunks by their IDs (from memory_search results). Use this when a search snippet or L0 abstract is too short and you need the complete text.",{ids:i.describe("Array of chunk IDs from memory_search results (chunkId field)")},async e=>{try{const r=n.expandChunks(e.ids);return 0===r.length?{content:[{type:"text",text:"No chunks found for the given IDs."}]}:{content:[{type:"text",text:JSON.stringify(r,null,2)}]}}catch(e){const r=e instanceof Error?e.message:String(e);return s.error(`memory_expand error: ${r}`),{content:[{type:"text",text:`Expand error: ${r}`}],isError:!0}}}),r("memory_get","Read a memory file (full or a specific slice) to get surrounding context after a search hit. Use the path from memory_search results.",{path:t.string().describe("Relative path to the memory file (from memory_search results)"),from:c.describe("Starting line number (1-based). Omit to read from the beginning."),lines:c.describe("Number of lines to read. Omit to read the entire file.")},async e=>{try{const r=n.readFile(e.path,e.from,e.lines);return{content:[{type:"text",text:JSON.stringify(r,null,2)}]}}catch(e){const r=e instanceof Error?e.message:String(e);return s.error(`memory_get error: ${r}`),{content:[{type:"text",text:`Read error: ${r}`}],isError:!0}}}),r("memory_list","List memory files within a date range. Returns relative paths (compatible with memory_get) for all .md files whose filename starts with a date between `from` and `to` (inclusive). Searches across all channels and subfolders.",{from:t.string().describe("Start date (inclusive), format YYYY-MM-DD"),to:t.string().describe("End date (inclusive), format YYYY-MM-DD")},async e=>{try{const r=n.listFiles(e.from,e.to);return 0===r.length?{content:[{type:"text",text:"No memory files found in the given date range."}]}:{content:[{type:"text",text:JSON.stringify(r,null,2)}]}}catch(e){const r=e instanceof Error?e.message:String(e);return s.error(`memory_list error: ${r}`),{content:[{type:"text",text:`List error: ${r}`}],isError:!0}}}),r("memory_record_access","Record that memory chunks were accessed/used in a response. This improves future retrieval by boosting frequently-used chunks. Automatically called by memory_expand, but can also be called manually for chunks from memory_search that were used without expanding.",{ids:i.describe("Array of chunk IDs that were used")},async e=>{try{return n.recordAccess(e.ids),{content:[{type:"text",text:`Recorded access for ${e.ids.length} chunks.`}]}}catch(e){const r=e instanceof Error?e.message:String(e);return s.error(`memory_record_access error: ${r}`),{content:[{type:"text",text:`Error: ${r}`}],isError:!0}}}),r("memory_get_recent_chunks","Get recently accessed memory chunks for importance re-scoring. Returns chunks accessed in the last N days with their content snippet, current importance, and access count. Used by dreaming for LLM-based importance refinement.",{days:c.describe("Number of days to look back (default 7)"),limit:c.describe("Maximum number of chunks to return (default 100)")},async e=>{try{const r=n.getRecentlyAccessedChunks(e.days,e.limit);return{content:[{type:"text",text:JSON.stringify(r,null,2)}]}}catch(e){return{content:[{type:"text",text:`Error: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}}),r("memory_update_importance","Update importance scores for specific memory chunks. Importance is 1-10 (1=filler, 5=neutral, 9-10=critical). Used by dreaming for LLM-based importance refinement after reviewing chunk content.",{updates:t.preprocess(e=>{if("string"==typeof e)try{return JSON.parse(e)}catch{return e}return e},t.array(t.object({chunkId:o.describe("Chunk ID"),importance:t.preprocess(e=>"string"==typeof e?Number(e):e,t.number().min(1).max(10)).describe("New importance score (1-10)")}))).describe("Array of chunk ID + importance pairs")},async e=>{try{return{content:[{type:"text",text:`Updated importance for ${n.updateImportance(e.updates)}/${e.updates.length} chunks.`}]}}catch(e){return{content:[{type:"text",text:`Error: ${e instanceof Error?e.message:String(e)}`}],isError:!0}}})]})}