diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4a22897..f2ebb47 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -161,6 +161,8 @@ host-specific UI/activation): | `bindery.sessionFocusShow` / `session-focus-show` | ✅ | ✅ | Show working state from SESSION.md (optionally one section) | | `bindery.sessionFocusUpdate` / `session-focus-update` | ✅ | ✅ | Update a neutral SESSION.md section (replace/append) | | `bindery.sessionFocusAppendHandoff` / `session-focus-append-handoff` | ✅ | ✅ | Append a handoff note to SESSION.md | +| `bindery.inboxProcess` / `inbox-process` | ✅ | ✅ | Enumerate Notes/Inbox.md items and propose destinations (read-only) | +| `bindery.inboxResolve` / `inbox-resolve` | ✅ | ✅ | Remove already-routed inbox items by number | | `bindery.registerMcp` | ✅ | — | Write .vscode/mcp.json for Claude/Codex MCP discovery (VS Code-only) | | `bindery.showMcpConfig` | — | ✅ | Display MCP configuration snippet (Obsidian-only) | @@ -181,9 +183,9 @@ Skills: `review`, `brainstorm`, `memory`, `translate`, `translation-review`, `st The **memory skill** uses `memory_list` → `memory_append` → `memory_compact` for session decisions and `note_list` / `note_get` / `note_create` / `note_append` for canonical story notes. Do not fall back to `get_text` + Edit tool for memory or note writes when a structured tool exists. -The split: durable story/project decisions → `memory_*` (`.bindery/memories/`); ephemeral working state (current focus, next actions, open questions, handoff) → `session_focus_*` (`SESSION.md`); durable working preferences → `PREFERENCES.md` (user-owned, never tool-written — propose changes instead). +The split: durable story/project decisions → `memory_*` (`.bindery/memories/`); ephemeral working state (current focus, next actions, open questions, handoff) → `session_focus_*` (`SESSION.md`); durable working preferences → `PREFERENCES.md` (user-owned, never tool-written — propose changes instead); rough/unsorted/pasted material → `Notes/Inbox.md`, triaged with `inbox_process` / `inbox_resolve` (not memory). -Current authoring-tool boundary: note, character, arc, memory, chapter-status, and session-focus MCP/LM tools exist and have matching VS Code/Obsidian host command wrappers. `session_focus_update` only touches neutral SESSION.md sections. Inbox-processing tools/commands are still planned. +Current authoring-tool boundary: note, character, arc, memory, chapter-status, session-focus, and inbox-triage MCP/LM tools exist and have matching VS Code/Obsidian host command wrappers. `session_focus_update` only touches neutral SESSION.md sections; `inbox_process` only proposes and `inbox_resolve` only removes named items (route confirmed items with the destination tools first). ### AI setup versioning `FILE_VERSION_INFO` in `bindery-core/src/templates.ts` is a per-file version table/map (a Record keyed by output path) that controls staleness detection. diff --git a/README.md b/README.md index 387b708..043e413 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes - **Story note management** — agents can list, read, create, and append notes under the configured notes folder while `get_notes` remains compatible with older recursive note layouts - **Session memory** — persistent `.bindery/memories/` files for cross-session decisions, with append, list, and compact operations - **Session focus** — `session_focus_*` tools maintain the ephemeral working-state file `SESSION.md` (current focus, next actions, open questions, handoff); durable preferences live in the user-owned `PREFERENCES.md` +- **Inbox triage** — `inbox_process` enumerates loose items in `Notes/Inbox.md` and proposes destinations (read-only); `inbox_resolve` clears items after they are routed and confirmed — rough/pasted material goes to the Inbox, not memory - **Chapter status tracking** — per-chapter progress tracker (`draft`, `in-progress`, `done`, `needs-review`) - **Structured arc & character management** — agents can create/update arc files and character profiles using structured tools that keep indexes in sync - **Host command parity** — VS Code and Obsidian expose command-palette actions for notes, characters, arcs, memory, and chapter status, backed by the same structured tool functions agents use diff --git a/bindery-core/src/templates/agents.ts b/bindery-core/src/templates/agents.ts index 2af5121..fc46595 100644 --- a/bindery-core/src/templates/agents.ts +++ b/bindery-core/src/templates/agents.ts @@ -2,7 +2,7 @@ import { audienceNote, languageSection, type TemplateContext, type TemplateMeta export const meta: TemplateMeta = { file: 'AGENTS.md', - version: 10, + version: 11, label: 'agents instructions', zip: null, }; @@ -35,6 +35,7 @@ export function render(ctx: TemplateContext): string { '- Shared workflows live in `.claude/skills/` and can be used by agents beyond Claude when the runtime supports workspace skills.', '- Prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, and `/character-setup` when the user is asking for one of those structured tasks.', '- Use `arc_*` tools for story structure, `character_*` tools for cast profiles, `note_*` tools for story notes, `memory_*` tools for session decisions, `chapter_status_*` tools for progress, and `session_focus_*` tools for current working state. PREFERENCES.md is user-owned — propose changes rather than writing it.', + '- Send rough, unsorted, or pasted material to `Notes/Inbox.md`, then triage with `inbox_process` (propose destinations) and `inbox_resolve` (clear routed items) — do not dump it into memory.', '', '## Writing guidelines', '- Do not rewrite paragraphs unless explicitly asked. Suggest edits only.', diff --git a/bindery-core/src/templates/bindery-readme.ts b/bindery-core/src/templates/bindery-readme.ts index 653714b..355f659 100644 --- a/bindery-core/src/templates/bindery-readme.ts +++ b/bindery-core/src/templates/bindery-readme.ts @@ -2,7 +2,7 @@ import type { TemplateContext, TemplateMeta } from './context'; export const meta: TemplateMeta = { file: '.bindery/README.md', - version: 8, + version: 9, label: 'bindery capabilities', zip: null, }; @@ -48,7 +48,7 @@ work from the same map: | \`${arcFolder}/Acts/\` | Act-level arc files. Default arc granularity: \`${arcGranularity}\`. | | \`${charactersFolder}/index.md\` | Cast overview. | | \`${charactersFolder}/.md\` | One durable profile per character. | -| \`${notesFolder}/Inbox.md\` | Loose notes, pasted mobile chats, and unsorted ideas before triage. | +| \`${notesFolder}/Inbox.md\` | Loose notes, pasted mobile chats, and unsorted ideas before triage. Triage with \`inbox_process\` / \`inbox_resolve\`. | | \`${notesFolder}/World/\`, \`${notesFolder}/Scenes/\`, \`${notesFolder}/Research/\` | Structured supporting notes. | | \`${memoriesFolder}/global.md\` | Durable cross-session decisions and story rules. | | \`${storyFolder}/\` | Chapter source files by language. | @@ -75,6 +75,7 @@ Treat \`${arcFolder}/\` as story architecture, \`${charactersFolder}/\` as cast | \`Bindery: List/Append/Compact Memories\` | Maintain durable session-memory files under \`${memoriesFolder}/\`. | | \`Bindery: Show/Update Chapter Status\` | Read or update \`.bindery/chapter-status.json\`. | | \`Bindery: Show/Update Session Focus\` / \`Append Handoff Note\` | Read or update the neutral working-state sections of \`${sessionFile}\`. | +| \`Bindery: Process Inbox\` / \`Resolve Inbox Items\` | Triage \`${notesFolder}/Inbox.md\`: list items with stable numbers, then remove routed items by number. | | \`Bindery: Register MCP Server\` | Write \`.vscode/mcp.json\` so Claude / Codex pick the bundled server up. | ### Default keybindings (markdown editors only) @@ -115,6 +116,7 @@ tagged **(writes)** modify files or git state. | \`memory_list\` / \`memory_append\` / \`memory_compact\` (reads / writes) | Manage \`.bindery/memories/\` files. | | \`chapter_status_get\` / \`chapter_status_update\` (reads / writes) | Per-chapter progress tracker in \`.bindery/chapter-status.json\`. | | \`session_focus_get\` / \`session_focus_update\` (reads / writes) | Read or update the neutral working-state sections of \`${sessionFile}\` (Current Focus, Next Actions, Open Questions, Handoff Notes). Leaves \`${preferencesFile}\` and other content untouched. | +| \`inbox_process\` / \`inbox_resolve\` (reads / writes) | \`inbox_process\` enumerates \`${notesFolder}/Inbox.md\` items with stable numbers and proposes destinations (read-only); \`inbox_resolve\` removes already-routed items by number after confirmation. | ### Tool Workflow Shortcuts @@ -124,10 +126,12 @@ tagged **(writes)** modify files or git state. - Use \`character_*\` for cast profiles under \`${charactersFolder}/\`. - Use \`note_*\` for canonical story notes under \`${notesFolder}/\`. - Use \`get_notes\` for broad note lookup, \`memory_*\` for durable session decisions, \`chapter_status_*\` for progress state, and \`session_focus_*\` for ephemeral current-focus/handoff in \`${sessionFile}\`. +- Send rough, unsorted, or pasted material to \`${notesFolder}/Inbox.md\` (e.g. \`note_append\`), then triage it with \`inbox_process\` (propose) and \`inbox_resolve\` (clear routed items) — do not dump it into memory. ### Current Tool Boundaries -- Dedicated note, character, arc, memory, chapter-status, and session-focus tools are available now. +- Dedicated note, character, arc, memory, chapter-status, session-focus, and inbox-triage tools are available now. +- \`inbox_process\` only reads and proposes; \`inbox_resolve\` only removes items you name after they have been routed. Neither moves or categorizes content on its own — route confirmed items with the destination tools first. - \`session_focus_*\` updates only the neutral working-state sections of \`${sessionFile}\`. \`${preferencesFile}\` is durable and user-owned — Bindery scaffolds it once and never edits it; propose preference changes for the author to apply. - VS Code and Obsidian host commands now mirror the structured note, character, arc, memory, chapter-status, and session-focus tools. Host prompts cover common fields; agents can still call the MCP tools directly for complete structured payloads. diff --git a/bindery-core/src/templates/claude.ts b/bindery-core/src/templates/claude.ts index 38b8d77..df71a51 100644 --- a/bindery-core/src/templates/claude.ts +++ b/bindery-core/src/templates/claude.ts @@ -2,7 +2,7 @@ import { audienceNote, languageSection, type TemplateContext, type TemplateMeta export const meta: TemplateMeta = { file: 'CLAUDE.md', - version: 14, + version: 15, label: 'project instructions', zip: null, }; @@ -114,6 +114,8 @@ export function render(ctx: TemplateContext): string { '| `chapter_status_update` | Upsert chapter progress entries (send only changed chapters) |', `| \`session_focus_get\` | Read current working state from ${sessionFile} (optionally one section) |`, `| \`session_focus_update\` | Update neutral ${sessionFile} sections (Current Focus, Next Actions, Open Questions, Handoff Notes); leaves ${preferencesFile} untouched |`, + `| \`inbox_process\` | Enumerate ${notesFolder}/Inbox.md items with stable numbers and propose destinations (read-only) |`, + `| \`inbox_resolve\` | Remove already-routed inbox items by number after confirmation |`, ); return lines.filter(l => l !== '\n').join('\n') + '\n'; } diff --git a/bindery-core/src/templates/copilot.ts b/bindery-core/src/templates/copilot.ts index f966f8e..5abb364 100644 --- a/bindery-core/src/templates/copilot.ts +++ b/bindery-core/src/templates/copilot.ts @@ -2,7 +2,7 @@ import { audienceNote, languageSection, type TemplateContext, type TemplateMeta export const meta: TemplateMeta = { file: '.github/copilot-instructions.md', - version: 10, + version: 11, label: 'copilot instructions', zip: null, }; @@ -35,6 +35,7 @@ export function render(ctx: TemplateContext): string { '- Workspace skill files live in `.claude/skills/` and may also be picked up by agents beyond Claude.', '- Prefer those shared slash workflows when available: `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, `/character-setup`.', '- Treat arc files as story architecture, not generic notes. Use `arc_*` tools for structure, `character_*` tools for durable cast facts, `note_*` tools for story notes, `memory_*` tools for cross-session decisions, and `session_focus_*` tools for current working state. Durable preferences are user-owned in PREFERENCES.md — propose changes rather than writing it.', + '- Send rough, unsorted, or pasted material to `Notes/Inbox.md`, then triage it with `inbox_process` (propose destinations) and `inbox_resolve` (clear routed items) — do not dump it into memory.', '', '## Writing guidelines', '- HTML comments `` in chapter files are writer notes — treat as context only.', diff --git a/bindery-core/src/templates/cursor.ts b/bindery-core/src/templates/cursor.ts index b059841..a9b5451 100644 --- a/bindery-core/src/templates/cursor.ts +++ b/bindery-core/src/templates/cursor.ts @@ -2,7 +2,7 @@ import type { TemplateContext, TemplateMeta } from './context'; export const meta: TemplateMeta = { file: '.cursor/rules', - version: 10, + version: 11, label: 'cursor rules', zip: null, }; @@ -28,6 +28,7 @@ export function render(ctx: TemplateContext): string { `- \`${notesFolder}/\` — story notes, like world rules, scene ideas, inbox, and research`, '- Shared workflows live in `.claude/skills/`; if your runtime exposes them, prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, and `/character-setup` for those tasks.', '- Use `arc_*` tools for story structure, `character_*` tools for cast profiles, `note_*` tools for story notes, `memory_*` tools for session decisions, `chapter_status_*` tools for progress, and `session_focus_*` tools for current working state.', + '- Send rough, unsorted, or pasted material to `Notes/Inbox.md`, then triage with `inbox_process` and `inbox_resolve` — do not dump it into memory.', '', '## Rules', '- HTML comments `` in chapter files are writer notes. Treat as context, not story content.', diff --git a/bindery-core/src/templates/skills/memory.ts b/bindery-core/src/templates/skills/memory.ts index 6d990e1..eaa962d 100644 --- a/bindery-core/src/templates/skills/memory.ts +++ b/bindery-core/src/templates/skills/memory.ts @@ -2,7 +2,7 @@ import type { TemplateContext, TemplateMeta } from '../context'; export const meta: TemplateMeta = { file: '.claude/skills/memory/SKILL.md', - version: 13, + version: 14, label: 'memory skill', zip: '.claude/skills/memory.zip', }; @@ -20,7 +20,7 @@ const CONTENT = [ "", "**Three-way split:** durable story/project decisions → memory (`.bindery/memories/`); durable working preferences (\"do it like this for me\") → `PREFERENCES.md` (user-owned, never tool-written); ephemeral current focus, next actions, open questions, and handoff → `SESSION.md` via `session_focus_*`.", "", - "**What memory is not:** A substitute for proper notes files. Character profiles, world rules, and writing conventions belong in their dedicated files, not accumulating in memory.", + "**What memory is not:** A substitute for proper notes files. Character profiles, world rules, and writing conventions belong in their dedicated files, not accumulating in memory. Rough, unsorted, or pasted material (mobile chats, brain-dumps) goes to the notes Inbox (`Notes/Inbox.md`), not memory — triage it later with `inbox_process`.", "", "## Prerequisites", "This skill requires a Bindery workspace. If unsure, call `identify_book` to check. If no workspace is found, tell the user and stop.", @@ -70,6 +70,7 @@ const CONTENT = [ "| **Notes / Arc files** (not memory) | Character profiles, world-building detail, magic rules, arc commitments — anything that has become settled enough to be a reference |", "| **`PREFERENCES.md`** (not memory) | Durable working preferences and writing conventions — \"do it like this for me\". User-owned; propose edits, do not write it with tools |", "| **`SESSION.md`** via `session_focus_update` (not memory) | Current focus, next actions, open questions, handoff — ephemeral working state |", + "| **`Notes/Inbox.md`** (not memory) | Rough, unsorted, or pasted material not yet triaged. Append with `note_append`; triage later with `inbox_process` / `inbox_resolve` |", "| **Discard** | Session-local context, tool/workflow observations, things that were speculative and were not confirmed |", "", "> If an item is a preference or suggestion rather than a confirmed decision, either discard it or note it explicitly as tentative (e.g., `\"Tentative: consider X\"`) — do not write it as a fact.", diff --git a/bindery-core/test/templates.test.ts b/bindery-core/test/templates.test.ts index 14c1bd1..a643b64 100644 --- a/bindery-core/test/templates.test.ts +++ b/bindery-core/test/templates.test.ts @@ -313,6 +313,8 @@ describe('renderTemplate — memory skill', () => { expect(result).toContain('character_update'); expect(result).toContain('session_focus_update'); expect(result).toContain('PREFERENCES.md'); + expect(result).toContain('inbox_process'); + expect(result).toContain('Notes/Inbox.md'); expect(result).not.toContain('no dedicated COWORK/session-focus write tools'); expect(result).not.toContain('## Future work'); }); @@ -533,6 +535,8 @@ describe('renderTemplate — bindery-readme', () => { expect(result).toContain('Show/Update Chapter Status'); expect(result).toContain('Show/Update Session Focus'); expect(result).toContain('session_focus_get'); + expect(result).toContain('inbox_process'); + expect(result).toContain('inbox_resolve'); expect(result).toContain('never edits it'); expect(result).not.toContain('session-focus/COWORK tools are not available yet'); }); diff --git a/mcp-ts/src/index.ts b/mcp-ts/src/index.ts index 42dfac1..1e59254 100644 --- a/mcp-ts/src/index.ts +++ b/mcp-ts/src/index.ts @@ -55,6 +55,8 @@ import { toolChapterStatusUpdate, toolSessionFocusGet, toolSessionFocusUpdate, + toolInboxProcess, + toolInboxResolve, } from './tools.js'; import { resolveBook, listBooks, findBookByPath } from './registry.js'; @@ -746,6 +748,34 @@ server.registerTool('session_focus_update', { } catch (e) { return err(e); } }); +server.registerTool('inbox_process', { + title: 'Inbox Process', + description: + 'Read the notes Inbox (Notes/Inbox.md) and return a structured triage proposal: each loose item enumerated with a stable number, ' + + 'plus the destination tools to route them (note_*, character_*, arc_*, memory_*, chapter_status_*, session_focus_*). ' + + 'This tool only reads and proposes — it never moves, deletes, or categorizes anything. ' + + 'After the user confirms and items are routed with the destination tools, call inbox_resolve with the item numbers to clear them.', + inputSchema: { book: bookSchema }, + annotations: { readOnlyHint: true }, +}, ({ book }) => { + try { return ok(toolInboxProcess(resolveBook(book).root)); } catch (e) { return err(e); } +}); + +server.registerTool('inbox_resolve', { + title: 'Inbox Resolve', + description: + 'Remove already-routed items from the notes Inbox (Notes/Inbox.md) by their item numbers, as enumerated by inbox_process. ' + + 'Use only after the items have been routed to their destinations and the user has confirmed. ' + + 'Item numbers are stable between inbox_process and inbox_resolve. Other items and the inbox heading/intro are preserved.', + inputSchema: { + book: bookSchema, + items: z.array(z.number().int()).describe('Item numbers to remove, as shown by inbox_process (1-based)'), + }, + annotations: { destructiveHint: true }, +}, ({ book, items }) => { + try { return ok(toolInboxResolve(resolveBook(book).root, { items })); } catch (e) { return err(e); } +}); + // ─── Start ──────────────────────────────────────────────────────────────────── async function main(): Promise { diff --git a/mcp-ts/src/tools.ts b/mcp-ts/src/tools.ts index 40ee467..e4a3571 100644 --- a/mcp-ts/src/tools.ts +++ b/mcp-ts/src/tools.ts @@ -2393,7 +2393,7 @@ function scaffoldOpinionatedWorkspace(root: string, settings: Record + /^##\s/.test(line) || /^\s*[-*+]\s/.test(line) || /^\s*\d+\.\s/.test(line); + if (idx < lines.length && !isItemStart(lines[idx])) { + while (idx < lines.length && lines[idx].trim() !== '') { preamble.push(lines[idx]); idx++; } + } + + const body = lines.slice(idx).join('\n').trim(); + let items: string[] = []; + if (body) { + items = /^##\s/m.test(body) + ? body.split(/(?=^##\s)/m).map(s => s.trim()).filter(Boolean) + : body.split(/\n{2,}/).map(s => s.trim()).filter(Boolean); + } + return { preamble: preamble.join('\n').replace(/\n+$/, ''), items }; +} + +function inboxItemPreview(item: string): string { + const firstLine = (item.split('\n').find(l => l.trim()) ?? '') + .replace(/^##\s+/, '') + .replace(/^\s*[-*+]\s+/, '') + .replace(/^\s*\d+\.\s+/, '') + .trim(); + const multiline = item.split('\n').filter(l => l.trim()).length > 1; + const clipped = firstLine.length > 100 ? firstLine.slice(0, 99) + '…' : firstLine; + return `${clipped}${multiline ? ' …' : ''}`; +} + +export function toolInboxProcess(root: string): string { + const { abs, rel } = inboxFilePath(root); + if (!fs.existsSync(abs)) { + return `Inbox not found at ${rel}. Run init_workspace to create it, or add notes there first.`; + } + const { items } = parseInboxItems(fs.readFileSync(abs, 'utf-8')); + if (items.length === 0) { + return `Inbox (${rel}) is empty — only the heading/intro remain. Nothing to triage.`; + } + + const lines: string[] = [`Inbox triage — ${items.length} item(s) in ${rel}`, '']; + items.forEach((item, i) => { + lines.push(`### Item ${i + 1}: ${inboxItemPreview(item)}`, '', item, ''); + }); + lines.push( + '## How to triage', + 'Propose a destination for each item, confirm with the user, then route confirmed items with the matching tool:', + '- Story note → `note_create` / `note_append` (World, Scenes, Research, or a custom category)', + '- Character → `character_create` / `character_update`', + '- Arc / structure → `arc_create` / `arc_update`', + '- Durable cross-session decision → `memory_append`', + '- Chapter progress → `chapter_status_update`', + '- Current focus / next action / handoff → `session_focus_update`', + '', + 'Do not move, delete, or categorize anything without the user\'s confirmation. ' + + 'After confirmed items are routed, call `inbox_resolve` with their item numbers to remove them from the inbox. ' + + 'Items left unconfirmed stay in the inbox.', + ); + return lines.join('\n'); +} + +export interface InboxResolveArgs { + items: number[]; +} + +export function toolInboxResolve(root: string, args: InboxResolveArgs): string { + const requested = Array.isArray(args.items) ? args.items : []; + if (requested.length === 0) { + return 'Error: provide the item numbers to remove (as shown by inbox_process).'; + } + const { abs, rel } = inboxFilePath(root); + if (!fs.existsSync(abs)) { + return `Inbox not found at ${rel}. Nothing to resolve.`; + } + + const { preamble, items } = parseInboxItems(fs.readFileSync(abs, 'utf-8')); + const remove = new Set(); + const invalid: number[] = []; + for (const n of requested) { + if (!Number.isInteger(n) || n < 1 || n > items.length) { invalid.push(n); } + else { remove.add(n); } + } + if (invalid.length > 0) { + return `Error: invalid item number(s): ${invalid.join(', ')}. Inbox has ${items.length} item(s); valid range is 1-${items.length}.`; + } + + const kept = items.filter((_, i) => !remove.has(i + 1)); + const parts = [preamble.trim(), ...kept].filter(Boolean); + fs.writeFileSync(abs, parts.join('\n\n') + '\n', 'utf-8'); + + return `Resolved ${remove.size} item(s) from ${rel}; ${kept.length} remaining.`; +} + // ─── Shared formatter ───────────────────────────────────────────────────────── function formatResult(r: SearchResult, idx: number): string { diff --git a/mcp-ts/test/tools.test.ts b/mcp-ts/test/tools.test.ts index ba858e4..e3f83a1 100644 --- a/mcp-ts/test/tools.test.ts +++ b/mcp-ts/test/tools.test.ts @@ -26,6 +26,8 @@ import { toolSetupAiFiles, toolSessionFocusGet, toolSessionFocusUpdate, + toolInboxProcess, + toolInboxResolve, } from '../src/tools'; const tempRoots: string[] = []; @@ -598,4 +600,68 @@ describe('mcp tools', () => { const root = makeRoot(); expect(toolSessionFocusUpdate(root, {})).toContain('provide at least one'); }); + + it('reports an empty state when the inbox has no items beyond the intro', () => { + const root = makeRoot(); + write(path.join(root, 'Notes', 'Inbox.md'), '# Inbox\n\nDrop loose ideas here.\n'); + expect(toolInboxProcess(root)).toContain('is empty'); + }); + + it('enumerates heading-delimited inbox items with stable numbers', () => { + const root = makeRoot(); + write(path.join(root, 'Notes', 'Inbox.md'), + '# Inbox\n\nDrop loose ideas here.\n\n## Dragon lore\nDragons hoard memories.\n\n## New side character\nA tinkerer named Pell.\n'); + const result = toolInboxProcess(root); + expect(result).toContain('2 item(s)'); + expect(result).toContain('### Item 1'); + expect(result).toContain('Dragon lore'); + expect(result).toContain('### Item 2'); + expect(result).toContain('Pell'); + // surfaces destination guidance and the no-auto-move rule + expect(result).toContain('session_focus_update'); + expect(result).toContain('without the user'); + }); + + it('splits blank-line blocks when there are no headings', () => { + const root = makeRoot(); + write(path.join(root, 'Notes', 'Inbox.md'), + '# Inbox\n\nIntro line.\n\nFirst loose idea.\n\nSecond loose idea.\n'); + const result = toolInboxProcess(root); + expect(result).toContain('2 item(s)'); + expect(result).toContain('First loose idea'); + expect(result).toContain('Second loose idea'); + }); + + it('resolves confirmed inbox items by number and preserves the rest and the intro', () => { + const root = makeRoot(); + write(path.join(root, 'Notes', 'Inbox.md'), + '# Inbox\n\nDrop loose ideas here.\n\n## One\nfirst\n\n## Two\nsecond\n\n## Three\nthird\n'); + + const msg = toolInboxResolve(root, { items: [1, 3] }); + expect(msg).toContain('Resolved 2'); + expect(msg).toContain('1 remaining'); + + const text = fs.readFileSync(path.join(root, 'Notes', 'Inbox.md'), 'utf-8'); + expect(text).toContain('# Inbox'); + expect(text).toContain('Drop loose ideas here.'); + expect(text).toContain('## Two'); + expect(text).not.toContain('## One'); + expect(text).not.toContain('## Three'); + }); + + it('rejects out-of-range inbox item numbers without modifying the file', () => { + const root = makeRoot(); + const inbox = '# Inbox\n\nintro\n\n## Only\nbody\n'; + write(path.join(root, 'Notes', 'Inbox.md'), inbox); + + const msg = toolInboxResolve(root, { items: [5] }); + expect(msg).toContain('invalid item number'); + expect(fs.readFileSync(path.join(root, 'Notes', 'Inbox.md'), 'utf-8')).toBe(inbox); + }); + + it('errors when inbox_resolve is called with no item numbers', () => { + const root = makeRoot(); + write(path.join(root, 'Notes', 'Inbox.md'), '# Inbox\n\nintro\n\n## A\nx\n'); + expect(toolInboxResolve(root, { items: [] })).toContain('provide the item numbers'); + }); }); diff --git a/mcpb/README.md b/mcpb/README.md index f51fc85..cc00cd7 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -12,6 +12,7 @@ Works with any Markdown book project structured with the Bindery VS Code extensi - **Opinionated authoring scaffold** — initialize Arc, Notes, Characters, SESSION, PREFERENCES, memory, and chapter-status files for agent-assisted writing - **Session memory** — append, list, and compact persistent cross-session notes in `.bindery/memories/` - **Session focus** — read and update the ephemeral working-state file `SESSION.md` (current focus, next actions, open questions, handoff); durable preferences stay user-owned in `PREFERENCES.md` +- **Inbox triage** — enumerate loose `Notes/Inbox.md` items and propose destinations (`inbox_process`), then clear routed items after confirmation (`inbox_resolve`) - **Workspace setup** — create or update `.bindery/settings.json`, `.bindery/translations.json`, `.bindery/README.md`, the opinionated authoring scaffold, and AI instruction files - **Chapter status tracking** — record and query per-chapter progress (draft, in-progress, done, needs-review) - **Typography formatting** — curly quotes, em-dashes, ellipses @@ -202,8 +203,10 @@ table with issue type, location, and the reference that contradicts it. | `chapter_status_update` | Upsert chapter progress entries — send only changed chapters; unmentioned entries are preserved | | `session_focus_get` | Read working state from `SESSION.md` (optionally a single section: Current Focus, Next Actions, Open Questions, Handoff Notes) | | `session_focus_update` | Update neutral `SESSION.md` sections (replace or append); leaves `PREFERENCES.md` and other content untouched | +| `inbox_process` | Enumerate `Notes/Inbox.md` items with stable numbers and propose destinations — read-only, never moves or categorizes | +| `inbox_resolve` | Remove already-routed inbox items by number after confirmation; preserves other items and the heading/intro | -Current boundary: Arc, character, note, memory, chapter-status, and session-focus workflows are available through MCP tools, with matching VS Code and Obsidian host command wrappers. `session_focus_update` touches only the neutral `SESSION.md` sections; `PREFERENCES.md` is user-owned and never tool-written. Inbox-processing workflows are planned but not yet part of this MCPB tool surface. +Current boundary: Arc, character, note, memory, chapter-status, session-focus, and inbox-triage workflows are available through MCP tools, with matching VS Code and Obsidian host command wrappers. `session_focus_update` touches only the neutral `SESSION.md` sections; `PREFERENCES.md` is user-owned and never tool-written. `inbox_process` only proposes and `inbox_resolve` only removes named items — route confirmed items with the destination tools first. ## Privacy Policy diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 014d850..1398c6b 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -99,6 +99,8 @@ { "name": "chapter_status_get", "description": "Read the current chapter progress tracker from .bindery/chapter-status.json. Returns a grouped summary by status (done, in-progress, draft, planned, needs-review). Returns a clear empty-state message if nothing has been recorded yet." }, { "name": "chapter_status_update", "description": "Upsert chapter progress entries in .bindery/chapter-status.json. Send only the chapters that changed — existing entries not in the payload are preserved. Creates the file if it does not exist." }, { "name": "session_focus_get", "description": "Read the ephemeral session file (default SESSION.md) holding current working state. Optionally pass a section name (Current Focus, Next Actions, Open Questions, Handoff Notes) to read just that section. Durable preferences live in PREFERENCES.md and durable decisions in .bindery/memories/. Returns a clear empty-state message if the session file does not exist yet." }, - { "name": "session_focus_update", "description": "Update neutral sections of the ephemeral session file (default SESSION.md): Current Focus, Next Actions, Open Questions, Handoff Notes. Only the sections you pass change; other content and the user-owned PREFERENCES.md are preserved. mode 'replace' (default) overwrites a section body; 'append' adds beneath existing content. Creates the file from the standard scaffold if missing." } + { "name": "session_focus_update", "description": "Update neutral sections of the ephemeral session file (default SESSION.md): Current Focus, Next Actions, Open Questions, Handoff Notes. Only the sections you pass change; other content and the user-owned PREFERENCES.md are preserved. mode 'replace' (default) overwrites a section body; 'append' adds beneath existing content. Creates the file from the standard scaffold if missing." }, + { "name": "inbox_process", "description": "Read the notes Inbox (Notes/Inbox.md) and return a structured triage proposal: each loose item enumerated with a stable number, plus destination tools (note_*, character_*, arc_*, memory_*, chapter_status_*, session_focus_*). Read-only — never moves, deletes, or categorizes. After items are routed and confirmed, call inbox_resolve to clear them." }, + { "name": "inbox_resolve", "description": "Remove already-routed items from the notes Inbox (Notes/Inbox.md) by their item numbers, as enumerated by inbox_process. Use only after items are routed and the user has confirmed. Item numbers are stable between inbox_process and inbox_resolve; other items and the heading/intro are preserved." } ] } diff --git a/obsidian-plugin/src/main.ts b/obsidian-plugin/src/main.ts index de45927..d8cbfd3 100644 --- a/obsidian-plugin/src/main.ts +++ b/obsidian-plugin/src/main.ts @@ -137,6 +137,8 @@ interface AuthoringTools { toolChapterStatusUpdate: (_root: string, _args: { chapters: AuthoringChapterStatusEntry[] }) => string; toolSessionFocusGet: (_root: string, _args: { section?: string }) => string; toolSessionFocusUpdate: (_root: string, _args: { currentFocus?: string; nextActions?: string; openQuestions?: string; handoffNotes?: string; mode?: 'replace' | 'append' }) => string; + toolInboxProcess: (_root: string) => string; + toolInboxResolve: (_root: string, _args: { items: number[] }) => string; } function loadAuthoringTools(): AuthoringTools { @@ -344,6 +346,8 @@ export default class BinderyPlugin extends Plugin { this.addCommand({ id: 'session-focus-show', name: 'Show session focus', callback: () => void this.sessionFocusShowCommand() }); this.addCommand({ id: 'session-focus-update', name: 'Update session focus', callback: () => void this.sessionFocusUpdateCommand() }); this.addCommand({ id: 'session-focus-append-handoff', name: 'Append handoff note', callback: () => void this.sessionFocusAppendHandoffCommand() }); + this.addCommand({ id: 'inbox-process', name: 'Process inbox', callback: () => void this.inboxProcessCommand() }); + this.addCommand({ id: 'inbox-resolve', name: 'Resolve inbox items', callback: () => void this.inboxResolveCommand() }); // Show MCP config snippet — copies JSON to clipboard this.addCommand({ @@ -859,6 +863,20 @@ export default class BinderyPlugin extends Plugin { }); } + private async inboxProcessCommand(): Promise { + await this.runAuthoringCommand('Process inbox', (root, tools) => tools.toolInboxProcess(root)); + } + + private async inboxResolveCommand(): Promise { + await this.runAuthoringCommand('Resolve inbox items', async (root, tools) => { + const raw = await this.promptRequired('Item numbers to remove (comma-separated):'); + if (!raw) { return null; } + const items = raw.split(',').map(s => Number(s.trim())).filter(n => Number.isInteger(n) && n > 0); + if (items.length === 0) { this.notify('Enter one or more item numbers, e.g. "1, 3".'); return null; } + return tools.toolInboxResolve(root, { items }); + }); + } + private async promptString(promptText: string, defaultValue: string = ''): Promise { return new Promise((resolve) => { new TextPromptModal(this.app, promptText, defaultValue, resolve).open(); diff --git a/scripts/check-command-parity.mjs b/scripts/check-command-parity.mjs index c315c6b..a8a6fd5 100644 --- a/scripts/check-command-parity.mjs +++ b/scripts/check-command-parity.mjs @@ -64,6 +64,8 @@ const expectedPairs = [ ['sessionFocusShow', 'bindery.sessionFocusShow', 'session-focus-show'], ['sessionFocusUpdate', 'bindery.sessionFocusUpdate', 'session-focus-update'], ['sessionFocusAppendHandoff', 'bindery.sessionFocusAppendHandoff', 'session-focus-append-handoff'], + ['inboxProcess', 'bindery.inboxProcess', 'inbox-process'], + ['inboxResolve', 'bindery.inboxResolve', 'inbox-resolve'], ]; const exceptions = [ diff --git a/vscode-ext/README.md b/vscode-ext/README.md index fe4ed00..1177fb3 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -104,6 +104,8 @@ Bindery includes a bundled MCP server that makes your book's chapters, arc files | `bindery_chapter_status_update` | Upsert chapter progress entries | | `bindery_session_focus_get` | Read working state from `SESSION.md` (optionally a single section) | | `bindery_session_focus_update` | Update neutral `SESSION.md` sections (replace/append); leaves `PREFERENCES.md` untouched | +| `bindery_inbox_process` | Enumerate `Notes/Inbox.md` items with stable numbers and propose destinations (read-only) | +| `bindery_inbox_resolve` | Remove already-routed inbox items by number after confirmation | `bindery_search` supports `lexical`, `semantic_rerank`, and `full_semantic` modes. Semantic modes require an Ollama instance and fall back to lexical results with a warning if unavailable. @@ -154,6 +156,7 @@ All commands are available from the Command Palette (`Ctrl+Shift+P`) under the * | `List Memories` / `Append Memory` / `Compact Memory` | Maintain durable `.bindery/memories/` files | | `Show Chapter Status` / `Update Chapter Status` | Read or update `.bindery/chapter-status.json` | | `Show Session Focus` / `Update Session Focus` / `Append Handoff Note` | Read or update the neutral working-state sections of `SESSION.md` | +| `Process Inbox` / `Resolve Inbox Items` | Triage `Notes/Inbox.md`: list items with stable numbers, then remove routed items by number | Keybindings only fire while editing a markdown file (`editorTextFocus && resourceLangId == markdown`); rebind via **File → Preferences → Keyboard Shortcuts** if they conflict with another extension. diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 9769195..46bd1fb 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -337,6 +337,16 @@ "title": "Append Handoff Note", "category": "Bindery" }, + { + "command": "bindery.inboxProcess", + "title": "Process Inbox", + "category": "Bindery" + }, + { + "command": "bindery.inboxResolve", + "title": "Resolve Inbox Items", + "category": "Bindery" + }, { "command": "bindery.registerMcp", "title": "Register MCP Server", @@ -1441,6 +1451,45 @@ } } } + }, + { + "name": "bindery_inbox_process", + "tags": [ + "bindery" + ], + "displayName": "Bindery: Process Inbox", + "toolReferenceName": "binderyInboxProcess", + "canBeReferencedInPrompt": true, + "modelDescription": "Read the notes Inbox (Notes/Inbox.md) and return a structured triage proposal: each loose item enumerated with a stable number, plus the destination tools to route them (note_*, character_*, arc_*, memory_*, chapter_status_*, session_focus_*). Read-only — never moves, deletes, or categorizes. After the user confirms and items are routed, call inbox_resolve with the item numbers to clear them.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "bindery_inbox_resolve", + "tags": [ + "bindery" + ], + "displayName": "Bindery: Resolve Inbox Items", + "toolReferenceName": "binderyInboxResolve", + "canBeReferencedInPrompt": true, + "modelDescription": "Remove already-routed items from the notes Inbox (Notes/Inbox.md) by their item numbers, as enumerated by inbox_process. Use only after the items have been routed and the user has confirmed. Item numbers are stable between inbox_process and inbox_resolve; other items and the inbox heading/intro are preserved.", + "inputSchema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Item numbers to remove, as shown by inbox_process (1-based)" + } + }, + "required": [ + "items" + ] + } } ], "menus": { diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 29d830f..4588847 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -84,6 +84,8 @@ interface McpToolsForAi { toolChapterStatusUpdate: (_root: string, _args: { chapters: AuthoringChapterStatusEntry[] }) => string; toolSessionFocusGet: (_root: string, _args: { section?: string }) => string; toolSessionFocusUpdate: (_root: string, _args: { currentFocus?: string; nextActions?: string; openQuestions?: string; handoffNotes?: string; mode?: 'replace' | 'append' }) => string; + toolInboxProcess: (_root: string) => string; + toolInboxResolve: (_root: string, _args: { items: number[] }) => string; } interface AuthoringCharacterInput { @@ -1640,6 +1642,23 @@ async function sessionFocusAppendHandoffCommand(context: vscode.ExtensionContext }); } +async function inboxProcessCommand(context: vscode.ExtensionContext): Promise { + await runAuthoringCommand(context, 'Process Inbox', (root, tools) => tools.toolInboxProcess(root)); +} + +async function inboxResolveCommand(context: vscode.ExtensionContext): Promise { + await runAuthoringCommand(context, 'Resolve Inbox Items', async (root, tools) => { + const raw = await promptRequired('Bindery: Resolve Inbox Items', 'Item numbers to remove (comma-separated)'); + if (!raw) { return undefined; } + const items = raw.split(',').map(s => Number(s.trim())).filter(n => Number.isInteger(n) && n > 0); + if (items.length === 0) { + vscode.window.showWarningMessage('Bindery: enter one or more item numbers, e.g. "1, 3".'); + return undefined; + } + return tools.toolInboxResolve(root, { items }); + }); +} + // ─── Activation ─────────────────────────────────────────────────────────────── export function activate(context: vscode.ExtensionContext) { @@ -1727,6 +1746,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('bindery.sessionFocusShow', () => sessionFocusShowCommand(context)), vscode.commands.registerCommand('bindery.sessionFocusUpdate', () => sessionFocusUpdateCommand(context)), vscode.commands.registerCommand('bindery.sessionFocusAppendHandoff', () => sessionFocusAppendHandoffCommand(context)), + vscode.commands.registerCommand('bindery.inboxProcess', () => inboxProcessCommand(context)), + vscode.commands.registerCommand('bindery.inboxResolve', () => inboxResolveCommand(context)), vscode.commands.registerCommand('bindery.registerMcp', () => registerMcpCommand(context)), ); diff --git a/vscode-ext/src/mcp.ts b/vscode-ext/src/mcp.ts index 8815162..7b56c66 100644 --- a/vscode-ext/src/mcp.ts +++ b/vscode-ext/src/mcp.ts @@ -77,6 +77,7 @@ interface MemoryCompactInput { file: string; compacted_content: string } interface ChapterStatusUpdateInput { chapters: Array<{ number: number; title: string; language: string; status: 'done' | 'in-progress' | 'draft' | 'planned' | 'needs-review'; wordCount?: number; notes?: string }> } interface SessionFocusGetInput { section?: string } interface SessionFocusUpdateInput { currentFocus?: string; nextActions?: string; openQuestions?: string; handoffNotes?: string; mode?: 'replace' | 'append' } +interface InboxResolveInput { items: number[] } interface McpTools { toolHealth: (_root: string) => string; @@ -119,6 +120,8 @@ interface McpTools { toolChapterStatusUpdate: (_root: string, _args: ChapterStatusUpdateInput) => string; toolSessionFocusGet: (_root: string, _args: SessionFocusGetInput) => string; toolSessionFocusUpdate: (_root: string, _args: SessionFocusUpdateInput) => string; + toolInboxProcess: (_root: string) => string; + toolInboxResolve: (_root: string, _args: InboxResolveInput) => string; } /** @@ -319,6 +322,14 @@ export function registerLmTools(context: vscode.ExtensionContext): void { vscode.lm.registerTool('bindery_session_focus_update', { invoke: (opts, _token) => ok(t.toolSessionFocusUpdate(requireRoot(), opts.input)), }), + + vscode.lm.registerTool('bindery_inbox_process', { + invoke: (_opts, _token) => ok(t.toolInboxProcess(requireRoot())), + }), + + vscode.lm.registerTool('bindery_inbox_resolve', { + invoke: (opts, _token) => ok(t.toolInboxResolve(requireRoot(), opts.input)), + }), ); }