Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to megg will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.0] - 2026-03-15

### Added
- **Stale info.md warning** - `context()` now warns when `info.md` hasn't been updated in >30 days
- Banner shown in context output: `⚠️ info.md last updated N days ago`
- Prompts to run `megg init --update`
- **`init --update` mode** - Intelligent update of existing `info.md`
- Loads current info, analyzes what may be outdated
- Asks targeted questions section by section
- Updates `updated` timestamp on save
- **Universal entry types** - New taxonomy that works for any domain (not just code)
- `rule` — always/never do X (replaces `pattern` + `gotcha`)
- `fact` — this is true about X (replaces `context`)
- `decision` — we chose X because Y (unchanged)
- `process` — how to do X step by step (new)

### Breaking Changes
- Entry types `pattern`, `gotcha`, `context` removed; replaced by `rule`, `fact`, `process`
- Existing knowledge.md files with old types will still parse (type is read as-is from file)
- New entries must use the new types

## [1.1.0] - 2026-01-17

### Added
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@ When you call `context("clients/acme")`, megg loads the full chain:

| Type | Use For | Example |
|------|---------|---------|
| `decision` | Architectural choices | "We chose PostgreSQL over MongoDB because..." |
| `pattern` | Team conventions | "API endpoints use kebab-case" |
| `gotcha` | Traps to avoid | "Don't use localStorage for auth tokens" |
| `context` | Background info | "This client requires HIPAA compliance" |
| `rule` | Always/never do X | "Don't use localStorage for auth tokens" |
| `fact` | This is true about X | "This client requires HIPAA compliance" |
| `decision` | We chose X because Y | "We chose PostgreSQL over MongoDB because..." |
| `process` | How to do X step by step | "Deploy: build → tag → push → migrate" |

### Smart Token Management

Expand Down Expand Up @@ -164,7 +164,10 @@ npx megg context
npx megg context . --topic auth

# Add a decision
npx megg learn "JWT Auth" decision "auth,security" "We use JWT with refresh tokens..."
npx megg learn "JWT Auth" decision "auth,security" "We use JWT because..."

# Add a rule
npx megg learn "No localStorage for tokens" rule "auth,security" "Use httpOnly cookies instead"

# Initialize megg
npx megg init
Expand Down Expand Up @@ -300,7 +303,7 @@ Brief description of what this project is.
3. When Z, prefer A

