diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ea32f3bc..a103ef874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - ubuntu-24.04-arm - macos-latest - windows-latest + # And its Windows counterpart, where the ARM64 release build comes + # from (#928). + - windows-11-arm steps: - name: Check out repository uses: actions/checkout@v6 @@ -56,7 +59,7 @@ jobs: run: npm ci - name: Verify terminal artifacts and legacy launchers - if: matrix.os != 'windows-latest' + if: runner.os != 'Windows' run: node --test tooling/scripts/terminal-artifact.test.mjs tooling/scripts/terminal-launcher.test.mjs - name: Verify standalone shared packages @@ -97,3 +100,13 @@ jobs: - name: Typecheck and build app run: npm run build:prod + + - name: Package the ARM64 Windows app and check it launches + if: matrix.os == 'windows-11-arm' + shell: bash + # No maintainer has an ARM64 Windows machine, so this is where the + # native build is proven to start before a release ships it (#928): + # a CDP page target, the app drawn, and an ARM64 executable. + run: | + npm exec --workspace @zennotes/desktop -- electron-builder --win dir --arm64 --publish never + node tooling/scripts/packaged-launch-check.mjs dist/win-arm64-unpacked/ZenNotes.exe --expect-arch arm64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5349e778..9ddacd42b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ on: - all - macOS - Windows + - Windows arm64 - Linux x64 - Linux arm64 keep: @@ -66,11 +67,15 @@ jobs: env: PLATFORM: ${{ inputs.platform || 'all' }} # "Linux arm64" is GitHub's free arm64 Linux runner for public repos: - # native aarch64 artifacts with no cross-compilation. + # native aarch64 artifacts with no cross-compilation. "Windows arm64" + # is its Windows counterpart (#928). Its script names the architecture + # (dist:win:arm64), so a toolchain that came up emulated as x64 cannot + # build an x64 copy that lands on the x64 job's file names. run: | all='[ {"name":"macOS","os":"macos-latest","command":"npm run dist:mac"}, {"name":"Windows","os":"windows-latest","command":"npm run dist:win"}, + {"name":"Windows arm64","os":"windows-11-arm","command":"npm run dist:win:arm64 --workspace @zennotes/desktop"}, {"name":"Linux x64","os":"ubuntu-latest","command":"npm run dist:linux"}, {"name":"Linux arm64","os":"ubuntu-24.04-arm","command":"npm run dist:linux"} ]' @@ -178,6 +183,8 @@ jobs: - name: Collect release artifacts id: collect shell: bash + env: + PLATFORM_NAME: ${{ matrix.name }} run: | mkdir -p release-assets find dist -maxdepth 1 -type f \ @@ -187,6 +194,28 @@ jobs: echo "No release artifacts found in dist/" >&2 exit 1 fi + # The two Windows jobs upload to the same release. Each must hold + # only its own architecture, or one overwrites the other's files. + case "${PLATFORM_NAME}" in + Windows) foreign='-win-arm64' ;; + "Windows arm64") foreign='-win-x64' ;; + *) foreign='' ;; + esac + if [ -n "${foreign}" ] && ls release-assets | grep -q -- "${foreign}"; then + echo "The ${PLATFORM_NAME} build produced ${foreign} files:" >&2 + ls release-assets >&2 + exit 1 + fi + if [ "${PLATFORM_NAME}" = "Windows arm64" ]; then + # electron-builder names every Windows feed latest.yml whatever the + # architecture; that name is the x64 job's. The ARM64 feed goes out + # as latest-arm64.yml, which the ARM64 app reads (updater.ts). + if [ ! -f release-assets/latest.yml ]; then + echo "The Windows arm64 build wrote no latest.yml." >&2 + exit 1 + fi + mv release-assets/latest.yml release-assets/latest-arm64.yml + fi ls -l release-assets - name: Keep the build as a workflow artifact diff --git a/README.md b/README.md index 4f961c709..26f190317 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,10 @@ Or download the `.dmg` for your chip, open it, and drag **ZenNotes** to Applicat ### Windows -Download and run `ZenNotes--win-x64.exe`. +Download and run the installer for your PC: + +- Intel and AMD: `ZenNotes--win-x64.exe` +- ARM (Snapdragon and other Windows on ARM): `ZenNotes--win-arm64.exe` ### Linux diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 72b81d3e4..d587e3f93 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -112,6 +112,11 @@ function rendererManualChunk(id: string): string | undefined { return 'vendor-editor-languages' } + // The merge view only serves reviewing an assistant's edits, which + // cm-agent-review.ts imports on demand. The rule below would pull it into + // vendor-editor, onto every launch; left alone it stays in that lazy chunk. + if (id.includes('/@codemirror/merge/')) return undefined + if ( id.includes('/@codemirror/') || id.includes('/codemirror/') || diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 79ca73743..c96d48998 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.67.0", + "version": "2.68.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -34,6 +34,7 @@ "pack": "npm run build:prod && electron-builder --dir", "dist:mac": "npm run build:prod && electron-builder --mac --publish never", "dist:win": "npm run build:prod && electron-builder --win --publish never", + "dist:win:arm64": "npm run build:prod && electron-builder --win --arm64 --publish never", "dist:linux": "npm run build:prod && electron-builder --linux --publish never" }, "dependencies": { diff --git a/apps/desktop/src/cli/commands/tasks.ts b/apps/desktop/src/cli/commands/tasks.ts index 52455e260..05776d326 100644 --- a/apps/desktop/src/cli/commands/tasks.ts +++ b/apps/desktop/src/cli/commands/tasks.ts @@ -58,7 +58,7 @@ export async function cmdTaskToggle(vault: VaultBackend, args: ParsedArgs): Prom const next = await vault.toggleTask(id) if (next == null) { throw new Error( - `Task ${id} no longer exists at that location — the file may have changed. Run \`zn task list\` again.` + `Task ${id} no longer exists at that location (the file may have changed). Run \`zn task list\` again.` ) } if (getBool(args, 'json')) { diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index 7ef82e0e8..a102c5a48 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -94,7 +94,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { name: 'list', description: 'List notes, most recent first', flags: '--folder --tag --limit --json' }, { name: 'read ', description: 'Print a note body to stdout', flags: '--meta --json' }, { name: 'create', description: 'Create a new note. Body from --body or stdin', flags: '--title --folder inbox|quick|archive --subpath

