+21
| { | ||
| "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", | ||
| "name": "io.github.shiporbleed/mindswap", | ||
| "title": "mindswap", | ||
| "description": "Your AI's black box recorder. Auto-track project state so any AI tool picks up where the last one stopped.", | ||
| "repository": { | ||
| "url": "https://github.com/ShipOrBleed/mindswap.git", | ||
| "source": "github" | ||
| }, | ||
| "version": "3.2.1", | ||
| "packages": [ | ||
| { | ||
| "registryType": "npm", | ||
| "identifier": "mindswap", | ||
| "version": "3.2.1", | ||
| "transport": { | ||
| "type": "stdio" | ||
| } | ||
| } | ||
| ] | ||
| } |
+159
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const SERVER_FILE = 'server.json'; | ||
| function getServerJsonPath(projectRoot) { | ||
| return path.join(projectRoot, SERVER_FILE); | ||
| } | ||
| function getRegistryName(packageJson) { | ||
| return packageJson.mcpName || null; | ||
| } | ||
| function buildRegistryManifest(packageJson, options = {}) { | ||
| const name = getRegistryName(packageJson); | ||
| if (!name) { | ||
| throw new Error('package.json is missing mcpName'); | ||
| } | ||
| const version = packageJson.version; | ||
| const repositoryUrl = typeof packageJson.repository === 'object' | ||
| ? packageJson.repository.url | ||
| : packageJson.repository; | ||
| const manifest = { | ||
| $schema: 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json', | ||
| name, | ||
| title: options.title || humanizeName(packageJson.name), | ||
| description: options.description || packageJson.description || '', | ||
| repository: repositoryUrl ? { | ||
| url: normalizeRepositoryUrl(repositoryUrl), | ||
| source: 'github', | ||
| } : undefined, | ||
| version, | ||
| packages: [{ | ||
| registryType: 'npm', | ||
| identifier: packageJson.name, | ||
| version, | ||
| transport: { | ||
| type: 'stdio', | ||
| }, | ||
| }], | ||
| }; | ||
| if (options.remoteUrl) { | ||
| manifest.remotes = [{ | ||
| type: 'streamable-http', | ||
| url: options.remoteUrl, | ||
| }]; | ||
| } | ||
| return stripUndefined(manifest); | ||
| } | ||
| function writeRegistryManifest(projectRoot, packageJson, options = {}) { | ||
| const manifest = buildRegistryManifest(packageJson, options); | ||
| const filePath = getServerJsonPath(projectRoot); | ||
| fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); | ||
| return { filePath, manifest }; | ||
| } | ||
| function readRegistryManifest(projectRoot) { | ||
| const filePath = getServerJsonPath(projectRoot); | ||
| if (!fs.existsSync(filePath)) return null; | ||
| try { | ||
| return JSON.parse(fs.readFileSync(filePath, 'utf-8')); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function validateRegistryMetadata(packageJson, manifest, options = {}) { | ||
| const issues = []; | ||
| if (!packageJson.mcpName) { | ||
| issues.push('package.json is missing mcpName'); | ||
| } | ||
| if (!manifest) { | ||
| issues.push('server.json is missing'); | ||
| return issues; | ||
| } | ||
| if (manifest.name !== packageJson.mcpName) { | ||
| issues.push('server.json name does not match package.json mcpName'); | ||
| } | ||
| if (manifest.version !== packageJson.version) { | ||
| issues.push('server.json version does not match package.json version'); | ||
| } | ||
| const npmPackage = manifest.packages?.find(item => item.registryType === 'npm'); | ||
| if (!npmPackage) { | ||
| issues.push('server.json is missing an npm package entry'); | ||
| } else { | ||
| if (npmPackage.identifier !== packageJson.name) { | ||
| issues.push('server.json npm identifier does not match package.json name'); | ||
| } | ||
| if (npmPackage.version !== packageJson.version) { | ||
| issues.push('server.json npm version does not match package.json version'); | ||
| } | ||
| } | ||
| if (options.requireRemote && !manifest.remotes?.length) { | ||
| issues.push('server.json is missing a remote transport entry'); | ||
| } | ||
| return issues; | ||
| } | ||
| function buildRegistryReport(packageJson, manifest, options = {}) { | ||
| const issues = validateRegistryMetadata(packageJson, manifest, options); | ||
| const ready = issues.length === 0; | ||
| return { | ||
| ready, | ||
| issues, | ||
| manifest, | ||
| package: { | ||
| name: packageJson.name, | ||
| version: packageJson.version, | ||
| mcpName: packageJson.mcpName || null, | ||
| }, | ||
| checklist: ready ? [ | ||
| 'package.json mcpName matches server.json', | ||
| 'server.json version matches package version', | ||
| 'npm package is published publicly', | ||
| 'mcp-publisher login github completed', | ||
| 'mcp-publisher publish can run', | ||
| ] : [], | ||
| }; | ||
| } | ||
| function humanizeName(name) { | ||
| return String(name || 'mindswap') | ||
| .replace(/^@[^/]+\//, '') | ||
| .replace(/[-_]+/g, ' ') | ||
| .replace(/\b\w/g, char => char.toUpperCase()); | ||
| } | ||
| function normalizeRepositoryUrl(url) { | ||
| return String(url || '').replace(/^git\+/, ''); | ||
| } | ||
| function stripUndefined(obj) { | ||
| if (Array.isArray(obj)) { | ||
| return obj.map(stripUndefined); | ||
| } | ||
| if (!obj || typeof obj !== 'object') return obj; | ||
| return Object.fromEntries(Object.entries(obj) | ||
| .filter(([, value]) => value !== undefined) | ||
| .map(([key, value]) => [key, stripUndefined(value)])); | ||
| } | ||
| module.exports = { | ||
| SERVER_FILE, | ||
| getServerJsonPath, | ||
| buildRegistryManifest, | ||
| writeRegistryManifest, | ||
| readRegistryManifest, | ||
| validateRegistryMetadata, | ||
| buildRegistryReport, | ||
| }; |
+122
-1
@@ -20,6 +20,7 @@ #!/usr/bin/env node | ||
| const { sync } = require('../src/sync'); | ||
| const { manageMemory, startMCPServer, startMCPHttpServer } = require('../src/mcp-server'); | ||
| const { save } = require('../src/save'); | ||
| const { pr } = require('../src/pr'); | ||
| const { startMCPServer } = require('../src/mcp-server'); | ||
| const { doctor } = require('../src/doctor'); | ||
| const { buildRegistryReport, readRegistryManifest, writeRegistryManifest } = require('../src/registry'); | ||
@@ -108,2 +109,50 @@ const program = new Command(); | ||
| // ─── memory ─── | ||
| program | ||
| .command('memory <action> [id] [message...]') | ||
| .description('Manage structured memory items. Actions: list, get, add, update, resolve, archive, delete') | ||
| .option('-t, --type <type>', 'Memory type') | ||
| .option('--tag <tag>', 'Tag filter or value') | ||
| .option('--status <status>', 'Status filter or value') | ||
| .option('--author <author>', 'Author filter or value') | ||
| .option('--source <source>', 'Source filter or value') | ||
| .option('--limit <limit>', 'Limit results', '20') | ||
| .option('--after <iso>', 'Created after timestamp') | ||
| .option('--before <iso>', 'Created before timestamp') | ||
| .option('--hard', 'Permanently delete instead of archiving') | ||
| .option('--json', 'Output as JSON') | ||
| .action(async (action, id, messageParts, opts) => { | ||
| try { | ||
| const message = action === 'add' | ||
| ? [id, ...(Array.isArray(messageParts) ? messageParts : [messageParts])].filter(Boolean).join(' ').trim() | ||
| : (Array.isArray(messageParts) ? messageParts.join(' ').trim() : String(messageParts || '').trim()); | ||
| const result = manageMemory(process.cwd(), { | ||
| action, | ||
| id: action === 'add' ? undefined : id, | ||
| message, | ||
| type: opts.type, | ||
| tag: opts.tag, | ||
| status: opts.status, | ||
| author: opts.author, | ||
| source: opts.source, | ||
| limit: opts.limit, | ||
| created_after: opts.after, | ||
| created_before: opts.before, | ||
| hard: opts.hard, | ||
| json: opts.json, | ||
| }); | ||
| if (result?.content?.length) { | ||
| for (const item of result.content) { | ||
| if (item?.type === 'text' && item.text) { | ||
| process.stdout.write(`${item.text}\n`); | ||
| } | ||
| } | ||
| } | ||
| if (result?.exitCode) process.exitCode = result.exitCode; | ||
| } catch (err) { | ||
| console.error(chalk.red('Error:'), err.message); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| // ─── status ─── | ||
@@ -339,2 +388,74 @@ program | ||
| // ─── mcp-http ─── | ||
| program | ||
| .command('mcp-http') | ||
| .description('Start mindswap as a remote MCP server over Streamable HTTP.') | ||
| .option('--host <host>', 'Host to bind to', '127.0.0.1') | ||
| .option('--port <port>', 'Port to listen on', '3000') | ||
| .option('--path <path>', 'MCP endpoint path', '/mcp') | ||
| .option('--token <token>', 'Bearer token required for requests') | ||
| .option('--origin <origin>', 'CORS allow-origin header', '*') | ||
| .action(async (opts) => { | ||
| try { | ||
| const httpServer = await startMCPHttpServer({ | ||
| host: opts.host, | ||
| port: opts.port, | ||
| path: opts.path, | ||
| token: opts.token, | ||
| origin: opts.origin, | ||
| }); | ||
| process.stdout.write(`mindswap MCP HTTP listening on ${httpServer.url}\n`); | ||
| } catch (err) { | ||
| process.stderr.write(`mindswap MCP error: ${err.message}\n`); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| // ─── registry ─── | ||
| program | ||
| .command('registry') | ||
| .description('Validate and generate MCP Registry metadata for this package.') | ||
| .option('--write', 'Write or update server.json from package metadata') | ||
| .option('--remote-url <url>', 'Include a remote Streamable HTTP endpoint in server.json') | ||
| .option('--json', 'Output as JSON') | ||
| .action(async (opts) => { | ||
| try { | ||
| const packageJson = pkg; | ||
| const projectRoot = process.cwd(); | ||
| let manifest = readRegistryManifest(projectRoot); | ||
| if (opts.write || !manifest) { | ||
| const written = writeRegistryManifest(projectRoot, packageJson, { | ||
| remoteUrl: opts.remoteUrl, | ||
| }); | ||
| manifest = written.manifest; | ||
| } | ||
| const report = buildRegistryReport(packageJson, manifest, { | ||
| requireRemote: Boolean(opts.remoteUrl), | ||
| }); | ||
| if (opts.json) { | ||
| process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); | ||
| return; | ||
| } | ||
| process.stdout.write(`\nMCP Registry\n`); | ||
| process.stdout.write(` package: ${report.package.name}@${report.package.version}\n`); | ||
| process.stdout.write(` mcpName: ${report.package.mcpName || 'missing'}\n`); | ||
| process.stdout.write(` server.json: ${report.ready ? 'ready' : 'needs attention'}\n`); | ||
| if (report.issues.length) { | ||
| process.stdout.write(` issues:\n`); | ||
| for (const issue of report.issues) { | ||
| process.stdout.write(` - ${issue}\n`); | ||
| } | ||
| } else { | ||
| process.stdout.write(` checklist:\n`); | ||
| for (const item of report.checklist) { | ||
| process.stdout.write(` - ${item}\n`); | ||
| } | ||
| } | ||
| process.stdout.write(`\nNext: mcp-publisher login github && mcp-publisher publish\n`); | ||
| } catch (err) { | ||
| console.error(chalk.red('Error:'), err.message); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| // ─── mcp install ─── | ||
@@ -341,0 +462,0 @@ program |
+3
-1
| { | ||
| "name": "mindswap", | ||
| "version": "3.2.1", | ||
| "version": "3.2.2", | ||
| "mcpName": "io.github.shiporbleed/mindswap", | ||
| "description": "Your AI's black box recorder. Auto-track project state so any AI tool picks up where the last one stopped.", | ||
@@ -13,2 +14,3 @@ "main": "src/index.js", | ||
| "assets/", | ||
| "server.json", | ||
| "README.md", | ||
@@ -15,0 +17,0 @@ "LICENSE" |
+38
-198
@@ -6,226 +6,66 @@ # mindswap | ||
| **Your AI's black box recorder.** CLI + MCP server. | ||
| Keep project context in the repo so AI tools can continue work without re-explaining the same codebase. | ||
| One command captures your entire project state. Switch between Claude Code, Cursor, Copilot, Codex — the next AI picks up instantly. Zero re-explaining. | ||
| ## Why it exists | ||
| <p align="center"> | ||
| <img src="assets/demo.svg" alt="mindswap demo" width="680"> | ||
| </p> | ||
| AI sessions reset too often. mindswap saves the current task, decisions, blockers, and handoff context so the next tool can pick up cleanly. | ||
| ## Install | ||
| ```bash | ||
| npm install mindswap --save-dev | ||
| npx mindswap init # once — auto-detects everything | ||
| npx mindswap ask "Why did we choose JWT?" # answer from project memory with cited matches | ||
| npx mindswap # save state when switching tools | ||
| npx mindswap doctor # diagnose setup, context freshness, and gaps | ||
| npx mindswap mcp-install # enable MCP for Claude Code / Cursor | ||
| ``` | ||
| ## The problem | ||
| ## Quick start | ||
| You're mid-feature in Codex. Tokens run out. You switch to Claude Code. It has **zero context** — doesn't know your architecture, your decisions, or that you're halfway through implementing auth middleware. | ||
| You spend 20 minutes re-explaining. Every. Single. Time. | ||
| ## The solution | ||
| Just run `mindswap`. That's it. | ||
| ```bash | ||
| $ npx mindswap | ||
| ⚡ Saving project state... | ||
| Task: user auth (auto-detected from branch) | ||
| Branch: feat/user-auth | ||
| Changed: 4 files | ||
| Tests: ✓ 12 passed, 0 failed | ||
| Decisions: 2 auto-logged from deps | ||
| ✓ State saved — ready to switch tools | ||
| ``` | ||
| It auto-detects your task from the branch name, captures git state, logs dependency changes as decisions, and generates context files for **every AI tool**: | ||
| | AI Tool | Generated File | Behavior | | ||
| |---------|---------------|----------| | ||
| | Universal | `HANDOFF.md` | Full overwrite | | ||
| | Claude Code | `CLAUDE.md` | Safe merge | | ||
| | Cursor | `.cursor/rules/mindswap-context.mdc` | Own file | | ||
| | GitHub Copilot | `.github/copilot-instructions.md` | Safe merge | | ||
| | Codex | `CODEX.md` | Safe merge | | ||
| | Gemini CLI | `GEMINI.md` | Safe merge | | ||
| | Windsurf | `.windsurfrules` | Own file | | ||
| | Cline | `.cline/mindswap-context.md` | Own file | | ||
| | Roo Code | `.roo/rules/mindswap-context.md` | Own file | | ||
| | Aider | `CONVENTIONS.md` | Safe merge | | ||
| | Amp | `.amp/mindswap-context.md` | Own file | | ||
| | AGENTS.md | `AGENTS.md` | Safe merge | | ||
| ## The entire flow | ||
| ```bash | ||
| npx mindswap init # once per project | ||
| npx mindswap resume # get an action-oriented start-of-session briefing | ||
| npx mindswap doctor # sanity-check setup and context health | ||
| npx mindswap # when switching tools | ||
| npx mindswap done # when feature is complete | ||
| ``` | ||
| Everything else is automatic — git hooks track commits, dependencies are auto-logged, branch state is auto-managed. | ||
| ## 15 commands | ||
| | Command | Alias | What it does | | ||
| |---------|-------|-------------| | ||
| | `mindswap` | `save` | **THE one command.** Auto-detects task, deps, state — generates all context files | | ||
| | `mindswap init` | — | Initialize. Auto-detects 30+ frameworks, imports existing AI context files | | ||
| | `mindswap switch <tool>` | `sw` | One-command tool switch — save + generate + open (cursor/claude/copilot/codex/windsurf) | | ||
| | `mindswap done [msg]` | `d` | Mark task complete, archive to history, reset to idle | | ||
| | `mindswap log <msg>` | `l` | Log a memory item. Decisions warn on conflicts; use `--type` for blockers, assumptions, questions, and resolutions | | ||
| | `mindswap status` | `s` | Current state — task, branch, build/test, conflicts. `--stats` for charts | | ||
| | `mindswap doctor` | — | Diagnose setup, hook health, stale context files, conflicts, and missing continuity signals. `--json` for automation | | ||
| | `mindswap resume` | — | Action-oriented briefing — state, blockers, and the next best move. `--compact` / `--json` | | ||
| | `mindswap ask <question>` | — | Semantic question answering from project memory and history. `--json` for machine use | | ||
| | `mindswap contracts` | — | Emit machine-readable interface contracts for the current workstream | | ||
| | `mindswap sync` | — | Push, pull, or inspect shared hub state. `--push`, `--pull`, `--force`, `--hub` | | ||
| | `mindswap summary` | `sum` | Full session narrative — task, commits, decisions, conflicts. `--json` for scripts | | ||
| | `mindswap gen --all` | `gen` | Generate context files for all AI tools. Safe merge — never overwrites | | ||
| | `mindswap watch` | `w` | Background watcher — auto-updates HANDOFF.md, or all context files with `--all`; `--save` runs a full save cycle | | ||
| | `mindswap reset` | `r` | Clear task state. Decisions preserved. `--full` to clear everything | | ||
| ## Key features | ||
| ### Auto-everything | ||
| - **Task detection** — from branch name (`feat/user-auth` → "user auth") + recent commits | ||
| - **Dependency tracking** — added Stripe? Auto-logged. Removed Redis? Logged too. Works across JS/TS, Python, Go, Rust, and Ruby manifests. | ||
| - **Git hooks** — auto-saves state on every commit | ||
| ### Branch-aware state | ||
| Each git branch has its own state. Switch to `feat/payments` — it loads that branch's task and decisions. Switch back to `main` — your main state is restored. | ||
| ### Team mode | ||
| Set `MINDSWAP_TEAM=1` to make history author-aware and surface a team handoff section in generated context files. | ||
| ### Native session normalization | ||
| mindswap reads recent Claude Code and Codex session files, normalizes them into a structured model, and surfaces the last session's findings, blockers, and edited files in `HANDOFF.md` and MCP context. | ||
| ### Decision conflict detection | ||
| Log "NOT using Redis" then later "using Redis"? mindswap warns you. Also catches reversed choices and package.json contradictions. | ||
| ### Architectural guardrails | ||
| When your diff touches code that contradicts a recorded decision, mindswap warns you in `save`, `doctor`, generated handoff files, and MCP context. It is a proactive drift check, not just a static conflict log. | ||
| ### Interface contracts | ||
| `mindswap contracts` emits a machine-readable JSON contract for the active workstream, including boundaries, blockers, assumptions, and recent history so another agent can resume without re-reading the whole repo. | ||
| ### Shared sync | ||
| `mindswap sync` can push or pull a local JSON hub file so teams can share continuity state across machines. Conflicts are explicit, and `doctor` surfaces the sync health. | ||
| ### Structured memory | ||
| Not everything is a decision. mindswap now keeps structured memory for blockers, assumptions, open questions, and resolutions in `.mindswap/memory.json`, while keeping `decisions.log` for compatibility and conflict checks. | ||
| ```bash | ||
| npx mindswap log "Need prod webhook secret" --type blocker | ||
| npx mindswap log "Assume single-region rollout for MVP" --type assumption | ||
| npx mindswap log "Should we rotate refresh tokens?" --type question | ||
| npx mindswap log "Moved to JWT after auth review" --type resolution | ||
| ``` | ||
| Generated context files surface unresolved blockers and questions separately so the next AI does not have to infer them from free-form notes. | ||
| ### Continuity diagnostics | ||
| ```bash | ||
| npx mindswap init | ||
| npx mindswap | ||
| npx mindswap doctor | ||
| ``` | ||
| Checks whether mindswap is initialized correctly, whether generated handoff files are stale, whether git hooks are installed, whether AI-tool-specific context files are missing, and whether conflicts or weak continuity signals need attention. | ||
| ### Semantic ask | ||
| ```bash | ||
| npx mindswap resume | ||
| npx mindswap ask "Why did we choose JWT?" | ||
| ``` | ||
| Answers natural-language questions against decisions, history, memory, and recent session context, then cites the strongest matching project records. | ||
| ### Safe merge | ||
| Already have a CLAUDE.md? mindswap appends its section inside `<!-- mindswap:start/end -->` markers. Your content is never touched. | ||
| ## What it gives you | ||
| ### Build/test tracking | ||
| ```bash | ||
| npx mindswap --check # runs tests, captures results | ||
| # Tests: ✓ 47 passed, 0 failed | ||
| ``` | ||
| The next AI knows "tests were passing" or exactly what's broken. | ||
| - `init` to set up a repo and import existing AI context | ||
| - `save` to capture the current task, git state, and decisions | ||
| - `doctor` to check setup health and stale context | ||
| - `resume` to start with a clean briefing | ||
| - `ask` to search project memory with citations | ||
| - `memory` to manage blockers, assumptions, questions, and resolutions | ||
| - `sync` to share continuity state across machines | ||
| - `mcp` and `mcp-http` to expose the same context to AI clients | ||
| ### Auto-sync | ||
| `mindswap switch` records session start/end hooks when configured, and `mindswap watch --save --all` can run a full save + context refresh loop for deeper IDE/tool integration. | ||
| ## MCP and AI tools | ||
| ### 30+ frameworks detected | ||
| Next.js, Remix, Astro, SolidJS, Angular, NestJS, Express, Fastify, Hono, Django, FastAPI, Flask, Gin, Echo, GoFr, Fiber, Actix, Axum, Rails, Spring Boot, and more. Plus databases, monorepo tools, CI/CD, and infrastructure. | ||
| mindswap generates context for tools like Claude Code, Cursor, Copilot, Codex, Windsurf, Cline, Roo, Aider, Amp, Gemini CLI, and AGENTS.md-based workflows. | ||
| ## What lives in `.mindswap/` | ||
| ``` | ||
| .mindswap/ | ||
| ├── HANDOFF.md ← any AI reads this | ||
| ├── state.json ← machine-readable state | ||
| ├── decisions.log ← decision log (kept for compatibility + conflicts) | ||
| ├── memory.json ← structured memory: blockers, assumptions, questions, resolutions | ||
| ├── config.json ← your preferences | ||
| ├── branches/ ← per-branch state (auto) | ||
| └── history/ ← checkpoint timeline | ||
| ``` | ||
| **Commit these** (handoff context): `state.json`, `decisions.log`, `memory.json`, `config.json`, `HANDOFF.md` | ||
| **Don't commit** (auto-added to .gitignore): `history/`, `branches/` | ||
| ## MCP Server | ||
| AI tools can query mindswap natively via [Model Context Protocol](https://modelcontextprotocol.io/) instead of reading static files. 3 tools, stdio transport. | ||
| ```bash | ||
| npx mindswap mcp-install # auto-configures Claude Code, Cursor, VS Code | ||
| npx mindswap mcp-install | ||
| npx mindswap mcp-http | ||
| ``` | ||
| | MCP Tool | When AI calls it | What it returns | | ||
| |----------|-----------------|-----------------| | ||
| | `mindswap_get_context` | Session start — "What do I need to know?" | Synthesized briefing: task, decisions, conflicts, tests, recent work, native session findings | | ||
| | `mindswap_save_context` | Session end — "Here's what I did" | Persists summary, decisions, next steps, blockers | | ||
| | `mindswap_search` | Mid-session — "What did we decide about auth?" | Searches decisions + history + state | | ||
| ## Project state | ||
| Only 3 tools by design. [Research shows](https://dev.to/aws-heroes/mcp-tool-design-why-your-ai-agent-is-failing-and-how-to-fix-it-40fc) AI accuracy drops from 82% to 73% past 20 tools. We chose quality over quantity. | ||
| The main repo data lives in `.mindswap/`: | ||
| ## Security | ||
| All generated context files are scanned for secrets before writing: | ||
| - 25+ patterns: AWS keys, GitHub tokens, Stripe keys, OpenAI keys, DB URLs, private keys, JWT secrets, passwords | ||
| - Auto-redacted — secrets never reach your HANDOFF.md | ||
| - Placeholder-aware — skips `YOUR_KEY_HERE` patterns | ||
| ## PR Integration | ||
| ```bash | ||
| npx mindswap pr # adds context summary to your GitHub PR | ||
| ```text | ||
| .mindswap/ | ||
| ├── HANDOFF.md | ||
| ├── state.json | ||
| ├── decisions.log | ||
| ├── memory.json | ||
| ├── config.json | ||
| ├── branches/ | ||
| └── history/ | ||
| ``` | ||
| Auto-injects task, decisions, test status into the PR description with safe markers. | ||
| ## npm package | ||
| ## FAQ | ||
| - npm: https://www.npmjs.com/package/mindswap | ||
| - GitHub: https://github.com/ShipOrBleed/mindswap | ||
| **Will it overwrite my existing CLAUDE.md?** | ||
| No. Uses `<!-- mindswap:start/end -->` markers. Your content is preserved. | ||
| ## Use it | ||
| **Does it work with my AI tool?** | ||
| If it reads markdown files (all of them do), yes. | ||
| **Does it slow things down?** | ||
| No. Only runs when you call it. Git hook runs silently on commit. | ||
| **Multiple branches?** | ||
| State is auto per-branch. Switch branches, state switches too. | ||
| **What languages?** | ||
| JS/TS, Python, Go, Rust, Ruby, Java/Kotlin. Auto-detects from project files. | ||
| ## License | ||
| MIT | ||
| If mindswap helps your workflow, star the repo and keep improving the handoff loop. |
+18
-1
@@ -28,3 +28,5 @@ const { init } = require('./init'); | ||
| const { pr } = require('./pr'); | ||
| const { readMemory, appendMemoryItem, getMemoryItems } = require('./memory'); | ||
| const { createMCPServer, startMCPServer, startMCPHttpServer, manageMemory } = require('./mcp-server'); | ||
| const { buildRegistryManifest, writeRegistryManifest, readRegistryManifest, validateRegistryMetadata, buildRegistryReport } = require('./registry'); | ||
| const { readMemory, appendMemoryItem, getMemoryItems, getMemoryItemById, updateMemoryItem, resolveMemoryItem, archiveMemoryItem, deleteMemoryItem, listMemoryItems } = require('./memory'); | ||
| const { doctor } = require('./doctor'); | ||
@@ -72,6 +74,21 @@ | ||
| pr, | ||
| createMCPServer, | ||
| startMCPServer, | ||
| startMCPHttpServer, | ||
| manageMemory, | ||
| buildRegistryManifest, | ||
| writeRegistryManifest, | ||
| readRegistryManifest, | ||
| validateRegistryMetadata, | ||
| buildRegistryReport, | ||
| readMemory, | ||
| appendMemoryItem, | ||
| getMemoryItems, | ||
| getMemoryItemById, | ||
| updateMemoryItem, | ||
| resolveMemoryItem, | ||
| archiveMemoryItem, | ||
| deleteMemoryItem, | ||
| listMemoryItems, | ||
| doctor, | ||
| }; |
+636
-7
| const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); | ||
| const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); | ||
| const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); | ||
| const http = require('http'); | ||
| const { randomUUID } = require('crypto'); | ||
| const { z } = require('zod'); | ||
@@ -14,13 +17,25 @@ const fs = require('fs'); | ||
| const { importSessions } = require('./session-import'); | ||
| const { appendMemoryItem, getOpenMemoryItems, getRecentMemoryItems } = require('./memory'); | ||
| const { | ||
| MEMORY_TYPES, | ||
| MEMORY_STATUSES, | ||
| appendMemoryItem, | ||
| getMemoryItemById, | ||
| getOpenMemoryItems, | ||
| getRecentMemoryItems, | ||
| listMemoryItems, | ||
| readMemory, | ||
| updateMemoryItem, | ||
| resolveMemoryItem, | ||
| archiveMemoryItem, | ||
| deleteMemoryItem, | ||
| } = require('./memory'); | ||
| const { parseNativeSessions, getSessionSummary } = require('./session-parser'); | ||
| const { analyzeGuardrails, buildGuardrailSection } = require('./guardrails'); | ||
| const { buildResumeBriefing, gatherResumeData } = require('./resume'); | ||
| /** | ||
| * Start the mindswap MCP server. | ||
| * 3 tools. That's it. | ||
| * Core tools for context, saving, search, and structured memory. | ||
| */ | ||
| async function startMCPServer() { | ||
| const projectRoot = process.cwd(); | ||
| function createMCPServer(projectRoot) { | ||
| const server = new McpServer({ | ||
@@ -99,3 +114,186 @@ name: 'mindswap', | ||
| // Start the server | ||
| // ═══════════════════════════════════════════════════ | ||
| // TOOL 4: mindswap_memory | ||
| // Structured memory CRUD for blockers, questions, assumptions, resolutions. | ||
| // ═══════════════════════════════════════════════════ | ||
| server.tool( | ||
| 'mindswap_memory', | ||
| `Manage structured memory items. Use this to list, add, update, resolve, archive, or delete blockers, assumptions, questions, and resolutions.`, | ||
| { | ||
| action: z.enum(['list', 'get', 'add', 'update', 'resolve', 'archive', 'delete']) | ||
| .describe('Operation to perform on memory'), | ||
| id: z.string().optional() | ||
| .describe('Memory item id for get/update/resolve/archive/delete'), | ||
| type: z.enum([...MEMORY_TYPES]).optional() | ||
| .describe('Memory type for add or filtering'), | ||
| message: z.string().optional() | ||
| .describe('Message for add/update'), | ||
| tag: z.string().optional() | ||
| .describe('Tag for add/update or filtering'), | ||
| status: z.enum([...MEMORY_STATUSES]).optional() | ||
| .describe('Status for add/update or filtering'), | ||
| author: z.string().optional() | ||
| .describe('Author for add/update or filtering'), | ||
| source: z.string().optional() | ||
| .describe('Source for add/update or filtering'), | ||
| limit: z.number().int().positive().max(200).optional() | ||
| .describe('Max number of items to return when listing'), | ||
| after: z.string().optional() | ||
| .describe('Only include items created after this timestamp'), | ||
| before: z.string().optional() | ||
| .describe('Only include items created before this timestamp'), | ||
| hard: z.boolean().default(false) | ||
| .describe('Hard delete instead of archiving'), | ||
| json: z.boolean().default(false) | ||
| .describe('Return JSON instead of formatted text'), | ||
| }, | ||
| async (args) => { | ||
| return manageMemory(projectRoot, args); | ||
| } | ||
| ); | ||
| // ═══════════════════════════════════════════════════ | ||
| // RESOURCES: stable read-only artifacts for clients | ||
| // ═══════════════════════════════════════════════════ | ||
| server.registerResource( | ||
| 'mindswap_context_current', | ||
| 'mindswap://context/current', | ||
| { | ||
| title: 'Current Context', | ||
| description: 'The current synthesized project context in text form.', | ||
| }, | ||
| async () => readStableResource(projectRoot, 'context') | ||
| ); | ||
| server.registerResource( | ||
| 'mindswap_state_current', | ||
| 'mindswap://state/current', | ||
| { | ||
| title: 'Current State', | ||
| description: 'The current machine-readable mindswap state as JSON.', | ||
| }, | ||
| async () => readStableResource(projectRoot, 'state') | ||
| ); | ||
| server.registerResource( | ||
| 'mindswap_decisions_recent', | ||
| 'mindswap://decisions/recent', | ||
| { | ||
| title: 'Recent Decisions', | ||
| description: 'Recent decisions and conflict signals as JSON.', | ||
| }, | ||
| async () => readStableResource(projectRoot, 'decisions') | ||
| ); | ||
| server.registerResource( | ||
| 'mindswap_memory_current', | ||
| 'mindswap://memory/current', | ||
| { | ||
| title: 'Structured Memory', | ||
| description: 'All structured memory items as JSON.', | ||
| }, | ||
| async () => readStableResource(projectRoot, 'memory') | ||
| ); | ||
| server.registerResource( | ||
| 'mindswap_handoff_current', | ||
| 'mindswap://handoff/current', | ||
| { | ||
| title: 'Current Handoff', | ||
| description: 'The generated HANDOFF.md content or a synthesized fallback.', | ||
| }, | ||
| async () => readStableResource(projectRoot, 'handoff') | ||
| ); | ||
| // ═══════════════════════════════════════════════════ | ||
| // PROMPTS: workflow templates for common handoff actions | ||
| // ═══════════════════════════════════════════════════ | ||
| server.registerPrompt( | ||
| 'mindswap_start_work', | ||
| { | ||
| title: 'Start Work', | ||
| description: 'Prepare a focused prompt for continuing active work in this repo.', | ||
| argsSchema: { | ||
| goal: z.string().optional().describe('Optional goal or feature to focus on'), | ||
| tool: z.string().optional().describe('Optional AI tool name for wording adjustments'), | ||
| compact: z.string().optional().describe('Set to "true" for a shorter prompt body'), | ||
| }, | ||
| }, | ||
| async ({ goal, tool, compact }) => ({ | ||
| messages: [{ | ||
| role: 'user', | ||
| content: { | ||
| type: 'text', | ||
| text: buildStartWorkPrompt(projectRoot, { goal, tool, compact: String(compact).toLowerCase() === 'true' }), | ||
| }, | ||
| }], | ||
| }) | ||
| ); | ||
| server.registerPrompt( | ||
| 'mindswap_resume_work', | ||
| { | ||
| title: 'Resume Work', | ||
| description: 'Prepare a restart prompt that emphasizes blockers and the next best action.', | ||
| argsSchema: { | ||
| compact: z.string().optional().describe('Set to "true" for a shorter prompt body'), | ||
| }, | ||
| }, | ||
| async ({ compact }) => ({ | ||
| messages: [{ | ||
| role: 'user', | ||
| content: { | ||
| type: 'text', | ||
| text: buildResumeWorkPrompt(projectRoot, { compact: String(compact).toLowerCase() === 'true' }), | ||
| }, | ||
| }], | ||
| }) | ||
| ); | ||
| server.registerPrompt( | ||
| 'mindswap_prepare_handoff', | ||
| { | ||
| title: 'Prepare Handoff', | ||
| description: 'Generate a handoff prompt that asks for the exact summary another agent needs.', | ||
| argsSchema: { | ||
| audience: z.string().optional().describe('Optional recipient or tool name'), | ||
| }, | ||
| }, | ||
| async ({ audience }) => ({ | ||
| messages: [{ | ||
| role: 'user', | ||
| content: { | ||
| type: 'text', | ||
| text: buildHandoffPrompt(projectRoot, { audience }), | ||
| }, | ||
| }], | ||
| }) | ||
| ); | ||
| server.registerPrompt( | ||
| 'mindswap_review_conflicts', | ||
| { | ||
| title: 'Review Conflicts', | ||
| description: 'Review decision and dependency conflicts before making changes.', | ||
| argsSchema: { | ||
| focus: z.string().optional().describe('Optional area to focus on, such as auth or database'), | ||
| }, | ||
| }, | ||
| async ({ focus }) => ({ | ||
| messages: [{ | ||
| role: 'user', | ||
| content: { | ||
| type: 'text', | ||
| text: buildConflictReviewPrompt(projectRoot, { focus }), | ||
| }, | ||
| }], | ||
| }) | ||
| ); | ||
| return server; | ||
| } | ||
| async function startMCPServer() { | ||
| const projectRoot = process.cwd(); | ||
| const server = createMCPServer(projectRoot); | ||
| const transport = new StdioServerTransport(); | ||
@@ -105,2 +303,133 @@ await server.connect(transport); | ||
| async function startMCPHttpServer(options = {}) { | ||
| const projectRoot = options.projectRoot || process.cwd(); | ||
| const server = createMCPServer(projectRoot); | ||
| const host = options.host || '127.0.0.1'; | ||
| const port = options.port ?? 3000; | ||
| const pathName = normalizeHttpPath(options.path || '/mcp'); | ||
| const allowedOrigin = options.origin || '*'; | ||
| const token = options.token || null; | ||
| const transports = new Map(); | ||
| const httpServer = http.createServer(async (req, res) => { | ||
| try { | ||
| if (req.method === 'OPTIONS') { | ||
| applyCorsHeaders(res, allowedOrigin); | ||
| res.writeHead(204); | ||
| res.end(); | ||
| return; | ||
| } | ||
| if (normalizeHttpPath(req.url || '/') !== pathName) { | ||
| res.writeHead(404, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ | ||
| jsonrpc: '2.0', | ||
| error: { code: -32601, message: 'Not found.' }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| if (token && !requestHasValidToken(req, token)) { | ||
| applyCorsHeaders(res, allowedOrigin); | ||
| res.writeHead(401, { | ||
| 'content-type': 'application/json', | ||
| 'www-authenticate': 'Bearer realm="mindswap"', | ||
| }); | ||
| res.end(JSON.stringify({ | ||
| jsonrpc: '2.0', | ||
| error: { code: -32001, message: 'Unauthorized.' }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| applyCorsHeaders(res, allowedOrigin); | ||
| const body = await readRequestBody(req); | ||
| let parsedBody; | ||
| if (body) { | ||
| try { | ||
| parsedBody = JSON.parse(body); | ||
| } catch { | ||
| res.writeHead(400, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ | ||
| jsonrpc: '2.0', | ||
| error: { code: -32700, message: 'Invalid JSON body.' }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| } | ||
| const sessionId = req.headers['mcp-session-id']; | ||
| let transport = sessionId ? transports.get(String(sessionId)) : null; | ||
| if (!transport) { | ||
| const isInitialize = parsedBody && isInitializeRequest(parsedBody); | ||
| if (!sessionId && isInitialize) { | ||
| transport = new StreamableHTTPServerTransport({ | ||
| sessionIdGenerator: () => randomUUID(), | ||
| enableJsonResponse: true, | ||
| onsessioninitialized: newSessionId => { | ||
| transports.set(String(newSessionId), transport); | ||
| }, | ||
| onsessionclosed: closedSessionId => { | ||
| transports.delete(String(closedSessionId)); | ||
| }, | ||
| }); | ||
| await server.connect(transport); | ||
| } else if (!sessionId) { | ||
| res.writeHead(400, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ | ||
| jsonrpc: '2.0', | ||
| error: { code: -32000, message: 'Bad Request: no session ID provided.' }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } else { | ||
| res.writeHead(404, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ | ||
| jsonrpc: '2.0', | ||
| error: { code: -32000, message: 'Unknown session.' }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| } | ||
| await transport.handleRequest(req, res, parsedBody); | ||
| } catch (err) { | ||
| if (!res.headersSent) { | ||
| res.writeHead(500, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify({ | ||
| jsonrpc: '2.0', | ||
| error: { code: -32603, message: err.message || 'Internal server error' }, | ||
| id: null, | ||
| })); | ||
| } | ||
| } | ||
| }); | ||
| await new Promise((resolve, reject) => { | ||
| httpServer.once('error', reject); | ||
| httpServer.listen(port, host, resolve); | ||
| }); | ||
| const address = httpServer.address(); | ||
| const actualPort = typeof address === 'object' && address ? address.port : port; | ||
| const localUrl = `http://${host}:${actualPort}${pathName}`; | ||
| return { | ||
| server: httpServer, | ||
| url: localUrl, | ||
| close: async () => { | ||
| for (const transport of transports.values()) { | ||
| await transport.close().catch(() => {}); | ||
| } | ||
| await new Promise(resolve => httpServer.close(() => resolve())); | ||
| }, | ||
| }; | ||
| } | ||
| // ═══════════════════════════════════════════════════ | ||
@@ -470,2 +799,263 @@ // Tool implementations | ||
| function renderContextText(projectRoot, focus = 'all', compact = false) { | ||
| const context = getContext(projectRoot, focus, compact); | ||
| return context?.content?.[0]?.text || ''; | ||
| } | ||
| function readStableResource(projectRoot, kind) { | ||
| const state = readState(projectRoot); | ||
| const liveData = gatherLiveData(projectRoot); | ||
| const memory = readMemory(projectRoot); | ||
| const handoffPath = path.join(projectRoot, 'HANDOFF.md'); | ||
| const handoffText = fs.existsSync(handoffPath) ? fs.readFileSync(handoffPath, 'utf-8') : renderContextText(projectRoot, 'all', false); | ||
| switch (kind) { | ||
| case 'context': | ||
| return buildTextResource('mindswap://context/current', renderContextText(projectRoot, 'all', false)); | ||
| case 'state': | ||
| return buildJsonResource('mindswap://state/current', state); | ||
| case 'decisions': | ||
| return buildJsonResource('mindswap://decisions/recent', { | ||
| decisions: liveData.decisions.slice(-20), | ||
| conflicts: findAllConflicts(projectRoot), | ||
| dependency_conflicts: checkDepsVsDecisions(projectRoot), | ||
| }); | ||
| case 'memory': | ||
| return buildJsonResource('mindswap://memory/current', memory); | ||
| case 'handoff': | ||
| return buildTextResource('mindswap://handoff/current', handoffText); | ||
| default: | ||
| throw new Error(`unknown resource kind: ${kind}`); | ||
| } | ||
| } | ||
| function buildTextResource(uri, text) { | ||
| return { | ||
| contents: [{ | ||
| uri, | ||
| mimeType: 'text/plain', | ||
| text, | ||
| }], | ||
| }; | ||
| } | ||
| function buildJsonResource(uri, value) { | ||
| return { | ||
| contents: [{ | ||
| uri, | ||
| mimeType: 'application/json', | ||
| text: JSON.stringify(value, null, 2), | ||
| }], | ||
| }; | ||
| } | ||
| function normalizeHttpPath(value) { | ||
| let pathname = String(value || '/').split('?')[0] || '/'; | ||
| if (!pathname.startsWith('/')) pathname = `/${pathname}`; | ||
| if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.replace(/\/+$/, ''); | ||
| return pathname || '/'; | ||
| } | ||
| function applyCorsHeaders(res, origin) { | ||
| res.setHeader('Access-Control-Allow-Origin', origin); | ||
| res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); | ||
| res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Session-Id, Last-Event-ID'); | ||
| res.setHeader('Access-Control-Expose-Headers', 'MCP-Session-Id, MCP-Protocol-Version'); | ||
| } | ||
| function requestHasValidToken(req, token) { | ||
| const auth = req.headers.authorization || req.headers.Authorization; | ||
| if (!auth) return false; | ||
| const [scheme, value] = String(auth).split(/\s+/, 2); | ||
| return /^bearer$/i.test(scheme) && value === token; | ||
| } | ||
| function isInitializeRequest(body) { | ||
| return Boolean(body && body.method === 'initialize'); | ||
| } | ||
| function readRequestBody(req) { | ||
| return new Promise((resolve, reject) => { | ||
| if (req.method === 'GET' || req.method === 'DELETE' || req.method === 'HEAD') { | ||
| resolve(''); | ||
| return; | ||
| } | ||
| const chunks = []; | ||
| req.on('data', chunk => chunks.push(Buffer.from(chunk))); | ||
| req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); | ||
| req.on('error', reject); | ||
| }); | ||
| } | ||
| function buildStartWorkPrompt(projectRoot, { goal, tool, compact } = {}) { | ||
| const contextText = renderContextText(projectRoot, compact ? 'task' : 'all', Boolean(compact)); | ||
| const lines = ['You are starting work in this repository.']; | ||
| if (tool) lines.push(`Target tool: ${tool}.`); | ||
| if (goal) lines.push(`Goal: ${goal}.`); | ||
| lines.push(''); | ||
| lines.push('Use the context below, identify the next safe action, and call out blockers before suggesting implementation steps.'); | ||
| if (contextText) { | ||
| lines.push(''); | ||
| lines.push(contextText); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function buildResumeWorkPrompt(projectRoot, { compact } = {}) { | ||
| const briefing = buildResumeBriefing(readState(projectRoot), gatherResumeData(projectRoot), { compact }); | ||
| const lines = [ | ||
| 'Resume this workstream from the current repo state.', | ||
| '', | ||
| briefing.summary, | ||
| '', | ||
| 'State:', | ||
| ...briefing.stateLines.map(line => `- ${line}`), | ||
| '', | ||
| 'Recommendation:', | ||
| `- ${briefing.recommendation.summary}`, | ||
| ...briefing.recommendation.next_steps.map(step => `- ${step}`), | ||
| ]; | ||
| if (briefing.recommendation.command) { | ||
| lines.push(`- Next command: ${briefing.recommendation.command}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function buildHandoffPrompt(projectRoot, { audience } = {}) { | ||
| const contextText = renderContextText(projectRoot, 'all', false); | ||
| const lines = [ | ||
| audience ? `Prepare a handoff for ${audience}.` : 'Prepare a handoff for the next agent.', | ||
| 'Summarize what changed, what is still open, and what should happen next.', | ||
| 'Include files, commands, blockers, and any unresolved decisions.', | ||
| ]; | ||
| if (contextText) { | ||
| lines.push(''); | ||
| lines.push(contextText); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function buildConflictReviewPrompt(projectRoot, { focus } = {}) { | ||
| const contextText = renderContextText(projectRoot, 'decisions', false); | ||
| const lines = [ | ||
| focus ? `Review conflicts with a focus on ${focus}.` : 'Review the current decision and dependency conflicts.', | ||
| 'Identify contradictions, explain the impact, and propose the smallest safe resolution.', | ||
| ]; | ||
| if (contextText) { | ||
| lines.push(''); | ||
| lines.push(contextText); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function manageMemory(projectRoot, opts = {}) { | ||
| const dataDir = getDataDir(projectRoot); | ||
| if (!fs.existsSync(dataDir)) { | ||
| return { | ||
| content: [{ type: 'text', text: 'mindswap not initialized. Run `npx mindswap init` first.' }], | ||
| }; | ||
| } | ||
| const action = String(opts.action || '').toLowerCase(); | ||
| const now = new Date().toISOString(); | ||
| let result = null; | ||
| switch (action) { | ||
| case 'list': { | ||
| const items = listMemoryItems(projectRoot, { | ||
| type: opts.type, | ||
| status: opts.status, | ||
| author: opts.author, | ||
| source: opts.source, | ||
| created_after: opts.after, | ||
| created_before: opts.before, | ||
| includeArchived: opts.status === 'archived' || opts.hard === true, | ||
| limit: opts.limit || 20, | ||
| }); | ||
| result = { action, count: items.length, items }; | ||
| break; | ||
| } | ||
| case 'get': { | ||
| if (!opts.id) throw new Error('memory get requires an id'); | ||
| const item = getMemoryItemById(projectRoot, opts.id); | ||
| result = item ? { action, item } : { action, item: null }; | ||
| break; | ||
| } | ||
| case 'add': { | ||
| if (!opts.message) throw new Error('memory add requires a message'); | ||
| const item = appendMemoryItem(projectRoot, { | ||
| type: opts.type || 'decision', | ||
| tag: opts.tag || 'general', | ||
| message: opts.message, | ||
| status: opts.status || undefined, | ||
| author: opts.author || null, | ||
| source: opts.source || 'cli', | ||
| created_at: now, | ||
| }); | ||
| result = { action, item }; | ||
| break; | ||
| } | ||
| case 'update': { | ||
| if (!opts.id) throw new Error('memory update requires an id'); | ||
| const item = updateMemoryItem(projectRoot, opts.id, { | ||
| type: opts.type, | ||
| tag: opts.tag, | ||
| message: opts.message, | ||
| status: opts.status, | ||
| author: opts.author, | ||
| source: opts.source, | ||
| updated_at: now, | ||
| }); | ||
| if (!item) throw new Error(`memory item not found: ${opts.id}`); | ||
| result = { action, item }; | ||
| break; | ||
| } | ||
| case 'resolve': { | ||
| if (!opts.id) throw new Error('memory resolve requires an id'); | ||
| const item = resolveMemoryItem(projectRoot, opts.id, { | ||
| message: opts.message, | ||
| tag: opts.tag, | ||
| author: opts.author, | ||
| source: opts.source, | ||
| resolved_at: now, | ||
| }); | ||
| if (!item) throw new Error(`memory item not found: ${opts.id}`); | ||
| result = { action, item }; | ||
| break; | ||
| } | ||
| case 'archive': { | ||
| if (!opts.id) throw new Error('memory archive requires an id'); | ||
| const item = archiveMemoryItem(projectRoot, opts.id, { | ||
| message: opts.message, | ||
| tag: opts.tag, | ||
| author: opts.author, | ||
| source: opts.source, | ||
| archived_at: now, | ||
| }); | ||
| if (!item) throw new Error(`memory item not found: ${opts.id}`); | ||
| result = { action, item }; | ||
| break; | ||
| } | ||
| case 'delete': { | ||
| if (!opts.id) throw new Error('memory delete requires an id'); | ||
| const item = deleteMemoryItem(projectRoot, opts.id, { hard: Boolean(opts.hard), archived_at: now }); | ||
| if (!item) throw new Error(`memory item not found: ${opts.id}`); | ||
| result = { action, item, deleted: Boolean(opts.hard) }; | ||
| break; | ||
| } | ||
| default: | ||
| throw new Error(`unknown memory action: ${opts.action}`); | ||
| } | ||
| const text = opts.json | ||
| ? JSON.stringify(result, null, 2) | ||
| : formatMemoryResult(result); | ||
| return { | ||
| content: [{ type: 'text', text }], | ||
| }; | ||
| } | ||
| // ═══════════════════════════════════════════════════ | ||
@@ -592,2 +1182,41 @@ // Helper — gather live project data | ||
| module.exports = { startMCPServer, searchContext, tokenize, scoreText, formatMemorySection }; | ||
| function formatMemoryResult(result) { | ||
| if (!result) return 'No memory result.'; | ||
| if (result.action === 'list') { | ||
| const lines = [`Memory items: ${result.count}`]; | ||
| for (const item of result.items || []) { | ||
| lines.push(`- [${item.type}/${item.status}] ${item.id}: ${item.message}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| if (result.item) { | ||
| const item = result.item; | ||
| return [ | ||
| `${result.action.toUpperCase()} memory item`, | ||
| `- id: ${item.id}`, | ||
| `- type: ${item.type}`, | ||
| `- status: ${item.status}`, | ||
| `- tag: ${item.tag}`, | ||
| `- message: ${item.message}`, | ||
| item.source ? `- source: ${item.source}` : null, | ||
| item.author ? `- author: ${item.author}` : null, | ||
| ].filter(Boolean).join('\n'); | ||
| } | ||
| return `${result.action} complete.`; | ||
| } | ||
| module.exports = { | ||
| createMCPServer, | ||
| startMCPServer, | ||
| startMCPHttpServer, | ||
| manageMemory, | ||
| searchContext, | ||
| tokenize, | ||
| scoreText, | ||
| formatMemorySection, | ||
| readStableResource, | ||
| buildStartWorkPrompt, | ||
| buildResumeWorkPrompt, | ||
| buildHandoffPrompt, | ||
| buildConflictReviewPrompt, | ||
| }; |
+156
-3
@@ -7,2 +7,3 @@ const fs = require('fs'); | ||
| const MEMORY_TYPES = new Set(['decision', 'blocker', 'assumption', 'question', 'resolution']); | ||
| const MEMORY_STATUSES = new Set(['open', 'resolved', 'archived']); | ||
@@ -51,3 +52,9 @@ function getMemoryPath(projectRoot) { | ||
| const now = item.created_at || new Date().toISOString(); | ||
| const status = item.status || (normalizedType === 'resolution' ? 'resolved' : 'open'); | ||
| const status = normalizeStatus(item.status || (normalizedType === 'resolution' ? 'resolved' : 'open')); | ||
| const resolvedAt = status === 'resolved' | ||
| ? (item.resolved_at || now) | ||
| : (item.resolved_at || null); | ||
| const archivedAt = status === 'archived' | ||
| ? (item.archived_at || now) | ||
| : (item.archived_at || null); | ||
| const entry = { | ||
@@ -60,4 +67,7 @@ id: item.id || generateId(), | ||
| created_at: now, | ||
| resolved_at: item.resolved_at || (status === 'resolved' ? now : null), | ||
| updated_at: item.updated_at || now, | ||
| resolved_at: resolvedAt, | ||
| archived_at: archivedAt, | ||
| source: item.source || 'cli', | ||
| author: item.author || null, | ||
| metadata: item.metadata || {}, | ||
@@ -79,7 +89,33 @@ }; | ||
| } | ||
| if (opts.id) { | ||
| const ids = Array.isArray(opts.id) ? opts.id : [opts.id]; | ||
| items = items.filter(item => ids.includes(item.id)); | ||
| } | ||
| if (opts.status) { | ||
| items = items.filter(item => item.status === opts.status); | ||
| } | ||
| if (opts.source) { | ||
| const sources = Array.isArray(opts.source) ? opts.source : [opts.source]; | ||
| items = items.filter(item => sources.includes(item.source)); | ||
| } | ||
| if (opts.author) { | ||
| const authors = Array.isArray(opts.author) ? opts.author : [opts.author]; | ||
| items = items.filter(item => authors.includes(item.author)); | ||
| } | ||
| if (opts.created_after) { | ||
| const after = timestampValue(opts.created_after); | ||
| items = items.filter(item => timestampValue(item.created_at) >= after); | ||
| } | ||
| if (opts.created_before) { | ||
| const before = timestampValue(opts.created_before); | ||
| items = items.filter(item => timestampValue(item.created_at) <= before); | ||
| } | ||
| if (!opts.includeArchived) { | ||
| items = items.filter(item => item.status !== 'archived'); | ||
| } | ||
| if (opts.limit) { | ||
| items = items.slice(-opts.limit); | ||
| const limit = Number(opts.limit); | ||
| if (Number.isFinite(limit) && limit > 0) { | ||
| items = items.slice(-limit); | ||
| } | ||
| } | ||
@@ -98,2 +134,92 @@ | ||
| function getMemoryItemById(projectRoot, id) { | ||
| if (!id) return null; | ||
| return getMemoryItems(projectRoot, { includeArchived: true }).find(item => item.id === id) || null; | ||
| } | ||
| function updateMemoryItem(projectRoot, id, updates = {}) { | ||
| const memory = readMemory(projectRoot); | ||
| const index = memory.items.findIndex(item => item.id === id); | ||
| if (index === -1) return null; | ||
| const current = memory.items[index]; | ||
| const next = { ...current }; | ||
| const now = updates.updated_at || new Date().toISOString(); | ||
| if (updates.type) next.type = normalizeType(updates.type); | ||
| if (updates.tag !== undefined) next.tag = updates.tag; | ||
| if (updates.message !== undefined) next.message = updates.message; | ||
| if (updates.source !== undefined) next.source = updates.source; | ||
| if (updates.author !== undefined) next.author = updates.author; | ||
| if (updates.metadata !== undefined) { | ||
| next.metadata = mergeMetadata(current.metadata, updates.metadata); | ||
| } | ||
| if (updates.status !== undefined) { | ||
| next.status = normalizeStatus(updates.status, current.status); | ||
| } | ||
| if (updates.resolved_at !== undefined) { | ||
| next.resolved_at = updates.resolved_at; | ||
| } | ||
| if (updates.archived_at !== undefined) { | ||
| next.archived_at = updates.archived_at; | ||
| } | ||
| if (next.status === 'resolved' && !next.resolved_at) { | ||
| next.resolved_at = now; | ||
| } | ||
| if (next.status === 'archived' && !next.archived_at) { | ||
| next.archived_at = now; | ||
| } | ||
| if (next.status !== 'resolved' && updates.resolved_at === null) { | ||
| next.resolved_at = null; | ||
| } | ||
| if (next.status !== 'archived' && updates.archived_at === null) { | ||
| next.archived_at = null; | ||
| } | ||
| next.updated_at = now; | ||
| memory.items[index] = next; | ||
| writeMemory(projectRoot, memory); | ||
| return next; | ||
| } | ||
| function resolveMemoryItem(projectRoot, id, updates = {}) { | ||
| const resolvedAt = updates.resolved_at || new Date().toISOString(); | ||
| return updateMemoryItem(projectRoot, id, { | ||
| ...updates, | ||
| status: 'resolved', | ||
| resolved_at: resolvedAt, | ||
| }); | ||
| } | ||
| function archiveMemoryItem(projectRoot, id, updates = {}) { | ||
| const archivedAt = updates.archived_at || new Date().toISOString(); | ||
| return updateMemoryItem(projectRoot, id, { | ||
| ...updates, | ||
| status: 'archived', | ||
| archived_at: archivedAt, | ||
| }); | ||
| } | ||
| function deleteMemoryItem(projectRoot, id, opts = {}) { | ||
| const memory = readMemory(projectRoot); | ||
| const index = memory.items.findIndex(item => item.id === id); | ||
| if (index === -1) return null; | ||
| const item = memory.items[index]; | ||
| if (opts.hard) { | ||
| memory.items.splice(index, 1); | ||
| writeMemory(projectRoot, memory); | ||
| return item; | ||
| } | ||
| return archiveMemoryItem(projectRoot, id, { | ||
| archived_at: opts.archived_at || new Date().toISOString(), | ||
| }); | ||
| } | ||
| function listMemoryItems(projectRoot, opts = {}) { | ||
| return getMemoryItems(projectRoot, opts); | ||
| } | ||
| function normalizeType(type) { | ||
@@ -103,2 +229,6 @@ return MEMORY_TYPES.has(type) ? type : 'decision'; | ||
| function normalizeStatus(status, fallback = 'open') { | ||
| return MEMORY_STATUSES.has(status) ? status : fallback; | ||
| } | ||
| function generateId() { | ||
@@ -108,5 +238,21 @@ return Math.random().toString(36).slice(2, 10); | ||
| function timestampValue(value) { | ||
| const parsed = Date.parse(value || ''); | ||
| return Number.isNaN(parsed) ? 0 : parsed; | ||
| } | ||
| function mergeMetadata(existing, incoming) { | ||
| if (!existing || typeof existing !== 'object' || Array.isArray(existing)) { | ||
| return incoming && typeof incoming === 'object' && !Array.isArray(incoming) ? { ...incoming } : {}; | ||
| } | ||
| if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { | ||
| return { ...existing }; | ||
| } | ||
| return { ...existing, ...incoming }; | ||
| } | ||
| module.exports = { | ||
| MEMORY_FILE, | ||
| MEMORY_TYPES, | ||
| MEMORY_STATUSES, | ||
| getMemoryPath, | ||
@@ -121,3 +267,10 @@ getDefaultMemory, | ||
| getRecentMemoryItems, | ||
| getMemoryItemById, | ||
| updateMemoryItem, | ||
| resolveMemoryItem, | ||
| archiveMemoryItem, | ||
| deleteMemoryItem, | ||
| listMemoryItems, | ||
| normalizeType, | ||
| normalizeStatus, | ||
| }; |
Network access
Supply chain riskThis module accesses the network.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
328737
9.03%39
5.41%8135
14.3%71
-69.26%46
2.22%7
16.67%