## Memory Files
- knowledge.md: decisions, patterns, gotchas
- knowledge.md: rules, facts, decisions, processes
```

### knowledge.md Entry Format
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "megg",
"version": "1.1.0",
"version": "1.2.0",
"description": "megg - Memory for AI Agents. Simplified knowledge system with auto-discovery and size-aware loading.",
"main": "build/index.js",
"bin": {
Expand Down
34 changes: 33 additions & 1 deletion src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ const FULL_LOAD_THRESHOLD = 8000; // Load full if under this
const SUMMARY_THRESHOLD = 16000; // Show summary if under this
// Above SUMMARY_THRESHOLD = blocked

const STALE_INFO_DAYS = 30; // Warn if info.md not updated in this many days

/**
* Parse `updated` timestamp from info.md frontmatter.
* Returns null if not found or unparseable.
*/
function parseUpdatedDate(infoContent: string): Date | null {
const match = infoContent.match(/^updated:\s*(.+)$/m);
if (!match) return null;
const d = new Date(match[1].trim());
return isNaN(d.getTime()) ? null : d;
}

/**
* Returns stale warning string if info.md is older than STALE_INFO_DAYS, else null.
*/
function getStaleWarning(infoContent: string, domain: string): string | undefined {
const updated = parseUpdatedDate(infoContent);
if (!updated) return undefined;
const daysSince = Math.floor((Date.now() - updated.getTime()) / (1000 * 60 * 60 * 24));
if (daysSince < STALE_INFO_DAYS) return undefined;
return `⚠️ ${domain}/info.md last updated ${daysSince} days ago — still current? Run: megg init --update`;
}

/**
* Main context command - gathers all relevant context for a path.
*/
Expand All @@ -54,11 +78,13 @@ export async function context(targetPath?: string, topic?: string): Promise<Cont
const infoPath = path.join(meggPath, INFO_FILE_NAME);
try {
const info = await readFile(infoPath);
const domain = getDomainName(meggPath);
chain.push({
domain: getDomainName(meggPath),
domain,
path: path.dirname(meggPath),
meggPath,
info,
staleWarning: getStaleWarning(info, domain),
});
} catch {
// Skip if can't read
Expand Down Expand Up @@ -191,6 +217,12 @@ export function formatContextForDisplay(result: ContextResult): string {
out += '\n';
}

// Stale warnings (any level in chain)
const staleWarnings = result.chain.filter(c => c.staleWarning).map(c => c.staleWarning!);
if (staleWarnings.length > 0) {
out += staleWarnings.join('\n') + '\n\n';
}

// Current context (deepest level info.md)
const deepest = result.chain[result.chain.length - 1];
if (deepest) {
Expand Down
99 changes: 81 additions & 18 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import path from 'path';
import fs from 'fs/promises';
import type { InitAnalysis, InitContent, DomainInfo, ProjectStructure } from '../types.js';
import type { InitAnalysis, InitContent, DomainInfo, ProjectStructure, UpdateAnalysis, InfoSection } from '../types.js';
import { exists, readFile, writeFile, getTimestamp, ensureDir } from '../utils/files.js';
import { findAncestorMegg, getDomainName, MEGG_DIR_NAME, INFO_FILE_NAME, KNOWLEDGE_FILE_NAME } from '../utils/paths.js';

Expand Down Expand Up @@ -55,28 +55,67 @@ const SKIP_DIRS = [
export async function init(
projectRoot?: string,
content?: InitContent
): Promise<InitAnalysis | { success: boolean; message: string }> {
): Promise<InitAnalysis | UpdateAnalysis | { success: boolean; message: string }> {
const root = projectRoot || process.cwd();
const meggDir = path.join(root, MEGG_DIR_NAME);
const infoPath = path.join(meggDir, INFO_FILE_NAME);

// If content provided, create the files
// If content provided, create or update the files
if (content) {
return createMeggFiles(root, content);
}

// Check if already initialized
// If already initialized → return update analysis instead of error
if (await exists(infoPath)) {
return {
status: 'already_initialized',
message: 'megg already initialized. Use context() to load.',
};
return analyzeForUpdate(infoPath);
}

// Analyze project
// Analyze project for fresh init
return analyzeProject(root);
}

/**
* Reads existing info.md and returns section-by-section update analysis.
*/
async function analyzeForUpdate(infoPath: string): Promise<UpdateAnalysis> {
const current = await readFile(infoPath);

// Parse updated date from frontmatter
const updatedMatch = current.match(/^updated:\s*(.+)$/m);
const updatedDate = updatedMatch ? new Date(updatedMatch[1].trim()) : null;
const daysSinceUpdate = updatedDate && !isNaN(updatedDate.getTime())
? Math.floor((Date.now() - updatedDate.getTime()) / (1000 * 60 * 60 * 24))
: 0;

// Parse sections (## headings) from info content (strip frontmatter first)
const bodyMatch = current.match(/^---[\s\S]*?---\n([\s\S]*)$/);
const body = bodyMatch ? bodyMatch[1] : current;

const sections: InfoSection[] = [];
const sectionRegex = /^(#{1,3} .+)\n([\s\S]*?)(?=^#{1,3} |\s*$)/gm;
let match;
while ((match = sectionRegex.exec(body)) !== null) {
const heading = match[1].replace(/^#+\s*/, '').trim();
const content = match[2].trim();
if (heading && content) {
sections.push({ heading, content });
}
}

// Generate targeted question per section
const questions = sections.map(s =>
`**${s.heading}** currently says:\n> ${s.content.split('\n').slice(0, 3).join('\n> ')}${s.content.split('\n').length > 3 ? '\n> ...' : ''}\n→ Is this still accurate? What's changed?`
);

return {
status: 'needs_update',
currentInfo: current,
sections,
questions,
daysSinceUpdate,
};
}

