| // --------------------------------------------------------------------------- | ||
| // Wiki context retrieval — replaces getRelevantMemories for prompt injection | ||
| // --------------------------------------------------------------------------- | ||
| import { searchIndex, getIndexSummary } from "./index-manager.js"; | ||
| import { readPage, ensureWikiStructure } from "./fs.js"; | ||
| /** | ||
| * Get relevant wiki context for a user query. | ||
| * Searches the index, reads top matching pages, and returns a formatted context block. | ||
| */ | ||
| export function getRelevantWikiContext(query, maxPages = 3) { | ||
| ensureWikiStructure(); | ||
| // Strip channel tags for cleaner matching | ||
| const cleanQuery = query.replace(/^\[via (?:telegram|tui)\]\s*/i, "").trim(); | ||
| const matches = searchIndex(cleanQuery, maxPages); | ||
| if (matches.length === 0) | ||
| return ""; | ||
| const sections = []; | ||
| for (const match of matches) { | ||
| const content = readPage(match.path); | ||
| if (!content) | ||
| continue; | ||
| // Strip frontmatter for cleaner context | ||
| const body = content.replace(/^---[\s\S]*?---\s*/, "").trim(); | ||
| // Cap each page at 600 chars to avoid prompt bloat | ||
| const trimmed = body.length > 600 ? body.slice(0, 600) + "…" : body; | ||
| sections.push(`### ${match.title}\n${trimmed}`); | ||
| } | ||
| if (sections.length === 0) | ||
| return ""; | ||
| return sections.join("\n\n"); | ||
| } | ||
| /** | ||
| * Get a summary of the wiki for the system message. | ||
| * Returns the index summary (compact list of all pages). | ||
| */ | ||
| export function getWikiSummary() { | ||
| ensureWikiStructure(); | ||
| return getIndexSummary(); | ||
| } | ||
| //# sourceMappingURL=context.js.map |
+153
| // --------------------------------------------------------------------------- | ||
| // Wiki file system primitives | ||
| // --------------------------------------------------------------------------- | ||
| import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, statSync } from "fs"; | ||
| import { join, dirname, relative, resolve, sep } from "path"; | ||
| import { WIKI_DIR, WIKI_PAGES_DIR, WIKI_SOURCES_DIR } from "../paths.js"; | ||
| const INDEX_PATH = join(WIKI_DIR, "index.md"); | ||
| const LOG_PATH = join(WIKI_DIR, "log.md"); | ||
| function getInitialIndex() { | ||
| return `# Wiki Index | ||
| _Max's knowledge base. This file is maintained automatically._ | ||
| Last updated: ${new Date().toISOString().slice(0, 10)} | ||
| ## Pages | ||
| _(No pages yet.)_ | ||
| `; | ||
| } | ||
| const INITIAL_LOG = `# Wiki Log | ||
| _Chronological record of wiki operations._ | ||
| `; | ||
| /** | ||
| * Create the wiki directory structure if it doesn't exist. | ||
| * Returns true if the wiki was just created (first run). | ||
| */ | ||
| export function ensureWikiStructure() { | ||
| const isNew = !existsSync(WIKI_DIR); | ||
| mkdirSync(WIKI_PAGES_DIR, { recursive: true }); | ||
| mkdirSync(WIKI_SOURCES_DIR, { recursive: true }); | ||
| if (!existsSync(INDEX_PATH)) { | ||
| writeFileSync(INDEX_PATH, getInitialIndex(), "utf-8"); | ||
| } | ||
| if (!existsSync(LOG_PATH)) { | ||
| writeFileSync(LOG_PATH, INITIAL_LOG, "utf-8"); | ||
| } | ||
| return isNew; | ||
| } | ||
| /** Read a wiki page by path relative to the wiki root. Returns undefined if not found. */ | ||
| export function readPage(relativePath) { | ||
| const fullPath = resolvePath(relativePath); | ||
| if (!existsSync(fullPath)) | ||
| return undefined; | ||
| return readFileSync(fullPath, "utf-8"); | ||
| } | ||
| /** Write a wiki page. Creates parent directories automatically. */ | ||
| export function writePage(relativePath, content) { | ||
| const fullPath = resolvePath(relativePath); | ||
| mkdirSync(dirname(fullPath), { recursive: true }); | ||
| writeFileSync(fullPath, content, "utf-8"); | ||
| } | ||
| /** Delete a wiki page. Returns true if the file existed and was removed. */ | ||
| export function deletePage(relativePath) { | ||
| const fullPath = resolvePath(relativePath); | ||
| if (!existsSync(fullPath)) | ||
| return false; | ||
| unlinkSync(fullPath); | ||
| return true; | ||
| } | ||
| /** Check if a wiki page exists. */ | ||
| export function pageExists(relativePath) { | ||
| return existsSync(resolvePath(relativePath)); | ||
| } | ||
| /** List all .md files under pages/, returning paths relative to the wiki root. */ | ||
| export function listPages() { | ||
| if (!existsSync(WIKI_PAGES_DIR)) | ||
| return []; | ||
| return walkDir(WIKI_PAGES_DIR) | ||
| .filter((f) => f.endsWith(".md")) | ||
| .map((f) => relative(WIKI_DIR, f)); | ||
| } | ||
| /** Save a raw source document (immutable). */ | ||
| export function writeRawSource(name, content) { | ||
| const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "-"); | ||
| const fullPath = join(WIKI_SOURCES_DIR, safeName); | ||
| mkdirSync(dirname(fullPath), { recursive: true }); | ||
| writeFileSync(fullPath, content, "utf-8"); | ||
| } | ||
| /** Read a raw source document. */ | ||
| export function readRawSource(name) { | ||
| const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "-"); | ||
| const fullPath = join(WIKI_SOURCES_DIR, safeName); | ||
| if (!existsSync(fullPath)) | ||
| return undefined; | ||
| return readFileSync(fullPath, "utf-8"); | ||
| } | ||
| /** List all source files. */ | ||
| export function listSources() { | ||
| if (!existsSync(WIKI_SOURCES_DIR)) | ||
| return []; | ||
| return readdirSync(WIKI_SOURCES_DIR).filter((f) => { | ||
| const full = join(WIKI_SOURCES_DIR, f); | ||
| return statSync(full).isFile(); | ||
| }); | ||
| } | ||
| /** Read index.md raw content. */ | ||
| export function readIndexFile() { | ||
| ensureWikiStructure(); | ||
| return readFileSync(INDEX_PATH, "utf-8"); | ||
| } | ||
| /** Write index.md content. */ | ||
| export function writeIndexFile(content) { | ||
| writeFileSync(INDEX_PATH, content, "utf-8"); | ||
| } | ||
| /** Read log.md raw content. */ | ||
| export function readLogFile() { | ||
| ensureWikiStructure(); | ||
| return readFileSync(LOG_PATH, "utf-8"); | ||
| } | ||
| /** Write log.md content. */ | ||
| export function writeLogFile(content) { | ||
| writeFileSync(LOG_PATH, content, "utf-8"); | ||
| } | ||
| /** Get the full wiki directory path (for external tools that need it). */ | ||
| export function getWikiDir() { | ||
| return WIKI_DIR; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Internal helpers | ||
| // --------------------------------------------------------------------------- | ||
| function resolvePath(relativePath) { | ||
| let base; | ||
| if (relativePath.startsWith("pages/") || relativePath.startsWith("sources/") || | ||
| relativePath === "index.md" || relativePath === "log.md") { | ||
| base = WIKI_DIR; | ||
| } | ||
| else { | ||
| base = WIKI_PAGES_DIR; | ||
| } | ||
| const resolved = resolve(base, relativePath); | ||
| // Prevent path traversal outside the wiki directory | ||
| if (!resolved.startsWith(WIKI_DIR + sep) && resolved !== WIKI_DIR) { | ||
| throw new Error(`Path escapes wiki directory: ${relativePath}`); | ||
| } | ||
| return resolved; | ||
| } | ||
| function walkDir(dir) { | ||
| const results = []; | ||
| for (const entry of readdirSync(dir, { withFileTypes: true })) { | ||
| const full = join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| results.push(...walkDir(full)); | ||
| } | ||
| else { | ||
| results.push(full); | ||
| } | ||
| } | ||
| return results; | ||
| } | ||
| //# sourceMappingURL=fs.js.map |
| // --------------------------------------------------------------------------- | ||
| // Wiki index.md manager — parse, update, and search the page catalog | ||
| // --------------------------------------------------------------------------- | ||
| import { readIndexFile, writeIndexFile } from "./fs.js"; | ||
| /** | ||
| * Parse index.md into structured entries. | ||
| * Expected format: | ||
| * ## Section Name | ||
| * - [Title](path) — Summary text | ||
| */ | ||
| export function parseIndex() { | ||
| const content = readIndexFile(); | ||
| const entries = []; | ||
| let currentSection = "Uncategorized"; | ||
| for (const line of content.split("\n")) { | ||
| // Section headers | ||
| const sectionMatch = line.match(/^##\s+(.+)/); | ||
| if (sectionMatch) { | ||
| currentSection = sectionMatch[1].trim(); | ||
| continue; | ||
| } | ||
| // Entry lines: - [Title](path) — Summary | ||
| const entryMatch = line.match(/^-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/); | ||
| if (entryMatch) { | ||
| entries.push({ | ||
| title: entryMatch[1].trim(), | ||
| path: entryMatch[2].trim(), | ||
| summary: entryMatch[3].trim(), | ||
| section: currentSection, | ||
| }); | ||
| } | ||
| } | ||
| return entries; | ||
| } | ||
| /** Regenerate index.md from a list of entries, grouped by section. */ | ||
| export function writeIndex(entries) { | ||
| const sections = new Map(); | ||
| for (const entry of entries) { | ||
| const list = sections.get(entry.section) || []; | ||
| list.push(entry); | ||
| sections.set(entry.section, list); | ||
| } | ||
| const lines = [ | ||
| "# Wiki Index", | ||
| "", | ||
| "_Max's knowledge base. This file is maintained automatically._", | ||
| "", | ||
| `Last updated: ${new Date().toISOString().slice(0, 10)}`, | ||
| "", | ||
| ]; | ||
| for (const [section, items] of sections) { | ||
| lines.push(`## ${section}`, ""); | ||
| for (const item of items) { | ||
| lines.push(`- [${item.title}](${item.path}) — ${item.summary}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| if (sections.size === 0) { | ||
| lines.push("## Pages", "", "_(No pages yet.)_", ""); | ||
| } | ||
| writeIndexFile(lines.join("\n")); | ||
| } | ||
| /** Add or update an entry in the index. Upserts by path. */ | ||
| export function addToIndex(entry) { | ||
| const entries = parseIndex(); | ||
| const existing = entries.findIndex((e) => e.path === entry.path); | ||
| if (existing >= 0) { | ||
| entries[existing] = entry; | ||
| } | ||
| else { | ||
| entries.push(entry); | ||
| } | ||
| writeIndex(entries); | ||
| } | ||
| /** Remove an entry from the index by path. */ | ||
| export function removeFromIndex(path) { | ||
| const entries = parseIndex(); | ||
| const filtered = entries.filter((e) => e.path !== path); | ||
| if (filtered.length === entries.length) | ||
| return false; | ||
| writeIndex(filtered); | ||
| return true; | ||
| } | ||
| /** | ||
| * Search the index for entries matching a query. | ||
| * Matches against title, summary, section, and path using keyword overlap. | ||
| */ | ||
| export function searchIndex(query, limit = 10) { | ||
| const entries = parseIndex(); | ||
| if (entries.length === 0) | ||
| return []; | ||
| const queryWords = new Set(query.toLowerCase().split(/\s+/).filter((w) => w.length > 2)); | ||
| if (queryWords.size === 0) { | ||
| return entries.slice(0, limit); | ||
| } | ||
| const scored = entries.map((entry) => { | ||
| const text = `${entry.title} ${entry.summary} ${entry.section} ${entry.path}`.toLowerCase(); | ||
| const words = text.split(/\s+/); | ||
| let hits = 0; | ||
| for (const w of words) { | ||
| for (const q of queryWords) { | ||
| if (w.includes(q)) { | ||
| hits++; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| return { entry, hits }; | ||
| }) | ||
| .filter((s) => s.hits > 0) | ||
| .sort((a, b) => b.hits - a.hits) | ||
| .slice(0, limit); | ||
| return scored.map((s) => s.entry); | ||
| } | ||
| /** Get a compact text summary of the index for injection into context. */ | ||
| export function getIndexSummary() { | ||
| const entries = parseIndex(); | ||
| if (entries.length === 0) | ||
| return ""; | ||
| const sections = new Map(); | ||
| for (const e of entries) { | ||
| const list = sections.get(e.section) || []; | ||
| list.push(`${e.title}: ${e.summary}`); | ||
| sections.set(e.section, list); | ||
| } | ||
| const parts = []; | ||
| for (const [section, items] of sections) { | ||
| parts.push(`**${section}**: ${items.join("; ")}`); | ||
| } | ||
| return parts.join("\n"); | ||
| } | ||
| //# sourceMappingURL=index-manager.js.map |
| // --------------------------------------------------------------------------- | ||
| // Wiki log.md manager — append-only chronological operation log | ||
| // --------------------------------------------------------------------------- | ||
| import { appendFileSync } from "fs"; | ||
| import { join } from "path"; | ||
| import { WIKI_DIR } from "../paths.js"; | ||
| import { ensureWikiStructure } from "./fs.js"; | ||
| const LOG_PATH = join(WIKI_DIR, "log.md"); | ||
| /** | ||
| * Append a timestamped entry to log.md. | ||
| * Format: `## [YYYY-MM-DD HH:MM] type | description` | ||
| */ | ||
| export function appendLog(type, description) { | ||
| ensureWikiStructure(); | ||
| const now = new Date(); | ||
| const ts = now.toISOString().slice(0, 16).replace("T", " "); | ||
| const entry = `## [${ts}] ${type} | ${description}\n\n`; | ||
| appendFileSync(LOG_PATH, entry, "utf-8"); | ||
| } | ||
| //# sourceMappingURL=log-manager.js.map |
| // --------------------------------------------------------------------------- | ||
| // One-time migration: SQLite memories → wiki pages | ||
| // --------------------------------------------------------------------------- | ||
| import { getDb, getState, setState } from "../store/db.js"; | ||
| import { ensureWikiStructure, writePage, readPage } from "./fs.js"; | ||
| import { addToIndex } from "./index-manager.js"; | ||
| import { appendLog } from "./log-manager.js"; | ||
| const MIGRATION_KEY = "wiki_migrated"; | ||
| /** Check whether a migration is needed (wiki not yet populated from SQLite). */ | ||
| export function shouldMigrate() { | ||
| return getState(MIGRATION_KEY) !== "true"; | ||
| } | ||
| /** Category → wiki page path and section name */ | ||
| const CATEGORY_MAP = { | ||
| preference: { path: "pages/preferences.md", title: "Preferences", section: "Knowledge" }, | ||
| fact: { path: "pages/facts.md", title: "Facts", section: "Knowledge" }, | ||
| project: { path: "pages/projects.md", title: "Projects", section: "Knowledge" }, | ||
| person: { path: "pages/people.md", title: "People", section: "Knowledge" }, | ||
| routine: { path: "pages/routines.md", title: "Routines", section: "Knowledge" }, | ||
| }; | ||
| /** | ||
| * Migrate all existing SQLite memories into wiki pages. | ||
| * Groups memories by category, creates one page per category. | ||
| * Returns the number of memories migrated. | ||
| */ | ||
| export function migrateMemoriesToWiki() { | ||
| ensureWikiStructure(); | ||
| const db = getDb(); | ||
| const rows = db.prepare(`SELECT id, category, content, source, created_at FROM memories ORDER BY category, id`).all(); | ||
| if (rows.length === 0) { | ||
| setState(MIGRATION_KEY, "true"); | ||
| appendLog("migrate", "No memories to migrate (empty table)."); | ||
| return 0; | ||
| } | ||
| // Group by category | ||
| const grouped = {}; | ||
| for (const row of rows) { | ||
| if (!grouped[row.category]) | ||
| grouped[row.category] = []; | ||
| grouped[row.category].push(row); | ||
| } | ||
| const now = new Date().toISOString().slice(0, 10); | ||
| for (const [category, items] of Object.entries(grouped)) { | ||
| const mapping = CATEGORY_MAP[category] || { | ||
| path: `pages/${category}.md`, | ||
| title: category.charAt(0).toUpperCase() + category.slice(1), | ||
| section: "Knowledge", | ||
| }; | ||
| // Build the page content | ||
| const lines = [ | ||
| "---", | ||
| `title: ${mapping.title}`, | ||
| `tags: [${category}, migrated]`, | ||
| `created: ${now}`, | ||
| `updated: ${now}`, | ||
| "---", | ||
| "", | ||
| `# ${mapping.title}`, | ||
| "", | ||
| `_Migrated from Max's memory store on ${now}._`, | ||
| "", | ||
| ]; | ||
| for (const item of items) { | ||
| lines.push(`- ${item.content} _(${item.source}, ${item.created_at.slice(0, 10)})_`); | ||
| } | ||
| lines.push(""); | ||
| // Check if a page already exists (avoid clobbering manual content) | ||
| const existing = readPage(mapping.path); | ||
| if (existing) { | ||
| // Extract only the bullet-point items to append | ||
| const bulletLines = lines.filter((l) => l.startsWith("- ")); | ||
| writePage(mapping.path, existing + "\n## Migrated Memories\n\n" + bulletLines.join("\n") + "\n"); | ||
| } | ||
| else { | ||
| writePage(mapping.path, lines.join("\n")); | ||
| } | ||
| // Update index | ||
| const entry = { | ||
| path: mapping.path, | ||
| title: mapping.title, | ||
| summary: `${items.length} ${category} memories (migrated from SQLite)`, | ||
| section: mapping.section, | ||
| }; | ||
| addToIndex(entry); | ||
| } | ||
| const total = rows.length; | ||
| const categories = Object.keys(grouped).join(", "); | ||
| appendLog("migrate", `Migrated ${total} memories across categories: ${categories}`); | ||
| setState(MIGRATION_KEY, "true"); | ||
| console.log(`[max] Wiki migration complete: ${total} memories → ${Object.keys(grouped).length} pages`); | ||
| return total; | ||
| } | ||
| //# sourceMappingURL=migrate.js.map |
@@ -8,6 +8,6 @@ import { approveAll } from "@github/copilot-sdk"; | ||
| import { resetClient } from "./client.js"; | ||
| import { logConversation, getState, setState, deleteState, getMemorySummary, getRecentConversation, getRelevantMemories, runMemoryMaintenance } from "../store/db.js"; | ||
| import { logConversation, getState, setState, deleteState, getRecentConversation, runMemoryMaintenance } from "../store/db.js"; | ||
| import { SESSIONS_DIR } from "../paths.js"; | ||
| import { resolveModel } from "./router.js"; | ||
| import { extractAndSaveMemories } from "./memory-extractor.js"; | ||
| import { getRelevantWikiContext, getWikiSummary } from "../wiki/context.js"; | ||
| const MAX_RETRIES = 3; | ||
@@ -131,3 +131,3 @@ const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000]; | ||
| const { tools, mcpServers, skillDirectories } = getSessionConfig(); | ||
| const memorySummary = getMemorySummary(); | ||
| const wikiSummary = getWikiSummary(); | ||
| const infiniteSessions = { | ||
@@ -148,3 +148,3 @@ enabled: true, | ||
| systemMessage: { | ||
| content: getOrchestratorSystemMessage(memorySummary || undefined, { selfEditEnabled: config.selfEditEnabled }), | ||
| content: getOrchestratorSystemMessage(wikiSummary || undefined, { selfEditEnabled: config.selfEditEnabled }), | ||
| }, | ||
@@ -173,3 +173,3 @@ tools, | ||
| systemMessage: { | ||
| content: getOrchestratorSystemMessage(memorySummary || undefined, { selfEditEnabled: config.selfEditEnabled }), | ||
| content: getOrchestratorSystemMessage(wikiSummary || undefined, { selfEditEnabled: config.selfEditEnabled }), | ||
| }, | ||
@@ -187,10 +187,10 @@ tools, | ||
| const recentHistory = getRecentConversation(30); | ||
| const recoveryMemorySummary = getMemorySummary(); | ||
| if (recentHistory || recoveryMemorySummary) { | ||
| console.log(`[max] Injecting recovery context into new session (${recentHistory ? "conversation + " : ""}${recoveryMemorySummary ? "memories" : ""})`); | ||
| const recoveryWikiSummary = getWikiSummary(); | ||
| if (recentHistory || recoveryWikiSummary) { | ||
| console.log(`[max] Injecting recovery context into new session (${recentHistory ? "conversation + " : ""}${recoveryWikiSummary ? "wiki" : ""})`); | ||
| 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 (recoveryWikiSummary) { | ||
| parts.push(`\n## Your Wiki Knowledge Base:\n${recoveryWikiSummary}`); | ||
| } | ||
@@ -253,12 +253,11 @@ if (recentHistory) { | ||
| currentCallback = callback; | ||
| // Inject relevant memories into the prompt (skip for background task results) | ||
| // Inject relevant wiki context 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}`; | ||
| const wikiContext = getRelevantWikiContext(prompt, 3); | ||
| if (wikiContext) { | ||
| // Cap at 1500 chars to balance context richness vs prompt bloat | ||
| const trimmed = wikiContext.length > 1500 ? wikiContext.slice(0, 1500) + "…" : wikiContext; | ||
| enrichedPrompt = `[Wiki context:\n${trimmed}\n]\n\n${prompt}`; | ||
| } | ||
@@ -393,9 +392,2 @@ } | ||
| catch { /* best-effort */ } | ||
| // Silently extract memorable facts from user messages | ||
| if (logRole === "user") { | ||
| try { | ||
| extractAndSaveMemories(prompt); | ||
| } | ||
| catch { /* best-effort */ } | ||
| } | ||
| return; | ||
@@ -402,0 +394,0 @@ } |
@@ -1,5 +0,5 @@ | ||
| export function getOrchestratorSystemMessage(memorySummary, opts) { | ||
| const memoryBlock = memorySummary | ||
| ? `\n## Long-Term Memory\nThese are things you've been asked to remember or have noted as important:\n\n${memorySummary}\n` | ||
| : ""; | ||
| export function getOrchestratorSystemMessage(wikiSummary, opts) { | ||
| const wikiBlock = wikiSummary | ||
| ? `\n## Wiki Knowledge Base\nYou maintain a persistent wiki at ~/.max/wiki/. Here's what's in it:\n\n${wikiSummary}\n` | ||
| : "\n## Wiki Knowledge Base\nYou maintain a persistent wiki at ~/.max/wiki/. It's currently empty — start building it!\n"; | ||
| const selfEditBlock = opts?.selfEditEnabled | ||
@@ -102,6 +102,11 @@ ? "" | ||
| ### Memory | ||
| - \`remember\`: Save something to long-term memory. Use when the user says "remember that...", states a preference, or shares important facts. Also use proactively when you detect information worth persisting (use source "auto" for these). | ||
| - \`recall\`: Search long-term memory by keyword and/or category. Use when you need to look up something the user told you before. | ||
| - \`forget\`: Remove a specific memory by ID. Use when the user asks to forget something or a memory is outdated. | ||
| ### Memory & Wiki | ||
| - \`remember\`: Save something to your wiki knowledge base. Use when the user says "remember that...", states a preference, or shares important facts. Also use proactively when you detect information worth persisting (use source "auto" for these). This writes to both the wiki and the legacy database. | ||
| - \`recall\`: Search your wiki and memory for stored facts, preferences, or information. | ||
| - \`forget\`: Remove specific content from wiki pages or legacy database entries. | ||
| - \`wiki_search\`: Search the wiki index for relevant knowledge pages. | ||
| - \`wiki_read\`: Read a specific wiki page by path (use after wiki_search). | ||
| - \`wiki_update\`: Create or update a full wiki page with structured content, cross-references, and synthesis. | ||
| - \`wiki_ingest\`: Process a source (URL, file, or text) into the wiki. Saves the raw source and returns content for you to organize into wiki pages. | ||
| - \`wiki_lint\`: Health-check the wiki for orphan pages, missing entries, and other issues. | ||
@@ -133,7 +138,9 @@ **Learning workflow**: When the user asks you to do something you don't have a skill for: | ||
| 12. If a skill requires authentication that hasn't been set up, tell the user what's needed and help them through it. | ||
| 13. **You have persistent memory.** Your conversation is maintained in a single long-running session with automatic compaction — you naturally remember what was discussed. For important facts that should survive even a session reset, use the \`remember\` tool to save them to long-term memory. | ||
| 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' -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}`; | ||
| 13. **You have a persistent wiki.** Your wiki at \`~/.max/wiki/\` is your long-term knowledge base. It's a collection of interlinked markdown files that you maintain. When you learn something important, save it to the wiki using \`remember\` (for quick facts) or \`wiki_update\` (for structured knowledge pages). | ||
| 14. **Proactive knowledge building**: 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. For richer knowledge (project architectures, research findings, detailed preferences), use \`wiki_update\` to create proper wiki pages. | ||
| 15. **Wiki maintenance**: Periodically, when conversation is light, consider running \`wiki_lint\` to check wiki health. When you create or update wiki pages, include cross-references to related pages using \`[[Page Title]]\` links. | ||
| 16. **Source ingestion**: When the user shares a URL, article, or document they want you to learn from, use \`wiki_ingest\` to save the raw source, then create wiki pages that synthesize the key information. Don't just store raw content — organize and cross-reference it. | ||
| 17. **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}${wikiBlock}`; | ||
| } | ||
| //# sourceMappingURL=system-message.js.map |
+263
-17
@@ -12,2 +12,5 @@ import { z } from "zod"; | ||
| import { getRouterConfig, updateRouterConfig } from "./router.js"; | ||
| import { ensureWikiStructure, readPage, writePage, listPages, writeRawSource, listSources } from "../wiki/fs.js"; | ||
| import { searchIndex, addToIndex, parseIndex } from "../wiki/index-manager.js"; | ||
| import { appendLog } from "../wiki/log-manager.js"; | ||
| function isTimeoutError(err) { | ||
@@ -404,4 +407,5 @@ const msg = err instanceof Error ? err.message : String(err); | ||
| }), | ||
| // ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) ----- | ||
| defineTool("remember", { | ||
| description: "Save something to Max's long-term memory. Use when the user says 'remember that...', " + | ||
| description: "Save something to Max's wiki knowledge base. Use when the user says 'remember that...', " + | ||
| "states a preference, shares a fact about themselves, or mentions something important " + | ||
@@ -417,12 +421,54 @@ "that should be remembered across conversations. Also use proactively when you detect " + | ||
| handler: async (args) => { | ||
| ensureWikiStructure(); | ||
| const categoryMap = { | ||
| preference: "pages/preferences.md", | ||
| fact: "pages/facts.md", | ||
| project: "pages/projects.md", | ||
| person: "pages/people.md", | ||
| routine: "pages/routines.md", | ||
| }; | ||
| const pagePath = categoryMap[args.category] || `pages/${args.category}.md`; | ||
| const title = args.category.charAt(0).toUpperCase() + args.category.slice(1); | ||
| const now = new Date().toISOString().slice(0, 10); | ||
| const tag = args.source === "auto" ? "auto" : "user"; | ||
| const existing = readPage(pagePath); | ||
| if (existing) { | ||
| // Append to existing page | ||
| const updated = existing.replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${now}`); | ||
| writePage(pagePath, updated.trimEnd() + `\n- ${args.content} _(${tag}, ${now})_\n`); | ||
| } | ||
| else { | ||
| const page = [ | ||
| "---", | ||
| `title: ${title}`, | ||
| `tags: [${args.category}]`, | ||
| `created: ${now}`, | ||
| `updated: ${now}`, | ||
| "---", | ||
| "", | ||
| `# ${title}`, | ||
| "", | ||
| `- ${args.content} _(${tag}, ${now})_`, | ||
| "", | ||
| ].join("\n"); | ||
| writePage(pagePath, page); | ||
| } | ||
| addToIndex({ | ||
| path: pagePath, | ||
| title: `${title}`, | ||
| summary: `${title} stored in Max's wiki`, | ||
| section: "Knowledge", | ||
| }); | ||
| appendLog("update", `remember (${args.category}): ${args.content.slice(0, 80)}`); | ||
| // Also write to SQLite for backwards compat during transition | ||
| const id = addMemory(args.category, args.content, args.source || "user"); | ||
| return `Remembered (#${id}, ${args.category}): "${args.content}"`; | ||
| return `Remembered (wiki + #${id}, ${args.category}): "${args.content}"`; | ||
| }, | ||
| }), | ||
| defineTool("recall", { | ||
| description: "Search Max's long-term memory for stored facts, preferences, or information. " + | ||
| description: "Search Max's wiki knowledge base for stored facts, preferences, or information. " + | ||
| "Use when you need to look up something the user told you before, or when the user " + | ||
| "asks 'do you remember...?' or 'what do you know about...?'", | ||
| parameters: z.object({ | ||
| keyword: z.string().optional().describe("Search term to match against memory content"), | ||
| keyword: z.string().optional().describe("Search term to match against wiki pages"), | ||
| category: z.enum(["preference", "fact", "project", "person", "routine"]).optional() | ||
@@ -432,24 +478,224 @@ .describe("Optional: filter by category"), | ||
| handler: async (args) => { | ||
| const results = searchMemories(args.keyword, args.category); | ||
| if (results.length === 0) { | ||
| return "No matching memories found."; | ||
| ensureWikiStructure(); | ||
| // Search wiki index | ||
| const query = [args.keyword, args.category].filter(Boolean).join(" "); | ||
| const matches = searchIndex(query || "", 5); | ||
| if (matches.length === 0) { | ||
| // Fall back to SQLite search for pre-migration content | ||
| const results = searchMemories(args.keyword, args.category); | ||
| if (results.length === 0) | ||
| return "No matching memories found in wiki or database."; | ||
| const lines = results.map((m) => `• [db#${m.id}] [${m.category}] ${m.content} (${m.source}, ${m.created_at})`); | ||
| return `Found ${results.length} in legacy database:\n${lines.join("\n")}`; | ||
| } | ||
| const lines = results.map((m) => `• #${m.id} [${m.category}] ${m.content} (${m.source}, ${m.created_at})`); | ||
| return `Found ${results.length} memory/memories:\n${lines.join("\n")}`; | ||
| const sections = []; | ||
| for (const match of matches) { | ||
| const content = readPage(match.path); | ||
| if (!content) | ||
| continue; | ||
| const body = content.replace(/^---[\s\S]*?---\s*/, "").trim(); | ||
| const trimmed = body.length > 800 ? body.slice(0, 800) + "…" : body; | ||
| sections.push(`**${match.title}** (${match.path}):\n${trimmed}`); | ||
| } | ||
| return sections.length > 0 | ||
| ? `Found ${matches.length} wiki page(s):\n\n${sections.join("\n\n")}` | ||
| : "No matching content found."; | ||
| }, | ||
| }), | ||
| defineTool("forget", { | ||
| description: "Remove a specific memory from Max's long-term storage. Use when the user asks " + | ||
| "to forget something, or when a memory is outdated/incorrect. Requires the memory ID " + | ||
| "(use recall to find it first).", | ||
| description: "Remove specific content from Max's knowledge base. For wiki content, specify the " + | ||
| "page path and the text to remove. For legacy database entries, specify the memory_id.", | ||
| parameters: z.object({ | ||
| memory_id: z.number().int().describe("The memory ID to remove (from recall results)"), | ||
| memory_id: z.number().int().optional().describe("Legacy database memory ID to remove"), | ||
| page_path: z.string().optional().describe("Wiki page path containing the content to remove"), | ||
| content: z.string().optional().describe("The specific text to remove from the wiki page"), | ||
| }), | ||
| handler: async (args) => { | ||
| const removed = removeMemory(args.memory_id); | ||
| return removed | ||
| ? `Memory #${args.memory_id} forgotten.` | ||
| : `Memory #${args.memory_id} not found — it may have already been removed.`; | ||
| const results = []; | ||
| // Remove from legacy DB if ID provided | ||
| if (args.memory_id !== undefined) { | ||
| const removed = removeMemory(args.memory_id); | ||
| results.push(removed | ||
| ? `Removed db#${args.memory_id}.` | ||
| : `db#${args.memory_id} not found.`); | ||
| } | ||
| // Remove from wiki if page + content provided | ||
| if (args.page_path && args.content) { | ||
| const page = readPage(args.page_path); | ||
| if (page) { | ||
| const lines = page.split("\n"); | ||
| const before = lines.length; | ||
| // Only remove bullet-point lines that contain the target content | ||
| const updated = lines | ||
| .filter((line) => { | ||
| if (line.trim().startsWith("-") && line.includes(args.content)) { | ||
| return false; | ||
| } | ||
| return true; | ||
| }) | ||
| .join("\n"); | ||
| const removed = before - updated.split("\n").length; | ||
| if (removed > 0) { | ||
| writePage(args.page_path, updated); | ||
| appendLog("update", `forget: removed ${removed} line(s) matching "${args.content.slice(0, 60)}" from ${args.page_path}`); | ||
| results.push(`Removed ${removed} line(s) from ${args.page_path}.`); | ||
| } | ||
| else { | ||
| results.push(`No matching bullet points found in ${args.page_path}.`); | ||
| } | ||
| } | ||
| else { | ||
| results.push(`Page ${args.page_path} not found.`); | ||
| } | ||
| } | ||
| return results.length > 0 ? results.join(" ") : "Nothing to remove — provide memory_id or page_path + content."; | ||
| }, | ||
| }), | ||
| // ----- New wiki tools ----- | ||
| defineTool("wiki_search", { | ||
| description: "Search Max's wiki knowledge base. Returns matching page titles, paths, and summaries " + | ||
| "from the wiki index. Use this to find relevant knowledge before answering questions.", | ||
| parameters: z.object({ | ||
| query: z.string().describe("What to search for in the wiki"), | ||
| }), | ||
| handler: async (args) => { | ||
| ensureWikiStructure(); | ||
| const matches = searchIndex(args.query, 10); | ||
| if (matches.length === 0) | ||
| return "No matching wiki pages found."; | ||
| const lines = matches.map((m) => `• [${m.title}](${m.path}) — ${m.summary}`); | ||
| return `Found ${matches.length} page(s):\n${lines.join("\n")}`; | ||
| }, | ||
| }), | ||
| defineTool("wiki_read", { | ||
| description: "Read a specific wiki page by path. Use after wiki_search to read full page content. " + | ||
| "Paths are relative to the wiki root (e.g. 'pages/preferences.md', 'index.md').", | ||
| parameters: z.object({ | ||
| path: z.string().describe("Path to the wiki page (e.g. 'pages/people/burke.md', 'index.md')"), | ||
| }), | ||
| handler: async (args) => { | ||
| ensureWikiStructure(); | ||
| const content = readPage(args.path); | ||
| if (!content) | ||
| return `Page not found: ${args.path}`; | ||
| return content; | ||
| }, | ||
| }), | ||
| defineTool("wiki_update", { | ||
| description: "Create or update a wiki page. You provide the full page content (markdown with optional " + | ||
| "YAML frontmatter). The page will be written to disk and the index updated. Use this for " + | ||
| "rich knowledge pages, entity pages, synthesis documents — anything more structured than " + | ||
| "a quick 'remember' call. After creating/updating a page, the index is automatically updated.", | ||
| parameters: z.object({ | ||
| path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/max.md')"), | ||
| title: z.string().describe("Page title for the index"), | ||
| summary: z.string().describe("One-line summary for the index"), | ||
| section: z.string().optional().describe("Index section (default: 'Knowledge')"), | ||
| content: z.string().describe("Full page content (markdown)"), | ||
| }), | ||
| handler: async (args) => { | ||
| ensureWikiStructure(); | ||
| writePage(args.path, args.content); | ||
| addToIndex({ | ||
| path: args.path, | ||
| title: args.title, | ||
| summary: args.summary, | ||
| section: args.section || "Knowledge", | ||
| }); | ||
| appendLog("update", `wiki_update: ${args.title} (${args.path})`); | ||
| return `Wiki page updated: ${args.title} (${args.path})`; | ||
| }, | ||
| }), | ||
| defineTool("wiki_ingest", { | ||
| description: "Ingest a source into the wiki. Saves the raw content as an immutable source document, " + | ||
| "then returns it so you can create wiki pages from it. Supports URLs (fetches the page) " + | ||
| "or raw text passed directly. For local files, read the file yourself and pass content as text.", | ||
| parameters: z.object({ | ||
| type: z.enum(["url", "text"]).describe("Source type: 'url' to fetch a web page, 'text' for raw content"), | ||
| source: z.string().describe("URL or raw text content"), | ||
| name: z.string().optional().describe("Name for the source (auto-generated if omitted)"), | ||
| }), | ||
| handler: async (args) => { | ||
| ensureWikiStructure(); | ||
| let content; | ||
| let sourceName; | ||
| if (args.type === "url") { | ||
| // Validate URL scheme | ||
| let parsedUrl; | ||
| try { | ||
| parsedUrl = new URL(args.source); | ||
| } | ||
| catch { | ||
| return "Invalid URL format."; | ||
| } | ||
| if (!["http:", "https:"].includes(parsedUrl.protocol)) { | ||
| return "Only http and https URLs are supported."; | ||
| } | ||
| // Block private/internal addresses | ||
| const host = parsedUrl.hostname.toLowerCase(); | ||
| if (host === "localhost" || host === "127.0.0.1" || host === "::1" || | ||
| host.startsWith("10.") || host.startsWith("192.168.") || | ||
| host.startsWith("169.254.") || host === "metadata.google.internal") { | ||
| return "Cannot fetch internal/private URLs."; | ||
| } | ||
| try { | ||
| const res = await fetch(args.source); | ||
| if (!res.ok) { | ||
| return `Fetch failed: ${res.status} ${res.statusText}`; | ||
| } | ||
| content = await res.text(); | ||
| // Strip HTML tags for a rough markdown conversion | ||
| content = content.replace(/<script[\s\S]*?<\/script>/gi, "") | ||
| .replace(/<style[\s\S]*?<\/style>/gi, "") | ||
| .replace(/<[^>]+>/g, " ") | ||
| .replace(/\s{2,}/g, " ") | ||
| .trim(); | ||
| } | ||
| catch (err) { | ||
| return `Failed to fetch URL: ${err instanceof Error ? err.message : err}`; | ||
| } | ||
| sourceName = args.name || parsedUrl.hostname + "-" + Date.now(); | ||
| } | ||
| else { | ||
| content = args.source; | ||
| sourceName = args.name || "text-" + Date.now(); | ||
| } | ||
| const fileName = `${new Date().toISOString().slice(0, 10)}-${sourceName}.md`; | ||
| writeRawSource(fileName, content); | ||
| appendLog("ingest", `Ingested ${args.type}: ${sourceName} (${content.length} chars)`); | ||
| // Return the content so the LLM can create wiki pages from it | ||
| const preview = content.length > 3000 ? content.slice(0, 3000) + "\n\n…(truncated)" : content; | ||
| return `Source saved as sources/${fileName} (${content.length} chars).\n\n` + | ||
| "Now create wiki pages from this content using wiki_update. " + | ||
| "Update existing pages and the index as needed.\n\n" + | ||
| `--- Source content ---\n${preview}`; | ||
| }, | ||
| }), | ||
| defineTool("wiki_lint", { | ||
| description: "Health-check the wiki. Looks for: orphan pages (not in index), index entries pointing " + | ||
| "to missing pages, and pages with no cross-references. Returns a report.", | ||
| parameters: z.object({}), | ||
| handler: async () => { | ||
| ensureWikiStructure(); | ||
| const indexEntries = parseIndex(); | ||
| const pages = listPages(); | ||
| const sources = listSources(); | ||
| const indexPaths = new Set(indexEntries.map((e) => e.path)); | ||
| const orphans = pages.filter((p) => !indexPaths.has(p)); | ||
| const missing = indexEntries.filter((e) => !readPage(e.path)); | ||
| const report = [`Wiki health report (${pages.length} pages, ${sources.length} sources):`]; | ||
| if (orphans.length > 0) { | ||
| report.push(`\n**Orphan pages** (not in index):\n${orphans.map((p) => `- ${p}`).join("\n")}`); | ||
| } | ||
| if (missing.length > 0) { | ||
| report.push(`\n**Missing pages** (in index but not on disk):\n${missing.map((e) => `- ${e.path}: ${e.title}`).join("\n")}`); | ||
| } | ||
| if (orphans.length === 0 && missing.length === 0) { | ||
| report.push("\n✅ No issues found. Index and pages are in sync."); | ||
| } | ||
| report.push(`\n**Suggestions**: Look for pages that should link to each other, topics mentioned but lacking their own page, and stale content that needs updating.`); | ||
| appendLog("lint", `${orphans.length} orphans, ${missing.length} missing`); | ||
| return report.join("\n"); | ||
| }, | ||
| }), | ||
| defineTool("restart_max", { | ||
@@ -456,0 +702,0 @@ description: "Restart the Max daemon process. Use when the user asks Max to restart himself, " + |
+12
-0
@@ -9,2 +9,4 @@ import { getClient, stopClient } from "./copilot/client.js"; | ||
| import { checkForUpdate } from "./update.js"; | ||
| import { ensureWikiStructure } from "./wiki/fs.js"; | ||
| import { shouldMigrate, migrateMemoriesToWiki } from "./wiki/migrate.js"; | ||
| function truncate(text, max = 200) { | ||
@@ -28,2 +30,12 @@ const oneLine = text.replace(/\n/g, " ").trim(); | ||
| console.log("[max] Database initialized"); | ||
| // Initialize wiki knowledge base | ||
| const wikiIsNew = ensureWikiStructure(); | ||
| if (wikiIsNew) { | ||
| console.log("[max] Created wiki at ~/.max/wiki/"); | ||
| } | ||
| if (shouldMigrate()) { | ||
| console.log("[max] Migrating SQLite memories to wiki..."); | ||
| const count = migrateMemoriesToWiki(); | ||
| console.log(`[max] Migrated ${count} memories to wiki`); | ||
| } | ||
| // Start Copilot SDK client | ||
@@ -30,0 +42,0 @@ console.log("[max] Starting Copilot SDK client..."); |
+6
-0
@@ -20,2 +20,8 @@ import { join } from "path"; | ||
| export const API_TOKEN_PATH = join(MAX_HOME, "api-token"); | ||
| /** Root of the LLM-maintained wiki knowledge base */ | ||
| export const WIKI_DIR = join(MAX_HOME, "wiki"); | ||
| /** Wiki pages (entity, concept, summary files) */ | ||
| export const WIKI_PAGES_DIR = join(WIKI_DIR, "pages"); | ||
| /** Raw ingested source documents (immutable) */ | ||
| export const WIKI_SOURCES_DIR = join(WIKI_DIR, "sources"); | ||
| /** Ensure ~/.max/ exists */ | ||
@@ -22,0 +28,0 @@ export function ensureMaxHome() { |
+1
-1
| { | ||
| "name": "heymax", | ||
| "version": "1.3.0", | ||
| "version": "1.4.0", | ||
| "description": "Max — a personal AI assistant for developers, built on the GitHub Copilot SDK", | ||
@@ -5,0 +5,0 @@ "bin": { |
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
253760
14.28%34
17.24%5289
15.05%24
9.09%6
20%