@hera-al/server 1.6.41 → 1.6.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-service.js +1 -1
- package/dist/agent/prompt-builder.d.ts +1 -1
- package/dist/agent/prompt-builder.js +1 -1
- package/dist/agent/session-agent.d.ts +1 -1
- package/dist/agent/session-agent.js +1 -1
- package/dist/agent/workspace-files.js +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +1 -1
- package/dist/gateway/bridge.d.ts +13 -3
- package/dist/gateway/channel-manager.d.ts +3 -9
- package/dist/gateway/channel-manager.js +1 -1
- package/dist/gateway/channels/mesh.d.ts +14 -0
- package/dist/gateway/channels/mesh.js +1 -1
- package/dist/gateway/channels/telegram/index.d.ts +22 -12
- package/dist/gateway/channels/telegram/index.js +1 -1
- package/dist/gateway/channels/webchat.d.ts +3 -6
- package/dist/gateway/channels/webchat.js +1 -1
- package/dist/gateway/channels/whatsapp.d.ts +3 -9
- package/dist/gateway/channels/whatsapp.js +1 -1
- package/dist/gateway/typing-coordinator.d.ts +43 -0
- package/dist/gateway/typing-coordinator.js +1 -0
- package/dist/memory/concept-store.d.ts +27 -0
- package/dist/memory/concept-store.js +1 -1
- package/dist/memory/memory-manager.d.ts +6 -1
- package/dist/memory/memory-manager.js +1 -1
- package/dist/memory/memory-search.d.ts +63 -0
- package/dist/memory/memory-search.js +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +1 -1
- package/dist/tools/cron-tools.js +1 -1
- package/dist/tools/memory-tools.js +1 -1
- package/dist/tools/operational-context-tools.d.ts +20 -0
- package/dist/tools/operational-context-tools.js +1 -0
- package/dist/tools/server-tools.d.ts +11 -1
- package/dist/tools/server-tools.js +1 -1
- package/installationPkg/AGENTS.md +4 -0
- package/installationPkg/BEHAVIOUR.md +95 -76
- package/installationPkg/config.example.yaml +31 -3
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"node:crypto";import{WebSocket as t}from"ws";import{createLogger as n}from"../../utils/logger.js";const s=n("Mesh");function
|
|
1
|
+
import e from"node:crypto";import{WebSocket as t}from"ws";import{createLogger as n}from"../../utils/logger.js";const s=n("Mesh");function i(t,n){const s=function(t){const n=Buffer.from(t,"hex"),s=Buffer.from("302e020100300506032b657004220420","hex"),i=Buffer.concat([s,n]);return e.createPrivateKey({key:i,format:"der",type:"pkcs8"})}(n),i=Buffer.from(t,"hex");return e.sign(null,i,s).toString("hex")}export class MeshChannel{name="mesh";ws=null;config;onMessage=null;sessionToken=null;connected=!1;reconnectTimer=null;reconnectDelay;stopping=!1;pingTimer=null;pendingOutbound=new Map;replyCallback=null;activeInbound=new Map;rateBuckets=new Map;rateLimitConfig;constructor(e){this.config=e,this.reconnectDelay=e.reconnectDelayMs??5e3,this.rateLimitConfig={maxPerPair:e.rateLimit?.maxPerPair??20,windowMs:e.rateLimit?.windowMs??36e5}}checkRateLimit(e){const t=Date.now(),n=this.rateBuckets.get(e);if(!n||t-n.windowStart>=this.rateLimitConfig.windowMs)return this.rateBuckets.set(e,{count:1,windowStart:t}),!0;if(n.count>=this.rateLimitConfig.maxPerPair){const i=this.rateLimitConfig.windowMs-(t-n.windowStart);return s.warn(`Rate limit reached for peer ${e}: ${n.count}/${this.rateLimitConfig.maxPerPair} in window. Resets in ${Math.ceil(i/1e3)}s`),!1}return n.count++,!0}onReply(e){this.replyCallback=e}async start(e){this.onMessage=e,this.stopping=!1,await this.connect()}connect(){return new Promise((e,n)=>{const{brokerUrl:o,agentId:r}=this.config;s.info(`Connecting to mesh broker at ${o} as ${r}...`);try{this.ws=new t(o)}catch(t){return s.error(`Failed to create WebSocket: ${t}`),this.scheduleReconnect(),void e()}let a=!1;const c=t=>{a||(a=!0,t?(s.error(`Mesh connection failed: ${t.message}`),this.scheduleReconnect(),e()):e())},h=setTimeout(()=>{c(new Error("Connection timeout")),this.ws?.close()},1e4);this.ws.on("open",()=>{}),this.ws.on("message",e=>{let t;try{t=JSON.parse(e.toString())}catch{return void s.warn("Invalid JSON from mesh broker")}switch(t.type){case"challenge":{const e=i(t.challenge,this.config.privateKey);this.sendRaw({type:"auth",agentId:this.config.agentId,signature:e,timestamp:Date.now()});break}case"auth_result":clearTimeout(h),t.authenticated&&t.sessionToken?(this.connected=!0,this.sessionToken=t.sessionToken,s.info(`Authenticated as ${this.config.agentId} on mesh`),this.startPing(),c()):c(new Error(`Auth failed: ${t.error??"unknown"}`));break;case"message":this.handleMeshMessage(t.message),this.sendRaw({type:"ack",messageId:t.message.id});break;case"presence":s.info(`Mesh presence: ${t.agentId} is ${t.status}`),this.knownPeers.set(t.agentId,t.status);break;case"ping":this.sendRaw({type:"pong"});break;case"pong":case"ack":break;case"error":s.warn(`Mesh error: ${t.error}`)}}),this.ws.on("close",(e,t)=>{clearTimeout(h),this.connected=!1,this.sessionToken=null,this.stopPing(),s.info(`Mesh connection closed (code=${e}, reason=${t?.toString()??"?"})`),this.stopping||this.scheduleReconnect(),c()}),this.ws.on("error",e=>{s.error(`Mesh WebSocket error: ${e.message}`)})})}async handleMeshMessage(t){if(!this.onMessage)return;const n=t.from,i=null!=t.replyTo&&this.pendingOutbound.has(t.replyTo),o=i&&t.replyTo?this.pendingOutbound.get(t.replyTo):void 0;if(t.replyTo&&this.pendingOutbound.has(t.replyTo)){const e=t.replyTo;setTimeout(()=>this.pendingOutbound.delete(e),3e5)}let r;if("string"==typeof t.payload)r=t.payload;else if(t.payload&&"object"==typeof t.payload){const e=t.payload;r="string"==typeof e.text?e.text:`[Mesh ${t.type}] ${JSON.stringify(t.payload)}`}else r=`[Mesh ${t.type}]`;if(i){if(s.info(`Reply from ${t.from} (to outbound ${t.replyTo}) → routing to origin session${o?` [${o}]`:""}`),o&&this.replyCallback)return void this.replyCallback(o,t.from,r)}else s.info(`New message from ${t.from}: ${t.type} → dispatching to agent (will auto-reply)`);const a={chatId:n,userId:t.from,channelName:"mesh",text:r,attachments:[],username:t.from};this.activeInbound.set(t.from,t.id);try{const o=await this.onMessage(a);if(!i&&o&&o.trim())if(this.checkRateLimit(n)){const i={id:e.randomUUID(),from:this.config.agentId,to:n,type:"text",payload:{text:o},timestamp:Date.now(),replyTo:t.id};this.sendRaw({type:"message",message:i}),s.debug(`Auto-replied to ${t.from}: ${o.slice(0,80)}...`)}else s.warn(`Dropping auto-reply to ${n}: rate limited`)}catch(e){s.error(`Error handling mesh message from ${t.from}: ${e}`)}finally{this.activeInbound.delete(t.from)}}async sendText(t,n,i){if(!this.connected||!this.ws)return void s.warn(`Cannot send to ${t}: mesh not connected`);if(!this.checkRateLimit(t))return void s.warn(`Dropping outbound message to ${t}: rate limited`);const o=e.randomUUID(),r=this.activeInbound.get(t),a={id:o,from:this.config.agentId,to:t,type:"text",payload:{text:n},timestamp:Date.now(),...r?{replyTo:r}:{}};r||this.pendingOutbound.set(o,i),this.sendRaw({type:"message",message:a}),s.debug(`Sent to ${t} (id=${o}${r?`, replyTo=${r}`:""}${i?`, origin=${i}`:""}): ${n.slice(0,80)}...`)}sendTyped(t,n,s,i){if(!this.connected||!this.ws)throw new Error("Mesh not connected");if(!this.checkRateLimit(t))throw new Error(`Rate limit reached for peer ${t} (max ${this.rateLimitConfig.maxPerPair} per ${this.rateLimitConfig.windowMs/1e3}s)`);const o={id:e.randomUUID(),from:this.config.agentId,to:t,type:n,payload:s,timestamp:Date.now(),replyTo:i};return this.sendRaw({type:"message",message:o}),o.id}getSessionToken(){return this.sessionToken}getAgentId(){return this.config.agentId}getBrokerHttpUrl(){return this.config.brokerUrl.replace("ws://","http://").replace("wss://","https://").replace("/ws","")}isConnected(){return this.connected}knownPeers=new Map;async getAgents(){try{const e=this.getBrokerHttpUrl(),t=await fetch(`${e}/api/agents`,{headers:this.sessionToken?{Authorization:`Bearer ${this.sessionToken}`}:{}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const n=await t.json();return Array.isArray(n.agents)?n.agents:[]}catch(e){s.warn(`Failed to fetch agents: ${e}`);const t=[];for(const[e,n]of this.knownPeers)t.push({agentId:e,status:n});return t}}async getMeshPromptInfo(){const e=(await this.getAgents()).filter(e=>e.agentId!==this.config.agentId);if(0===e.length)return"";const t=e.map(e=>`- **${e.agentId}** (${e.status??"unknown"})`);return["## Mesh (Inter-Agent Communication)",`You are connected to the Hera Mesh as **${this.config.agentId}**.`,"Other agents on the mesh:",...t,"",'To message an agent: use send_message(channel="mesh", chatId="<agentId>", text="...").',"When you receive a mesh message, it appears as a normal incoming message from channel=mesh."].join("\n")}sendRaw(e){this.ws&&this.ws.readyState===t.OPEN&&this.ws.send(JSON.stringify(e))}startPing(){this.stopPing(),this.pingTimer=setInterval(()=>{this.connected&&this.sendRaw({type:"ping"})},3e4)}stopPing(){this.pingTimer&&(clearInterval(this.pingTimer),this.pingTimer=null)}scheduleReconnect(){this.stopping||this.reconnectTimer||(s.info(`Reconnecting in ${this.reconnectDelay}ms...`),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this.connect().catch(e=>{s.error(`Reconnect failed: ${e}`)})},this.reconnectDelay))}async stop(){this.stopping=!0,this.stopPing(),this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.ws&&(this.ws.close(1e3,"Channel stopping"),this.ws=null),this.connected=!1,s.info("Mesh channel stopped")}}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Advanced retry policy
|
|
11
11
|
* - Poll creation
|
|
12
12
|
*/
|
|
13
|
-
import type { ChannelAdapter, MessageHandler, InlineButton } from "../../bridge.js";
|
|
13
|
+
import type { ChannelAdapter, MessageHandler, ReactionHandler, InlineButton } from "../../bridge.js";
|
|
14
14
|
import type { TokenDB } from "../../../auth/token-db.js";
|
|
15
15
|
import type { TelegramAccountConfig } from "../../../config.js";
|
|
16
16
|
export declare class TelegramChannel implements ChannelAdapter {
|
|
@@ -19,19 +19,15 @@ export declare class TelegramChannel implements ChannelAdapter {
|
|
|
19
19
|
private config;
|
|
20
20
|
private accountId;
|
|
21
21
|
private tokenDb;
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
start(onMessage: MessageHandler): Promise<void>;
|
|
22
|
+
/** Per-chat debounce buffers for rapid-fire message aggregation */
|
|
23
|
+
private debounceBuffers;
|
|
24
|
+
constructor(config: TelegramAccountConfig, accountId: string, tokenDb: TokenDB);
|
|
25
|
+
start(onMessage: MessageHandler, onReaction?: ReactionHandler): Promise<void>;
|
|
27
26
|
sendText(chatId: string, text: string): Promise<void>;
|
|
27
|
+
/** One-shot: send a single "typing" signal to Telegram. */
|
|
28
28
|
setTyping(chatId: string): Promise<void>;
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
clearTyping(chatId: string): Promise<void>;
|
|
32
|
-
releaseTyping(chatId: string): Promise<void>;
|
|
33
|
-
private startTypingInterval;
|
|
34
|
-
private stopTypingInterval;
|
|
29
|
+
/** One-shot: Telegram has no explicit "stop typing" API — it clears on message send. */
|
|
30
|
+
clearTyping(_chatId: string): Promise<void>;
|
|
35
31
|
sendButtons(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
|
|
36
32
|
sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
|
|
37
33
|
reactMessage(chatId: string, messageId: string, emoji: string, remove?: boolean): Promise<void>;
|
|
@@ -42,8 +38,22 @@ export declare class TelegramChannel implements ChannelAdapter {
|
|
|
42
38
|
/**
|
|
43
39
|
* Process an incoming message in the background.
|
|
44
40
|
* Not awaited by the Grammy handler so the polling loop stays unblocked.
|
|
41
|
+
*
|
|
42
|
+
* When inputDebounceMs > 0, rapid-fire messages from the same chat are
|
|
43
|
+
* buffered and merged into a single IncomingMessage before being forwarded
|
|
44
|
+
* to the agent. This prevents Telegram's message-splitting from causing
|
|
45
|
+
* multiple steer interrupts.
|
|
45
46
|
*/
|
|
46
47
|
private handleIncoming;
|
|
48
|
+
/**
|
|
49
|
+
* Flush the debounce buffer for a chat: merge all buffered messages into
|
|
50
|
+
* one IncomingMessage and process it.
|
|
51
|
+
*/
|
|
52
|
+
private flushDebounce;
|
|
53
|
+
/**
|
|
54
|
+
* Process a single (possibly merged) message — send to agent and deliver response.
|
|
55
|
+
*/
|
|
56
|
+
private processMessage;
|
|
47
57
|
private buildIncomingMessage;
|
|
48
58
|
private downloadFile;
|
|
49
59
|
private sendChunked;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Bot as
|
|
1
|
+
import{Bot as e,InputFile as t}from"grammy";import{validateChannelUser as i}from"../../../auth/auth-middleware.js";import{parseMediaLines as o}from"../../../utils/media-response.js";import{markdownToTelegramHtmlChunks as a}from"../../../utils/telegram-format.js";import{createLogger as n}from"../../../utils/logger.js";import{sendMessageTelegram as s}from"./send.js";import{reactMessageTelegram as r,resolveReactionLevel as c}from"./reactions.js";import{editMessageTelegram as d,deleteMessageTelegram as m}from"./edit-delete.js";import{sendStickerTelegram as l,cacheSticker as u}from"./stickers.js";import{validateButtonsForChatId as f}from"./inline-buttons.js";const h=n("Telegram");export class TelegramChannel{name="telegram";bot;config;accountId;tokenDb;debounceBuffers=new Map;constructor(t,i,o){this.config=t,this.accountId=i,this.tokenDb=o,this.bot=new e(t.botToken)}async start(e,t){this.bot.on("message",t=>{this.handleIncoming(t,e)}),this.bot.on("callback_query:data",t=>{const i=t.callbackQuery.data,o=String(t.from?.id??"unknown"),a=String(t.chat?.id??t.callbackQuery.message?.chat?.id??"unknown"),n=t.from?.username;t.answerCallbackQuery().catch(()=>{});e({chatId:a,userId:o,channelName:"telegram",text:i,attachments:[],username:n,rawContext:t}).then(async e=>{e&&e.trim()&&await this.sendText(a,e)}).catch(e=>{h.error(`Error handling callback from ${o}: ${e}`)})}),t&&this.bot.on("message_reaction",e=>{try{const i=e.messageReaction,o=String(i.chat.id),a=String(i.user?.id??"unknown"),n=i.user?.username,s=(i.new_reaction??[]).filter(e=>"emoji"===e.type).map(e=>e.emoji),r=(i.old_reaction??[]).filter(e=>"emoji"===e.type).map(e=>e.emoji);for(const e of s.filter(e=>!r.includes(e)))t({chatId:o,userId:a,channelName:"telegram",messageId:i.message_id,emoji:e,removed:!1,username:n});for(const e of r.filter(e=>!s.includes(e)))t({chatId:o,userId:a,channelName:"telegram",messageId:i.message_id,emoji:e,removed:!0,username:n})}catch(e){h.warn(`Error handling reaction: ${e}`)}}),h.info("Starting Telegram bot..."),this.bot.start({onStart:e=>{h.info(`Telegram bot started: @${e.username}`)},allowed_updates:["message","callback_query","message_reaction"]}).catch(e=>{String(e).includes("Aborted delay")?h.debug("Telegram polling stopped"):h.error(`Telegram bot polling error: ${e}`)})}async sendText(e,t){try{await s(e,t,{token:this.config.botToken,accountId:this.accountId,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}catch(e){throw h.error(`Failed to send message: ${e}`),e}}async setTyping(e){await this.bot.api.sendChatAction(e,"typing").catch(()=>{})}async clearTyping(e){}async sendButtons(e,t,i){const o=i.map(e=>e.map(e=>({text:e.text,callback_data:e.callbackData??e.text})));try{f(o,this.config,e)}catch(i){return h.warn(`Button validation failed: ${i}, sending without buttons`),void await this.sendText(e,t)}await s(e,t,{token:this.config.botToken,accountId:this.accountId,buttons:o,textChunkLimit:this.config.textChunkLimit,linkPreview:this.config.linkPreview,retry:this.config.retry})}async sendAudio(e,i,o){const a=new t(i);o?await this.bot.api.sendVoice(e,a):await this.bot.api.sendAudio(e,a)}async reactMessage(e,t,i,o){const{level:a}=c(this.config);"off"!==a?await r(e,t,i,this.config.botToken,{remove:o}):h.debug("Reactions disabled for this account")}async editMessage(e,t,i,o){const a=o?.map(e=>({text:e.text,callback_data:e.callbackData??e.text}));await d(e,t,i,this.config.botToken,{buttons:a?[a]:void 0,linkPreview:this.config.linkPreview})}async deleteMessage(e,t){await m(e,t,this.config.botToken,this.config.retry)}async sendSticker(e,t){await l(e,t,this.config.botToken,{retry:this.config.retry})}async stop(){for(const[,e]of this.debounceBuffers)clearTimeout(e.timer);this.debounceBuffers.clear();try{await this.bot.stop(),h.info("Telegram bot stopped"),await new Promise(e=>setTimeout(e,100))}catch(e){h.warn(`Error stopping Telegram bot: ${e}`)}}async handleIncoming(e,t){const o=String(e.from?.id??"unknown"),a=String(e.chat?.id??"unknown"),n=e.from?.username,s=i(this.tokenDb,o,"telegram",this.config.dmPolicy,this.config.allowFrom);if(!s.authorized)return h.warn(`Unauthorized message from ${o} (@${n})`),void await e.reply(s.reason??"Not authorized.");const r=await this.buildIncomingMessage(e,a,o,n),c=this.config.inputDebounceMs??0;if(c<=0)return void await this.processMessage(r,e,t);const d=this.debounceBuffers.get(a);if(d)d.messages.push(r),d.contexts.push(e),clearTimeout(d.timer),d.timer=setTimeout(()=>this.flushDebounce(a),c),h.debug(`[${a}] Debounce: buffered message (total=${d.messages.length})`);else{const i={messages:[r],contexts:[e],timer:setTimeout(()=>this.flushDebounce(a),c),onMessage:t};this.debounceBuffers.set(a,i),h.debug(`[${a}] Debounce: started (${c}ms)`)}}async flushDebounce(e){const t=this.debounceBuffers.get(e);if(!t)return;this.debounceBuffers.delete(e);const{messages:i,contexts:o,onMessage:a}=t;if(1===i.length)return h.debug(`[${e}] Debounce: flush single message`),void await this.processMessage(i[0],o[0],a);h.info(`[${e}] Debounce: merging ${i.length} messages`);const n=i.map(e=>e.text).filter(Boolean).join("\n"),s=i.flatMap(e=>e.attachments),r={...i[0],text:n||void 0,attachments:s};await this.processMessage(r,o[0],a)}async processMessage(e,i,a){try{const n=await a(e),{textParts:s,mediaEntries:r}=o(n);for(const e of r)try{const o=new t(e.path);e.asVoice?await i.replyWithVoice(o):await i.replyWithAudio(o)}catch(e){h.error(`Failed to send audio: ${e}`)}const c=s.join("\n").trim();c&&await this.sendChunked(i,c)}catch(t){h.error(`Error handling message from ${e.userId}: ${t}`),await i.reply("An error occurred while processing your message.").catch(()=>{})}}async buildIncomingMessage(e,t,i,o){const a=e.message,n=[];let s=a.text??a.caption??void 0;if(a.photo&&a.photo.length>0){const t=a.photo[a.photo.length-1];n.push({type:"image",mimeType:"image/jpeg",fileSize:t.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,t.file_id)})}if(a.voice&&n.push({type:"voice",mimeType:a.voice.mime_type??"audio/ogg",duration:a.voice.duration,fileSize:a.voice.file_size,getBuffer:()=>this.downloadFile(e,a.voice.file_id)}),a.audio&&n.push({type:"audio",mimeType:a.audio.mime_type??"audio/mpeg",fileName:a.audio.file_name,duration:a.audio.duration,fileSize:a.audio.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,a.audio.file_id)}),a.document&&n.push({type:"document",mimeType:a.document.mime_type,fileName:a.document.file_name,fileSize:a.document.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,a.document.file_id)}),a.video&&n.push({type:"video",mimeType:a.video.mime_type??"video/mp4",fileName:a.video.file_name,duration:a.video.duration,fileSize:a.video.file_size,caption:a.caption,getBuffer:()=>this.downloadFile(e,a.video.file_id)}),a.video_note&&n.push({type:"video_note",mimeType:"video/mp4",duration:a.video_note.duration,fileSize:a.video_note.file_size,getBuffer:()=>this.downloadFile(e,a.video_note.file_id)}),a.sticker){if(this.config.actions?.sticker)try{u({fileId:a.sticker.file_id,fileUniqueId:a.sticker.file_unique_id,emoji:a.sticker.emoji,setName:a.sticker.set_name,description:a.sticker.emoji?`${a.sticker.emoji} sticker${a.sticker.set_name?` from ${a.sticker.set_name}`:""}`:a.sticker.set_name??"sticker"})}catch(e){h.warn(`Failed to cache sticker: ${e}`)}n.push({type:"sticker",mimeType:a.sticker.is_animated?"application/x-tgsticker":a.sticker.is_video?"video/webm":"image/webp",metadata:{emoji:a.sticker.emoji,setName:a.sticker.set_name},getBuffer:()=>this.downloadFile(e,a.sticker.file_id)})}return a.location&&n.push({type:"location",metadata:{latitude:a.location.latitude,longitude:a.location.longitude},getBuffer:async()=>Buffer.alloc(0)}),a.contact&&n.push({type:"contact",metadata:{phoneNumber:a.contact.phone_number,firstName:a.contact.first_name,lastName:a.contact.last_name,userId:a.contact.user_id},getBuffer:async()=>Buffer.alloc(0)}),{chatId:t,userId:i,channelName:"telegram",text:s,attachments:n,username:o,rawContext:e}}async downloadFile(e,t){const i=await e.api.getFile(t),o=`https://api.telegram.org/file/bot${this.config.botToken}/${i.file_path}`,a=await fetch(o);if(!a.ok)throw new Error(`Failed to download file: ${a.statusText}`);return Buffer.from(await a.arrayBuffer())}async sendChunked(e,t){const i=a(t,4096);for(const o of i)try{await e.reply(o,{parse_mode:"HTML"})}catch{const i=g(t,4096);for(const t of i)await e.reply(t);break}}}function g(e,t){if(e.length<=t)return[e];const i=[];let o=e;for(;o.length>0;){if(o.length<=t){i.push(o);break}let e=o.lastIndexOf("\n",t);e<=0&&(e=t),i.push(o.slice(0,e)),o=o.slice(e).trimStart()}return i}
|
|
@@ -9,8 +9,6 @@ export declare class WebChatChannel implements ChannelAdapter {
|
|
|
9
9
|
readonly name = "webchat";
|
|
10
10
|
private connections;
|
|
11
11
|
private onMessage;
|
|
12
|
-
private typingIntervals;
|
|
13
|
-
private inflightCount;
|
|
14
12
|
private pendingMessages;
|
|
15
13
|
start(onMessage: MessageHandler): Promise<void>;
|
|
16
14
|
registerConnection(chatId: string, ws: WebSocket): void;
|
|
@@ -23,6 +21,7 @@ export declare class WebChatChannel implements ChannelAdapter {
|
|
|
23
21
|
/**
|
|
24
22
|
* Handle a chat message arriving from a paired node.
|
|
25
23
|
* Fire-and-forget: caller does not await.
|
|
24
|
+
* Typing lifecycle is fully managed by TypingCoordinator via server.ts.
|
|
26
25
|
*/
|
|
27
26
|
handleNodeChat(chatId: string, nodeId: string, msg: {
|
|
28
27
|
text?: string;
|
|
@@ -30,14 +29,12 @@ export declare class WebChatChannel implements ChannelAdapter {
|
|
|
30
29
|
}): Promise<void>;
|
|
31
30
|
sendText(chatId: string, text: string): Promise<void>;
|
|
32
31
|
sendButtons(chatId: string, text: string, buttons: InlineButton[][]): Promise<void>;
|
|
32
|
+
/** One-shot: send a single typing=true signal to the webchat client. */
|
|
33
33
|
setTyping(chatId: string): Promise<void>;
|
|
34
|
+
/** One-shot: send typing=false to the webchat client. */
|
|
34
35
|
clearTyping(chatId: string): Promise<void>;
|
|
35
|
-
releaseTyping(chatId: string): Promise<void>;
|
|
36
36
|
sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
|
|
37
37
|
stop(): Promise<void>;
|
|
38
|
-
private startTypingInterval;
|
|
39
|
-
private stopTypingInterval;
|
|
40
|
-
private resendTypingIfActive;
|
|
41
38
|
private sendWs;
|
|
42
39
|
private enqueuePending;
|
|
43
40
|
private flushPending;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFileSync as
|
|
1
|
+
import{readFileSync as e}from"node:fs";import{basename as t,extname as s}from"node:path";import{parseMediaLines as n}from"../../utils/media-response.js";import{createLogger as a}from"../../utils/logger.js";const o=a("WebChat");export function buildWebChatId(e,t){return`${(e||"node").toLowerCase().replace(/[^a-z0-9_-]/g,"_").replace(/_+/g,"_").replace(/^_|_$/g,"")||"node"}-${t.slice(0,8)}`}export class WebChatChannel{name="webchat";connections=new Map;onMessage=null;pendingMessages=new Map;async start(e){this.onMessage=e,o.info("WebChat channel started (virtual — connections managed by Nostromo)")}registerConnection(e,t){const s=!this.connections.has(e);this.connections.set(e,t),s&&o.info(`WebChat connection registered: ${e}`),this.flushPending(e)}unregisterConnection(e){this.connections.delete(e)&&o.info(`WebChat connection unregistered: ${e}`)}unregisterByWs(e){for(const[t,s]of this.connections)s===e&&(this.connections.delete(t),o.info(`WebChat connection unregistered: ${t}`))}async handleNodeChat(e,t,s){if(!this.onMessage)return void o.warn("WebChat: message received but no onMessage handler registered");const a=[];if(s.attachments&&Array.isArray(s.attachments))for(const e of s.attachments)a.push({type:e.type,mimeType:e.mimeType,fileName:e.fileName,duration:e.duration,caption:e.caption,getBuffer:()=>Promise.resolve(Buffer.from(e.data,"base64"))});const i={chatId:e,userId:t,channelName:"webchat",text:s.text,attachments:a,username:e};try{const t=await this.onMessage(i),{textParts:s,mediaEntries:a}=n(t);for(const t of a)try{await this.sendAudio(e,t.path,t.asVoice)}catch(t){o.error(`WebChat: failed to send audio to ${e}: ${t}`)}const r=s.join("\n").trim();r&&this.sendWs(e,{type:"chat_response",role:"assistant",text:r})}catch(t){o.error(`WebChat: error handling message from ${e}: ${t}`),this.sendWs(e,{type:"chat_response",role:"assistant",text:"Error processing message."})}}async sendText(e,t){this.sendWs(e,{type:"chat_message",role:"assistant",text:t})}async sendButtons(e,t,s){const n=s.flat();this.sendWs(e,{type:"chat_message",role:"assistant",text:t,buttons:n.map(e=>({text:e.text,callbackData:e.callbackData??e.text,...e.url?{url:e.url}:{}}))})}async setTyping(e){this.sendWs(e,{type:"typing_indicator",typing:!0},!1)}async clearTyping(e){this.sendWs(e,{type:"typing_indicator",typing:!1},!1)}async sendAudio(n,a,i){try{const o=e(a),r=t(a),c=s(a).toLowerCase().replace(".",""),d={mp3:"audio/mpeg",ogg:"audio/ogg",opus:"audio/ogg",wav:"audio/wav",m4a:"audio/mp4",flac:"audio/flac"}[c]||"audio/mpeg";this.sendWs(n,{type:"chat_media",role:"assistant",mediaType:"audio",mimeType:d,fileName:r,data:o.toString("base64"),asVoice:i??!1})}catch(e){o.error(`WebChat: failed to read audio file ${a}: ${e}`)}}async stop(){this.onMessage=null,o.info("WebChat channel stopped")}sendWs(e,t,s=!0){const n=this.connections.get(e);if(n&&n.readyState===n.OPEN)try{n.send(JSON.stringify({...t,chatId:e}))}catch(t){o.error(`WebChat: failed to send to ${e}: ${t}`)}else s&&this.enqueuePending(e,t)}enqueuePending(e,t){let s=this.pendingMessages.get(e);for(s||(s=[],this.pendingMessages.set(e,s)),s.push(t);s.length>10;)s.shift();o.info(`WebChat: queued pending message for ${e} (${s.length}/10)`)}flushPending(e){const t=this.pendingMessages.get(e);if(t&&0!==t.length){o.info(`WebChat: flushing ${t.length} pending message(s) for ${e}`),this.pendingMessages.delete(e);for(const s of t)this.sendWs(e,s,!1)}}}
|
|
@@ -12,10 +12,7 @@ export declare class WhatsAppChannel implements ChannelAdapter {
|
|
|
12
12
|
private qrCallback;
|
|
13
13
|
private connected;
|
|
14
14
|
private stopping;
|
|
15
|
-
|
|
16
|
-
private inflightTyping;
|
|
17
|
-
private inflightCount;
|
|
18
|
-
constructor(config: WhatsAppAccountConfig, inflightTyping?: boolean);
|
|
15
|
+
constructor(config: WhatsAppAccountConfig);
|
|
19
16
|
setQrCallback(cb: QrCallback): void;
|
|
20
17
|
isConnected(): boolean;
|
|
21
18
|
start(onMessage: MessageHandler): Promise<void>;
|
|
@@ -27,13 +24,10 @@ export declare class WhatsAppChannel implements ChannelAdapter {
|
|
|
27
24
|
private handleIncoming;
|
|
28
25
|
private checkAccess;
|
|
29
26
|
sendText(chatId: string, text: string): Promise<void>;
|
|
27
|
+
/** One-shot: send a single "composing" signal to WhatsApp. */
|
|
30
28
|
setTyping(chatId: string): Promise<void>;
|
|
31
|
-
/**
|
|
32
|
-
private resendTypingIfActive;
|
|
29
|
+
/** One-shot: send "paused" to WhatsApp to stop the typing indicator. */
|
|
33
30
|
clearTyping(chatId: string): Promise<void>;
|
|
34
|
-
releaseTyping(chatId: string): Promise<void>;
|
|
35
|
-
private startTypingInterval;
|
|
36
|
-
private stopTypingInterval;
|
|
37
31
|
sendAudio(chatId: string, filePath: string, asVoice?: boolean): Promise<void>;
|
|
38
32
|
stop(): Promise<void>;
|
|
39
33
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{DisconnectReason as
|
|
1
|
+
import{DisconnectReason as e,fetchLatestBaileysVersion as s,makeCacheableSignalKeyStore as t,makeWASocket as o,useMultiFileAuthState as n}from"@whiskeysockets/baileys";import{mkdirSync as c,existsSync as i,readFileSync as a}from"node:fs";import{resolve as r}from"node:path";import{renderQrPngBase64 as h}from"./qr-image.js";import{convertMarkdownTables as d}from"../../utils/markdown/tables.js";import{chunkText as l}from"../../utils/chunk.js";import{createLogger as p}from"../../utils/logger.js";const u=p("WhatsApp");export class WhatsAppChannel{name="whatsapp";sock=null;config;qrCallback=null;connected=!1;stopping=!1;constructor(e){this.config=e}setQrCallback(e){this.qrCallback=e}isConnected(){return this.connected}async start(e){const s=r(this.config.authDir);i(s)||c(s,{recursive:!0}),await this.connect(s,e)}async connect(c,i){const{state:a,saveCreds:r}=await n(c),{version:d}=await s(),l={level:"silent",trace:()=>{},debug:()=>{},info:()=>{},warn:()=>{},error:()=>{},fatal:()=>{},child:()=>l};this.sock=o({auth:{creds:a.creds,keys:t(a.keys,l)},version:d,logger:l,printQRInTerminal:!1,browser:["GrabMeABeer","Web","1.0"],syncFullHistory:!1,markOnlineOnConnect:!1}),this.sock.ev.on("creds.update",r),this.sock.ev.on("connection.update",async s=>{try{const{connection:t,lastDisconnect:o,qr:n}=s;if(n){u.info("QR code received, rendering...");const e=`data:image/png;base64,${await h(n)}`;this.qrCallback?.(e,!1)}if("open"===t&&(this.connected=!0,u.info("WhatsApp connected"),this.qrCallback?.(null,!0)),"close"===t){this.connected=!1;const s=o?.error?.output?.statusCode??o?.error?.status;s===e.loggedOut?(u.warn("WhatsApp session logged out. Re-scan QR via Nostromo."),this.qrCallback?.(null,!1,"Session logged out. Please re-scan QR code.")):this.stopping||(u.info(`WhatsApp disconnected (code ${s}), reconnecting...`),setTimeout(()=>this.connect(c,i),3e3))}}catch(e){u.error(`connection.update handler error: ${e}`)}}),this.sock.ws&&"function"==typeof this.sock.ws.on&&this.sock.ws.on("error",e=>{u.error(`WebSocket error: ${e.message}`)}),this.sock.ev.on("messages.upsert",({messages:e,type:s})=>{if("notify"===s)for(const s of e){if(!s.message||s.key.fromMe)continue;const e=s.key.remoteJid;if(!e)continue;const t=e.replace(/@s\.whatsapp\.net$/,""),o=s.message.conversation??s.message.extendedTextMessage?.text??void 0;if(!this.checkAccess(t)){u.warn(`Unauthorized message from ${t}`),this.sock?.sendMessage(e,{text:"Not authorized."});continue}if(!o)continue;const n={chatId:e,userId:t,channelName:"whatsapp",text:o,attachments:[],username:s.pushName??void 0};this.handleIncoming(n,e,t,i)}})}async handleIncoming(e,s,t,o){try{const t=await o(e),n=d(t,"code"),c=l(n,4e3);for(const e of c)await(this.sock?.sendMessage(s,{text:e}))}catch(e){u.error(`Error handling message from ${t}: ${e}`)}}checkAccess(e){const s=this.config.dmPolicy||"allowlist";if("open"===s)return!0;if("allowlist"===s){return(this.config.allowFrom??[]).some(s=>String(s)===e)}return!0}async sendText(e,s){if(!this.sock)return;const t=d(s,"code"),o=l(t,4e3);for(const s of o)await this.sock.sendMessage(e,{text:s})}async setTyping(e){this.sock&&await this.sock.sendPresenceUpdate("composing",e).catch(()=>{})}async clearTyping(e){this.sock?.sendPresenceUpdate("paused",e).catch(()=>{})}async sendAudio(e,s,t){if(!this.sock)return;const o=a(s);t?await this.sock.sendMessage(e,{audio:o,mimetype:"audio/ogg; codecs=opus",ptt:!0}):await this.sock.sendMessage(e,{audio:o,mimetype:"audio/mpeg"})}async stop(){this.stopping=!0;try{this.sock?.ws?.close()}catch{}this.sock=null,this.connected=!1,u.info("WhatsApp stopped")}}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ChannelManager } from "./channel-manager.js";
|
|
2
|
+
/**
|
|
3
|
+
* Centralised typing-indicator lifecycle manager.
|
|
4
|
+
*
|
|
5
|
+
* Owns the refcount and the renewal interval for every (channel, chatId) pair.
|
|
6
|
+
* Channel adapters are reduced to dumb one-shot signal senders:
|
|
7
|
+
* - setTyping() → fire a single platform-native "typing" signal
|
|
8
|
+
* - clearTyping() → fire a single platform-native "stop typing" signal
|
|
9
|
+
*
|
|
10
|
+
* The coordinator calls these at the right time; adapters no longer manage
|
|
11
|
+
* intervals, refcounts, or any typing state of their own.
|
|
12
|
+
*/
|
|
13
|
+
export declare class TypingCoordinator {
|
|
14
|
+
private channelManager;
|
|
15
|
+
/** How many handlers are actively processing for this key. */
|
|
16
|
+
private refcounts;
|
|
17
|
+
/** Self-renewing intervals that pump typing signals. */
|
|
18
|
+
private intervals;
|
|
19
|
+
constructor(channelManager: ChannelManager);
|
|
20
|
+
private key;
|
|
21
|
+
/**
|
|
22
|
+
* A handler started processing a message — increment refcount and ensure
|
|
23
|
+
* a typing-renewal loop is running.
|
|
24
|
+
*/
|
|
25
|
+
acquire(channel: string, chatId: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* A handler finished processing — decrement refcount.
|
|
28
|
+
* The loop will self-destruct at the next tick when it sees count === 0.
|
|
29
|
+
*/
|
|
30
|
+
release(channel: string, chatId: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* Reinforce typing after a message send (Telegram cancels typing on send).
|
|
33
|
+
* Only sends if there are still active handlers.
|
|
34
|
+
*/
|
|
35
|
+
reinforceIfActive(channel: string, chatId: string): void;
|
|
36
|
+
/** Force-clear everything — safety net for hard resets / shutdown. */
|
|
37
|
+
forceStop(channel: string, chatId: string): void;
|
|
38
|
+
/** Clear all intervals (called on server shutdown). */
|
|
39
|
+
stopAll(): void;
|
|
40
|
+
private startLoop;
|
|
41
|
+
private stopLoop;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=typing-coordinator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createLogger as t}from"../utils/logger.js";t("TypingCoordinator");export class TypingCoordinator{channelManager;refcounts=new Map;intervals=new Map;constructor(t){this.channelManager=t}key(t,e){return`${t}:${e}`}acquire(t,e){const s=this.key(t,e),n=(this.refcounts.get(s)??0)+1;this.refcounts.set(s,n),1===n&&this.startLoop(t,e)}release(t,e){const s=this.key(t,e),n=Math.max(0,(this.refcounts.get(s)??0)-1);n>0?this.refcounts.set(s,n):(this.refcounts.delete(s),this.stopLoop(t,e))}reinforceIfActive(t,e){const s=this.key(t,e);(this.refcounts.get(s)??0)>0&&this.channelManager.setTyping(t,e).catch(()=>{})}forceStop(t,e){const s=this.key(t,e);this.refcounts.delete(s),this.stopLoop(t,e)}stopAll(){for(const[,t]of this.intervals)clearInterval(t);this.intervals.clear(),this.refcounts.clear()}startLoop(t,e){const s=this.key(t,e),n=this.intervals.get(s);n&&clearInterval(n),this.channelManager.setTyping(t,e).catch(()=>{});const r=setInterval(()=>{(this.refcounts.get(s)??0)>0?this.channelManager.setTyping(t,e).catch(()=>{}):this.stopLoop(t,e)},4e3);this.intervals.set(s,r)}stopLoop(t,e){const s=this.key(t,e),n=this.intervals.get(s);n&&(clearInterval(n),this.intervals.delete(s)),this.channelManager.clearTyping(t,e).catch(()=>{})}}
|
|
@@ -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],
|
|
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
|
-
|
|
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
|
|
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,10 @@ 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;
|
|
64
69
|
private openai;
|
|
65
70
|
private indexDbPath;
|
|
66
71
|
private searchDbPath;
|
|
@@ -69,11 +74,50 @@ export declare class MemorySearch {
|
|
|
69
74
|
private searchHnswPath;
|
|
70
75
|
private searchNextHnswPath;
|
|
71
76
|
constructor(memoryDir: string, dataDir: string, opts: MemorySearchOptions);
|
|
77
|
+
/**
|
|
78
|
+
* Wire the concept graph for spreading activation.
|
|
79
|
+
* Called by Server after both MemorySearch and ConceptStore are created.
|
|
80
|
+
* Triggers Phase 3 entity index population if needed.
|
|
81
|
+
*/
|
|
82
|
+
setConceptStore(store: ConceptStore): void;
|
|
83
|
+
/**
|
|
84
|
+
* Get or refresh the concept cache. Concepts change only during dreaming,
|
|
85
|
+
* so a 1-hour TTL is more than adequate.
|
|
86
|
+
*/
|
|
87
|
+
private getConceptCacheEntries;
|
|
88
|
+
/**
|
|
89
|
+
* Extract entity IDs mentioned in text via substring matching.
|
|
90
|
+
* Uses the closed vocabulary from the concept graph — no NER model needed.
|
|
91
|
+
*/
|
|
92
|
+
private extractEntities;
|
|
93
|
+
/**
|
|
94
|
+
* Compute spreading activation S_i for a chunk, given query entities.
|
|
95
|
+
*
|
|
96
|
+
* Phase 1: Direct overlap — query entities that appear in the chunk get a boost
|
|
97
|
+
* weighted by the fan effect (S_max - ln(fan)).
|
|
98
|
+
* Phase 2: Graph-distance propagation — entities in the chunk that are 1-hop
|
|
99
|
+
* connected to query entities get an attenuated boost.
|
|
100
|
+
*/
|
|
101
|
+
private computeSpreadingActivation;
|
|
102
|
+
/**
|
|
103
|
+
* Phase 3: Populate chunk_entities table for all existing chunks.
|
|
104
|
+
* One-time backfill when ConceptStore is first connected.
|
|
105
|
+
*/
|
|
106
|
+
private populateChunkEntities;
|
|
107
|
+
/**
|
|
108
|
+
* Phase 3: Get entities for chunks using the pre-computed index.
|
|
109
|
+
* Falls back to substring matching if index is empty.
|
|
110
|
+
*/
|
|
111
|
+
private getChunkEntities;
|
|
72
112
|
private isOpenAI;
|
|
73
113
|
getMaxInjectedChars(): number;
|
|
74
114
|
start(): Promise<void>;
|
|
75
115
|
stop(): void;
|
|
76
116
|
private migrateEmbeddingsTable;
|
|
117
|
+
/**
|
|
118
|
+
* Migrate chunk_utility: add importance + access_times columns if missing.
|
|
119
|
+
*/
|
|
120
|
+
private migrateChunkUtility;
|
|
77
121
|
/**
|
|
78
122
|
* Check if embedding model or dimensions changed since last run.
|
|
79
123
|
* If so, wipe embeddings + HNSW and re-embed everything.
|
|
@@ -88,6 +132,25 @@ export declare class MemorySearch {
|
|
|
88
132
|
* Writes to indexDb — will propagate to searchDb on next snapshot.
|
|
89
133
|
*/
|
|
90
134
|
recordAccess(chunkIds: number[]): void;
|
|
135
|
+
/**
|
|
136
|
+
* Get recently accessed chunks for importance re-scoring (used by dreaming).
|
|
137
|
+
* Returns chunks accessed in the last N days, with their content snippet.
|
|
138
|
+
*/
|
|
139
|
+
getRecentlyAccessedChunks(days?: number, limit?: number): Array<{
|
|
140
|
+
chunkId: number;
|
|
141
|
+
content: string;
|
|
142
|
+
path: string;
|
|
143
|
+
importance: number;
|
|
144
|
+
accessCount: number;
|
|
145
|
+
lastAccessed: string;
|
|
146
|
+
}>;
|
|
147
|
+
/**
|
|
148
|
+
* Update importance scores for specific chunks (used by dreaming LLM re-scoring).
|
|
149
|
+
*/
|
|
150
|
+
updateImportance(updates: Array<{
|
|
151
|
+
chunkId: number;
|
|
152
|
+
importance: number;
|
|
153
|
+
}>): number;
|
|
91
154
|
/**
|
|
92
155
|
* Fetch utility data for a set of chunk IDs from the search DB.
|
|
93
156
|
* Returns a Map of chunkId → { access_count, last_accessed, first_accessed }.
|