/**
* Analyzes project and returns information for agent.
*/
Expand Down Expand Up @@ -199,20 +238,29 @@ async function createMeggFiles(
): Promise<{ success: boolean; message: string }> {
const meggDir = path.join(root, MEGG_DIR_NAME);
const now = getTimestamp();
const infoPath = path.join(meggDir, INFO_FILE_NAME);

try {
await ensureDir(meggDir);

// Create info.md
// Preserve `created` timestamp if updating existing file
let createdTimestamp = now;
if (content.update && await exists(infoPath)) {
const existing = await readFile(infoPath);
const createdMatch = existing.match(/^created:\s*(.+)$/m);
if (createdMatch) createdTimestamp = createdMatch[1].trim();
}

// Create/update info.md
const infoContent = `---
created: ${now}
created: ${createdTimestamp}
updated: ${now}
type: context
---

${content.info}
`;
await writeFile(path.join(meggDir, INFO_FILE_NAME), infoContent);
await writeFile(infoPath, infoContent);

// Create knowledge.md if provided
if (content.knowledge) {
Expand All @@ -231,7 +279,9 @@ ${content.knowledge}

return {
success: true,
message: `✓ megg initialized in ${meggDir}`,
message: content.update
? `✓ megg info.md updated in ${meggDir}`
: `✓ megg initialized in ${meggDir}`,
};
} catch (err) {
return {
Expand All @@ -247,13 +297,15 @@ ${content.knowledge}
export async function initCommand(
projectRoot?: string,
infoContent?: string,
knowledgeContent?: string
knowledgeContent?: string,
update?: boolean
): Promise<string> {
// If content provided, create files
// If content provided, create or update files
if (infoContent) {
const content: InitContent = {
info: infoContent,
knowledge: knowledgeContent,
update,
};

const result = await init(projectRoot, content);
Expand All @@ -267,11 +319,22 @@ export async function initCommand(
const analysis = await init(projectRoot);

if ('status' in analysis) {
if (analysis.status === 'already_initialized') {
return analysis.message || 'Already initialized.';
// Update analysis — show sections with targeted questions
if (analysis.status === 'needs_update') {
const a = analysis as import('../types.js').UpdateAnalysis;
let output = '# megg Update Analysis\n\n';
if (a.daysSinceUpdate > 0) {
output += `> ⚠️ info.md last updated ${a.daysSinceUpdate} days ago\n\n`;
}
output += 'Review each section and confirm what has changed:\n\n';
for (const q of a.questions) {
output += q + '\n\n---\n\n';
}
output += 'Call `init(path, { info: "...", update: true })` with the updated content to save.';
return output;
}

// Format analysis for display
// Format fresh analysis for display
let output = '# megg Init Analysis\n\n';

if (analysis.parentChain && analysis.parentChain.length > 0) {
Expand Down
6 changes: 3 additions & 3 deletions src/commands/learn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export interface LearnPromptResult {
* Validates entry type.
*/
export function isValidEntryType(type: string): type is EntryType {
return ['decision', 'pattern', 'gotcha', 'context'].includes(type);
return ['rule', 'fact', 'decision', 'process'].includes(type);
}

/**
Expand All @@ -127,7 +127,7 @@ export async function learnCommand(
): Promise<string> {
// Validate type
if (!isValidEntryType(type)) {
return `Error: Invalid type "${type}". Must be one of: decision, pattern, gotcha, context`;
return `Error: Invalid type "${type}". Must be one of: rule, fact, decision, process`;
}

// Parse topics
Expand Down Expand Up @@ -162,7 +162,7 @@ export async function learnCommand(
*/
export async function quickLearn(
content: string,
type: EntryType = 'context',
type: EntryType = 'fact',
targetPath?: string
): Promise<LearnResult> {
// Extract title from first line or first sentence
Expand Down
19 changes: 10 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { state, formatStateForDisplay } from "./commands/state.js";
// Create server instance
const server = new McpServer({
name: "megg",
version: "1.1.0",
version: "1.2.0",
});

const PROJECT_ROOT = process.cwd();
Expand Down Expand Up @@ -54,7 +54,7 @@ server.tool(
"Add a knowledge entry to the nearest .megg/knowledge.md. Entries have type (decision/pattern/gotcha/context), topics for categorization, and content.",
{
title: z.string().describe("Short title for the entry"),
type: z.enum(["decision", "pattern", "gotcha", "context"]).describe("Entry type: decision (architectural choice), pattern (how we do things), gotcha (trap to avoid), context (background info)"),
type: z.enum(["rule", "fact", "decision", "process"]).describe("Entry type: rule (always/never do X), fact (this is true about X), decision (we chose X because Y), process (how to do X step by step)"),
topics: z.array(z.string()).describe("Tags for categorization (e.g., ['auth', 'api', 'security'])"),
content: z.string().describe("The knowledge content in markdown"),
path: z.string().optional().describe("Target path (defaults to cwd, finds nearest .megg)"),
Expand Down Expand Up @@ -93,26 +93,27 @@ server.tool(

server.tool(
"init",
"Initialize megg in current directory. Without content: analyzes project and returns questions to ask. With content: creates .megg/info.md and optionally knowledge.md.",
"Initialize megg in current directory. Without content: analyzes project (or returns update analysis if already initialized). With content: creates .megg/info.md and optionally knowledge.md. Use update=true to update existing info.md (preserves created timestamp).",
{
projectRoot: z.string().optional().describe("Root directory (defaults to cwd)"),
info: z.string().optional().describe("Content for info.md (if provided, creates the file)"),
info: z.string().optional().describe("Content for info.md (if provided, creates or updates the file)"),
knowledge: z.string().optional().describe("Initial content for knowledge.md (optional)"),
update: z.boolean().optional().describe("If true, update existing info.md instead of creating new (preserves created timestamp)"),
},
async ({ projectRoot, info, knowledge }) => {
async ({ projectRoot, info, knowledge, update }) => {
try {
const root = projectRoot || PROJECT_ROOT;

if (info) {
// Create files mode
const result = await init(root, { info, knowledge });
// Create or update files mode
const result = await init(root, { info, knowledge, update });
if ('success' in result) {
return { content: [{ type: "text", text: result.message }] };
}
}

// Analysis mode
const output = await initCommand(root);
// Analysis mode (fresh init or update analysis)
const output = await initCommand(root, undefined, undefined, update);
return { content: [{ type: "text", text: output }] };
} catch (err: any) {
return {
Expand Down
Loading