Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

heymax

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

heymax - npm Package Compare versions

Comparing version
1.2.2
to
1.3.0
+79
dist/copilot/memory-extractor.js
import { addMemory, findSimilarMemory } from "../store/db.js";
// Patterns that signal memorable information in user messages.
// Each entry: [regex, category, capture group index for the content].
const PATTERNS = [
// Preferences
[/\bi (?:always |usually )?prefer\s+(.{5,80}?)(?:\.|,|$)/i, "preference", 1],
[/\bi (?:always |usually )?use\s+(.{3,60}?)(?:\s+(?:for|when|because)\b.{0,80})?(?:\.|,|$)/i, "preference", 1],
[/\bi (?:don'?t |never )(?:like|use|want)\s+(.{3,80}?)(?:\.|,|$)/i, "preference", 1],
[/\bi always\s+(.{5,80}?)(?:\.|,|$)/i, "preference", 1],
[/\bi never\s+(.{5,80}?)(?:\.|,|$)/i, "preference", 1],
// Identity / facts
[/\bmy name is\s+(.{2,40}?)(?:\.|,|$)/i, "fact", 1],
[/\bi(?:'m| am) (?:a |an )?(.{3,60}?)(?:\.|,|$)/i, "fact", 1],
[/\bi work (?:at|for)\s+(.{2,60}?)(?:\.|,|$)/i, "fact", 1],
[/\bi live in\s+(.{2,60}?)(?:\.|,|$)/i, "fact", 1],
// Projects
[/\b(?:the |our |my )?repo(?:sitory)? is (?:at )?\s*(.{5,100}?)(?:\.|,|$)/i, "project", 1],
[/\bwe use\s+(.{3,60}?)\s+for\s+(.{3,60}?)(?:\.|,|$)/i, "project", 0],
[/\b(?:our|the) stack (?:is|includes)\s+(.{5,100}?)(?:\.|,|$)/i, "project", 1],
[/\b(?:our|the) (?:tech|technology) stack\b.{0,20}\b(?:is|includes)\s+(.{5,100}?)(?:\.|,|$)/i, "project", 1],
// People
[/\b(\w+) is (?:my |our )(.{3,60}?)(?:\.|,|$)/i, "person", 0],
// Routines
[/\bevery (?:day|morning|evening|week|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b.{0,10}\bi\s+(.{5,80}?)(?:\.|,|$)/i, "routine", 0],
// Explicit memory requests (these also get handled by the LLM's remember tool,
// but capturing them here ensures they're saved even if the LLM doesn't call it)
[/\bremember (?:that |this:?\s*)(.{5,200}?)(?:\.|$)/i, "fact", 1],
[/\bdon'?t forget (?:that |this:?\s*)(.{5,200}?)(?:\.|$)/i, "fact", 1],
[/\bkeep in mind (?:that |this:?\s*)(.{5,200}?)(?:\.|$)/i, "fact", 1],
];
/**
* Extract memorable facts from a user message using pattern matching.
* Returns extracted memories that don't already exist in the store.
*/
export function extractMemories(userMessage) {
// Strip channel tags
const text = userMessage
.replace(/^\[via (?:telegram|tui)\]\s*/i, "")
.trim();
if (text.length < 10)
return [];
const results = [];
for (const [pattern, category, groupIndex] of PATTERNS) {
const match = text.match(pattern);
if (!match)
continue;
let content;
if (groupIndex === 0) {
content = match[0].trim();
}
else {
content = match[groupIndex]?.trim() ?? "";
}
if (content.length < 3 || content.length > 200)
continue;
// Skip if a similar memory already exists
if (findSimilarMemory(content))
continue;
results.push({ category, content });
}
return results;
}
/**
* Extract and save memories from a user message. Non-throwing.
* Returns the number of memories saved.
*/
export function extractAndSaveMemories(userMessage) {
try {
const memories = extractMemories(userMessage);
for (const mem of memories) {
addMemory(mem.category, mem.content, "auto");
}
return memories.length;
}
catch {
return 0;
}
}
//# sourceMappingURL=memory-extractor.js.map
import { approveAll } from "@github/copilot-sdk";
import { addMemory, updateMemory, searchMemories, addSummary } from "../store/db.js";
// ---------------------------------------------------------------------------
// LLM-powered memory extraction — dedicated gpt-4.1 session
// ---------------------------------------------------------------------------
const EXTRACTOR_MODEL = "gpt-4.1";
const EXTRACT_TIMEOUT_MS = 15_000;
const SUMMARY_TIMEOUT_MS = 30_000;
const VALID_CATEGORIES = new Set([
"preference", "fact", "project", "person", "routine", "task", "decision", "context",
]);
const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction engine for a personal AI assistant called Max. Your job is to analyze conversation turns and extract facts worth remembering long-term.
## Output Format
Respond with ONLY valid JSON — no markdown fences, no explanation. Return an object:
{
"memories": [
{
"action": "add" | "update",
"category": "preference" | "fact" | "project" | "person" | "routine" | "task" | "decision" | "context",
"content": "concise statement of the fact",
"importance": 1-5,
"context": "one sentence: why this is worth remembering",
"existing_id": null | number
}
]
}
## Categories
- preference: User likes/dislikes, settings, working style
- fact: Identity, general knowledge, location, employer
- project: Codebase info, repos, tech stack, architecture
- person: People the user mentions — names, roles, relationships
- routine: Schedules, habits, recurring tasks
- task: Active tasks, goals, things the user is working on
- decision: Decisions made during conversation (technical or otherwise)
- context: Situational context about what's happening right now
## Importance Scale
- 5: Core identity, critical preferences, key project info (e.g., "I work at GitHub", "My main project is Max")
- 4: Important recurring facts (e.g., "I use TypeScript for everything", "Alice is my manager")
- 3: Useful context (e.g., "Working on authentication this week")
- 2: Minor preferences or transient details
- 1: Ephemeral, probably won't matter next session
## Rules
- Be CONSERVATIVE. Only extract facts that would be useful in a future conversation.
- Do NOT extract: greetings, acknowledgments, conversation mechanics, questions without answers, task instructions to the assistant.
- If the user corrects a previous fact, use "action": "update" with the existing_id.
- If nothing is worth remembering, return {"memories": []}.
- Keep content concise — max 150 characters per memory.
- One fact per memory entry. Don't combine multiple facts.`;
const SUMMARY_SYSTEM_PROMPT = `You are a conversation summarizer for a personal AI assistant called Max. Summarize the key points of a conversation segment in 3-6 bullet points.
Focus on:
- What the user asked for and what was accomplished
- Decisions made
- Important context or preferences expressed
- Ongoing tasks or commitments
Keep it concise — this summary is used for context recovery after session restarts. Max 300 words.
Respond with ONLY the summary text — no JSON, no markdown fences.`;
let extractorSession;
let summarySession;
let extractorClient;
let summaryClient;
async function ensureExtractorSession(client) {
if (extractorSession && extractorClient === client)
return extractorSession;
if (extractorSession) {
extractorSession.destroy().catch(() => { });
extractorSession = undefined;
}
extractorSession = await client.createSession({
model: EXTRACTOR_MODEL,
streaming: false,
systemMessage: { content: EXTRACTION_SYSTEM_PROMPT },
onPermissionRequest: approveAll,
});
extractorClient = client;
return extractorSession;
}
async function ensureSummarySession(client) {
if (summarySession && summaryClient === client)
return summarySession;
if (summarySession) {
summarySession.destroy().catch(() => { });
summarySession = undefined;
}
summarySession = await client.createSession({
model: EXTRACTOR_MODEL,
streaming: false,
systemMessage: { content: SUMMARY_SYSTEM_PROMPT },
onPermissionRequest: approveAll,
});
summaryClient = client;
return summarySession;
}
/**
* Extract memories from a user+assistant conversation turn using LLM.
* Returns the number of memories added/updated.
*/
export async function extractMemoriesWithLLM(client, userMessage, assistantResponse) {
try {
// Get existing relevant memories for comparison
const cleanMsg = userMessage.replace(/^\[via (?:telegram|tui)\]\s*/i, "").trim();
const existing = searchMemories(undefined, undefined, 30);
const existingBlock = existing.length > 0
? `\n\nExisting memories (for dedup/update):\n${existing.map((m) => `#${m.id} [${m.category}] ${m.content}`).join("\n")}`
: "";
const session = await ensureExtractorSession(client);
const prompt = `User message: ${cleanMsg}\n\nAssistant response: ${assistantResponse.slice(0, 2000)}${existingBlock}`;
const result = await session.sendAndWait({ prompt }, EXTRACT_TIMEOUT_MS);
const raw = result?.data?.content || "";
// Parse JSON — strip markdown fences if present
const jsonStr = raw.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "").trim();
const parsed = JSON.parse(jsonStr);
if (!Array.isArray(parsed.memories))
return 0;
let count = 0;
for (const mem of parsed.memories) {
if (!mem.content || mem.content.length < 3 || mem.content.length > 200)
continue;
if (!VALID_CATEGORIES.has(mem.category))
continue;
const category = mem.category;
const importance = Math.max(1, Math.min(5, mem.importance || 3));
if (mem.action === "update" && mem.existing_id) {
if (updateMemory(mem.existing_id, { content: mem.content, importance, context: mem.context })) {
count++;
}
}
else {
addMemory(category, mem.content, "auto", importance, mem.context);
count++;
}
}
return count;
}
catch (err) {
console.log(`[max] LLM memory extraction failed (non-fatal): ${err instanceof Error ? err.message : err}`);
// Destroy broken session
if (extractorSession) {
extractorSession.destroy().catch(() => { });
extractorSession = undefined;
}
return 0;
}
}
/**
* Generate a conversation summary from recent turns.
* Returns the summary text, or empty string on failure.
*/
export async function generateConversationSummary(client, turns) {
if (turns.length === 0)
return "";
try {
const session = await ensureSummarySession(client);
const formatted = turns.map((t) => {
const tag = t.role === "user" ? `[${t.source}] User` : t.role === "system" ? `[${t.source}] System` : "Max";
const content = t.content.length > 1000 ? t.content.slice(0, 1000) + "…" : t.content;
return `${tag}: ${content}`;
}).join("\n\n");
const result = await session.sendAndWait({ prompt: `Summarize this conversation segment:\n\n${formatted}` }, SUMMARY_TIMEOUT_MS);
const summary = (result?.data?.content || "").trim();
if (summary.length < 10)
return "";
// Store the summary
const firstTs = turns[0]?.ts;
const lastTs = turns[turns.length - 1]?.ts;
addSummary(summary, turns.length, firstTs, lastTs);
return summary;
}
catch (err) {
console.log(`[max] Summary generation failed (non-fatal): ${err instanceof Error ? err.message : err}`);
if (summarySession) {
summarySession.destroy().catch(() => { });
summarySession = undefined;
}
return "";
}
}
/** Tear down extractor sessions (e.g. on shutdown). */
export function stopMemoryLLM() {
if (extractorSession) {
extractorSession.destroy().catch(() => { });
extractorSession = undefined;
}
if (summarySession) {
summarySession.destroy().catch(() => { });
summarySession = undefined;
}
extractorClient = undefined;
summaryClient = undefined;
}
//# sourceMappingURL=memory-llm.js.map
+27
-4

@@ -32,3 +32,3 @@ import express from "express";

app.use((req, res, next) => {
if (!apiToken || req.path === "/status" || req.path === "/send-photo")
if (!apiToken || req.path === "/status")
return next();

@@ -45,3 +45,3 @@ const auth = req.headers.authorization;

let connectionCounter = 0;
// Health check
// Health check — intentionally unauthenticated, returns no sensitive data
app.get("/status", (_req, res) => {

@@ -52,3 +52,2 @@ res.json({

name: w.name,
workingDir: w.workingDir,
status: w.status,

@@ -163,2 +162,15 @@ })),

});
// List all available models
app.get("/models", async (_req, res) => {
try {
const { getClient } = await import("../copilot/client.js");
const client = await getClient();
const models = await client.listModels();
res.json({ models: models.map((m) => m.id), current: config.copilotModel });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({ error: `Failed to list models: ${msg}` });
}
});
// Get auto-routing config

@@ -211,3 +223,3 @@ app.get("/auto", (_req, res) => {

});
// Send a photo to Telegram (protected by bearer token auth middleware)
// Send a photo to Telegram
app.post("/send-photo", async (req, res) => {

@@ -219,2 +231,13 @@ const { photo, caption } = req.body;

}
// Restrict local file paths to the system temp directory to prevent arbitrary file exfiltration
if (!photo.startsWith("http://") && !photo.startsWith("https://")) {
const { resolve } = await import("path");
const { tmpdir } = await import("os");
const resolvedPhoto = resolve(photo);
const allowedBase = resolve(tmpdir());
if (!resolvedPhoto.startsWith(allowedBase + "/") && resolvedPhoto !== allowedBase) {
res.status(403).json({ error: "Local file paths must be within the system temp directory. Use a URL or save the file to the temp dir first." });
return;
}
}
try {

@@ -221,0 +244,0 @@ await sendPhoto(photo, caption);

@@ -8,5 +8,6 @@ import { approveAll } from "@github/copilot-sdk";

import { resetClient } from "./client.js";
import { logConversation, getState, setState, deleteState, getMemorySummary, getRecentConversation } from "../store/db.js";
import { logConversation, getState, setState, deleteState, getMemorySummary, getRecentConversation, getRelevantMemories, runMemoryMaintenance } from "../store/db.js";
import { SESSIONS_DIR } from "../paths.js";
import { resolveModel } from "./router.js";
import { extractAndSaveMemories } from "./memory-extractor.js";
const MAX_RETRIES = 3;

@@ -182,9 +183,18 @@ const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];

// Recover conversation context if available (session was lost, not first run)
const recentHistory = getRecentConversation(10);
if (recentHistory) {
console.log(`[max] Injecting recent conversation context into new session`);
const recentHistory = getRecentConversation(30);
const recoveryMemorySummary = getMemorySummary();
if (recentHistory || recoveryMemorySummary) {
console.log(`[max] Injecting recovery context into new session (${recentHistory ? "conversation + " : ""}${recoveryMemorySummary ? "memories" : ""})`);
const parts = [
"[System: Session recovered] Your previous session was lost. Absorb this context silently — do NOT respond to it.",
];
if (recoveryMemorySummary) {
parts.push(`\n## Your Long-Term Memories:\n${recoveryMemorySummary}`);
}
if (recentHistory) {
parts.push(`\n## Recent Conversation (last 30 turns):\n${recentHistory}`);
}
parts.push("\n(End of recovery context. Wait for the next real message.)");
try {
await session.sendAndWait({
prompt: `[System: Session recovered] Your previous session was lost. Here's the recent conversation for context — do NOT respond to these messages, just absorb the context silently:\n\n${recentHistory}\n\n(End of recovery context. Wait for the next real message.)`,
}, 60_000);
await session.sendAndWait({ prompt: parts.join("\n") }, 60_000);
}

@@ -218,2 +228,12 @@ catch (err) {

startHealthCheck();
// Run memory maintenance on startup (best-effort)
try {
const { deduped, pruned, capped } = runMemoryMaintenance();
if (deduped + pruned + capped > 0) {
console.log(`[max] Memory maintenance: ${deduped} deduped, ${pruned} stale pruned, ${capped} capped`);
}
}
catch (err) {
console.log(`[max] Memory maintenance failed (non-fatal): ${err instanceof Error ? err.message : err}`);
}
// Eagerly create/resume the orchestrator session

@@ -228,5 +248,19 @@ try {

/** Send a prompt on the persistent session, return the response. */
async function executeOnSession(prompt, callback) {
async function executeOnSession(prompt, callback, attachments) {
const session = await ensureOrchestratorSession();
currentCallback = callback;
// Inject relevant memories into the prompt (skip for background task results)
let enrichedPrompt = prompt;
if (!prompt.startsWith("[Background task completed]")) {
try {
const relevant = getRelevantMemories(prompt, 5);
if (relevant.length > 0) {
const memBlock = relevant.join("; ");
// Cap at 500 chars to avoid prompt bloat
const trimmed = memBlock.length > 500 ? memBlock.slice(0, 500) + "…" : memBlock;
enrichedPrompt = `[Memory context: ${trimmed}]\n\n${prompt}`;
}
}
catch { /* non-fatal */ }
}
let accumulated = "";

@@ -248,3 +282,3 @@ let toolCallExecuted = false;

try {
const result = await session.sendAndWait({ prompt }, 300_000);
const result = await session.sendAndWait({ prompt: enrichedPrompt, ...(attachments && attachments.length > 0 ? { attachments } : {}) }, 300_000);
const finalContent = result?.data?.content || accumulated || "(No response)";

@@ -288,4 +322,15 @@ return finalContent;

config.copilotModel = routeResult.model;
orchestratorSession = undefined;
deleteState(ORCHESTRATOR_SESSION_KEY);
// Use setModel() to switch in-place, preserving conversation history
if (orchestratorSession) {
try {
await orchestratorSession.setModel(routeResult.model);
currentSessionModel = routeResult.model;
console.log(`[max] Model switched in-place via setModel()`);
}
catch (err) {
console.log(`[max] setModel() failed, will recreate session: ${err instanceof Error ? err.message : err}`);
orchestratorSession = undefined;
deleteState(ORCHESTRATOR_SESSION_KEY);
}
}
}

@@ -298,3 +343,3 @@ if (routeResult.tier) {

lastRouteResult = routeResult;
const result = await executeOnSession(item.prompt, item.callback);
const result = await executeOnSession(item.prompt, item.callback, item.attachments);
item.resolve(result);

@@ -313,3 +358,3 @@ }

}
export async function sendToOrchestrator(prompt, source, callback) {
export async function sendToOrchestrator(prompt, source, callback, attachments) {
const sourceLabel = source.type === "telegram" ? "telegram" :

@@ -332,3 +377,3 @@ source.type === "tui" ? "tui" : "background";

const finalContent = await new Promise((resolve, reject) => {
messageQueue.push({ prompt: taggedPrompt, callback, sourceChannel, resolve, reject });
messageQueue.push({ prompt: taggedPrompt, attachments, callback, sourceChannel, resolve, reject });
processQueue();

@@ -351,2 +396,9 @@ });

catch { /* best-effort */ }
// Silently extract memorable facts from user messages
if (logRole === "user") {
try {
extractAndSaveMemories(prompt);
}
catch { /* best-effort */ }
}
return;

@@ -399,2 +451,9 @@ }

}
/** Switch the model on the live orchestrator session without destroying it. */
export async function switchSessionModel(newModel) {
if (orchestratorSession) {
await orchestratorSession.setModel(newModel);
currentSessionModel = newModel;
}
}
export function getWorkers() {

@@ -401,0 +460,0 @@ return workers;

+1
-1

@@ -96,3 +96,3 @@ import { getState, setState } from "../store/db.js";

if (isFollowUp)
return recentTiers[0];
return recentTiers[recentTiers.length - 1];
}

@@ -99,0 +99,0 @@ // LLM classification

@@ -134,5 +134,5 @@ export function getOrchestratorSystemMessage(memorySummary, opts) {

14. **Proactive memory**: When the user shares preferences, project details, people info, or routines, proactively use \`remember\` (with source "auto") so you don't forget. Don't ask for permission — just save it.
15. **Sending media to Telegram**: You can send photos/images to the user on Telegram by calling: \`curl -s -X POST http://127.0.0.1:7777/send-photo -H 'Content-Type: application/json' -d '{"photo": "<path-or-url>", "caption": "<optional caption>"}'\`. Use this whenever you have an image to share — download it to a local file first, then send it via this endpoint.
15. **Sending media to Telegram**: You can send photos/images to the user on Telegram by calling: \`curl -s -X POST http://127.0.0.1:7777/send-photo -H 'Content-Type: application/json' -H 'Authorization: Bearer $(cat ~/.max/api-token)' -d '{"photo": "<tmpdir-path-or-https-url>", "caption": "<optional caption>"}'\`. Local file paths **must** be inside the system temp directory (use \`$TMPDIR\` or \`/tmp\`). Download images to a temp path first, then send. HTTPS URLs are also accepted.
${selfEditBlock}${memoryBlock}`;
}
//# sourceMappingURL=system-message.js.map

@@ -10,3 +10,3 @@ import { z } from "zod";

import { SESSIONS_DIR } from "../paths.js";
import { getCurrentSourceChannel } from "./orchestrator.js";
import { getCurrentSourceChannel, switchSessionModel } from "./orchestrator.js";
import { getRouterConfig, updateRouterConfig } from "./router.js";

@@ -368,8 +368,15 @@ function isTimeoutError(err) {

persistModel(args.model_id);
// Apply model change to the live session immediately
try {
await switchSessionModel(args.model_id);
}
catch (err) {
console.log(`[max] setModel() failed during switch_model (will apply on next session): ${err instanceof Error ? err.message : err}`);
}
// Disable router when manually switching — user has explicit preference
if (getRouterConfig().enabled) {
updateRouterConfig({ enabled: false });
return `Switched model from '${previous}' to '${args.model_id}'. Auto-routing disabled (use /auto or toggle_auto to re-enable). Takes effect on next message.`;
return `Switched model from '${previous}' to '${args.model_id}'. Auto-routing disabled (use /auto or toggle_auto to re-enable).`;
}
return `Switched model from '${previous}' to '${args.model_id}'. Takes effect on next message.`;
return `Switched model from '${previous}' to '${args.model_id}'.`;
}

@@ -376,0 +383,0 @@ catch (err) {

@@ -5,2 +5,3 @@ import Database from "better-sqlite3";

let logInsertCount = 0;
let fts5Available = false;
export function getDb() {

@@ -68,4 +69,41 @@ if (!db) {

}
// Prune conversation log at startup
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`).run();
// Prune conversation log at startup — keep more history for better recovery
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
// Set up FTS5 for memory search (graceful fallback if not available)
try {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content,
content_rowid='id'
)
`);
// Sync triggers
db.exec(`
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
END
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
END
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
END
`);
// Backfill: check if FTS is in sync by comparing row counts
const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
const ftsCount = db.prepare(`SELECT COUNT(*) as c FROM memories_fts`).get().c;
if (memCount > 0 && ftsCount < memCount) {
db.exec(`INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')`);
}
fts5Available = true;
}
catch {
// FTS5 not available in this SQLite build — fall back to LIKE queries
fts5Available = false;
}
}

@@ -92,6 +130,6 @@ return db;

db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`).run(role, content, source);
// Keep last 200 entries to support context recovery after session loss
// Keep last 1000 entries to support context recovery after session loss
logInsertCount++;
if (logInsertCount % 50 === 0) {
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`).run();
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
}

@@ -112,3 +150,3 @@ }

// Truncate long messages to keep context manageable
const content = r.content.length > 500 ? r.content.slice(0, 500) + "…" : r.content;
const content = r.content.length > 1500 ? r.content.slice(0, 1500) + "…" : r.content;
return `${tag}: ${content}`;

@@ -123,10 +161,39 @@ }).join("\n\n");

}
/** Search memories by keyword and/or category. */
/** Search memories by keyword and/or category. Uses FTS5 when available. */
export function searchMemories(keyword, category, limit = 20) {
const db = getDb();
// FTS5 path: better ranking and matching
if (keyword && fts5Available) {
try {
// Sanitize FTS5 query: wrap each word in quotes to avoid syntax errors
const ftsQuery = keyword.split(/\s+/).filter(Boolean).map((w) => `"${w.replace(/"/g, '""')}"`).join(" OR ");
const categoryFilter = category ? `AND m.category = ?` : "";
const params = [ftsQuery];
if (category)
params.push(category);
params.push(limit);
const rows = db.prepare(`
SELECT m.id, m.category, m.content, m.source, m.created_at
FROM memories_fts f
JOIN memories m ON m.id = f.rowid
WHERE memories_fts MATCH ? ${categoryFilter}
ORDER BY bm25(memories_fts) LIMIT ?
`).all(...params);
if (rows.length > 0) {
const placeholders = rows.map(() => "?").join(",");
db.prepare(`UPDATE memories SET last_accessed = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`).run(...rows.map((r) => r.id));
}
return rows;
}
catch {
// FTS5 query failed — fall through to LIKE
}
}
// LIKE fallback
const conditions = [];
const params = [];
if (keyword) {
conditions.push(`content LIKE ?`);
params.push(`%${keyword}%`);
const escapedKeyword = keyword.replace(/[%_\\]/g, "\\$&");
conditions.push(`content LIKE ? ESCAPE '\\'`);
params.push(`%${escapedKeyword}%`);
}

@@ -140,3 +207,2 @@ if (category) {

const rows = db.prepare(`SELECT id, category, content, source, created_at FROM memories ${where} ORDER BY last_accessed DESC LIMIT ?`).all(...params);
// Update last_accessed for returned memories
if (rows.length > 0) {

@@ -173,2 +239,140 @@ const placeholders = rows.map(() => "?").join(",");

}
/** Check if a similar memory already exists (≥70% word overlap). */
export function findSimilarMemory(content) {
const db = getDb();
const words = new Set(content.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
if (words.size === 0)
return false;
const rows = db.prepare(`SELECT content FROM memories`).all();
for (const row of rows) {
const existingWords = new Set(row.content.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
if (existingWords.size === 0)
continue;
let overlap = 0;
for (const w of words) {
if (existingWords.has(w))
overlap++;
}
const similarity = overlap / Math.max(words.size, existingWords.size);
if (similarity >= 0.7)
return true;
}
return false;
}
/** Search memories for content relevant to a query. Uses FTS5 when available, falls back to word overlap. */
export function getRelevantMemories(query, limit = 5) {
const db = getDb();
// Strip channel tags for cleaner matching
const cleanQuery = query.replace(/^\[via (?:telegram|tui)\]\s*/i, "").trim();
const queryWords = new Set(cleanQuery.toLowerCase().split(/\s+/).filter((w) => w.length > 3));
if (queryWords.size === 0) {
const rows = db.prepare(`SELECT content FROM memories ORDER BY last_accessed DESC LIMIT ?`).all(Math.min(limit, 3));
return rows.map((r) => r.content);
}
// Try FTS5 first
if (fts5Available) {
try {
const ftsQuery = [...queryWords].map((w) => `"${w.replace(/"/g, '""')}"`).join(" OR ");
const rows = db.prepare(`
SELECT m.id, m.content
FROM memories_fts f
JOIN memories m ON m.id = f.rowid
WHERE memories_fts MATCH ?
ORDER BY bm25(memories_fts) LIMIT ?
`).all(ftsQuery, limit);
if (rows.length > 0) {
const placeholders = rows.map(() => "?").join(",");
db.prepare(`UPDATE memories SET last_accessed = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`).run(...rows.map((r) => r.id));
return rows.map((r) => r.content);
}
}
catch { /* fall through to word overlap */ }
}
// Word overlap fallback
const rows = db.prepare(`SELECT id, content FROM memories ORDER BY last_accessed DESC`).all();
const scored = rows.map((row) => {
const memWords = row.content.toLowerCase().split(/\s+/);
let hits = 0;
for (const w of memWords) {
if (queryWords.has(w))
hits++;
}
return { ...row, hits };
}).filter((r) => r.hits >= 2)
.sort((a, b) => b.hits - a.hits)
.slice(0, limit);
if (scored.length === 0) {
const recent = db.prepare(`SELECT content FROM memories ORDER BY last_accessed DESC LIMIT ?`).all(Math.min(limit, 3));
return recent.map((r) => r.content);
}
if (scored.length > 0) {
const placeholders = scored.map(() => "?").join(",");
db.prepare(`UPDATE memories SET last_accessed = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`).run(...scored.map((r) => r.id));
}
return scored.map((r) => r.content);
}
const AUTO_MEMORY_CAP = 500;
const STALE_DAYS = 90;
/** Remove near-duplicate memories (≥70% word overlap), keeping the newer one. */
export function deduplicateMemories() {
const db = getDb();
const rows = db.prepare(`SELECT id, content FROM memories ORDER BY id ASC`).all();
const toDelete = [];
const seen = [];
for (const row of rows) {
const words = new Set(row.content.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
if (words.size === 0)
continue;
let isDup = false;
for (const prev of seen) {
let overlap = 0;
for (const w of words) {
if (prev.words.has(w))
overlap++;
}
const similarity = overlap / Math.max(words.size, prev.words.size);
if (similarity >= 0.7) {
// Keep the newer one (higher id), delete the older
toDelete.push(prev.id);
prev.id = row.id;
prev.words = words;
isDup = true;
break;
}
}
if (!isDup) {
seen.push({ id: row.id, words });
}
}
if (toDelete.length > 0) {
const placeholders = toDelete.map(() => "?").join(",");
db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`).run(...toDelete);
}
return toDelete.length;
}
/** Remove auto-generated memories not accessed in the given number of days. */
export function pruneStaleMemories(maxAgeDays = STALE_DAYS) {
const db = getDb();
const result = db.prepare(`DELETE FROM memories WHERE source = 'auto' AND last_accessed < datetime('now', '-' || ? || ' days')`).run(maxAgeDays);
return result.changes;
}
/** Cap auto-generated memories at a maximum count, evicting least-recently-accessed first. */
export function capAutoMemories(maxCount = AUTO_MEMORY_CAP) {
const db = getDb();
const count = db.prepare(`SELECT COUNT(*) as c FROM memories WHERE source = 'auto'`).get().c;
if (count <= maxCount)
return 0;
const excess = count - maxCount;
const result = db.prepare(`DELETE FROM memories WHERE source = 'auto' AND id IN (
SELECT id FROM memories WHERE source = 'auto' ORDER BY last_accessed ASC LIMIT ?
)`).run(excess);
return result.changes;
}
/** Run all memory maintenance tasks. Returns summary of actions taken. */
export function runMemoryMaintenance() {
const deduped = deduplicateMemories();
const pruned = pruneStaleMemories();
const capped = capAutoMemories();
return { deduped, pruned, capped };
}
export function closeDb() {

@@ -175,0 +379,0 @@ if (db) {

@@ -9,3 +9,65 @@ import { Bot } from "grammy";

import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
import { tmpdir } from "os";
import { join } from "path";
import { writeFile, unlink } from "fs/promises";
let bot;
/** Download a Telegram photo (largest size) to a temp file and return the path. */
async function downloadTelegramPhoto(fileId, label) {
if (!bot || !config.telegramBotToken)
return undefined;
try {
const file = await bot.api.getFile(fileId);
if (!file.file_path)
return undefined;
const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${file.file_path}`;
const response = await fetch(url);
if (!response.ok)
return undefined;
const buffer = new Uint8Array(await response.arrayBuffer());
const ext = file.file_path.split(".").pop() ?? "jpg";
const tmpPath = join(tmpdir(), `max-tg-${label}-${Date.now()}.${ext}`);
await writeFile(tmpPath, buffer);
return tmpPath;
}
catch {
return undefined;
}
}
/** Build reply context (prefix text + attachments) from a replied-to message. */
async function buildReplyContext(replyTo) {
if (!replyTo)
return { prefix: "", attachments: [] };
const attachments = [];
let prefix = "";
if ("text" in replyTo && replyTo.text) {
prefix = `[Replying to: "${replyTo.text}"]\n\n`;
}
else if ("caption" in replyTo && replyTo.caption) {
prefix = `[Replying to message with caption: "${replyTo.caption}"]\n\n`;
}
else {
prefix = "[Replying to a message]\n\n";
}
// If the replied-to message contains a photo, download the largest size
if ("photo" in replyTo && replyTo.photo && replyTo.photo.length > 0) {
const largest = replyTo.photo[replyTo.photo.length - 1];
const tmpPath = await downloadTelegramPhoto(largest.file_id, "reply");
if (tmpPath) {
attachments.push({ type: "file", path: tmpPath, displayName: "replied-to-image" });
if (!("text" in replyTo && replyTo.text)) {
prefix = "[Replying to an image" + ("caption" in replyTo && replyTo.caption ? ` with caption: "${replyTo.caption}"` : "") + "]\n\n";
}
}
}
return { prefix, attachments };
}
/** Delete temp attachment files after they've been sent to the AI. */
async function cleanupAttachments(attachments) {
for (const a of attachments) {
try {
await unlink(a.path);
}
catch { /* best-effort */ }
}
}
export function createBot() {

@@ -19,6 +81,6 @@ if (!config.telegramBotToken) {

bot = new Bot(config.telegramBotToken);
// Auth middleware — only allow the authorized user
// Auth middleware — only allow the authorized user; reject all messages if no user ID is configured
bot.use(async (ctx, next) => {
if (config.authorizedUserId !== undefined && ctx.from?.id !== config.authorizedUserId) {
return; // Silently ignore unauthorized users
if (config.authorizedUserId === undefined || ctx.from?.id !== config.authorizedUserId) {
return; // Silently ignore unauthorized or unconfigured users
}

@@ -35,2 +97,3 @@ await next();

"/model <name> — Switch model\n" +
"/models — List all available models\n" +
"/auto — Toggle auto model routing\n" +

@@ -76,2 +139,19 @@ "/memory — Show stored memories\n" +

});
bot.command("models", async (ctx) => {
try {
const { getClient } = await import("../copilot/client.js");
const client = await getClient();
const models = await client.listModels();
if (models.length === 0) {
await ctx.reply("No models available.");
return;
}
const lines = models.map((m) => m.id === config.copilotModel ? `• ${m.id} ← current` : `• ${m.id}`);
await ctx.reply(lines.join("\n"));
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await ctx.reply(`Failed to list models: ${msg}`);
}
});
bot.command("memory", async (ctx) => {

@@ -129,2 +209,5 @@ const memories = searchMemories(undefined, undefined, 50);

const replyParams = { message_id: userMessageId };
// Build reply context if this message is a reply to another
const { prefix, attachments: replyAttachments } = await buildReplyContext(ctx.message.reply_to_message);
const prompt = prefix + ctx.message.text;
// Show "typing..." indicator, repeat every 4s while processing

@@ -145,5 +228,6 @@ let typingInterval;

startTyping();
sendToOrchestrator(ctx.message.text, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
if (done) {
stopTyping();
void cleanupAttachments(replyAttachments);
// Send final message — use chunking for long responses, reply-quote original

@@ -186,4 +270,79 @@ void (async () => {

}
});
}, replyAttachments.length > 0 ? replyAttachments : undefined);
});
// Handle photo messages (with optional caption and optional reply context)
bot.on("message:photo", async (ctx) => {
const chatId = ctx.chat.id;
const userMessageId = ctx.message.message_id;
const replyParams = { message_id: userMessageId };
// Download the largest photo size
const photos = ctx.message.photo;
const largest = photos[photos.length - 1];
const photoPath = await downloadTelegramPhoto(largest.file_id, "photo");
const attachments = [];
if (photoPath) {
attachments.push({ type: "file", path: photoPath, displayName: "image" });
}
// Build reply context if this is a reply
const { prefix: replyPrefix, attachments: replyAttachments } = await buildReplyContext(ctx.message.reply_to_message);
attachments.push(...replyAttachments);
const caption = ctx.message.caption ?? "";
const prompt = replyPrefix + (caption || "[Image attached]");
const allAttachments = attachments.length > 0 ? attachments : undefined;
// Show "typing..." indicator
let typingInterval;
const startTyping = () => {
void ctx.replyWithChatAction("typing").catch(() => { });
typingInterval = setInterval(() => {
void ctx.replyWithChatAction("typing").catch(() => { });
}, 4000);
};
const stopTyping = () => {
if (typingInterval) {
clearInterval(typingInterval);
typingInterval = undefined;
}
};
startTyping();
sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
if (done) {
stopTyping();
void cleanupAttachments(attachments);
void (async () => {
const routeResult = getLastRouteResult();
let indicatorSuffix = "";
if (routeResult && routeResult.routerMode === "auto") {
indicatorSuffix = `\n\n_⚡ auto · ${routeResult.model}_`;
}
const formatted = toTelegramMarkdown(text) + indicatorSuffix;
const chunks = chunkMessage(formatted);
const fallbackText = routeResult && routeResult.routerMode === "auto"
? text + `\n\n⚡ auto · ${routeResult.model}`
: text;
const fallbackChunks = chunkMessage(fallbackText);
const sendChunk = async (chunk, fallback, isFirst) => {
const opts = isFirst
? { parse_mode: "MarkdownV2", reply_parameters: replyParams }
: { parse_mode: "MarkdownV2" };
await ctx.reply(chunk, opts).catch(() => ctx.reply(fallback, isFirst ? { reply_parameters: replyParams } : {}));
};
try {
for (let i = 0; i < chunks.length; i++) {
await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i === 0);
}
}
catch {
try {
for (let i = 0; i < fallbackChunks.length; i++) {
await ctx.reply(fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
}
}
catch {
// Nothing more we can do
}
}
})();
}
}, allAttachments);
});
return bot;

@@ -190,0 +349,0 @@ }

@@ -757,2 +757,22 @@ import * as readline from "readline";

}
function cmdModels() {
apiGet("/models", (data) => {
if (data.error) {
console.log(C.red(` Error: ${data.error}\n`));
return;
}
const models = data.models ?? [];
const current = data.current ?? "";
if (models.length === 0) {
console.log(C.dim(" No models available.\n"));
return;
}
console.log();
for (const id of models) {
const marker = id === current ? C.dim(" ← current") : "";
console.log(` ${C.cyan(id)}${marker}`);
}
console.log();
});
}
function cmdMemory() {

@@ -851,2 +871,3 @@ apiGet("/memory", (memories) => {

console.log(` ${C.coral("/model")} ${C.dim("[name]")} show or switch model`);
console.log(` ${C.coral("/models")} list all available models`);
console.log(` ${C.coral("/auto")} toggle auto model routing`);

@@ -942,2 +963,6 @@ console.log(` ${C.coral("/memory")} show stored memories`);

}
if (trimmed === "/models") {
cmdModels();
return;
}
if (trimmed.startsWith("/model")) {

@@ -944,0 +969,0 @@ cmdModel(trimmed.slice(6).trim());

{
"name": "heymax",
"version": "1.2.2",
"version": "1.3.0",
"description": "Max — a personal AI assistant for developers, built on the GitHub Copilot SDK",

@@ -5,0 +5,0 @@ "bin": {