--tag --body "..."|-' }, - { name: 'write ', description: 'Replace a note body. Destructive — prefer append', flags: '--body "..."|-' }, + { name: 'write ', description: 'Replace a note body. Destructive, so prefer append', flags: '--body "..."|-' }, { name: 'append ', description: 'Append text to the end of a note', flags: '--body "..."|-' }, { name: 'prepend ', description: 'Insert text at the top, after frontmatter', flags: '--body "..."|-' }, { name: 'rename ', description: 'Rename a note (filename only)', flags: '--to ' }, diff --git a/apps/desktop/src/main/agent-edit-files.ts b/apps/desktop/src/main/agent-edit-files.ts new file mode 100644 index 000000000..cbd871259 --- /dev/null +++ b/apps/desktop/src/main/agent-edit-files.ts @@ -0,0 +1,155 @@ +// Agent edit records on disk: `.zennotes/agent-edits/.json`, one per +// note an assistant changed through MCP and nobody has reviewed yet (see +// @shared/agent-edits and docs/ideas/agent-edit-review.md). The MCP server +// writes them and the app reviews them, and the MCP server cannot import +// vault.ts (it pulls in Electron), so this stays free of Electron like +// note-sidecars.ts. +import { promises as fs } from 'node:fs' +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import { + AGENT_EDITS_DIR, + AGENT_EDITS_SUFFIX, + latestAgentEdit, + parseAgentEditRecord, + serializeAgentEditRecord, + type AgentEditRecord +} from '@shared/agent-edits' +import type { AgentEditSummary } from '@shared/ipc' +import { renameWithRetry } from './atomic-write' + +const INTERNAL_VAULT_DIR = '.zennotes' + +function toPosix(rel: string): string { + return rel.split(path.sep).join('/') +} + +export function agentEditsRoot(root: string): string { + return path.join(root, INTERNAL_VAULT_DIR, AGENT_EDITS_DIR) +} + +/** The record file for a vault-relative note path, refusing any escape. */ +export function agentEditPath(root: string, rel: string): string { + const base = path.resolve(agentEditsRoot(root)) + const abs = path.resolve(base, `${toPosix(rel)}${AGENT_EDITS_SUFFIX}`) + if (!abs.startsWith(base + path.sep)) throw new Error(`Path escapes the vault: ${rel}`) + return abs +} + +/** The note's record, or null when there is none or it is not readable. */ +export async function readAgentEditRecord(root: string, rel: string): Promise { + try { + return parseAgentEditRecord(JSON.parse(await fs.readFile(agentEditPath(root, rel), 'utf8'))) + } catch { + return null + } +} + +/** Write the record (temp file, fsync, rename), or remove it for `null`. */ +export async function writeAgentEditRecord( + root: string, + rel: string, + record: AgentEditRecord | null +): Promise { + const file = agentEditPath(root, rel) + if (record === null) { + await removeAgentEditFile(root, file) + return + } + await fs.mkdir(path.dirname(file), { recursive: true }) + const temporary = `${file}.${randomUUID()}.tmp` + try { + const handle = await fs.open(temporary, 'wx') + try { + await handle.writeFile(serializeAgentEditRecord(record)) + await handle.sync() + } finally { + await handle.close() + } + await renameWithRetry(temporary, file) + } finally { + await fs.rm(temporary, { force: true }) + } +} + +/** The folder holding the records of every note under `folderRel`, for + * moving or deleting a whole folder with its notes. */ +export function agentEditFolderPath(root: string, folderRel: string): string { + const base = path.resolve(agentEditsRoot(root)) + const abs = path.resolve(base, toPosix(folderRel)) + if (!abs.startsWith(base + path.sep)) throw new Error(`Path escapes the vault: ${folderRel}`) + return abs +} + +/** Remove a note's record, or with `directory` every record under a folder: + * a deleted note has nothing left to review. */ +export async function removeAgentEditRecord( + root: string, + rel: string, + directory = false +): Promise { + if (!directory) { + await removeAgentEditFile(root, agentEditPath(root, rel)) + return + } + const folder = agentEditFolderPath(root, rel) + await fs.rm(folder, { recursive: true, force: true }) + await pruneEmptyDirs(root, path.dirname(folder)) +} + +async function removeAgentEditFile(root: string, file: string): Promise { + await fs.rm(file, { force: true }) + await pruneEmptyDirs(root, path.dirname(file)) +} + +/** Leave no empty folders behind, agent-edits itself included: a vault whose + * changes have all been reviewed carries no trace of them. */ +async function pruneEmptyDirs(root: string, from: string): Promise { + const stop = path.resolve(agentEditsRoot(root)) + for (let dir = path.resolve(from); dir === stop || dir.startsWith(stop + path.sep); dir = path.dirname(dir)) { + try { + await fs.rmdir(dir) + } catch { + break + } + } +} + +/** Every record in the vault, newest edit first. Unreadable files are skipped. */ +export async function listAgentEditRecords(root: string): Promise { + const base = agentEditsRoot(root) + const found: AgentEditSummary[] = [] + async function walk(dir: string): Promise { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const abs = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(abs) + continue + } + if (!entry.isFile() || !entry.name.endsWith(AGENT_EDITS_SUFFIX)) continue + const notePath = toPosix(path.relative(base, abs)).slice(0, -AGENT_EDITS_SUFFIX.length) + let record: AgentEditRecord | null = null + try { + record = parseAgentEditRecord(JSON.parse(await fs.readFile(abs, 'utf8'))) + } catch { + record = null + } + if (!record) continue + const latest = latestAgentEdit(record) + found.push({ + notePath, + agent: latest?.agent ?? 'Assistant', + at: latest?.at ?? 0, + edits: record.edits.length + }) + } + } + await walk(base) + return found.sort((a, b) => b.at - a.at) +} diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index df0bb6817..db54a75a3 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -197,7 +197,7 @@ describe('TOML serialization', () => { ) expect(text).toContain('[vim]') expect(text).toContain( - 'wrapped_line_motions = "display" # display | logical — how $, I, A and dependent operators treat soft-wrapped lines' + 'wrapped_line_motions = "display" # display | logical: how $, I, A and dependent operators treat soft-wrapped lines' ) expect(text).toContain('[view]') // Keymaps: every action listed as a commented, grouped default reference. diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index bc34b59d3..6e8753766 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -39,12 +39,12 @@ interface ScalarFieldMap { } // Order sections are emitted in. Keeps the file stable across rewrites. -const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'view'] as const +const SECTION_ORDER = ['vim', 'search', 'editor', 'appearance', 'typography', 'view', 'mcp', 'privacy'] as const // Scalar (string / number / boolean) portable prefs → [section].key (snake_case). const SCALAR_FIELDS: Partial> = { // vim - vimMode: { section: 'vim', tomlKey: 'enabled', comment: 'true | false — CodeMirror Vim bindings' }, + vimMode: { section: 'vim', tomlKey: 'enabled', comment: 'true | false: CodeMirror Vim bindings' }, vimInsertEscape: { section: 'vim', tomlKey: 'insert_escape', @@ -63,7 +63,7 @@ const SCALAR_FIELDS: Partial> = { vimWrappedLineMotions: { section: 'vim', tomlKey: 'wrapped_line_motions', - comment: 'display | logical — how $, I, A and dependent operators treat soft-wrapped lines' + comment: 'display | logical: how $, I, A and dependent operators treat soft-wrapped lines' }, whichKeyHints: { section: 'vim', @@ -117,6 +117,16 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'sync_title_heading_on_rename', comment: "renaming a note rewrites its leading '# heading' to match" }, + reviewAgentEdits: { + section: 'mcp', + tomlKey: 'review_agent_edits', + comment: 'keep what a note said before an assistant edits it through MCP, so you can review the change' + }, + usageCount: { + section: 'privacy', + tomlKey: 'usage_count', + comment: 'send an anonymous count when ZenNotes installs or updates: version, OS, architecture, install kind; no identifier' + }, markdownSnippets: { section: 'editor', tomlKey: 'markdown_snippets', @@ -172,7 +182,7 @@ const SCALAR_FIELDS: Partial> = { editorScrollOff: { section: 'editor', tomlKey: 'scroll_off', - comment: 'vim scrolloff — lines kept above/below the cursor (0 = off)' + comment: 'vim scrolloff: lines kept above/below the cursor (0 = off)' }, timeFormat: { section: 'editor', @@ -192,7 +202,7 @@ const SCALAR_FIELDS: Partial> = { completedTaskStyle: { section: 'editor', tomlKey: 'completed_task_style', - comment: 'none | strikethrough | gray | gray-strikethrough — style checked-task text' + comment: 'none | strikethrough | gray | gray-strikethrough: style checked-task text' }, mathRenderer: { section: 'editor', @@ -202,7 +212,7 @@ const SCALAR_FIELDS: Partial> = { typstTagPreambles: { section: 'editor', tomlKey: 'typst_tag_preambles', - comment: 'true | false — prepend Typst definitions from notes in a `typst` folder, chosen by a note\'s tags' + comment: 'true | false: prepend Typst definitions from notes in a `typst` folder, chosen by a note\'s tags' }, harperEnabled: { section: 'editor', @@ -222,7 +232,7 @@ const SCALAR_FIELDS: Partial> = { defaultPaneMode: { section: 'editor', tomlKey: 'default_view_mode', - comment: 'edit | split | preview — the mode a note opens in before it has a remembered one' + comment: 'edit | split | preview: the mode a note opens in before it has a remembered one' }, keepViewModeAcrossNotes: { section: 'editor', @@ -263,7 +273,7 @@ const SCALAR_FIELDS: Partial> = { pdfExportUseTheme: { section: 'appearance', tomlKey: 'pdf_export_use_theme', - comment: 'true | false — export PDFs using the current theme instead of a clean light print theme' + comment: 'true | false: export PDFs using the current theme instead of a clean light print theme' }, themeFamily: { section: 'appearance', @@ -438,12 +448,12 @@ const LIST_FIELDS: Partial> = { kanbanStatuses: { section: 'view', tomlKey: 'kanban_statuses', - comment: 'custom-status Kanban columns, in order — e.g. ["backlog", "in_progress", "review", "done"]' + comment: 'custom-status Kanban columns in order, e.g. ["backlog", "in_progress", "review", "done"]' }, hiddenWorkflowPresets: { section: 'view', tomlKey: 'hidden_workflow_presets', - comment: 'built-in workflow recipes hidden from the gallery, by id — e.g. ["reading-log"]' + comment: 'built-in workflow recipe ids hidden from the gallery, e.g. ["reading-log"]' } } @@ -462,7 +472,7 @@ const MAP_TABLE_FIELDS: Partial> = { keymapOverrides: { table: 'keymaps', comment: [ - 'Keymap overrides — only list the bindings you want to change.', + 'Keymap overrides. Only list the bindings you want to change.', 'Set a binding to "" to remove the key entirely.', 'Find the full list of action IDs in Settings → Keymaps.' ], @@ -480,7 +490,7 @@ const MAP_TABLE_FIELDS: Partial> = { }, enabledOverrides: { table: 'overrides', - comment: ['Enabled CSS overrides — list the filenames you want active.'], + comment: ['Enabled CSS overrides. List the filenames you want active.'], example: '"focus.css" = "on"' }, themeTweaks: { @@ -520,10 +530,10 @@ const FILE_HEADER = `# ZenNotes configuration # # Holds your portable preferences (theme, editor, vim, keymaps, …) so you can # sync them across machines with git, stow, chezmoi, and friends. Managed by -# ZenNotes but safe to hand-edit — changes apply live, no restart needed. +# ZenNotes but safe to hand-edit: changes apply live, no restart needed. # # Every available option is listed below with its current value; the inline -# comment shows the allowed values. Edit a value to customize it — ZenNotes +# comment shows the allowed values. Edit a value to customize it. ZenNotes # keeps this list complete, so a removed option reappears with its default. # Note: comments you add yourself may be dropped when the app rewrites the file. @@ -825,7 +835,7 @@ async function readConfigFile(): Promise<{ text: string; portable: AppConfigPort const { portable } = deserializeConfig(text) return { text, portable } } catch (err) { - console.warn('Failed to parse config.toml — keeping last good config', err) + console.warn('Failed to parse config.toml, keeping last good config', err) return null } } diff --git a/apps/desktop/src/main/custom-themes.ts b/apps/desktop/src/main/custom-themes.ts index 6c3a73190..b033c7bd4 100644 --- a/apps/desktop/src/main/custom-themes.ts +++ b/apps/desktop/src/main/custom-themes.ts @@ -83,7 +83,7 @@ function softPaperManifest(): string { name: 'Soft Paper', author: 'Nick Milo (port)', version: '1.0.0', - description: 'A warm, paper-like theme — light + dark.', + description: 'A warm, paper-like theme (light + dark).', modes: 'both', preview: { light: '#1a7da4', dark: '#85c1dc' } }, @@ -126,7 +126,7 @@ my-theme/ \`\`\` \`modes\` is \`"light"\`, \`"dark"\`, or \`"both"\`. \`preview\` colors are only used for -the swatch on the Settings card. A missing manifest is fine — the theme is then +the swatch on the Settings card. A missing manifest is fine: the theme is then named after its folder and assumed to support both modes. ## theme.css diff --git a/apps/desktop/src/main/deep-links.test.ts b/apps/desktop/src/main/deep-links.test.ts index fe4eaf09d..72c787484 100644 --- a/apps/desktop/src/main/deep-links.test.ts +++ b/apps/desktop/src/main/deep-links.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { buildOpenNoteDeepLink, + cloudSignInUsesLoopback, parseCloudAuthDeepLink, parseOpenNoteDeepLink, - parseQuickCaptureDeepLink + parseQuickCaptureDeepLink, + registersDeepLinkSchemeAtLaunch } from './deep-links' describe('parseOpenNoteDeepLink', () => { @@ -104,3 +106,33 @@ describe('parseCloudAuthDeepLink', () => { expect(parseCloudAuthDeepLink('https://zennotes.org/auth?code=abc&state=state')).toBeNull() }) }) + +describe('registersDeepLinkSchemeAtLaunch', () => { + it('registers on Windows, where no installer writes the scheme (#509)', () => { + expect(registersDeepLinkSchemeAtLaunch('win32')).toBe(true) + }) + + it('keeps registering on macOS', () => { + expect(registersDeepLinkSchemeAtLaunch('darwin')).toBe(true) + }) + + it('leaves Linux to the package desktop file and the user choice', () => { + expect(registersDeepLinkSchemeAtLaunch('linux')).toBe(false) + }) +}) + +describe('cloudSignInUsesLoopback', () => { + it('returns Windows and Linux sign-ins through the loopback page (#509)', () => { + expect(cloudSignInUsesLoopback(true, 'win32')).toBe(true) + expect(cloudSignInUsesLoopback(true, 'linux')).toBe(true) + }) + + it('keeps the zennotes:// link for packaged macOS', () => { + expect(cloudSignInUsesLoopback(true, 'darwin')).toBe(false) + }) + + it('uses the loopback in development builds everywhere, as before', () => { + expect(cloudSignInUsesLoopback(false, 'darwin')).toBe(true) + expect(cloudSignInUsesLoopback(false, 'win32')).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/deep-links.ts b/apps/desktop/src/main/deep-links.ts index 4eae5a9b6..f089371c0 100644 --- a/apps/desktop/src/main/deep-links.ts +++ b/apps/desktop/src/main/deep-links.ts @@ -95,6 +95,28 @@ export function buildOpenNoteDeepLink(relPath: string): string { return `${ZENNOTES_DEEP_LINK_SCHEME}://open?path=${encoded}` } +/** Whether the app registers itself for zennotes:// links each time it starts. + * Windows has to: electron-builder's `protocols` option is macOS only, so + * neither the NSIS installer nor the zip writes the scheme to the registry, + * and until 2.68 no Windows install could open a zennotes:// link at all. + * Registering per user at launch also covers the zip, wherever it was + * unpacked. Linux stays with the package's .desktop file, which already + * declares the scheme; setting the default there would override the user's + * own xdg-mime choice. */ +export function registersDeepLinkSchemeAtLaunch(platform: NodeJS.Platform): boolean { + return platform === 'darwin' || platform === 'win32' +} + +/** Whether a Cloud sign-in returns to the app through a one-time page on + * http://127.0.0.1 (RFC 8252's loopback redirect) instead of a zennotes:// + * link. The loopback needs no link handler and no browser prompt, so it is + * the default; only packaged macOS keeps the link, which its signed bundle + * always registers. Windows and Linux sign-ins used to hang on a link nothing + * handled (an AppImage without desktop integration, every Windows install). */ +export function cloudSignInUsesLoopback(packaged: boolean, platform: NodeJS.Platform): boolean { + return !packaged || platform !== 'darwin' +} + export function normalizeDeepLinkNotePath(rawPath: string | null | undefined): string | null { const trimmed = rawPath?.trim() if (!trimmed || trimmed.includes('\0')) return null diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 629df348b..981299321 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -116,6 +116,12 @@ import { writeNoteComments, writeNote, } from "./vault"; +import { + listAgentEditRecords, + readAgentEditRecord, + writeAgentEditRecord, +} from "./agent-edit-files"; +import { parseAgentEditRecord } from "@shared/agent-edits"; import { initAppConfig, getPortableConfigSnapshot, @@ -263,6 +269,7 @@ import { getRaycastExtensionStatus, installRaycastExtension, } from "./raycast-integration"; +import { sendUsageCount } from "./usage-count"; import { checkForAppUpdates, describeInstall, @@ -286,9 +293,11 @@ import { } from "../mcp/instructions-store"; import { recordBootMark, recordMainPerf } from "./perf"; import { + cloudSignInUsesLoopback, parseCloudAuthDeepLink, parseOpenNoteDeepLink, parseQuickCaptureDeepLink, + registersDeepLinkSchemeAtLaunch, ZENNOTES_DEEP_LINK_SCHEME, } from "./deep-links"; import { @@ -641,7 +650,7 @@ function openAllowedExternalUrl(url: string): void { } function registerAppDeepLinkProtocol(): void { - if (!isMac()) return; + if (!registersDeepLinkSchemeAtLaunch(process.platform)) return; try { const defaultApp = @@ -3095,11 +3104,11 @@ function registerIpc(): void { app.isPackaged, process.env["ZENNOTES_CLOUD_BASE_URL"], ); - const callbackUrl = app.isPackaged - ? undefined - : await getCloudAuthLoopbackServer().start( + const callbackUrl = cloudSignInUsesLoopback(app.isPackaged, process.platform) + ? await getCloudAuthLoopbackServer().start( async (request) => await completeCloudAuthCallback(request), - ); + ) + : undefined; let result; try { result = await getCloudAuthManager().connect(baseUrl, callbackUrl); @@ -3383,6 +3392,7 @@ function registerIpc(): void { }); handle(IPC.APP_UPDATER_GET_STATE, () => getAppUpdateState()); handle(IPC.APP_UPDATER_CHECK, async () => await checkForAppUpdates()); + handle(IPC.APP_USAGE_COUNT, async (_e, event: unknown) => await sendUsageCount(event)); handle(IPC.APP_UPDATER_CHECK_WITH_UI, async () => { await runMenuUpdateCheck(); }); @@ -3929,6 +3939,35 @@ function registerIpc(): void { }, ); + // Agent edit records (docs/ideas/agent-edit-review.md), local vaults only: a + // remote vault's notes are edited through its server, which does not record + // assistant edits yet, so there is nothing to review there. + handle(IPC.VAULT_LIST_AGENT_EDITS, async () => { + if (isRemoteWorkspaceActive()) return []; + return await listAgentEditRecords(requireVault().root); + }); + + handle(IPC.VAULT_READ_AGENT_EDIT, async (_e, relPath: string) => { + if (isRemoteWorkspaceActive() || typeof relPath !== "string") return null; + return await readAgentEditRecord(requireVault().root, relPath); + }); + + handle( + IPC.VAULT_WRITE_AGENT_EDIT, + async (_e, relPath: string, record: unknown) => { + if (isRemoteWorkspaceActive()) return; + if (typeof relPath !== "string") throw new Error("A note path is required"); + const v = requireVault(); + if (isEphemeralRoot(v.root)) return; + // The renderer is not trusted with the shape: only a well-formed record + // (or null, which deletes it) reaches the vault, and only inside + // .zennotes/agent-edits (agentEditPath refuses anything else). + const parsed = record === null ? null : parseAgentEditRecord(record); + if (record !== null && !parsed) throw new Error("Not an agent edit record"); + await writeAgentEditRecord(v.root, relPath, parsed); + }, + ); + handle(IPC.VAULT_SCAN_TASKS, async () => { if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().scanTasks(); diff --git a/apps/desktop/src/main/note-sidecars.ts b/apps/desktop/src/main/note-sidecars.ts index 58c3b8a38..bba4e33d6 100644 --- a/apps/desktop/src/main/note-sidecars.ts +++ b/apps/desktop/src/main/note-sidecars.ts @@ -1,7 +1,8 @@ -// A note's two app-owned sidecars, and moving a note (or a folder) together -// with them. Comments live at `.zennotes/comments/.comments.json` and -// the creation date at `.zennotes/note-metadata/.metadata.json` (see -// note-creation-metadata.ts). Desktop main, the MCP server and the workflow +// A note's app-owned sidecars, and moving a note (or a folder) together with +// them. Comments live at `.zennotes/comments/.comments.json`, the +// creation date at `.zennotes/note-metadata/.metadata.json` (see +// note-creation-metadata.ts), and an assistant's unreviewed edit at +// `.zennotes/agent-edits/.json` (see agent-edit-files.ts). Desktop main, the MCP server and the workflow // applier all move notes, and the MCP server cannot import vault.ts (it pulls // in Electron), so this stays free of Electron and is the one place that knows // how a note's sidecars travel with it. @@ -10,6 +11,7 @@ import { randomUUID } from 'node:crypto' import path from 'node:path' import { NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX } from '@shared/note-comments' import { noteMetadataPath, removeNoteCreation } from './note-creation-metadata' +import { agentEditPath, removeAgentEditRecord } from './agent-edit-files' const INTERNAL_VAULT_DIR = '.zennotes' @@ -136,12 +138,16 @@ export async function relocateNote( if (!(await pathExists(toAbs))) { if (await pathExists(toComments)) throw new Error(leftoverCommentsMessage(root, toAbs)) await removeNoteCreation(root, toRel) + // Like a creation date, an edit record with no note beside it belongs to + // nobody, and reviewing it against another note would be wrong. + await removeAgentEditRecord(root, toRel) } await relocateFolderTrees( [ [resolveSafe(root, fromRel), toAbs], [noteCommentsPath(root, fromRel), toComments], - [await noteMetadataPath(root, fromRel), await noteMetadataPath(root, toRel)] + [await noteMetadataPath(root, fromRel), await noteMetadataPath(root, toRel)], + [agentEditPath(root, fromRel), agentEditPath(root, toRel)] ], persistSettings ) diff --git a/apps/desktop/src/main/overrides.ts b/apps/desktop/src/main/overrides.ts index ed29525dc..2d6a1e3fe 100644 --- a/apps/desktop/src/main/overrides.ts +++ b/apps/desktop/src/main/overrides.ts @@ -29,9 +29,9 @@ function isSafeName(name: unknown): name is string { ) } -const EXAMPLE = `/* ZenNotes override — a cookbook. Toggle it on in Settings → Appearance → +const EXAMPLE = `/* ZenNotes override: a cookbook. Toggle it on in Settings → Appearance → * Overrides, then uncomment a recipe below to use it. As shipped, this file - * changes nothing — every recipe is commented out. + * changes nothing: every recipe is commented out. * * Overrides layer on top of whichever theme is active. Target :root[data-theme] * so your rule wins over both a built-in theme's :root[data-theme="…"] block and @@ -70,7 +70,7 @@ const EXAMPLE = `/* ZenNotes override — a cookbook. Toggle it on in Settings } */ -/* Any other CSS works too — use the "Developer tools" button in Settings → +/* Any other CSS works too. Use the "Developer tools" button in Settings → * Appearance → Overrides to inspect an element, find its class, then style it. * Fonts and text size live in Settings → Typography, so set those there. */ ` diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0b66c3030..db3cb525a 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -43,7 +43,9 @@ import { osReleasePrettyName, OFFLINE_POLL_MS, OFFLINE_RETRY_BASE_MS, - OFFLINE_RETRY_MAX_MS + OFFLINE_RETRY_MAX_MS, + WINDOWS_ARM64_UPDATE_CHANNEL, + windowsUpdateChannel } from './updater' describe('installLabel (:version, issue #814)', () => { @@ -95,6 +97,28 @@ describe('installLabel (:version, issue #814)', () => { }) }) +describe('windowsUpdateChannel (#928)', () => { + const channel = (platform: NodeJS.Platform, arch: string, runningUnderARM64Translation = false) => + windowsUpdateChannel({ platform, arch, runningUnderARM64Translation }) + + it('sends the ARM64 build to the ARM64 feed the release publishes', () => { + expect(WINDOWS_ARM64_UPDATE_CHANNEL).toBe('latest-arm64') + expect(channel('win32', 'arm64')).toBe('latest-arm64') + }) + + it('moves an x64 copy under ARM64 emulation to the native build', () => { + expect(channel('win32', 'x64', true)).toBe('latest-arm64') + }) + + it('leaves x64 Windows, macOS and Linux on their default feeds', () => { + expect(channel('win32', 'x64')).toBeNull() + // electron-updater picks the Mac and Linux arm64 files itself. + expect(channel('darwin', 'arm64')).toBeNull() + expect(channel('darwin', 'x64', true)).toBeNull() + expect(channel('linux', 'arm64')).toBeNull() + }) +}) + describe('osReleasePrettyName', () => { it('reads PRETTY_NAME and strips its quotes', () => { expect( diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 530fbf6c5..45eb289aa 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -298,6 +298,27 @@ export function getAppUpdateState(): AppUpdateState { return { ...updateState } } +/** The feed of the Windows ARM64 build (#928). electron-builder names every + * Windows feed `latest.yml`, whatever the architecture, so the release + * workflow publishes the x64 build's under that name and the ARM64 build's + * as `latest-arm64.yml`. electron-updater reads `.yml` on Windows. */ +export const WINDOWS_ARM64_UPDATE_CHANNEL = 'latest-arm64' + +/** The update channel a Windows copy reads, or null for the default feed. + * An x64 copy running under ARM64 emulation reads the ARM64 feed as well, so + * its next update is the native build: electron-updater already does that + * for an Intel copy under Rosetta on a Mac. */ +export function windowsUpdateChannel(input: { + platform: NodeJS.Platform + arch: string + runningUnderARM64Translation: boolean +}): string | null { + if (input.platform !== 'win32') return null + return input.arch === 'arm64' || input.runningUnderARM64Translation + ? WINDOWS_ARM64_UPDATE_CHANNEL + : null +} + export function initAppUpdater(): void { if (initialized) return initialized = true @@ -340,6 +361,18 @@ export function initAppUpdater(): void { updater = chosen updater.autoDownload = false updater.autoInstallOnAppQuit = true + const channel = windowsUpdateChannel({ + platform: process.platform, + arch: process.arch, + runningUnderARM64Translation: app.runningUnderARM64Translation + }) + if (channel) { + updater.channel = channel + // Setting a channel also allows downgrades (electron-updater's way back + // from a beta channel). This feed is the same release line, so a newer + // version stays the only update. + updater.allowDowngrade = false + } updater.on('checking-for-update', () => { setUpdateState( @@ -872,15 +905,7 @@ export function describeInstall(): { os: string; install: string } { mas: Boolean(process.mas), windowsStore: Boolean(process.windowsStore), portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, - linuxFormat: () => - linuxUpdaterFormat({ - isAppImage: Boolean(process.env.APPIMAGE), - isOfficialSystemPackage: isOfficialLinuxSystemPackage( - process.resourcesPath, - existsSync(join(process.resourcesPath, 'package-type')) - ), - osRelease - }) + linuxFormat: () => currentLinuxFormat(osRelease) }) } catch { install = 'unknown' @@ -888,6 +913,18 @@ export function describeInstall(): { os: string; install: string } { return { os, install } } +/** This copy's Linux install format, as the updater acts on it. */ +export function currentLinuxFormat(osRelease: string | null = readOsReleaseOrNull()): LinuxPackageFormat { + return linuxUpdaterFormat({ + isAppImage: Boolean(process.env.APPIMAGE), + isOfficialSystemPackage: isOfficialLinuxSystemPackage( + process.resourcesPath, + existsSync(join(process.resourcesPath, 'package-type')) + ), + osRelease + }) +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } diff --git a/apps/desktop/src/main/usage-count.test.ts b/apps/desktop/src/main/usage-count.test.ts new file mode 100644 index 000000000..c963fbbe3 --- /dev/null +++ b/apps/desktop/src/main/usage-count.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getVersion: () => '2.68.0', isPackaged: true }, net: { fetch: vi.fn() } })) +vi.mock('./updater', () => ({ currentLinuxFormat: () => 'appimage' })) + +import { createUsageCounter, USAGE_COUNT_URL, usageCountBody, usageInstallKind, type UsageCount } from './usage-count' + +const base = { + event: 'update' as unknown, + version: '2.68.0', + platform: 'darwin' as NodeJS.Platform, + arch: 'arm64', + install: 'dmg' as const, + isPackaged: true, + env: {} as NodeJS.ProcessEnv +} + +describe('usageInstallKind', () => { + const kind = (platform: NodeJS.Platform, extra: Partial[0]> = {}) => + usageInstallKind({ + platform, + mas: false, + windowsStore: false, + portableExecutableDir: undefined, + linuxFormat: () => 'pacman', + ...extra + }) + + it('names each install the way the server expects', () => { + expect(kind('darwin')).toBe('dmg') + expect(kind('darwin', { mas: true })).toBe('mas') + expect(kind('win32')).toBe('nsis') + expect(kind('win32', { portableExecutableDir: 'C:\\\\Tools' })).toBe('portable') + expect(kind('win32', { windowsStore: true })).toBe('store') + expect(kind('linux')).toBe('pacman') + expect(kind('freebsd')).toBe('unknown') + }) +}) + +describe('usageCountBody', () => { + it('carries the event, version, OS, architecture and install kind, and nothing else', () => { + expect(usageCountBody(base)).toEqual({ + event: 'update', + version: '2.68.0', + platform: 'macos', + arch: 'arm64', + install: 'dmg' + }) + expect(usageCountBody({ ...base, platform: 'win32', arch: 'x64', install: 'nsis' })?.platform).toBe('windows') + }) + + it('counts no development build, test run or CI run', () => { + expect(usageCountBody({ ...base, isPackaged: false })).toBeNull() + expect(usageCountBody({ ...base, env: { ZEN_PERF: '1' } })).toBeNull() + expect(usageCountBody({ ...base, env: { CI: 'true' } })).toBeNull() + }) + + it('refuses what the server would refuse', () => { + expect(usageCountBody({ ...base, event: 'launch' })).toBeNull() + expect(usageCountBody({ ...base, arch: 'ia32' })).toBeNull() + expect(usageCountBody({ ...base, platform: 'freebsd' })).toBeNull() + expect(usageCountBody({ ...base, version: '2.68.0-beta.1' })).toBeNull() + }) +}) + +describe('createUsageCounter', () => { + const body = (event: unknown): UsageCount | null => usageCountBody({ ...base, event }) + + it('sends one POST with the JSON body and a bare user agent', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true }) + await createUsageCounter({ fetch, body })('update') + expect(fetch).toHaveBeenCalledTimes(1) + const [url, init] = fetch.mock.calls[0] + expect(url).toBe(USAGE_COUNT_URL) + expect(init.method).toBe('POST') + expect(init.credentials).toBe('omit') + expect(init.headers['user-agent']).toBe('ZenNotes') + expect(init.headers['accept-language']).toBe('*') + expect(JSON.parse(init.body)).toEqual({ event: 'update', version: '2.68.0', platform: 'macos', arch: 'arm64', install: 'dmg' }) + }) + + it('sends each event at most once per run, whatever the renderer asks', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true }) + const send = createUsageCounter({ fetch, body }) + await send('update') + await send('update') + await send('install') + expect(fetch.mock.calls.map(([, init]) => JSON.parse(init.body).event)).toEqual(['update', 'install']) + }) + + it('sends nothing for a refused event, and drops a failed send quietly', async () => { + const fetch = vi.fn().mockRejectedValue(new Error('offline')) + const send = createUsageCounter({ fetch, body }) + await send('launch') + expect(fetch).not.toHaveBeenCalled() + await expect(send('update')).resolves.toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/usage-count.ts b/apps/desktop/src/main/usage-count.ts new file mode 100644 index 000000000..d91a3abbe --- /dev/null +++ b/apps/desktop/src/main/usage-count.ts @@ -0,0 +1,137 @@ +/** + * The anonymous usage count (Adib, 2026-10-12: on by default, a switch in + * Settings > About, no identifier). Once per fresh install and once per + * update, the renderer asks for one message to zennotes.org: the event, the + * version, the operating system, the architecture and how the copy was + * installed. Nothing else: no identifier, nothing about notes, vaults or the + * account, a bare user agent. The server keeps daily totals only. + * + * The renderer decides when (lib/whats-new.ts, after the version it recorded) + * and whether (the setting); this side decides what, and refuses anything + * that is not a real install of a release: a development build, a test run, + * an event it does not know. + */ +import { app, net } from 'electron' +import { currentLinuxFormat, type LinuxPackageFormat } from './updater' + +export const USAGE_COUNT_URL = 'https://zennotes.org/api/v1/app/usage' + +export type UsageCountEvent = 'install' | 'update' +export type UsageInstallKind = + | 'dmg' + | 'mas' + | 'nsis' + | 'portable' + | 'store' + | 'appimage' + | 'deb' + | 'rpm' + | 'pacman' + | 'managed' + | 'unknown' + +export interface UsageCount { + event: UsageCountEvent + version: string + platform: 'macos' | 'windows' | 'linux' + arch: 'x64' | 'arm64' + install: UsageInstallKind +} + +/** How this copy was installed, in the server's vocabulary. */ +export function usageInstallKind(input: { + platform: NodeJS.Platform + mas: boolean + windowsStore: boolean + portableExecutableDir: string | undefined + linuxFormat: () => LinuxPackageFormat +}): UsageInstallKind { + switch (input.platform) { + case 'darwin': + return input.mas ? 'mas' : 'dmg' + case 'win32': + if (input.windowsStore) return 'store' + return input.portableExecutableDir ? 'portable' : 'nsis' + case 'linux': + return input.linuxFormat() + default: + return 'unknown' + } +} + +/** The message, or null for a copy that is not counted: a development build, + * a test run (ZEN_PERF, CI), or anything the server would refuse. */ +export function usageCountBody(input: { + event: unknown + version: string + platform: NodeJS.Platform + arch: string + install: UsageInstallKind + isPackaged: boolean + env: NodeJS.ProcessEnv +}): UsageCount | null { + const { event, version, arch, install } = input + if (event !== 'install' && event !== 'update') return null + if (!input.isPackaged || input.env.ZEN_PERF === '1' || input.env.CI) return null + const platform = ( + { darwin: 'macos', win32: 'windows', linux: 'linux' } as Partial> + )[input.platform] + if (!platform) return null + if (arch !== 'x64' && arch !== 'arm64') return null + if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(version)) return null + return { event, version, platform, arch, install } +} + +type Fetch = (url: string, init: RequestInit) => Promise + +/** The whole request: no cookies (the app's session may hold zennotes.org + * ones from a Cloud page), a bare user agent, and an Accept-Language that + * names no language (Chromium otherwise adds the system's, e.g. en-US). On + * the wire everything but the five fields is the same for every copy. */ +export const USAGE_COUNT_REQUEST = (body: UsageCount): RequestInit => ({ + method: 'POST', + credentials: 'omit', + cache: 'no-store', + headers: { 'content-type': 'application/json', 'user-agent': 'ZenNotes', 'accept-language': '*' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000) +}) + +/** A sender that sends each event at most once per run of the app, whatever + * the renderer asks: a renderer bug must not turn the count into a stream. */ +export function createUsageCounter(deps: { + fetch: Fetch + body: (event: unknown) => UsageCount | null +}): (event: unknown) => Promise { + const sent = new Set() + return async (event) => { + const body = deps.body(event) + if (!body || sent.has(body.event)) return + sent.add(body.event) + try { + await deps.fetch(USAGE_COUNT_URL, USAGE_COUNT_REQUEST(body)) + } catch { + // A count that cannot be sent is dropped: it is a tally, not a record. + } + } +} + +export const sendUsageCount = createUsageCounter({ + fetch: (url, init) => net.fetch(url, init), + body: (event) => + usageCountBody({ + event, + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + install: usageInstallKind({ + platform: process.platform, + mas: Boolean(process.mas), + windowsStore: Boolean(process.windowsStore), + portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, + linuxFormat: () => currentLinuxFormat() + }), + isPackaged: app.isPackaged, + env: process.env + }) +}) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 33a62bdd6..5f92a3c94 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -17,6 +17,7 @@ import { ensureVaultLayout, folderForRelativePath, forgetLocalVault, + generateDemoTour, getVaultSettings, importFiles, importPastedImage, @@ -38,6 +39,7 @@ import { renameLocalVault, deleteFolder, readNoteComments, + removeDemoTour, writeNoteComments, restoreDeletedAsset, restoreFromTrash, @@ -1305,6 +1307,41 @@ describe('listNotes asset embeds (#185 usage)', () => { }) }) +describe('demo tour under its older note names', () => { + // Built from its code point so this file never contains the character itself. + const LONG_DASH = String.fromCharCode(0x2014) + const olderStart = `inbox/demo/00 ${LONG_DASH} Start Here.md` + const olderMath = `inbox/demo/05b ${LONG_DASH} Math Diagrams.md` + const ownNote = 'inbox/demo/My own note.md' + + async function vaultWithOlderTour(): Promise { + const root = await makeTempDir('zennotes-demo-tour-') + await ensureVaultLayout(root) + await mkdir(path.join(root, 'inbox', 'demo'), { recursive: true }) + for (const rel of [olderStart, olderMath, ownNote]) { + await writeFile(path.join(root, rel), 'older\n') + } + return root + } + + it('regenerating replaces the older files instead of writing a second tour', async () => { + const root = await vaultWithOlderTour() + const result = await generateDemoTour(root) + const names = await fsPromises.readdir(path.join(root, 'inbox', 'demo')) + expect(names.filter((name) => name.includes(LONG_DASH))).toEqual([]) + expect(names).toContain('00 Start Here.md') + expect(names).toContain('My own note.md') + expect(result.notePaths).toContain('inbox/demo/00 Start Here.md') + }) + + it('removing deletes the older files too and reports them', async () => { + const root = await vaultWithOlderTour() + const result = await removeDemoTour(root) + expect(result.notePaths).toEqual(expect.arrayContaining([olderStart, olderMath])) + expect(await fsPromises.readdir(path.join(root, 'inbox', 'demo'))).toEqual(['My own note.md']) + }) +}) + describe('archive / trash round-trips', () => { async function makeVaultWithNestedNote(): Promise<{ root: string }> { const root = await makeTempDir('zennotes-folder-moves-') diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 25e192f87..f7d64f354 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -5,6 +5,7 @@ import { relocateFolderTrees, relocateNote } from './note-sidecars' +import { agentEditFolderPath, removeAgentEditRecord } from './agent-edit-files' import { promises as fs, type Dirent } from 'node:fs' import { execFile, spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' @@ -59,7 +60,7 @@ import { VaultChangeEvent, VaultInfo } from '@shared/ipc' -import { DEMO_TOUR_DIR } from '@shared/demo-tour' +import { currentDemoTourPath, DEMO_TOUR_DIR } from '@shared/demo-tour' import { normalizeNoteComments } from '@shared/note-comments' import { FRONTMATTER_BLOCK_RE, frontmatterTags } from '@shared/frontmatter' import { rewriteAssetReferences } from '@shared/asset-link-rename' @@ -1610,7 +1611,7 @@ async function typstPreambleFolderFor(root: string): Promise { */ export function vaultChangeAffectsSettings(ev: VaultChangeEvent): boolean { if (ev.scope === 'vault-settings') return true - if (ev.scope === 'comments' || ev.scope === 'database') return false + if (ev.scope === 'comments' || ev.scope === 'agent-edits' || ev.scope === 'database') return false // A root-level entry (`Notes.md`, or a directory named `Notes`) is what the // inference looks at; anything nested carries a separator. return !ev.path.includes('/') @@ -3499,6 +3500,7 @@ export async function createNote( const abs = path.join(dir, `${finalTitle}.md`) const body = `# ${finalTitle}\n\n` await removeNoteCreation(root, toPosix(path.relative(root, abs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, body, 'utf8') invalidateNoteMetaCache(root, toPosix(path.relative(root, abs))) invalidateVaultTextSearchCache(root) @@ -3530,6 +3532,7 @@ export async function createExcalidraw( } const abs = path.join(dir, `${finalTitle}.excalidraw`) await removeNoteCreation(root, toPosix(path.relative(root, abs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, JSON.stringify(emptyExcalidrawDocument(), null, 2), 'utf8') invalidateNoteMetaCache(root, toPosix(path.relative(root, abs))) return await readMeta(root, abs, folder) @@ -3571,6 +3574,7 @@ export async function convertObsidianExcalidraw(root: string, rel: string): Prom } const destAbs = path.join(dir, `${finalTitle}.excalidraw`) await removeNoteCreation(root, toPosix(path.relative(root, destAbs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, destAbs))) await fs.writeFile(destAbs, JSON.stringify(scene, null, 2), 'utf8') invalidateNoteMetaCache(root, toPosix(path.relative(root, destAbs))) return await readMeta(root, destAbs, folder) @@ -3590,6 +3594,7 @@ export async function importExternalNote(root: string, sourceAbsPath: string): P const destAbs = path.join(destDir, `${finalTitle}.md`) const body = await fs.readFile(source, 'utf8') await removeNoteCreation(root, toPosix(path.relative(root, destAbs))) + await removeAgentEditRecord(root, toPosix(path.relative(root, destAbs))) await fs.writeFile(destAbs, body, 'utf8') await fs.rm(source, { force: true }) const rel = toPosix(path.relative(root, destAbs)) @@ -3742,6 +3747,7 @@ export async function trashNoteToSystem(root: string, rel: string): Promise console.warn('Agent edit cleanup pending', error)) invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) return meta @@ -3771,6 +3777,7 @@ export async function emptyTrash(root: string): Promise { [await noteMetadataPath(root, trashRel, true), path.join(temporary, 'metadata')] ], async () => {}) await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Trash cleanup pending', error)) + await removeAgentEditRecord(root, trashRel, true).catch((error) => console.warn('Agent edit cleanup pending', error)) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -3800,6 +3807,9 @@ export async function deleteNote(root: string, rel: string): Promise { // Once detached, cleanup cannot attach the old discussion to a new note. await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Note cleanup pending', error)) } + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))).catch((error) => + console.warn('Agent edit cleanup pending', error) + ) invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) } @@ -4057,7 +4067,8 @@ export async function renameFolderTrees( const oldComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, oldAbs))) const newComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, newAbs))) await relocateFolderTrees([[oldAbs, newAbs], [oldComments, newComments], - [await noteMetadataPath(root, oldRelative, true), await noteMetadataPath(root, newRelative, true)]], persistSettings) + [await noteMetadataPath(root, oldRelative, true), await noteMetadataPath(root, newRelative, true)], + [agentEditFolderPath(root, toPosix(path.relative(root, oldAbs))), agentEditFolderPath(root, toPosix(path.relative(root, newAbs)))]], persistSettings) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -4133,6 +4144,7 @@ export async function deleteFolder( }) if (isEphemeralRoot(root) && !hasComments) { await fs.rm(abs, { recursive: true, force: true }) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs)), true) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) return @@ -4157,6 +4169,9 @@ export async function deleteFolder( await fs .rm(temporary, { recursive: true, force: true }) .catch((error) => console.warn('Folder cleanup pending', error)) + await removeAgentEditRecord(root, toPosix(path.relative(root, abs)), true).catch((error) => + console.warn('Agent edit cleanup pending', error) + ) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -4236,9 +4251,29 @@ async function removeDirIfEmpty(abs: string): Promise { } } +/** Tour notes still on disk under their older names (see currentDemoTourPath). */ +async function olderDemoTourNotePaths(root: string): Promise { + const current = new Set(DEMO_TOUR_NOTES.map((note) => note.path)) + let names: string[] + try { + names = await fs.readdir(resolveSafe(root, DEMO_TOUR_DIR)) + } catch { + return [] + } + return names + .map((name) => `${DEMO_TOUR_DIR}/${name}`) + .filter((rel) => rel !== currentDemoTourPath(rel) && current.has(currentDemoTourPath(rel))) +} + export async function generateDemoTour(root: string): Promise { await ensureVaultLayout(root) + // Regenerating replaces a tour seeded under the older names rather than + // writing a second copy beside it. + for (const rel of await olderDemoTourNotePaths(root)) { + await removeFileIfExists(resolveSafe(root, rel)) + } + for (const note of DEMO_TOUR_NOTES) { const abs = resolveSafe(root, note.path) await fs.mkdir(path.dirname(abs), { recursive: true }) @@ -4260,8 +4295,12 @@ export async function generateDemoTour(root: string): Promise { - for (const note of DEMO_TOUR_NOTES) { - await removeFileIfExists(resolveSafe(root, note.path)) + const notePaths = [ + ...DEMO_TOUR_NOTES.map((note) => note.path), + ...(await olderDemoTourNotePaths(root)) + ] + for (const rel of notePaths) { + await removeFileIfExists(resolveSafe(root, rel)) } for (const asset of DEMO_TOUR_ASSETS) { @@ -4273,7 +4312,7 @@ export async function removeDemoTour(root: string): Promise invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) return { - notePaths: DEMO_TOUR_NOTES.map((note) => note.path), + notePaths, assetPaths: DEMO_TOUR_ASSETS.map((asset) => asset.path) } } @@ -4399,6 +4438,7 @@ export async function duplicateNote(root: string, rel: string): Promise [!note]\` blocks -- **Mermaid diagrams** — code-fenced \`\`\`mermaid blocks render inline -- **Full-text search** — press \`Space s t\` in Vim mode, or run **Search Text in Vault** from the command palette +- **GitHub-flavored markdown**: tables, task lists, footnotes, strikethrough +- **Wiki links**: jump between notes with [[double brackets]] +- **Tags**: write a hashtag like \`#project\` in any note and it appears in the sidebar +- **Math**: inline like $e^{i\\pi}+1=0$ or as blocks +- **Callouts**: Obsidian-style \`> [!note]\` blocks +- **Mermaid diagrams**: code-fenced \`\`\`mermaid blocks render inline +- **Full-text search**: press \`Space s t\` in Vim mode, or run **Search Text in Vault** from the command palette ## Try it diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index 04b3e5d24..faa79458f 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -2,6 +2,7 @@ import path from 'node:path' import chokidar, { FSWatcher } from 'chokidar' import type { NoteFolder, VaultChangeEvent, VaultChangeKind, VaultSettings } from '@shared/ipc' import { databaseCsvPathFor } from '@shared/databases' +import { AGENT_EDITS_DIR, AGENT_EDITS_SUFFIX } from '@shared/agent-edits' import { folderForRelativePath, getVaultSettings, isAtomicWriteTempPath } from './vault' const ATTACHMENTS_DIRS = new Set(['assets', 'attachements', '_assets']) @@ -10,6 +11,7 @@ const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json` const NOTE_COMMENTS_PREFIX = `${INTERNAL_VAULT_DIR}/comments/` const NOTE_COMMENTS_SUFFIX = '.comments.json' const TEMPLATES_PREFIX = `${INTERNAL_VAULT_DIR}/templates/` +const AGENT_EDITS_PREFIX = `${INTERNAL_VAULT_DIR}/${AGENT_EDITS_DIR}/` function toPosix(p: string): string { return p.split(path.sep).join('/') @@ -37,6 +39,14 @@ function commentsNotePath(root: string, abs: string): string | null { return rel.slice(NOTE_COMMENTS_PREFIX.length, -NOTE_COMMENTS_SUFFIX.length) } +/** The note an agent edit record belongs to. A record being written is a + * `.tmp` beside it and stays out until it is renamed into place. */ +function agentEditNotePath(root: string, abs: string): string | null { + const rel = relativeVaultPath(root, abs) + if (!rel.startsWith(AGENT_EDITS_PREFIX) || !rel.endsWith(AGENT_EDITS_SUFFIX)) return null + return rel.slice(AGENT_EDITS_PREFIX.length, -AGENT_EDITS_SUFFIX.length) || null +} + /** A custom template: a `.md` file directly inside `.zennotes/templates/`, * the flat directory the template module serves. Dotfiles and nested paths * are not templates there either. Mirrors templatePath in the Go watcher. */ @@ -143,6 +153,20 @@ export class VaultWatcher { ) return } + // An assistant edited a note through MCP, or a review moved on: the + // note's banner and the review list follow the record. + const agentEditPath = agentEditNotePath(this.root, absPath) + if (agentEditPath) { + this.classified(() => + onEvent({ + kind, + path: agentEditPath, + folder: folderForRelativePath(agentEditPath, this.settings) ?? 'inbox', + scope: 'agent-edits' + }) + ) + return + } // A template is not a note: its own scope has the renderer re-list // templates instead of the note tree (#723). Another window on this // vault, or a synced dotfile, is how one changes behind the app's back. diff --git a/apps/desktop/src/main/workflow-apply.test.ts b/apps/desktop/src/main/workflow-apply.test.ts index 3d4cdebe5..b87275274 100644 --- a/apps/desktop/src/main/workflow-apply.test.ts +++ b/apps/desktop/src/main/workflow-apply.test.ts @@ -657,6 +657,23 @@ describe("a note's sidecars travel with it", () => { expect(await pathExists(path.join(root, '.zennotes', 'note-metadata', 'archive'))).toBe(false) }) + it('carries an assistant edit waiting for review, and undo brings it back', async () => { + const root = await makeVault() + await seedWithSidecars(root, 'inbox/demo/Note.md') + const record = '{"version":1,"before":"old\\n","after":"body\\n","edits":[],"outside":[]}\n' + const recordRel = (note: string): string => `.zennotes/agent-edits/${note}.json` + await mkdir(path.join(root, '.zennotes', 'agent-edits', 'inbox', 'demo'), { recursive: true }) + await writeFile(path.join(root, recordRel('inbox/demo/Note.md')), record) + + const receipt = await apply(root, [{ kind: 'archive', path: 'inbox/demo/Note.md' }]) + expect(await readOrNull(root, recordRel('archive/demo/Note.md'))).toBe(record) + expect(await readOrNull(root, recordRel('inbox/demo/Note.md'))).toBeNull() + + await undoWorkflowRun(root, receipt.runId) + expect(await readOrNull(root, recordRel('inbox/demo/Note.md'))).toBe(record) + expect(await pathExists(path.join(root, '.zennotes', 'agent-edits', 'archive'))).toBe(false) + }) + it('undo moves an untouched sidecar back instead of rewriting it', async () => { const root = await makeVault() await seedWithSidecars(root, 'inbox/Note.md') diff --git a/apps/desktop/src/main/workflow-apply.ts b/apps/desktop/src/main/workflow-apply.ts index b9ffab843..d1298f820 100644 --- a/apps/desktop/src/main/workflow-apply.ts +++ b/apps/desktop/src/main/workflow-apply.ts @@ -99,6 +99,7 @@ import { import { WORKFLOWS_REL_DIR } from '@shared/workflows-view' import { isEphemeralRoot } from './ephemeral-vaults' import { noteMetadataPath, noteMetadataRoot, prepareNoteCreation } from './note-creation-metadata' +import { agentEditPath, agentEditsRoot } from './agent-edit-files' import { leftoverCommentsMessage, noteCommentsPath, @@ -206,15 +207,17 @@ interface NoteJournalEntry extends WorkflowJournalEntry { link?: string } -/** The two files `.zennotes` keeps for a note. */ -type SidecarKind = 'comments' | 'metadata' +/** The files `.zennotes` keeps for a note: its comments, its creation date, + * and an assistant's edits waiting for review (agent-edit-files.ts). */ +type SidecarKind = 'comments' | 'metadata' | 'agent-edits' -const SIDECAR_KINDS: readonly SidecarKind[] = ['comments', 'metadata'] +const SIDECAR_KINDS: readonly SidecarKind[] = ['comments', 'metadata', 'agent-edits'] /** How a failure names a sidecar, since the user knows it by what it holds. */ const SIDECAR_LABELS: Record = { comments: 'comments', - metadata: 'creation date' + metadata: 'creation date', + 'agent-edits': 'assistant edits waiting for review' } /** @@ -322,11 +325,15 @@ export function resolveVaultPath(root: string, rel: string): string { */ async function sidecarPathOf(root: string, note: string, sidecar: SidecarKind): Promise { const rel = toPosix(path.relative(path.resolve(root), resolveVaultPath(root, note))) - return sidecar === 'comments' ? noteCommentsPath(root, rel) : await noteMetadataPath(root, rel) + if (sidecar === 'comments') return noteCommentsPath(root, rel) + if (sidecar === 'agent-edits') return agentEditPath(root, rel) + return await noteMetadataPath(root, rel) } function sidecarRootOf(root: string, sidecar: SidecarKind): string { - return sidecar === 'comments' ? noteCommentsRoot(root) : noteMetadataRoot(root) + if (sidecar === 'comments') return noteCommentsRoot(root) + if (sidecar === 'agent-edits') return agentEditsRoot(root) + return noteMetadataRoot(root) } /** Every vault path an op could touch, for the pre-flight containment check. */ @@ -1892,7 +1899,7 @@ function parseSidecarEntry(value: unknown): LedgerSidecarEntry | null { if (!isRecord(value)) return null const { note, sidecar, before, after } = value if (typeof note !== 'string') return null - if (sidecar !== 'comments' && sidecar !== 'metadata') return null + if (sidecar !== 'comments' && sidecar !== 'metadata' && sidecar !== 'agent-edits') return null if (typeof before !== 'string' && before !== null) return null const entry: LedgerSidecarEntry = { note, sidecar, before } if (typeof after === 'string' || after === null) entry.after = after diff --git a/apps/desktop/src/mcp/agent-edit-recorder.test.ts b/apps/desktop/src/mcp/agent-edit-recorder.test.ts new file mode 100644 index 000000000..c0987a71e --- /dev/null +++ b/apps/desktop/src/mcp/agent-edit-recorder.test.ts @@ -0,0 +1,129 @@ +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentEditBaseline } from '@shared/agent-edits' +import { createBackend, type VaultBackend } from '../cli/backend' +import { readAgentEditRecord } from '../main/agent-edit-files' +import { callTool } from './server' + +// Agent edit records written by the MCP tools (docs/ideas/agent-edit-review.md), +// run against a real folder through the local backend, so the record the app +// reviews is exactly what these write. + +let root: string +let configDir: string +let backend: VaultBackend +const NOTE = 'inbox/Plan.md' +const previousConfigDir = process.env.ZENNOTES_CONFIG_DIR + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'zennotes-agent-edits-')) + configDir = await mkdtemp(path.join(os.tmpdir(), 'zennotes-agent-edits-config-')) + process.env.ZENNOTES_CONFIG_DIR = configDir + await mkdir(path.join(root, 'inbox'), { recursive: true }) + await writeFile(path.join(root, NOTE), '# Plan\n\n- [ ] Ship the beta\nThe migration runs at night.\n') + backend = createBackend({ kind: 'local', root }) +}) + +afterEach(async () => { + if (previousConfigDir === undefined) delete process.env.ZENNOTES_CONFIG_DIR + else process.env.ZENNOTES_CONFIG_DIR = previousConfigDir + await rm(root, { recursive: true, force: true }) + await rm(configDir, { recursive: true, force: true }) +}) + +const noteText = () => readFile(path.join(root, NOTE), 'utf8') + +describe('recording assistant edits', () => { + it('keeps the note from before an edit, and the note after it', async () => { + const original = await noteText() + await callTool('replace_in_note', { path: NOTE, find: 'at night', replace: 'on Sunday' }, backend) + const record = await readAgentEditRecord(root, NOTE) + expect(record).not.toBeNull() + expect(record!.before).toBe(original) + expect(record!.after).toBe(await noteText()) + expect(record!.edits.map((e) => [e.agent, e.tool])).toEqual([['Assistant', 'replace_in_note']]) + expect(agentEditBaseline(record!, await noteText())).toBe(original) + }) + + it('stacks every edit of a session onto one record', async () => { + const original = await noteText() + await callTool('append_to_note', { path: NOTE, text: 'A new line.' }, backend) + await callTool('prepend_to_note', { path: NOTE, text: 'Top.' }, backend) + await callTool('insert_at_line', { path: NOTE, lineNumber: 2, text: 'Inserted.' }, backend) + await callTool('write_note', { path: NOTE, body: `${await noteText()}Written.\n` }, backend) + const record = await readAgentEditRecord(root, NOTE) + expect(record!.before).toBe(original) + expect(record!.after).toBe(await noteText()) + expect(record!.edits.map((e) => e.tool)).toEqual([ + 'append_to_note', + 'prepend_to_note', + 'insert_at_line', + 'write_note' + ]) + }) + + it('records a task toggled through its id', async () => { + const original = await noteText() + await callTool('toggle_task', { id: `${NOTE}#0` }, backend) + expect(await noteText()).toContain('- [x] Ship the beta') + const record = await readAgentEditRecord(root, NOTE) + expect(record!.before).toBe(original) + expect(record!.edits.map((e) => e.tool)).toEqual(['toggle_task']) + }) + + it('leaves no record when nothing changed', async () => { + const result = (await callTool( + 'replace_in_note', + { path: NOTE, find: 'nowhere in the note', replace: 'x' }, + backend + )) as { replacements: number } + expect(result.replacements).toBe(0) + expect(await readAgentEditRecord(root, NOTE)).toBeNull() + await expect(stat(path.join(root, '.zennotes', 'agent-edits'))).rejects.toThrow() + }) + + it('keeps an earlier record as it was when a later edit changes nothing', async () => { + await callTool('append_to_note', { path: NOTE, text: 'First.' }, backend) + const before = await readAgentEditRecord(root, NOTE) + await callTool('replace_in_note', { path: NOTE, find: 'nowhere', replace: 'x' }, backend) + expect(await readAgentEditRecord(root, NOTE)).toEqual(before) + }) + + it('records nothing for tools that only read', async () => { + await callTool('read_note', { path: NOTE }, backend) + await callTool('list_notes', {}, backend) + expect(await readAgentEditRecord(root, NOTE)).toBeNull() + }) + + it('records nothing when Review agent edits is off', async () => { + await writeFile(path.join(configDir, 'config.toml'), '[mcp]\nreview_agent_edits = false\n') + await callTool('append_to_note', { path: NOTE, text: 'Unrecorded.' }, backend) + expect(await noteText()).toContain('Unrecorded.') + expect(await readAgentEditRecord(root, NOTE)).toBeNull() + }) + + it('takes concurrent edits to one note in turn, losing none', async () => { + await Promise.all( + ['one', 'two', 'three'].map((word) => callTool('append_to_note', { path: NOTE, text: word }, backend)) + ) + const record = await readAgentEditRecord(root, NOTE) + expect(record!.edits).toHaveLength(3) + expect(record!.after).toBe(await noteText()) + }) +}) + +describe('writes into .zennotes', () => { + it('are refused before a byte lands, so an assistant cannot plant a record', async () => { + const forged = '.zennotes/agent-edits/inbox/Plan.md.json' + await expect(callTool('write_note', { path: forged, body: '{}' }, backend)).rejects.toThrow( + /Note not in a known folder/ + ) + await expect(stat(path.join(root, forged))).rejects.toThrow() + const comments = '.zennotes/comments/inbox/Plan.md.comments.json' + await expect(callTool('append_to_note', { path: comments, text: 'x' }, backend)).rejects.toThrow( + /Note not in a known folder/ + ) + }) +}) diff --git a/apps/desktop/src/mcp/agent-edit-recorder.ts b/apps/desktop/src/mcp/agent-edit-recorder.ts new file mode 100644 index 000000000..b02e31ccb --- /dev/null +++ b/apps/desktop/src/mcp/agent-edit-recorder.ts @@ -0,0 +1,152 @@ +// Keeping what a note said before an assistant changed it, so the user can +// review the change in the app (docs/ideas/agent-edit-review.md). Every tool +// that rewrites a note's text runs through `runRecordingAgentEdit`: it writes +// the record BEFORE the edit (the workflow journal's rule, so a process that +// dies mid-edit still leaves the bytes to compare against), lets the tool do +// its work, and then notes how the note looks now. +// +// The Go `zn` (ZenNotes/tui internal/mcp), which a managed install runs, +// mirrors this file: same tools, same order, same record (see +// @shared/agent-edits for why the writing side is kept to string comparisons). +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { parse as parseToml } from 'smol-toml' +import { agentEditRecordable, beginAgentEdit, finishAgentEdit } from '@shared/agent-edits' +import { readAgentEditRecord, writeAgentEditRecord } from '../main/agent-edit-files' +import type { VaultBackend } from '../cli/backend' +import { portableConfigDir, splitTaskId } from './vault-ops' + +function stringArg(args: Record, key: string): string | null { + const value = args[key] + return typeof value === 'string' && value.trim() ? value : null +} + +/** The tools that change a note's text, and where each one names the note. + * Creating, moving, renaming and deleting notes are not recorded: a new + * note has nothing to compare against, and Trash already keeps the rest. */ +const RECORDED_TOOLS: Record) => string | null> = { + write_note: (args) => stringArg(args, 'path'), + append_to_note: (args) => stringArg(args, 'path'), + prepend_to_note: (args) => stringArg(args, 'path'), + insert_at_line: (args) => stringArg(args, 'path'), + replace_in_note: (args) => stringArg(args, 'path'), + toggle_task: (args) => { + const id = stringArg(args, 'id') + if (!id) return null + try { + return splitTaskId(id).rel + } catch { + return null + } + } +} + +export function isRecordedTool(name: string): boolean { + return name in RECORDED_TOOLS +} + +/** + * Settings → MCP → "Review agent edits", from the portable config file the + * app writes (`[mcp] review_agent_edits`). On when the file or the key is + * missing, matching the app's default; off stops recording, and records + * already written stay reviewable. + */ +export async function reviewAgentEditsEnabled(): Promise { + try { + const raw = await fs.readFile(path.join(portableConfigDir(), 'config.toml'), 'utf8') + const parsed = parseToml(raw) as { mcp?: { review_agent_edits?: unknown } } + const value = parsed.mcp?.review_agent_edits + return typeof value === 'boolean' ? value : true + } catch { + return true + } +} + +// Tool calls are handled concurrently, and two edits to one note racing on +// its record would lose one of them, so edits to the same note take turns. +const queues = new Map>() + +function oneAtATime(key: string, run: () => Promise): Promise { + const previous = queues.get(key) ?? Promise.resolve() + const next = previous.then(run, run) + queues.set(key, next) + const forget = (): void => { + if (queues.get(key) === next) queues.delete(key) + } + next.then(forget, forget) + return next +} + +async function noteText(backend: VaultBackend, rel: string): Promise { + try { + return (await backend.readNote(rel)).body + } catch { + return null + } +} + +/** + * Run a tool, recording its edit when it rewrites a note of a local vault. + * Recording never stands in the way of the edit the user asked for: if the + * record cannot be written, the tool still runs and the change is simply not + * offered for review. + */ +export async function runRecordingAgentEdit( + toolName: string, + args: Record, + backend: VaultBackend, + agent: string, + run: () => Promise +): Promise { + const noteOf = RECORDED_TOOLS[toolName] + // A remote vault's notes are written by its server; recording there needs + // the server's own change (see the design doc). + if (!noteOf || backend.kind !== 'local') return await run() + const rel = noteOf(args) + if (!rel) return await run() + + return await oneAtATime(`${backend.root}\0${rel}`, async () => { + if (!(await reviewAgentEditsEnabled())) return await run() + const before = await noteText(backend, rel) + // Not a note we can read (the tool reports its own error), or one too + // large to keep copies of. + if (before === null || !agentEditRecordable(before)) return await run() + + const existing = await readAgentEditRecord(backend.root, rel) + const started = beginAgentEdit(existing, before) + try { + await writeAgentEditRecord(backend.root, rel, started) + } catch (error) { + console.error(`zennotes mcp: could not record the edit to ${rel}:`, error) + return await run() + } + + let result: T + let failure: unknown = null + try { + result = await run() + } catch (error) { + failure = error + } + + const after = await noteText(backend, rel) + try { + if (after === null || after === before) { + // Nothing changed (no match, a task that moved, a failed write): + // the record goes back to what it was. + await writeAgentEditRecord(backend.root, rel, existing) + } else { + await writeAgentEditRecord( + backend.root, + rel, + finishAgentEdit(started, after, { at: Date.now(), agent, tool: toolName }) + ) + } + } catch (error) { + console.error(`zennotes mcp: could not finish the record for ${rel}:`, error) + } + + if (failure !== null) throw failure + return result! + }) +} diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index 10a41e8dd..cc2f0d310 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -8,7 +8,7 @@ * connected \u2014 without being told how. */ -export const MCP_SERVER_INSTRUCTIONS = `You are connected to a user's ZenNotes vault \u2014 plain .md files on +export const MCP_SERVER_INSTRUCTIONS = `You are connected to a user's ZenNotes vault: plain .md files on disk, rendered live with KaTeX, TikZ, function-plot, JSXGraph, and Mermaid. Treat the vault like a shared filesystem. Prompts will be short; you must infer the right note shape, voice, and visuals from @@ -55,10 +55,10 @@ the subject itself. ZenNotes vaults run in one of two modes: -- \`primaryNotesLocation: inbox\` \u2014 notes for the conceptual inbox +- \`primaryNotesLocation: inbox\`: notes for the conceptual inbox area live under \`/inbox/\` (paths look like \`inbox/MyNote.md\`). -- \`primaryNotesLocation: root\` \u2014 Obsidian-style. Notes for the +- \`primaryNotesLocation: root\`: Obsidian-style. Notes for the conceptual inbox area live directly at the vault root (paths look like \`MyNote.md\`, no \`inbox/\` prefix). @@ -67,7 +67,7 @@ mode this vault uses. It also says where the vault lives: a folder on this machine, or a self-hosted ZenNotes server the desktop app is connected to (the tools behave the same either way; paths are what the server reports). The folder enum in tool arguments (\`inbox\`, -\`quick\`, \`archive\`, \`trash\`) stays the same in both modes \u2014 only +\`quick\`, \`archive\`, \`trash\`) stays the same in both modes; only the on-disk shape differs. The \`path\` returned by every tool already reflects the mode; use it verbatim and you never have to think about this again. @@ -135,7 +135,7 @@ handbook, a wiki, a trip plan with multiple destinations): order (\`01 - Vectors.md\`, \`02 - Vector Spaces.md\`). - Create a **map / index note first** (\`00 - Course Map.md\` or \`README.md\`). Update it with a \`[[wikilink]]\` as each new note is - created \u2014 append_to_note is perfect for this. + created (append_to_note is perfect for this). - Keep **heading structure, section names, voice, and level of detail identical** across sibling notes. If module 1 has "Intuition / Definition / Examples / Exercises / Related", every module does. @@ -151,7 +151,7 @@ or \`$$\u2026$$\`. Not \`R^n\`, not \`x_1\`, not \`|v|\`, not \`A^T\`, not **Diagrams \u2192 native renderers. ASCII art is banned.** A drawing made of \`/ \\ | - + * ^ . o > < \u2192 \u2190 \u2191 \u2193 \u2022\` or -box-drawing characters \u2014 fenced or not \u2014 is a bug. If you +box-drawing characters, fenced or not, is a bug. If you catch yourself about to make one, delete it and emit a \`\`\`tikz (or \`\`\`function-plot / \`\`\`jsxgraph / \`\`\`mermaid) block instead. Triggers that mean "use TikZ, not ASCII": axes, origin, point at, @@ -160,12 +160,12 @@ span, plane, angle, triangle, unit circle, subspace, tangent, normal, perpendicular, before/after, tree, graph, stack, heap, ring, lattice. Pick the renderer by intent: -- \`\`\`tikz \u2014 vectors, geometric figures, commutative diagrams, +- \`\`\`tikz: vectors, geometric figures, commutative diagrams, math pictures. Default for anything you'd draw in LaTeX. -- \`\`\`function-plot \u2014 Cartesian plots, parametric curves, 2D +- \`\`\`function-plot: Cartesian plots, parametric curves, 2D vectors over axes. -- \`\`\`jsxgraph \u2014 interactive / draggable constructions. -- \`\`\`mermaid \u2014 flow, sequence, class, state, ER, mindmap, +- \`\`\`jsxgraph: interactive / draggable constructions. +- \`\`\`mermaid: flow, sequence, class, state, ER, mindmap, Gantt. NOT for coordinate geometry. Fence body rules: @@ -178,7 +178,7 @@ Fence body rules: **Wikilinks \u2192 aggressive.** First mention of a concept, person, project, or term that has (or deserves) its own note becomes a -\`[[wikilink]]\` \u2014 not bold, not italics. Before writing, run +\`[[wikilink]]\`, not bold, not italics. Before writing, run list_notes / search_by_title. After writing, rescan for wikilink candidates. Non-trivial notes end with a \`## Related\` section of 2\u20136 wikilinks. Notes with zero inbound + zero outbound links are @@ -203,13 +203,13 @@ starts it expanded; use \`-\` for long blocks a reader opens on demand. - read_note before overwriting. Always. - Surgical edits: append_to_note, prepend_to_note, replace_in_note. write_note only for full rewrites the user asked for. -- create_note (not write_note) for new notes \u2014 it sanitizes +- create_note (not write_note) for new notes: it sanitizes filenames and avoids collisions. - Preserve frontmatter and unknown content verbatim. - Deletion: move_to_trash first, empty_trash only with explicit confirmation. - Before rename_note, run backlinks. -- Task ids from list_tasks (\`path#index\`) are stable \u2014 pass +- Task ids from list_tasks (\`path#index\`) are stable, so pass them to toggle_task. - list_tasks omits notes opted out of Tasks (frontmatter \`tasks: false\`/\`note\`, excluded folders). Pass @@ -299,7 +299,7 @@ flowchart LR B -- no --> D[Do Y] \`\`\` -Adjust these \u2014 don't invent ASCII replacements. +Adjust these. Don't invent ASCII replacements. ## Intent mapping diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 3fed6e0ff..29189d8dc 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -20,6 +20,7 @@ import { import { resolveInstructions } from './instructions-store.js' import { createBackend, type VaultBackend } from '../cli/backend.js' +import { runRecordingAgentEdit } from './agent-edit-recorder.js' import { resolveDefaultTarget, sameVault, type VaultTarget } from '../cli/vault-target.js' import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' @@ -186,7 +187,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'list_notes', description: - "List notes in the vault with metadata (title, folder, tags, wikilinks, excerpt, timestamps). Optional filters narrow by folder, tag, or wikilink target. Use this before editing to pick the right note. Trashed notes are included only when folder='trash' is explicit. The returned `path` field is the canonical reference — pass it back verbatim to read_note / write_note / move_note. In `primaryNotesLocation: root` vaults, inbox notes have a path WITHOUT an `inbox/` prefix (e.g. `MyNote.md`); never add one. Each note also carries `link`, a clickable zennotes:// URL that opens it in the app; use it verbatim when rendering note titles as markdown links for the user.", + "List notes in the vault with metadata (title, folder, tags, wikilinks, excerpt, timestamps). Optional filters narrow by folder, tag, or wikilink target. Use this before editing to pick the right note. Trashed notes are included only when folder='trash' is explicit. The returned `path` field is the canonical reference: pass it back verbatim to read_note / write_note / move_note. In `primaryNotesLocation: root` vaults, inbox notes have a path WITHOUT an `inbox/` prefix (e.g. `MyNote.md`); never add one. Each note also carries `link`, a clickable zennotes:// URL that opens it in the app; use it verbatim when rendering note titles as markdown links for the user.", inputSchema: { type: 'object', properties: { @@ -262,7 +263,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'read_note', description: - 'Read the full body and metadata of a single note. Always read before you write. Use the path verbatim from list_notes / search_* / create_note — never invent or prefix it.', + 'Read the full body and metadata of a single note. Always read before you write. Use the path verbatim from list_notes / search_* / create_note. Never invent or prefix it.', inputSchema: { type: 'object', properties: { @@ -284,7 +285,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'write_note', description: - 'Overwrite the full body of an existing note. Destructive — prefer append_to_note / replace_in_note for incremental edits. Keep frontmatter intact unless the user asked you to change it.', + 'Overwrite the full body of an existing note. Destructive, so prefer append_to_note / replace_in_note for incremental edits. Keep frontmatter intact unless the user asked you to change it.', inputSchema: { type: 'object', properties: { @@ -305,7 +306,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'create_note', description: - 'Create a new note. Always picks a non-conflicting filename by appending a counter if needed. Use the `path` from the response to refer to the note in any follow-up tool calls — DO NOT reconstruct it from `folder + title`. In root-mode vaults, `folder: "inbox"` means the note lands directly at the vault root with a path like "MyNote.md" (no inbox/ prefix).', + 'Create a new note. Always picks a non-conflicting filename by appending a counter if needed. Use the `path` from the response to refer to the note in any follow-up tool calls. DO NOT reconstruct it from `folder + title`. In root-mode vaults, `folder: "inbox"` means the note lands directly at the vault root with a path like "MyNote.md" (no inbox/ prefix).', inputSchema: { type: 'object', properties: { @@ -423,7 +424,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'empty_trash', description: - 'Permanently delete every note in trash/. Confirm with the user before calling — this is irreversible.', + 'Permanently delete every note in trash/. Confirm with the user before calling: this is irreversible.', inputSchema: { type: 'object', properties: { @@ -650,7 +651,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'backlinks', description: - 'Return every note that links to the given note via [[wikilink]]. Run this before renaming a note — the rename will orphan these links.', + 'Return every note that links to the given note via [[wikilink]]. Run this before renaming a note: the rename will orphan these links.', inputSchema: { type: 'object', properties: { @@ -956,7 +957,9 @@ export async function callTool( ): Promise { const tool = TOOLS.find((t) => t.schema.name === name) if (!tool) throw new Error(`Unknown tool: ${name}`) - return await tool.handler(args, backend) + return await runRecordingAgentEdit(name, args, backend, defaultCommentAuthor(), () => + tool.handler(args, backend) + ) } export function listToolNames(): string[] { @@ -1125,9 +1128,11 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise, backend, { - previous - }) + const toolArgs = (args ?? {}) as Record + // An edit to a note is recorded so the user can review it in the app. + const result = await runRecordingAgentEdit(name, toolArgs, backend, defaultCommentAuthor(), () => + tool.handler(toolArgs, backend, { previous }) + ) const payload = typeof result === 'string' ? result : JSON.stringify(result, null, 2) return { content: [{ type: 'text', text: payload }] } diff --git a/apps/desktop/src/mcp/vault-ops-write-guard.test.ts b/apps/desktop/src/mcp/vault-ops-write-guard.test.ts new file mode 100644 index 000000000..45bc07c63 --- /dev/null +++ b/apps/desktop/src/mcp/vault-ops-write-guard.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendToNote, insertAtLine, prependToNote, replaceInNote, writeNote } from './vault-ops' + +// The edits an assistant makes through MCP used to write the file and check +// afterwards that it was a note, so `write_note` on `.zennotes/...` created +// the file and only then failed. These pin the order: nothing outside the note +// folders is written, and a real note still is. + +let root: string + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'zennotes-mcp-write-guard-')) + await mkdir(path.join(root, 'inbox'), { recursive: true }) + await mkdir(path.join(root, '.zennotes', 'comments', 'inbox'), { recursive: true }) + await writeFile(path.join(root, 'inbox', 'Plan.md'), '# Plan\n') + await writeFile(path.join(root, '.zennotes', 'comments', 'inbox', 'Plan.md.comments.json'), '[]\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const exists = (rel: string): Promise => + stat(path.join(root, rel)).then( + () => true, + () => false + ) + +describe('MCP edits outside the note folders', () => { + it('write_note refuses a .zennotes path before creating the file', async () => { + const forged = '.zennotes/agent-edits/inbox/Plan.md.json' + await expect(writeNote(root, forged, '{}')).rejects.toThrow(/Note not in a known folder/) + expect(await exists(forged)).toBe(false) + expect(await readdir(path.join(root, '.zennotes'))).toEqual(['comments']) + }) + + it('the other edits refuse an internal file and leave it as it was', async () => { + const comments = '.zennotes/comments/inbox/Plan.md.comments.json' + await expect(appendToNote(root, comments, 'x')).rejects.toThrow(/Note not in a known folder/) + await expect(prependToNote(root, comments, 'x')).rejects.toThrow(/Note not in a known folder/) + await expect(insertAtLine(root, comments, 0, 'x')).rejects.toThrow(/Note not in a known folder/) + await expect(replaceInNote(root, comments, '[]', 'x')).rejects.toThrow(/Note not in a known folder/) + expect(await readFile(path.join(root, comments), 'utf8')).toBe('[]\n') + }) + + it('still writes a note, new or existing', async () => { + await writeNote(root, 'inbox/New.md', '# New\n') + expect(await exists('inbox/New.md')).toBe(true) + await appendToNote(root, 'inbox/Plan.md', 'More.') + expect(await readFile(path.join(root, 'inbox', 'Plan.md'), 'utf8')).toContain('More.') + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index a965b2d50..889c9a3e6 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -1,5 +1,6 @@ import { readNoteCreatedAt, prepareNoteCreation, removeNoteCreation, noteMetadataPath } from '../main/note-creation-metadata' import { noteCommentsPath, noteCommentsRoot, relocateFolderTrees, relocateNote } from '../main/note-sidecars' +import { agentEditFolderPath, removeAgentEditRecord } from '../main/agent-edit-files' /** * Vault operations used by the MCP server. Mirrors the filesystem * behavior of src/main/vault.ts, but without Electron dependencies — @@ -330,7 +331,7 @@ export async function readVaultRootFromConfig(): Promise { * in main/app-config.ts, which reaches it through Electron's `app`; this * process has none. Note this is NOT `userDataDir()` above; the portable * prefs live beside it, in an XDG-style location the user can sync. */ -function portableConfigDir(): string { +export function portableConfigDir(): string { const explicit = process.env.ZENNOTES_CONFIG_DIR?.trim() if (explicit) return explicit const xdg = process.env.XDG_CONFIG_HOME?.trim() @@ -599,6 +600,20 @@ function resolveSafe(root: string, rel: string): string { return abs } +/** + * Resolve a note an edit is about to write, and refuse anything outside the + * note folders BEFORE a byte lands. The edits used to write first and check + * after, so `write_note` on `.zennotes/...` created the file and only then + * reported an error: an assistant could plant comments, creation dates or + * agent edit records, the files that say what a note used to be. + */ +async function noteForWrite(root: string, rel: string): Promise<{ abs: string; folder: NoteFolder }> { + const abs = resolveSafe(root, rel) + const folder = await folderOf(root, abs) + if (!folder) throw new Error(`Note not in a known folder: ${rel}`) + return { abs, folder } +} + async function folderOf(root: string, abs: string): Promise { const rel = toPosix(path.relative(root, abs)) if (!rel || rel.startsWith('..')) return null @@ -889,12 +904,10 @@ export async function readNote(root: string, rel: string): Promise } export async function writeNote(root: string, rel: string, body: string): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, body, 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -980,6 +993,8 @@ export async function createNote( const abs = path.join(dir, `${finalTitle}.md`) const content = body ?? `# ${finalTitle}\n\n` await removeNoteCreation(root, toPosix(path.relative(root, abs))) + // A record left by an earlier note of this name would offer a bogus review. + await removeAgentEditRecord(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, content, 'utf8') return await readMeta(root, abs, folder) } @@ -1123,6 +1138,7 @@ export async function deleteNote(root: string, rel: string): Promise { // be taken over by the next note created under this name. await fs.rm(noteCommentsPath(root, notePath), { force: true }) await removeNoteCreation(root, notePath) + await removeAgentEditRecord(root, notePath) } export async function emptyTrash(root: string): Promise { @@ -1133,6 +1149,7 @@ export async function emptyTrash(root: string): Promise { const trashRel = toPosix(path.relative(root, trashDir)) await fs.rm(resolveSafe(noteCommentsRoot(root), trashRel), { recursive: true, force: true }) await removeNoteCreation(root, trashRel, true) + await removeAgentEditRecord(root, trashRel, true) } catch { /* no trash dir */ } @@ -1168,13 +1185,15 @@ export async function renameFolder( } const oldRel = toPosix(path.relative(root, oldAbs)) const newRel = toPosix(path.relative(root, newAbs)) - // The folder's comments and creation dates move as one with it, the way the - // desktop's renameFolderTrees does; a failure puts all three back. + // The folder's comments, creation dates and agent edit records move as one + // with it, the way the desktop's renameFolderTrees does; a failure puts all + // of them back. await relocateFolderTrees( [ [oldAbs, newAbs], [resolveSafe(noteCommentsRoot(root), oldRel), resolveSafe(noteCommentsRoot(root), newRel)], - [await noteMetadataPath(root, oldRel, true), await noteMetadataPath(root, newRel, true)] + [await noteMetadataPath(root, oldRel, true), await noteMetadataPath(root, newRel, true)], + [agentEditFolderPath(root, oldRel), agentEditFolderPath(root, newRel)] ], async () => {} ) @@ -1194,6 +1213,7 @@ export async function deleteFolder( await fs.rm(abs, { recursive: true, force: true }) await fs.rm(resolveSafe(noteCommentsRoot(root), rel), { recursive: true, force: true }) await removeNoteCreation(root, rel, true) + await removeAgentEditRecord(root, rel, true) } /* ---------- Text search ---------------------------------------------- */ @@ -1839,12 +1859,10 @@ export function appendToBody(body: string, text: string): string { } export async function appendToNote(root: string, rel: string, text: string): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, appendToBody(body, text), 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -1858,12 +1876,10 @@ export function prependToBody(body: string, text: string): string { } export async function prependToNote(root: string, rel: string, text: string): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, prependToBody(body, text), 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -1901,18 +1917,12 @@ export async function replaceInNote( replace: string, occurrence: 'first' | 'all' = 'first' ): Promise<{ meta: NoteMeta; replacements: number }> { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') const { body: next, replacements } = replaceInBody(body, find, replace, occurrence) - if (replacements === 0) { - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) - return { meta: await readMeta(root, abs, folder), replacements: 0 } - } + if (replacements === 0) return { meta: await readMeta(root, abs, folder), replacements: 0 } await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, next, 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return { meta: await readMeta(root, abs, folder), replacements } } @@ -1922,12 +1932,10 @@ export async function insertAtLine( lineNumber: number, text: string ): Promise { - const abs = resolveSafe(root, rel) + const { abs, folder } = await noteForWrite(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, insertAtLineInBody(body, lineNumber, text), 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 06db5c49d..25c1f7757 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -80,6 +80,8 @@ import type { ListNotesPageResponse, NoteComment, NoteCommentInput, + AgentEditRecord, + AgentEditSummary, NoteContent, NoteFolder, NoteMeta, @@ -129,7 +131,8 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { supportsCustomTemplates: true, supportsCustomCodeLanguages: true, supportsWorkflows: true, - supportsUndoFile: true + supportsUndoFile: true, + supportsAgentEditReview: true } const DESKTOP_APP_INFO: ZenAppInfo = { @@ -257,6 +260,7 @@ const api: ZenBridge = { resetAppZoom: (): Promise => ipcRenderer.invoke(IPC.APP_ZOOM_RESET), getAppUpdateState: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_GET_STATE), checkForAppUpdates: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK), + sendUsageCount: (event: 'install' | 'update'): Promise => ipcRenderer.invoke(IPC.APP_USAGE_COUNT, event), checkForAppUpdatesWithUi: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK_WITH_UI), downloadAppUpdate: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD), @@ -541,6 +545,11 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.VAULT_READ_COMMENTS, relPath), writeNoteComments: (relPath: string, comments: NoteCommentInput[]): Promise => ipcRenderer.invoke(IPC.VAULT_WRITE_COMMENTS, relPath, comments), + listAgentEdits: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_AGENT_EDITS), + readAgentEdit: (relPath: string): Promise => + ipcRenderer.invoke(IPC.VAULT_READ_AGENT_EDIT, relPath), + writeAgentEdit: (relPath: string, record: AgentEditRecord | null): Promise => + ipcRenderer.invoke(IPC.VAULT_WRITE_AGENT_EDIT, relPath, record), scanTasks: (): Promise => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS), scanTasksForPath: (relPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS_FOR, relPath), diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index 529302acc..b8fa0b11b 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -399,7 +399,7 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { } /* Keep tall (portrait) images within the printable page height so they scale down proportionally instead of overflowing the page and being - clipped at the page boundary — a single can't paginate (#231). + clipped at the page boundary, since a single can't paginate (#231). Letter page height is 11in - 2 * 0.7in margins = 9.6in; cap a touch under that to leave space for the image frame and caption. */ .export-note-shell img { @@ -413,8 +413,8 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { In export that stretches even a tiny image to the full content width, and the max-height above then blows it up to a full page (#256, a regression surfaced by #231). Here, size embeds to the image's - intrinsic dimensions instead — only ever scaling DOWN to fit the - content width or the page height — and shrink the frame/caption to + intrinsic dimensions instead (only ever scaling DOWN to fit the + content width or the page height) and shrink the frame/caption to hug it. The .prose-zen prefix beats the shared (.prose-zen img-embed) rule on specificity regardless of stylesheet order. */ .export-note-shell .prose-zen .local-image-embed { diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index ce9a5ea6a..50bc983e0 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.6.3", - "commit": "e608fe3755b0a1fa86ab6c0033a384610b8f6c28", + "version": "0.7.0", + "commit": "9650f8d6ce0c87c94e11c61719aa5cfeb7ba7b4b", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.3/zn_0.6.3_darwin_arm64.tar.gz", - "sha256": "531de36cac1f38103c4d05ab80f5aec5e039ae26bdd39e816548541713e1227b" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_darwin_arm64.tar.gz", + "sha256": "6272909db88987a6fda33f7e9686067ee15b514892ebda016d66f2b07af6e957" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.3/zn_0.6.3_darwin_amd64.tar.gz", - "sha256": "0a53e218b994ca15be20e6c7004028c1554269013730fedca7611dbd92be154c" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_darwin_amd64.tar.gz", + "sha256": "bd7292fb6b24e81afc54fd5af3958aa4022f224cca3e0cba34208b306de4dd65" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.3/zn_0.6.3_linux_arm64.tar.gz", - "sha256": "2735fa644c5769a92080d4f0cb45f0af977fe8c6e96d7e6977272bf877003167" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_linux_arm64.tar.gz", + "sha256": "943ca6a123c8e4a4fdd56f08e2fd8095b614e60ef6cfb0defe4b292b83ab3666" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.3/zn_0.6.3_linux_amd64.tar.gz", - "sha256": "12c033e5d2b9b7fcf407f28b552bcd20d323d2747ea0a2aaea3d0b84d1646198" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_linux_amd64.tar.gz", + "sha256": "50d794e67125126fd176e40a49a0abc17fd0cd5888ddab609bad0d6af22ccc55" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index d4fe29ed5..77dc09294 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.67.0", + "version": "2.68.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 4fde5ef6e..2c26faa62 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.67.0", + "version": "2.68.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index a3df1170b..ffc6a6c85 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.67.0", + "version": "2.68.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/docs/ideas/agent-edit-review.md b/docs/ideas/agent-edit-review.md new file mode 100644 index 000000000..c86c0f095 --- /dev/null +++ b/docs/ideas/agent-edit-review.md @@ -0,0 +1,172 @@ +# Reviewing agent edits + +Status: v1 built on the 2.68.0 branch (2026-10-12), not released. Requested by Kevin +(Discord, 2026-10-09); related to #448 (per-note history), which this does not solve. +The desktop side is in this repo; the Go `zn` side is on ZenNotes/tui branch +`agent-edit-review` and ships with a zn release. + +## The problem + +People drive their vaults with AI assistants through the MCP server (`zn mcp`): +Claude Code, Codex, Cursor and others call `replace_in_note`, `append_to_note`, +`write_note` and friends. When an assistant edits a note, ZenNotes shows the new +text and nothing else. To see what changed, people leave the app: Kevin keeps his +vault in Git and reads the diff in his terminal. There is no way to take back one +change and keep the rest, and no way to tell the assistant "not like this" next to +the change itself. + +The app cannot tell an agent's edit apart on its own: `zn mcp` is a separate +process that writes the file directly, so the app sees an outside change, the same +as a `git pull` or another editor. + +## What v1 does + +1. **The MCP server records** what a note said before an assistant changed it. +2. **The app shows** that a note has changes to review: a banner on the note, and + one palette entry per note waiting ("Review Agent Changes: "). +3. **The review happens in the note**: each change inline, the removed lines above + the new ones, with Accept and Reject on each, and Accept all / Reject all / Done. +4. **Comments** on a change are ordinary comments, so the assistant reads them with + `list_comments`, which it already can. +5. When every change is decided, the record is deleted. + +Decided with Adib (2026-10-12): + +- Records live **in the vault**, beside comments, so they travel with Git and Cloud. +- Recording is **on by default**, with a setting to turn it off. Nothing happens + unless an assistant the user connected edits a note; ZenNotes runs no AI itself. +- The review is **inline in the note**, not a dialog. +- The change bands run the full width of the editor (Adib, after the first look). + +## The record + +One file per note: `.zennotes/agent-edits/.json`, next to +`.zennotes/comments/.comments.json`. + +```json +{ + "version": 1, + "before": "the note before the first recorded edit", + "after": "the note right after the latest edit, or null while an edit is in flight", + "edits": [{ "at": 1791820000000, "agent": "Claude Code", "tool": "replace_in_note" }], + "outside": [{ "from": "the note after one edit", "to": "the note the next edit started from" }] +} +``` + +### Two writers, one reader + +Two MCP engines write records: the TypeScript server in `apps/desktop/src/mcp` +and the Go `zn` (ZenNotes/tui `internal/mcp`), which is what a managed install +runs. So the writing side is kept to string comparisons and JSON, and every merge +happens in the app (`packages/shared-domain/src/agent-edits.ts`): + +- **Writing** (`beginAgentEdit` / `finishAgentEdit`, mirrored in Go + `internal/vault/agent_edits.go`): read the note, write the record with + `after: null` BEFORE the edit (the workflow journal's rule: a process that dies + mid-edit leaves a record that shows more than the assistant did, never less), + make the edit, then store the note as `after`. If the note no longer matches + `after` when the next edit starts, someone else changed it: the writer appends + `{from: after, to: note}` to `outside` and never merges. Past 20 outside changes + it starts a fresh record. An edit that changes nothing puts the record back as it + was. Edits to one note take turns (a per-note lock), since MCP calls run in + parallel. +- **Reading** (`agentEditBaseline`): fold each outside change into `before` with + the three-way merge Cloud conflicts use (`mergeCloudSyncText`), then merge the + note as it is now against `after`. The result is the note without the + assistant's changes but with everyone else's, so the review shows only the + assistant's work. Where the user rewrote lines the assistant changed, the user's + version wins: those lines have been dealt with, and an all-rewritten record is + deleted instead of shown empty. + +Recorded tools: `write_note`, `append_to_note`, `prepend_to_note`, +`insert_at_line`, `replace_in_note`, `toggle_task` (the note comes from the task id). +Not recorded in v1: `create_note` (a new note has nothing to compare against), +moves, renames, trash and deletes (Trash keeps those), and vaults on a +self-hosted server (their MCP writes go through the Go server, which needs its own +change). Notes over 1 MB are not recorded. + +The assistant's name comes from the MCP `initialize` request's client name, mapped +the way comment authors already are (`claude-code` is "Claude Code", `codex-cli` is +"Codex"). + +### Where records go + +- **Moves.** A record moves with its note wherever ZenNotes moves notes: + `relocateNote` and folder renames (desktop and MCP), the Go vault's rename, move + and archive. A record waiting at a destination with no note belongs to nobody + and is discarded, like a stale creation date. +- **Deletes.** Deleting a note, emptying Trash, deleting a folder, or sending a + note to the system trash removes its record. +- **Creation.** Creating, importing or duplicating a note at a path clears a stale + record there. +- **Sync.** Cloud sync carries `.zennotes/agent-edits/*.json` (temp files stay + local), so a review can happen on another computer. +- **Cleanup.** An emptied `agent-edits` folder is removed: a vault whose changes + were all reviewed carries no trace. + +### A fix this needed + +The MCP edit paths used to write a file and only then check it was a note: +`write_note` on `.zennotes/...` created the file before failing. An assistant could +have planted its own record (or comments, or creation dates). Both engines now +refuse anything outside the note folders before a byte lands. + +## The review + +- **Banner**: "Claude Code changed this note 4 minutes ago" with **Review changes**. + From Preview, the button switches the pane to Edit first. +- **Review mode** (`packages/app-core/src/lib/cm-agent-review.ts`, imported on + demand so `@codemirror/merge` stays off the boot path; the renderer chunk rule + in `electron.vite.config.ts` excludes it from `vendor-editor` for that reason): + - CodeMirror's `unifiedMergeView` against the comparison text above. + - Accept moves a change into the comparison text; Reject puts the old text back + as an ordinary edit, saved before the record is updated. + - The note is read-only, and Live Preview is off (a diff is about the source). + - A bar on top: "Reviewing Claude Code's changes: 2 left", **Accept all**, + **Reject all**, **Done**. +- **Progress**: each decision saves the record (`reviewedAgentEdit`), so a half-done + review survives a restart. The last decision deletes it. +- **Keys** (all rebindable in the keymap settings): + +| Action | Without Vim | Vim | +| --- | --- | --- | +| Next / previous change | Mod+Alt+↓ / ↑ | `]c` / `[c` | +| Accept change | Mod+Shift+Enter | `dp` (diff put) | +| Reject change | Mod+Shift+Backspace | `do` (diff obtain) | +| Leave, keeping what is undecided | Escape, Done | Escape (when no command is half-typed), Done | + +`dp` and `do` cannot be Vim mappings: codemirror-vim runs a full match as soon as +it has one, so `d` always starts the delete operator first. Review mode reads +normal-mode keys ahead of Vim instead, holds a key that continues a sequence, and +lets every other key through. The note is read-only, so a held `d` costs nothing, +and Escape after it cancels the `d` rather than leaving the review. + +- **Comments**: select text in a change and comment as usual; the assistant reads + it with `list_comments`. To say why a change was rejected, comment before + rejecting it. + +## Settings + +Settings → MCP → **Review agent edits** (on by default), stored in the portable +config file as `[mcp] review_agent_edits`, which both MCP engines read without the +app running. Turning it off stops recording; records already written stay +reviewable. + +## Verified + +Built app over CDP, with a real MCP client named `claude-code` driving the real MCP +server process: the record, the banner, the review with both changes inline, Reject +by button, Accept by keyboard, the record deleted at the end; and the same in Vim +mode with `do`, `]c` and `dp` (scripts in the session scratchpad, `agent-review/e2e.mjs`). + +## Later + +- New notes created by an assistant ("Keep" or "Move to Trash"). +- Vaults on a self-hosted server: recording in the Go server's write path. +- Phones: reviewing synced records needs the phone bridges to read and write them. +- Telling the assistant what was rejected (an MCP tool reading recent decisions). +- An agent edit landing while the note has unsaved typing is still overwritten by + the app's save of the buffer, as before; the record is then cleaned up, so the + assistant's edit is lost silently. Worth a conflict prompt. +- Per-note history (#448): a bounded, always-on history of every change, with the + same review view. diff --git a/package-lock.json b/package-lock.json index 1ee25d8f9..4362d6997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.67.0", + "version": "2.68.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.67.0", + "version": "2.68.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.67.0", + "version": "2.68.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.67.0", + "version": "2.68.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.67.0", + "version": "2.68.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.67.0", + "version": "2.68.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1923,6 +1923,19 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/search": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", @@ -16377,7 +16390,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.67.0", + "version": "2.68.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16398,6 +16411,7 @@ "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", "@codemirror/lint": "^6.9.7", + "@codemirror/merge": "^6.12.2", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -16464,14 +16478,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.67.0", + "version": "2.68.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.67.0", + "version": "2.68.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16485,7 +16499,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.67.0" + "version": "2.68.0" } } } diff --git a/package.json b/package.json index 7e697f7dd..2d072538c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.67.0", + "version": "2.68.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 70fab4d98..53a1e7224 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.67.0", + "version": "2.68.0", "type": "module", "exports": { "./main": "./src/main.tsx", @@ -42,6 +42,7 @@ "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", "@codemirror/lint": "^6.9.7", + "@codemirror/merge": "^6.12.2", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index e2f6ad029..75ba6f2b5 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -223,6 +223,12 @@ const SettingsModal = lazy(async () => { return { default: module.SettingsModal } }) +const WhatsNewModal = lazy(() => import('./components/WhatsNewModal')) + +/** How long after launch What's New waits, so it opens over a settled + * window rather than racing the vault and the restored tabs. */ +const WHATS_NEW_DELAY_MS = 1200 + const EmptyVault = lazy(async () => { const module = await import('./components/EmptyVault') return { default: module.EmptyVault } @@ -387,6 +393,7 @@ function App(): JSX.Element { const unifiedSidebar = useStore((s) => s.unifiedSidebar) const settingsOpen = useStore((s) => s.settingsOpen) const setSettingsOpen = useStore((s) => s.setSettingsOpen) + const whatsNewOpen = useStore((s) => s.whatsNewOpen) const themeId = useStore((s) => s.themeId) const themeFamily = useStore((s) => s.themeFamily) const themeMode = useStore((s) => s.themeMode) @@ -421,6 +428,14 @@ function App(): JSX.Element { void init() }, [init]) + // The first launch after an update shows what it brought (lib/whats-new.ts). + useEffect(() => { + const timer = window.setTimeout(() => { + void useStore.getState().showWhatsNewAfterUpdate() + }, WHATS_NEW_DELAY_MS) + return () => window.clearTimeout(timer) + }, []) + useEffect(() => { if (!vault) return undefined ensureCloudAutoSyncStarted(vault.root) @@ -1247,6 +1262,11 @@ function App(): JSX.Element { )} + {whatsNewOpen && ( + + + + )} diff --git a/packages/app-core/src/components/AgentEditReview.tsx b/packages/app-core/src/components/AgentEditReview.tsx new file mode 100644 index 000000000..4ec3a79c8 --- /dev/null +++ b/packages/app-core/src/components/AgentEditReview.tsx @@ -0,0 +1,279 @@ +import { useCallback, useLayoutEffect, useRef, useState, type MutableRefObject } from 'react' +import type { Compartment, Extension } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import { agentEditBaseline, latestAgentEdit, reviewedAgentEdit } from '@shared/agent-edits' +import type { AgentEditRecord } from '@shared/ipc' +import { loadAgentReview } from '../lib/agent-review-runtime' +export { REVIEW_AGENT_EDITS_EVENT } from '../lib/agent-review-runtime' +import { getKeymapBinding, isUnboundBinding, type KeymapId } from '../lib/keymaps' +import { useToastStore } from '../lib/toast' +import { toCodeMirrorKey } from '../lib/vim-half-page-keymap' +import { useStore } from '../store' +import { Button } from './ui/Button' + +/** + * Reviewing an assistant's edits to a note (docs/ideas/agent-edit-review.md): + * the banner that says a note has changes to review, the bar shown while + * reviewing, and the hook that puts a pane's editor in and out of review mode. + */ + +function timeAgo(at: number, now: number): string { + const minutes = Math.round((now - at) / 60000) + if (minutes < 1) return 'just now' + if (minutes === 1) return 'a minute ago' + if (minutes < 60) return `${minutes} minutes ago` + const hours = Math.round(minutes / 60) + if (hours === 1) return 'an hour ago' + if (hours < 24) return `${hours} hours ago` + const days = Math.round(hours / 24) + if (days === 1) return 'yesterday' + if (days < 7) return `${days} days ago` + return `on ${new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}` +} + +export interface AgentReviewSession { + path: string + agent: string + remaining: number +} + +export function AgentEditNoteBanner({ + path, + reviewing, + onReview +}: { + path: string + reviewing: boolean + onReview: () => void +}): JSX.Element | null { + const summary = useStore((s) => s.agentEdits[path] ?? null) + if (!summary || reviewing) return null + return ( +

+

+ {summary.agent} changed this note {timeAgo(summary.at, Date.now())}. +

+ +
+ ) +} + +export function AgentReviewBar({ + session, + onAcceptAll, + onRejectAll, + onDone +}: { + session: AgentReviewSession + onAcceptAll: () => void + onRejectAll: () => void + onDone: () => void +}): JSX.Element { + const keep = (event: React.MouseEvent): void => event.preventDefault() + return ( +
+

+ Reviewing {session.agent}’s changes:{' '} + {session.remaining === 1 ? '1 left' : `${session.remaining} left`} +

+ + + +
+ ) +} + +function keySpecs(id: KeymapId): string[] { + const binding = getKeymapBinding(useStore.getState().keymapOverrides, id) + return isUnboundBinding(binding) ? [] : [toCodeMirrorKey(binding)] +} + +/** A Vim sequence binding ("d p") as the keys it is typed with. */ +function vimSequence(id: KeymapId): string[][] { + const binding = getKeymapBinding(useStore.getState().keymapOverrides, id) + if (isUnboundBinding(binding)) return [] + const keys = binding.split(' ').filter(Boolean) + return keys.length > 0 ? [keys] : [] +} + +function notify(message: string): void { + useToastStore.getState().addToast(message, 'info') +} + +/** + * Review mode for one pane's editor. The editor is reused across notes, so + * the review always ends before the pane shows another note: the layout + * effect below runs ahead of the pane's own document swap. + */ +export function useAgentEditReview(options: { + viewRef: MutableRefObject + reviewCompartmentRef: MutableRefObject + livePreviewCompartmentRef: MutableRefObject + /** Live Preview's extensions as the pane would have them now. */ + livePreviewExtensions: () => Extension + activePath: string | null +}): { + session: AgentReviewSession | null + /** Review `path` as soon as this pane's editor shows it (now, or once it + * has opened the note and left Preview). */ + requestReview: (path: string) => void + /** Called by the pane whenever its editor may have become ready. */ + flushPendingReview: () => void + stopReview: () => void + acceptAll: () => void + rejectAll: () => void +} { + const { viewRef, reviewCompartmentRef, livePreviewCompartmentRef, livePreviewExtensions, activePath } = options + const [session, setSession] = useState(null) + const sessionRef = useRef(null) + sessionRef.current = session + const pendingRef = useRef(null) + + const stopReview = useCallback(() => { + const view = viewRef.current + const compartment = reviewCompartmentRef.current + if (view && compartment && sessionRef.current) { + const effects = [compartment.reconfigure([])] + const livePreview = livePreviewCompartmentRef.current + if (livePreview) effects.push(livePreview.reconfigure(livePreviewExtensions())) + view.dispatch({ effects }) + } + sessionRef.current = null + setSession(null) + }, [viewRef, reviewCompartmentRef, livePreviewCompartmentRef, livePreviewExtensions]) + + const startReview = useCallback( + async (path: string) => { + const read = window.zen.readAgentEdit + const write = window.zen.writeAgentEdit + const view = viewRef.current + const compartment = reviewCompartmentRef.current + if (!read || !write || !view || !compartment || path !== activePath) return + const record = await read(path) + if (!record) { + await useStore.getState().refreshAgentEdit(path) + return + } + const current = view.state.doc.toString() + const original = agentEditBaseline(record, current) + if (original === current) { + // The assistant put its change back, or every line it touched has + // since been rewritten: nothing is left to decide. + await write(path, null) + await useStore.getState().refreshAgentEdit(path) + notify('Nothing left to review in this note.') + return + } + const review = await loadAgentReview() + if (viewRef.current !== view || path !== activePath) return + const agent = latestAgentEdit(record)?.agent ?? 'Assistant' + let latest: AgentEditRecord = record + + const extension = review.agentReviewExtension({ + original, + keys: { + next: keySpecs('editor.reviewNextChange'), + previous: keySpecs('editor.reviewPreviousChange'), + accept: keySpecs('editor.reviewAcceptChange'), + reject: keySpecs('editor.reviewRejectChange'), + vim: useStore.getState().vimMode + ? { + next: vimSequence('vim.reviewNextChange'), + previous: vimSequence('vim.reviewPreviousChange'), + accept: vimSequence('vim.reviewAcceptChange'), + reject: vimSequence('vim.reviewRejectChange') + } + : undefined + }, + onDecision: (comparison, remaining) => { + void (async () => { + // A rejected change is an edit: save it before the record says it + // was decided, so a crash cannot leave the two disagreeing. + await useStore.getState().persistNote(path) + latest = (await read(path)) ?? latest + if (remaining === 0) { + await write(path, null) + stopReview() + notify(`Every change from ${agent} is decided.`) + } else { + await write(path, reviewedAgentEdit(latest, comparison, view.state.doc.toString())) + } + await useStore.getState().refreshAgentEdit(path) + })().catch((error) => console.error('Saving the review failed', error)) + }, + onRemaining: (remaining) => { + setSession((s) => (s && s.remaining !== remaining ? { ...s, remaining } : s)) + }, + onExit: stopReview + }) + + const effects = [compartment.reconfigure(extension)] + const livePreview = livePreviewCompartmentRef.current + // A diff is about the Markdown itself, so the review shows the source. + if (livePreview) effects.push(livePreview.reconfigure([])) + view.dispatch({ effects, selection: { anchor: 0 } }) + review.nextChange(view) + view.focus() + const next = { path, agent, remaining: review.remainingChanges(view.state) } + sessionRef.current = next + setSession(next) + }, + [activePath, viewRef, reviewCompartmentRef, livePreviewCompartmentRef, stopReview] + ) + + // The pane is about to show another note: leave the review first. + useLayoutEffect(() => { + if (sessionRef.current && sessionRef.current.path !== activePath) stopReview() + }, [activePath, stopReview]) + + const flushPendingReview = useCallback(() => { + const path = pendingRef.current + if (!path || path !== activePath || !viewRef.current) return + if (useStore.getState().noteContents[path] == null) return + pendingRef.current = null + void startReview(path) + }, [activePath, viewRef, startReview]) + + const requestReview = useCallback( + (path: string) => { + pendingRef.current = path + flushPendingReview() + }, + [flushPendingReview] + ) + + const acceptAll = useCallback(() => { + const view = viewRef.current + if (!view) return + void loadAgentReview().then((review) => review.acceptAllChanges(view)) + }, [viewRef]) + + const rejectAll = useCallback(() => { + const view = viewRef.current + if (!view) return + void loadAgentReview().then((review) => review.rejectAllChanges(view)) + }, [viewRef]) + + return { session, requestReview, flushPendingReview, stopReview, acceptAll, rejectAll } +} diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 580be1722..eb39be2dc 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -840,7 +840,7 @@ export function CalendarPanel({ }} onClick={() => void openTaskAt(task)} onContextMenu={(e) => openTaskMenu(e, task)} - title={`${task.content} — drag to a day to reschedule, right-click for more`} + title={`${task.content} · drag to a day to reschedule, right-click for more`} className={[ 'flex w-full cursor-grab items-center gap-2 rounded px-1.5 py-1 text-left text-xs text-ink-700 transition-colors active:cursor-grabbing', opts?.isActive @@ -1034,7 +1034,7 @@ export function CalendarPanel({ if (dragged) openDropMenu(e.clientX, e.clientY, dragged, iso, day) }} disabled={!dailyEnabled} - title={`${iso} — click to view, double-click to open`} + title={`${iso} · click to view, double-click to open`} className={[ 'relative flex flex-col items-center rounded py-1 text-xs leading-tight transition-colors', !dailyEnabled @@ -1082,7 +1082,7 @@ export function CalendarPanel({
{grabbedTask && (
- Moving “{grabbedTask.content || 'task'}” — h/j/k/l pick a day · Enter place · Esc cancel + Moving “{grabbedTask.content || 'task'}”: h/j/k/l pick a day · Enter place · Esc cancel
)}
diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 333a6c92c..8745d6aa2 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -382,12 +382,12 @@ export function CommandPalette(): JSX.Element { {mode === 'theme' - ? 'Theme preview — ↵ to keep, esc to revert' + ? 'Theme preview · ↵ to keep, esc to revert' : mode === 'workflow' - ? 'Run workflow — ↵ to run, esc to return' + ? 'Run workflow · ↵ to run, esc to return' : vaultAction === 'new-window' - ? 'Open vault in new window — ↵ to open, esc to return' - : 'Switch vault — ↵ to open, esc to return'} + ? 'Open vault in new window · ↵ to open, esc to return' + : 'Switch vault · ↵ to open, esc to return'}
)} diff --git a/packages/app-core/src/components/DatabaseBoardView.tsx b/packages/app-core/src/components/DatabaseBoardView.tsx index 68b1d96c2..fab75984d 100644 --- a/packages/app-core/src/components/DatabaseBoardView.tsx +++ b/packages/app-core/src/components/DatabaseBoardView.tsx @@ -137,7 +137,7 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { >
- {titleField ? row.cells[titleField.id] || '—' : '—'} + {titleField ? row.cells[titleField.id] || 'Untitled' : 'Untitled'}
{selected.length === 0 ? ( - — + None ) : ( selected.map((v) => ( {field.optionsSource ? draft - ? 'No matches — Enter adds it as an option.' + ? 'No matches. Enter adds it as an option.' : 'Type to search notes or add an option.' - : 'No options yet — add one below.'} + : 'No options yet. Add one below.'}
)}
@@ -1347,7 +1347,7 @@ function NoteCell({ field, value, editing, onStartEdit, onEndEdit, onCommit }: C const chips = (
{targets.length === 0 ? ( - — + None ) : ( targets.map((target) => { const note = resolveWikilinkTarget(notes, target) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 2c38c052e..c2912347f 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -781,6 +781,10 @@ function registerVimCommands(): void { }, ); + Vim.defineEx("whatsnew", "whatsnew", () => { + void useStore.getState().openWhatsNew(); + }); + Vim.defineEx("daily", "daily", () => { void useStore.getState().openTodayDailyNote(); }); @@ -1581,7 +1585,7 @@ function renderWildmenu( // they should type more to filter rather than keep tabbing forever. if (matches.length > slice.length) { const more = document.createElement("div"); - more.textContent = `+${matches.length - slice.length} more — type to filter`; + more.textContent = `+${matches.length - slice.length} more · type to filter`; more.style.cssText = "padding: 4px 8px 2px; font-size: 10px; opacity: 0.6; color: rgb(var(--z-fg))"; menu.list.appendChild(more); diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 52515be52..31b1fdf09 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -146,6 +146,12 @@ import { shouldShowSelectionToolbar } from '../lib/cm-selection-toolbar' import { editorCursorPosition } from '../lib/editor-cursor-position' import { EditorSelectionToolbar } from './EditorSelectionToolbar' import { CloudConflictNoteBanner } from './CloudConflictNoteBanner' +import { + AgentEditNoteBanner, + AgentReviewBar, + REVIEW_AGENT_EDITS_EVENT, + useAgentEditReview +} from './AgentEditReview' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { LazyDiagramTabView, LazyPreview as Preview } from './LazyPreview' import { ConnectionsPanel } from './ConnectionsPanel' @@ -1137,6 +1143,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const scrolloffCompartmentRef = useRef(null) const drawSelectionCompartmentRef = useRef(null) const tabSizeCompartmentRef = useRef(null) + // Review mode for an assistant's edits (AgentEditReview.tsx): empty until a + // review starts. + const agentReviewCompartmentRef = useRef(null) // history() lives in a compartment so we can reset undo history on a note // switch — otherwise Cmd+Z crosses notes and overwrites the current one (#247). // The outgoing note's history is set aside first and handed back when that @@ -1171,6 +1180,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { * sync effect updates it whenever we swap the view's document. */ const viewPathRef = useRef(null) + // Declared here, ahead of the layout effect that swaps the editor's + // document, so a review always ends before the pane shows another note. + const agentReviewLivePreview = useCallback( + (): Extension => (useStore.getState().livePreview ? currentWysiwygExtensions(viewPathRef.current) : []), + [] + ) + const agentReview = useAgentEditReview({ + viewRef, + reviewCompartmentRef: agentReviewCompartmentRef, + livePreviewCompartmentRef, + livePreviewExtensions: agentReviewLivePreview, + activePath: activeTab + }) /** The newest entry of the store's `recentPathRewrites` this editor has * accounted for. Only newer ones can explain a path change as a rename, so * an old rename can never make a real note switch look like one. */ @@ -1378,6 +1400,26 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setFocusedPanel ]) + // The palette's Review Agent Changes: the active pane opens the review, + // leaving Preview first, since the review happens in the editor. + const { requestReview: requestAgentReview } = agentReview + const reviewAgentChanges = useCallback( + (path: string) => { + if (modeRef.current === 'preview') applyPaneMode('edit') + requestAgentReview(path) + }, + [applyPaneMode, requestAgentReview] + ) + useEffect(() => { + if (!isActive) return + const handler = (event: Event): void => { + const path = (event as CustomEvent<{ path?: string }>).detail?.path + if (path) reviewAgentChanges(path) + } + window.addEventListener(REVIEW_AGENT_EDITS_EVENT, handler) + return () => window.removeEventListener(REVIEW_AGENT_EDITS_EVENT, handler) + }, [isActive, reviewAgentChanges]) + // `zen:toggle-outline` — routed only to the active pane, same pattern // as the connections toggle. useEffect(() => { @@ -1976,6 +2018,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const drawSelectionCompartment = new Compartment() const tabSizeCompartment = new Compartment() const historyCompartment = new Compartment() + const agentReviewCompartment = new Compartment() + agentReviewCompartmentRef.current = agentReviewCompartment vimCompartmentRef.current = vimCompartment harperCompartmentRef.current = harperCompartment editorKeymapCompartmentRef.current = editorKeymapCompartment @@ -2035,6 +2079,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { void importPastedImagesRef.current?.(files, view) }), commentDecorationField, + agentReviewCompartment.of([]), wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []), scrolloffCompartment.of(scrollOff(s0.editorScrollOff)), markdownCompartment.of( @@ -3653,7 +3698,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { onDoubleClick={() => { if (tab.preview) promoteTabInPane(paneId, tab.path) }} - title={tab.preview ? `${tab.title} — preview (double-click to keep open)` : undefined} + title={tab.preview ? `${tab.title} · preview (double-click to keep open)` : undefined} className="flex min-w-0 flex-1 items-center gap-1.5 truncate px-1.5 text-left" > {tab.isTasks && ( @@ -3981,6 +4026,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { content?.path ?? null, editorHydration ) + const { flushPendingReview } = agentReview + useEffect(() => { + if (editorReady && mode !== 'preview') flushPendingReview() + }, [editorReady, mode, content?.path, flushPendingReview]) const previewSourceMarkdown = showPreview ? content?.body ?? '' : '' const previewSettleKey = showPreview && content ? content.path : '' const { settledMarkdown: previewMarkdown, isStale: previewIsStale } = useSettledMarkdown( @@ -4405,6 +4454,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { sibling that renders nothing keeps the editor's DOM in place when it comes and goes. */} + reviewAgentChanges(content.path)} + /> + {agentReview.session?.path === content.path && ( + + )}
{isServerVaultSetup ? 'Choose the vault directory on the server running ZenNotes. The normal self-hosted path is `make up`, which serves the browser app and server together.' - : 'Choose a folder on your computer to use as your vault. ZenNotes will store your notes there as plain markdown files — yours to keep, back up, and sync any way you like.'} + : 'Choose a folder on your computer to use as your vault. ZenNotes will store your notes there as plain markdown files, yours to keep, back up, and sync any way you like.'}

{isServerVaultSetup && (

diff --git a/packages/app-core/src/components/OnboardingWizard.tsx b/packages/app-core/src/components/OnboardingWizard.tsx index 41d779c78..a426c8fff 100644 --- a/packages/app-core/src/components/OnboardingWizard.tsx +++ b/packages/app-core/src/components/OnboardingWizard.tsx @@ -327,7 +327,7 @@ function StepRail({ }} disabled={!reachable || active} aria-current={active ? 'step' : undefined} - aria-label={`${STEP_LABELS[step]} — step ${i + 1} of ${steps.length}`} + aria-label={`${STEP_LABELS[step]}, step ${i + 1} of ${steps.length}`} className={[ 'group flex flex-1 flex-col items-stretch gap-1.5 rounded-md p-1 text-left transition-colors', reachable && !active @@ -481,7 +481,7 @@ function VimStep({

@@ -820,7 +820,7 @@ function LayoutStep({
@@ -931,7 +931,7 @@ function DoneStep({ subtitle={ hasVault ? "Here are a few shortcuts to get you started. You can revisit any of this from Settings." - : 'You skipped the vault step — open the welcome screen any time to pick one.' + : 'You skipped the vault step. Open the welcome screen any time to pick one.' } /> diff --git a/packages/app-core/src/components/OutlinePanel.tsx b/packages/app-core/src/components/OutlinePanel.tsx index 4707bd122..834d7c94d 100644 --- a/packages/app-core/src/components/OutlinePanel.tsx +++ b/packages/app-core/src/components/OutlinePanel.tsx @@ -81,7 +81,7 @@ export function OutlinePanel({ note, activeLine, onJump, fitWidth }: Props): JSX
{items.length === 0 - ? 'No headings yet — add `#` to build an outline.' + ? 'No headings yet. Add `#` to build an outline.' : `${items.length} heading${items.length === 1 ? '' : 's'}`}
{items.length > 0 && ( diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 711c5e0d5..95b082e3b 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -368,7 +368,7 @@ export function QuickCaptureApp(): JSX.Element { if (!view) return false const plan = planQuickCaptureSave(mode, view.state.doc.toString()) if (plan.op === 'noop') { - if (!opts.silent) setError('Nothing to save yet — start writing.') + if (!opts.silent) setError('Nothing to save yet. Start writing.') return false } setSubmitting(true) @@ -706,7 +706,7 @@ export function QuickCaptureApp(): JSX.Element {
{customThemes.length === 0 ? (

- Create a theme — or drop a folder with a{" "} + Create a theme, or drop a folder with a{" "} manifest.json {" "} @@ -1812,7 +1812,7 @@ export function SettingsModal(): JSX.Element { )}

- Adjust the active theme — colors and a few layout options — + Adjust the active theme (colors and a few layout options) with no CSS. These apply on top of whichever theme is selected; reset any one with ↺.

@@ -2147,7 +2147,7 @@ export function SettingsModal(): JSX.Element { { id: "scroll-off", title: "Scroll offset", - description: "Vim scrolloff — lines kept above and below the cursor.", + description: "Vim scrolloff: lines kept above and below the cursor.", keywords: ["scroll", "scrolloff", "vim", "cursor", "margin"], }, { @@ -2689,7 +2689,7 @@ export function SettingsModal(): JSX.Element { />
@@ -3678,7 +3678,7 @@ export function SettingsModal(): JSX.Element { description="Maximum width for preview and split-preview content." value={previewMaxWidth} min={640} - max={1400} + max={1600} step={20} unit="px" settingId="reading-width" @@ -4796,7 +4796,7 @@ export function SettingsModal(): JSX.Element { />
{hideBuiltinTemplates - ? "The shipped templates are hidden from the picker and palette — your custom ones still show. Restore them anytime." + ? "The shipped templates are hidden from the picker and palette. Your custom ones still show. Restore them anytime." : "Hide every shipped template from the picker and palette. Your custom templates are unaffected."}
@@ -5454,7 +5454,7 @@ export function SettingsModal(): JSX.Element {
{template.category} - {template.description ? ` — ${template.description}` : ""} + {template.description ? ` · ${template.description}` : ""}
@@ -5541,6 +5541,23 @@ export function SettingsModal(): JSX.Element { "uninstall", ], }, + { + id: "review-agent-edits", + title: "Review agent edits", + description: + "Keep what a note said before an assistant edits it through MCP, so you can accept or reject each change.", + keywords: [ + "review", + "agent", + "assistant", + "ai", + "changes", + "diff", + "accept", + "reject", + "mcp", + ], + }, { id: "mcp-instructions", title: "MCP instructions", @@ -5651,6 +5668,22 @@ export function SettingsModal(): JSX.Element { description: "Company and product details.", keywords: ["company", "lumary", "logo"], }, + { + id: "usage-count", + title: "Count this copy anonymously", + description: + "Once per install and per update, tell zennotes.org the version, system and install type, with no identifier.", + keywords: [ + "privacy", + "anonymous", + "count", + "usage", + "telemetry", + "analytics", + "statistics", + ], + available: appInfo.runtime === "desktop", + }, { id: "config-file", title: "Configuration file", @@ -5726,6 +5759,7 @@ export function SettingsModal(): JSX.Element {
+ {appInfo.runtime === "desktop" && } {appInfo.runtime === "desktop" && } ), @@ -6200,7 +6234,7 @@ function KeymapSettings({ {conflict && ( Conflicts with {conflict.title} @@ -6491,7 +6525,7 @@ function KeymapRecorderModal({ Already used by{" "} {conflict.title}. Two - global shortcuts can’t share a key — reset or reassign that one + global shortcuts can’t share a key. Reset or reassign that one first. @@ -6709,7 +6743,7 @@ function ConfigFileSection(): JSX.Element { return (
@@ -7171,7 +7205,7 @@ function TemplateSelectRow({ const triggerLabel = missing ? "[Missing template]" : selected - ? `${selected.category} — ${selected.name}` + ? `${selected.category} · ${selected.name}` : "None (blank note)"; // Custom dropdown (not a native