| 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; |
@@ -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) { |
+213
-9
@@ -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) { |
+164
-5
@@ -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 @@ } |
+25
-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()); |
+1
-1
| { | ||
| "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": { |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
222053
17.66%29
7.41%4597
19.31%22
10%5
25%