🚀. Socket Launch Week Day 2:Introducing Manifest Alerts.Learn more
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.3.0
to
1.4.0
+40
dist/wiki/context.js
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// 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
+16
-24

@@ -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 @@ }

+19
-12

@@ -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

@@ -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, " +

@@ -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...");

@@ -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() {

{
"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": {