feat: add inbox triage tools (inbox_process / inbox_resolve)

Add a confirmation-gated inbox triage workflow over Notes/Inbox.md:

- inbox_process (read-only): enumerates loose inbox items with stable
  numbers and returns a structured triage proposal plus the destination
  tools to route them. Never moves, deletes, or categorizes.
- inbox_resolve (destructive): removes already-routed items by their
  item numbers after the user confirms, preserving other items and the
  heading/intro.

A shared deterministic parser splits Inbox.md on `## ` headings, or on
blank-line blocks when there are no headings, so item numbers stay stable
between process and resolve.

- tools + registration (mcp-ts), VS Code + Obsidian commands, LM tools,
  MCPB manifest, and command/tool parity rows (44 tools, 38 commands)
- guidance update: rough/unsorted/pasted material routes to the Inbox and
  is triaged with inbox_process/inbox_resolve, not dumped into memory
  (memory skill, generated assistant templates, generated README, public
  docs); bumped touched template versions
- inbox scaffold intro references the triage tools
- tests for inbox_process / inbox_resolve incl. empty state, heading vs
  blank-line splitting, out-of-range rejection, and intro preservation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik van der Boom 2026-05-29 15:49:38 +02:00
parent 21d6e5768f
commit b582f11aff
20 changed files with 348 additions and 14 deletions

View file

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

View file

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

View file

@ -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.',

View file

@ -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}/<character>.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.

View file

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

View file

@ -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.',

View file

@ -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.',

View file

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

View file

@ -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');
});

View file

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

View file

@ -2393,7 +2393,7 @@ function scaffoldOpinionatedWorkspace(root: string, settings: Record<string, unk
[preferencesFileName, preferencesFileTemplate(settings)],
[`${arcFolderName}/index.md`, arcIndexTemplate(arcFolderName)],
[`${arcFolderName}/Overall.md`, overallArcTemplate()],
[`${notesFolderName}/Inbox.md`, noteIndexTemplate('Inbox', 'Drop loose ideas, pasted mobile chats, and unsorted notes here. Process them into structured notes when ready.')],
[`${notesFolderName}/Inbox.md`, noteIndexTemplate('Inbox', 'Drop loose ideas, pasted mobile chats, and unsorted notes here. Triage them with inbox_process, then route confirmed items to notes, characters, arcs, or memory and clear them with inbox_resolve.')],
[`${notesFolderName}/World/index.md`, noteIndexTemplate('World Notes', 'World rules, setting facts, magic/technology constraints, and culture notes.')],
[`${notesFolderName}/Scenes/index.md`, noteIndexTemplate('Scene Notes', 'Loose scene ideas, set pieces, fragments, and placement candidates.')],
[`${notesFolderName}/Research/index.md`, noteIndexTemplate('Research Notes', 'Research references, factual checks, and source links.')],
@ -2951,6 +2951,118 @@ export function toolSessionFocusUpdate(root: string, args: SessionFocusUpdateArg
return `Session focus updated in ${rel} (${mode}): ${touched.join(', ')}.`;
}
// ─── Inbox processing (Notes/Inbox.md) ──────────────────────────────────────────
function inboxFilePath(root: string): { abs: string; rel: string } {
const notesFolder = getNotesFolder(readSettings(root) ?? null);
const rel = normalizeSlashes(path.posix.join(notesFolder, 'Inbox.md'));
return { abs: path.join(root, notesFolder, 'Inbox.md'), rel };
}
interface ParsedInbox { preamble: string; items: string[]; }
/**
* Split Inbox.md into a preamble (H1 + intro paragraph) plus discrete items.
* Deterministic so `inbox_process` and `inbox_resolve` enumerate items identically:
* if any `## ` headings exist the body is split on them, otherwise on blank-line blocks.
*/
function parseInboxItems(text: string): ParsedInbox {
const lines = text.split(/\r?\n/);
const preamble: string[] = [];
let idx = 0;
if (idx < lines.length && /^#\s/.test(lines[idx])) { preamble.push(lines[idx]); idx++; }
while (idx < lines.length && lines[idx].trim() === '') { preamble.push(lines[idx]); idx++; }
const isItemStart = (line: string): boolean =>
/^##\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<number>();
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 {

View file

@ -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');
});
});

View file

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

View file

@ -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." }
]
}

View file

@ -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<void> {
await this.runAuthoringCommand('Process inbox', (root, tools) => tools.toolInboxProcess(root));
}
private async inboxResolveCommand(): Promise<void> {
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<string | null> {
return new Promise((resolve) => {
new TextPromptModal(this.app, promptText, defaultValue, resolve).open();

View file

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

View file

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

View file

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

View file

@ -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<void> {
await runAuthoringCommand(context, 'Process Inbox', (root, tools) => tools.toolInboxProcess(root));
}
async function inboxResolveCommand(context: vscode.ExtensionContext): Promise<void> {
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)),
);

View file

@ -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<SessionFocusUpdateInput>('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<InboxResolveInput>('bindery_inbox_resolve', {
invoke: (opts, _token) => ok(t.toolInboxResolve(requireRoot(), opts.input)),
}),
);
}