SLOPSHOPPER

obsidian-bridge

Mirrors each Claude session into an Obsidian note and takes replies that you write in that note.

newguardcommandtoastpromptprocess
v0.1.0MITupdated 2026-10-15utsavsingh899/claude-obsidian-bridge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · obsidian-bridge
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ obsidian-bridge │ ⏺ Read(src/auth.ts) │ obsidian-bridge: The Obsidian CLI does not │ ⎿ Read 6 lines │ answer. Claude writes the note file │ ⏺ Update(src/auth.ts) │ directly until it answers. │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ obsidian-bridge │ │ Session note: Claude Sessions/2026-10-16 │ ● Done. refresh now rejects expired claims and logs an audit event. │ 2-21 PM - fix the failing auth test and │ │ add an.md │ ✻ Worked for 42s · done 4:20 PM ╰────────────────────────────────────────────╯ › /obsidian ⎿ obsidian-bridge: Mirror: on ⎿ obsidian-bridge: Away mode: off ⎿ obsidian-bridge: Note: Claude Sessions/2026-10-16 2-21 PM - fix the failing auth test and add an.md ⎿ obsidian-bridge: Writes through: the file system (vault not found) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

obsidian-bridge

obsidian-bridge is a mod for Claude Code. It keeps one note in your Obsidian vault for each Claude session. You can read the session on your phone, and you can send replies to Claude from the note.

What it does

  • It makes one note for each Claude session. The default folder in the vault is Claude Sessions.
  • The note shows your prompts and the messages from Claude.
  • The note shows each tool step as one short line, for example ✏️ Edited src/app.ts (+2 −1). It does not show the code, the command output or the diff.
  • At the end of each turn, the note shows how many files and lines the turn changed.
  • The note name is the start date and time, then the session name. An example is 2026-10-15 6-20 PM - Fix the login bug.md. The first name comes from your first prompt. When the desktop app gives the session a name in its sidebar, the mod changes the note name to that name.
  • The status property of the note shows the state of the session: 🟢 working, 🟡 waiting for you, ❓ question on the Mac or ⚪ ended.

Reply from your phone

  1. Open the session note in Obsidian on your phone.
  2. Write your message under the Reply to Claude heading, above the Send box.
  3. Tick the Send box. You can also write a line with only /send.
  4. Do not change the reply area after that.

Claude takes the reply only when the reply area stays the same for the settle time. The default settle time is 5 seconds. Claude then gets your text as the next prompt. If Claude is busy, it starts your message after the current turn. Text below a ticked box stays in the reply area for your next message.

A reply from the phone turns on away mode. In away mode, Claude does not open question dialogs on the Mac. Claude writes the question and the options in its reply, and you answer in the note. A prompt that you type on the Mac turns off away mode.

How it writes the note

The mod writes to the note through the Obsidian command line interface (CLI) with obsidian eval. It changes the note only when the note is still the same as the copy that it read. Because of this, the mod does not overwrite your edits from the phone. If the CLI does not answer, the mod writes the note file directly.

Requirements

  • macOS. The mod uses macOS paths to find Obsidian and its vaults.
  • A Claude Code version that can load mods (plugins with function hooks). The tests pass with Claude Code 2.1.294.
  • Obsidian 1.12.7 or later on the Mac.
  • The Obsidian CLI must be on. In Obsidian, go to Settings → General → Advanced and turn on Command line interface.
  • Obsidian Sync or a different sync service that copies the vault to your phone.
  • The Mac must stay awake, and Obsidian must stay open on the Mac. Claude reads your replies from the vault on the Mac.

Install

Install from the marketplace

  1. Start Claude Code in a terminal.
  2. Type this command:
   /plugin install obsidian-bridge --marketplace utsavsingh899/claude-obsidian-bridge
  1. Type y to accept.
  2. Select a scope. The user scope installs the mod for all of your sessions.
  3. Restart Claude Code.

You can also install the mod from a shell:

claude plugin install obsidian-bridge --marketplace utsavsingh899/claude-obsidian-bridge

Install from a local folder

  1. Clone this repository:
   git clone https://github.com/utsavsingh899/claude-obsidian-bridge.git ~/.claude/mods/obsidian-bridge
  1. Add the absolute path of the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/.claude/mods/obsidian-bridge"
     }
   }
  1. Restart Claude Code.

The /obsidian command

CommandWhat it does
/obsidianShows the status: the mirror, away mode, the note path, an obsidian:// link, and how the mod writes the note.
/obsidian onTurns on the note for this session.
/obsidian offWrites the last entries, then turns off the note for this session.
/obsidian awayTurns on away mode. Claude asks its questions in its reply, not in a dialog.
/obsidian backTurns off away mode.

Options

The options are the userConfig fields in .claude-plugin/plugin.json. To see the values, run this command in a shell:

claude plugin configure obsidian-bridge
OptionDefaultDescription
enabledtrueMake a note for each session. /obsidian on and /obsidian off change this for one session.
vaultemptyThe name of the vault, as the Obsidian CLI knows it. When it is empty, the mod uses the name of the vault folder.
vaultPathemptyThe absolute path of the vault. When it is empty, the mod finds the vault in the Obsidian vault list. It selects the vault with the name in vault, then the open vault, then the first vault.
folderClaude SessionsThe folder in the vault for the session notes.
cliemptyThe path of the Obsidian CLI. When it is empty, the mod tries the usual locations.
pollSeconds3How often Claude reads the note for a reply, in seconds.
settleSeconds5The time in seconds that the reply area must stay the same before Claude takes the reply.
flushSeconds15How often Claude writes the collected tool steps into the note, in seconds.

Limits

  • A permission prompt still stops the session. You cannot answer a permission prompt from the note. Answer it on the Mac.
  • You get no push alerts. Open the note to see new messages from Claude.
  • The note does not show the steps of subagents.
  • The mod works only on macOS.

Privacy

The note holds the text of your session. This text includes your prompts, the messages from Claude, the paths of the files that Claude reads and changes, and the path of the project folder. The mod writes this text into your vault. Your sync service then copies the text to your other devices and, for most services, to its servers. Do not use the mod for a session with secrets or other data that must not leave the Mac. To stop the note for one session, use /obsidian off.

The mod reads the session transcript in ~/.claude/projects only to find the session name. The mod itself sends no data to a server.

Development

The note logic is in hooks/note.ts. The hooks are in hooks/register.ts.

To run the tests, use this command:

claude plugin test .

To check the manifests and the hooks, use this command:

claude plugin validate .

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 558 lines
1import type { Hook, PluginOptions, Register } from 'claude-code'
2
3import * as N from './note'
4
5type Engine = Parameters<Hook<'session.start'>>[0]
6
7type Options = {
8  isEnabled: boolean
9  vault: string
10  vaultPath: string
11  folder: string
12  cli: string
13  pollMs: number
14  settleMs: number
15  flushMs: number
16}
17
18type Outcome = 'ok' | 'conflict' | 'missing' | 'created' | 'exists' | 'error'
19
20const ME = 'obsidian-bridge'
21
22const AWAY_DENY =
23  'The user is away from the Mac and reads this session in an Obsidian note on the phone. ' +
24  'The user cannot answer this dialog. Write your question and the options as plain text in your reply, ' +
25  "then end your turn. The user's answer comes as the next prompt."
26
27// The module state. A hot reload starts it again; the note path and the
28// session modes are in `$.store`, so they stay.
29// A log entry that waits for the next write. Tool calls wait up to the flush
30// time; messages from you or Claude go out at the next tick.
31type Entry = { text: string; isUrgent: boolean; at: number }
32
33let items: Entry[] = []
34let props: Record<string, string> = {}
35let isBusy = false
36let hasTurnText = false
37// The files that the current turn changed, with the lines added and removed.
38const turnFiles = new Map<string, { added: number; removed: number }>()
39let firstPrompt: string | undefined
40let offsetMinutes = 0
41let home = ''
42let vaultRoot = ''
43// The vault name for the CLI: the `vault` option, or the name of the vault folder.
44let vaultName = ''
45let cliPath: string | undefined
46let cliCheckedAt = 0
47let settle: { zone: string; since: number } | undefined
48let lock: Promise<unknown> = Promise.resolve()
49let isTickQueued = false
50let conflictStreak = 0
51const shown = new Set<string>()
52// The transcript file of each session. Its "customTitle" lines hold the name
53// that the desktop app shows in its sidebar.
54const transcripts = new Map<string, string>()
55let lastTitleCheck = 0
56let isTitleDue = true
57
58function readOptions(options: PluginOptions): Options {
59  const num = (value: unknown, fallback: number): number =>
60    typeof value === 'number' && Number.isFinite(value) ? value : fallback
61  return {
62    isEnabled: options.enabled !== false,
63    vault: String(options.vault ?? ''),
64    vaultPath: String(options.vaultPath ?? ''),
65    folder: String(options.folder ?? 'Claude Sessions').replace(/^\/+|\/+$/g, ''),
66    cli: String(options.cli ?? ''),
67    pollMs: Math.max(1, num(options.pollSeconds, 3)) * 1000,
68    settleMs: Math.max(0, num(options.settleSeconds, 5)) * 1000,
69    flushMs: Math.max(1, num(options.flushSeconds, 15)) * 1000,
70  }
71}
72
73// Runs one job at a time, so two writes to the note never overlap.
74function serial<T>(job: () => Promise<T>): Promise<T> {
75  const run = lock.then(job, job)
76  lock = run.catch(() => undefined)
77  return run
78}
79
80function now(): { ms: number; date: string; time: string; fileTime: string } {
81  const ms = Date.now()
82  return { ms, ...N.clock(ms, offsetMinutes) }
83}
84
85function showOnce($: Engine, text: string): void {
86  if (shown.has(text)) return
87  shown.add(text)
88  $.ui.toast(`${ME}: ${text}`)
89}
90
91function pushBlock(block: string, isUrgent = true): void {
92  items.push({ text: block, isUrgent, at: Date.now() })
93  if (items.length > 500) items = items.slice(-500)
94}
95
96function units(entries: readonly Entry[]): string[] {
97  return entries.map(entry => entry.text)
98}
99
100function isDue(opt: Options): boolean {
101  const oldest = items[0]
102  return (
103    items.some(entry => entry.isUrgent) ||
104    (oldest !== undefined && Date.now() - oldest.at >= opt.flushMs) ||
105    Object.keys(props).length > 0
106  )
107}
108
109async function isOn($: Engine, sessionId: string, opt: Options): Promise<boolean> {
110  const mode = await $.store.get(`mode:${sessionId}`)
111  return mode === 'on' ? true : mode === 'off' ? false : opt.isEnabled
112}
113
114async function setup($: Engine, opt: Options): Promise<void> {
115  const [env, zone] = await Promise.all([
116    $.process.run(['/usr/bin/printenv', 'HOME']),
117    $.process.run(['/bin/date', '+%z']),
118  ])
119  home = env.stdout.trim()
120  offsetMinutes = N.parseOffset(zone.stdout)
121  vaultRoot = opt.vaultPath.replace(/^~(?=\/|$)/, home)
122  if (!vaultRoot) vaultRoot = await findVault($, opt.vault)
123  vaultName = opt.vault || vaultRoot.replace(/\/+$/, '').split('/').pop() || ''
124  await findCli($, opt)
125}
126
127async function findVault($: Engine, name: string): Promise<string> {
128  try {
129    const raw = await $.fs.read(`${home}/Library/Application Support/obsidian/obsidian.json`)
130    const vaults = Object.values((JSON.parse(raw) as { vaults?: Record<string, { path?: string; open?: boolean }> }).vaults ?? {})
131    const pick =
132      vaults.find(v => v.path?.split('/').pop() === name) ?? vaults.find(v => v.open) ?? vaults[0]
133    return pick?.path ?? ''
134  } catch {
135    return ''
136  }
137}
138
139async function findCli($: Engine, opt: Options): Promise<void> {
140  cliCheckedAt = Date.now()
141  const candidates = [
142    opt.cli,
143    '/usr/local/bin/obsidian',
144    '/opt/homebrew/bin/obsidian',
145    `${home}/.local/bin/obsidian`,
146    '/Applications/Obsidian.app/Contents/MacOS/obsidian-cli',
147  ].filter(Boolean)
148  for (const candidate of candidates) {
149    if (!(await $.fs.exists(candidate).catch(() => false))) continue
150    try {
151      const r = await $.process.run([candidate, ...N.vaultArgs(vaultName), 'version'], { timeoutMs: 15_000 })
152      const said = `${r.stdout}\n${r.stderr}`
153      if (r.exitCode === 0 && !/not enabled|error/i.test(said)) {
154        cliPath = candidate
155        return
156      }
157    } catch {
158      // Try the next place.
159    }
160  }
161  cliPath = undefined
162  showOnce($, 'The Obsidian CLI does not answer. Claude writes the note file directly until it answers.')
163}
164
165function absolute(path: string): string {
166  return `${vaultRoot}/${path}`
167}
168
169async function readNote($: Engine, path: string): Promise<string | undefined> {
170  try {
171    return await $.fs.read(absolute(path))
172  } catch {
173    return undefined
174  }
175}
176
177async function runApp($: Engine, opt: Options, payload: unknown): Promise<string> {
178  if (!cliPath) return 'no-cli'
179  const r = await $.process.run([cliPath, ...N.vaultArgs(vaultName), 'eval', `code=${N.appScript(payload)}`], {
180    timeoutMs: 20_000,
181  })
182  // `eval` prints the result as "=> value". An error prints "Error: ..." with exit code 0.
183  const found = /^=> (ok|conflict|missing|created|exists)\s*$/m.exec(r.stdout)
184  return found?.[1] ?? `unknown: ${(r.stdout + r.stderr).trim().slice(0, 200)}`
185}
186
187async function createNote($: Engine, opt: Options, path: string, content: string): Promise<Outcome> {
188  if (cliPath) {
189    const said = await runApp($, opt, { path, create: content })
190    if (said === 'created' || said === 'exists') return said
191    showOnce($, `Obsidian did not create the note (${said}). Claude writes the file directly.`)
192  }
193  if (await $.fs.exists(absolute(path))) return 'exists'
194  await $.fs.write(absolute(path), content)
195  return 'created'
196}
197
198// Applies the splices only when the note is still `before`.
199async function applyToNote($: Engine, opt: Options, path: string, before: string, splices: N.Splice[]): Promise<Outcome> {
200  const sorted = N.sortSplices(splices)
201  const after = N.applySplices(before, sorted)
202  if (cliPath) {
203    const said = await runApp($, opt, { path, hash: N.hash(before), splices: sorted })
204    conflictStreak = said === 'conflict' ? conflictStreak + 1 : 0
205    // Obsidian keeps a copy that does not match the file. Write the file directly.
206    if (said === 'conflict' && conflictStreak >= 5) {
207      showOnce($, 'Obsidian and the note file do not match. Claude wrote the file directly.')
208      conflictStreak = 0
209      return applyToFile($, path, before, after)
210    }
211    if (said === 'ok' || said === 'conflict' || said === 'missing') return said
212    // The CLI gave no clear answer. Look at the file to find out.
213    const current = await readNote($, path)
214    if (current === after) return 'ok'
215    if (current === undefined) return 'missing'
216    showOnce($, `Unclear answer from the Obsidian CLI: ${said}`)
217    return current === before ? 'conflict' : 'error'
218  }
219  return applyToFile($, path, before, after)
220}
221
222async function applyToFile($: Engine, path: string, before: string, after: string): Promise<Outcome> {
223  const current = await readNote($, path)
224  if (current === undefined) return 'missing'
225  if (current !== before) return 'conflict'
226  await $.fs.write(absolute(path), after)
227  return 'ok'
228}
229
230async function notePath($: Engine, sessionId: string): Promise<string | undefined> {
231  const path = await $.store.get(`note:${sessionId}`)
232  return typeof path === 'string' ? path : undefined
233}
234
235async function makeNote($: Engine, opt: Options, sessionId: string): Promise<string | undefined> {
236  const t = now()
237  const cwd = await $.session.cwd()
238  const project = cwd.split('/').pop() || 'session'
239  // The sidebar name comes after the first turn starts. Until then, use the first prompt.
240  const sessionName = await sessionTitle($, sessionId).catch(() => undefined)
241  const name = sessionName ?? N.slug(firstPrompt ?? 'session')
242  const template = N.newNote({
243    sessionId,
244    project,
245    cwd,
246    title: name,
247    date: t.date,
248    time: t.time,
249    settleSeconds: Math.round(opt.settleMs / 1000),
250  })
251  const content = N.applySplices(template, [
252    ...(items.length > 0 ? [N.logSplice(template, units(items))] : []),
253    ...N.propsSplices(template, { ...props, updated: t.time, ...(sessionName ? { session: sessionName } : {}) }),
254  ])
255  for (let copy = 1; copy <= 9; copy++) {
256    const path = N.notePathFor(opt.folder, `${t.date} ${t.fileTime}`, name, copy)
257    if (await $.fs.exists(absolute(path)).catch(() => false)) continue
258    if ((await createNote($, opt, path, content)) !== 'created') continue
259    await $.store.set(`note:${sessionId}`, path)
260    if (sessionName) await $.store.set(`named:${sessionId}`, sessionName)
261    items = []
262    props = {}
263    $.ui.toast(`Session note: ${path}`)
264    return path
265  }
266  showOnce($, `Could not make a note in ${opt.folder}.`)
267  return undefined
268}
269
270// The session name from the sidebar, or undefined when the session has none yet.
271async function sessionTitle($: Engine, sessionId: string): Promise<string | undefined> {
272  let transcript = transcripts.get(sessionId)
273  if (transcript === undefined) {
274    const found = await $.process.run(['/usr/bin/find', `${home}/.claude/projects`, '-maxdepth', '2', '-name', `${sessionId}.jsonl`])
275    transcript = found.stdout.trim().split('\n')[0] ?? ''
276    if (!transcript) return undefined
277    transcripts.set(sessionId, transcript)
278  }
279  const r = await $.process.run(['/usr/bin/grep', '-oE', '"customTitle":"([^"\\\\]|\\\\.)*"', transcript])
280  return N.parseTitle(r.stdout)
281}
282
283async function renameFile($: Engine, opt: Options, from: string, to: string): Promise<Outcome> {
284  if (cliPath) {
285    const said = await runApp($, opt, { path: from, rename: to })
286    if (said === 'ok' || said === 'exists' || said === 'missing') return said
287  }
288  const r = await $.process.run(['/bin/mv', '-n', absolute(from), absolute(to)])
289  return r.exitCode === 0 && (await $.fs.exists(absolute(to))) ? 'ok' : 'error'
290}
291
292// Renames the note to "<start date and time> - <session name>.md" when the
293// session gets a name or you rename the session in the sidebar.
294async function syncName($: Engine, opt: Options, sessionId: string): Promise<void> {
295  const path = await notePath($, sessionId)
296  if (path === undefined) return
297  const title = await sessionTitle($, sessionId)
298  if (title === undefined || title === (await $.store.get(`named:${sessionId}`))) return
299  const stamp = N.stampOf(path) ?? `${now().date} ${now().fileTime}`
300  let current = path
301  for (let copy = 1; copy <= 9; copy++) {
302    const target = N.notePathFor(opt.folder, stamp, title, copy)
303    if (target === path) break
304    if (await $.fs.exists(absolute(target)).catch(() => false)) continue
305    if ((await renameFile($, opt, path, target)) !== 'ok') return
306    current = target
307    await $.store.set(`note:${sessionId}`, target)
308    break
309  }
310  // Show the new name in the note heading and its properties too.
311  const before = await readNote($, current)
312  if (before === undefined) return
313  const heading = N.headingSplice(before, title)
314  const splices = [...N.propsSplices(before, { session: title }), ...(heading ? [heading] : [])]
315  if ((await applyToNote($, opt, current, before, splices)) !== 'ok') return
316  await $.store.set(`named:${sessionId}`, title)
317}
318
319async function writePending($: Engine, opt: Options, sessionId: string): Promise<void> {
320  if (items.length === 0 && Object.keys(props).length === 0) return
321  const path = await notePath($, sessionId)
322  if (path === undefined) {
323    await makeNote($, opt, sessionId)
324    return
325  }
326  for (let attempt = 0; attempt < 3; attempt++) {
327    const before = await readNote($, path)
328    if (before === undefined) {
329      // You deleted the note. Start a new one.
330      await $.store.delete(`note:${sessionId}`)
331      await makeNote($, opt, sessionId)
332      return
333    }
334    const writing = items.length
335    const values = { ...props, updated: now().time }
336    const splices = [...N.propsSplices(before, values)]
337    if (writing > 0) splices.push(N.logSplice(before, units(items.slice(0, writing))))
338    const outcome = await applyToNote($, opt, path, before, splices)
339    if (outcome === 'ok') {
340      items = items.slice(writing)
341      props = {}
342      return
343    }
344    if (outcome !== 'conflict') return
345    await $.clock.sleep(300)
346  }
347}
348
349async function takeReply($: Engine, opt: Options, sessionId: string): Promise<void> {
350  const path = await notePath($, sessionId)
351  if (path === undefined) return
352  const note = await readNote($, path)
353  if (note === undefined) return
354  const reply = N.parseReply(note)
355  if (reply?.message === undefined) {
356    settle = undefined
357    return
358  }
359  const t = now()
360  // Take the message only after the reply area stays the same for the settle time.
361  if (settle?.zone !== reply.zone) {
362    settle = { zone: reply.zone, since: t.ms }
363    return
364  }
365  if (t.ms - settle.since < opt.settleMs) return
366  const message = reply.message
367  const youBlock = message
368    ? N.callout('quote', `${t.time} · You (phone)`, message + (isBusy ? '\n\n_Claude starts this after the current turn._' : ''))
369    : ''
370  const status: Record<string, string> = message ? { status: '🟢 working', updated: t.time } : { updated: t.time }
371  // Write the entries that wait, so the log stays in order.
372  const outcome = await applyToNote($, opt, path, note, N.consumeSplices(note, reply, [...units(items), ...(youBlock ? [youBlock] : [])], status))
373  if (outcome !== 'ok') return
374  items = []
375  settle = undefined
376  if (!message) return
377  await $.store.set(`away:${sessionId}`, true)
378  void $.prompt.submit({ text: message, asUser: true }).catch(() => showOnce($, 'Could not send your reply to Claude.'))
379}
380
381async function tick($: Engine, opt: Options): Promise<void> {
382  if (isTickQueued) return
383  isTickQueued = true
384  await serial(async () => {
385    isTickQueued = false
386    try {
387      const sessionId = await $.session.id()
388      if (!(await isOn($, sessionId, opt))) {
389        items = []
390        props = {}
391        return
392      }
393      if (!cliPath && Date.now() - cliCheckedAt > 60_000) await findCli($, opt)
394      if (isDue(opt)) await writePending($, opt, sessionId)
395      if (isTitleDue || Date.now() - lastTitleCheck >= 30_000) {
396        lastTitleCheck = Date.now()
397        isTitleDue = false
398        await syncName($, opt, sessionId)
399      }
400      await takeReply($, opt, sessionId)
401    } catch (error) {
402      showOnce($, `The note update failed: ${error instanceof Error ? error.message : String(error)}`)
403    }
404  })
405}
406
407function statusText(path: string | undefined, isMirrored: boolean, isAway: boolean): string {
408  const link = path && vaultName ? `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(path)}` : ''
409  return [
410    `Mirror: ${isMirrored ? 'on' : 'off'}`,
411    `Away mode: ${isAway ? 'on (Claude asks questions in its reply, not in a dialog)' : 'off'}`,
412    `Note: ${path ?? '(Claude makes it with the next update)'}`,
413    link ? `Open: ${link}` : '',
414    `Writes through: ${cliPath ? `Obsidian CLI (${cliPath})` : `the file system (${vaultRoot || 'vault not found'})`}`,
415  ]
416    .filter(Boolean)
417    .join('\n')
418}
419
420export const register: Register = (on, options) => {
421  const opt = readOptions(options)
422
423  on('session.start', async ($, e, next) => {
424    await $.command.register({
425      name: 'obsidian',
426      description: 'Show or change the Obsidian session note',
427      argumentHint: '[on|off|away|back]',
428      immediate: true,
429    })
430    await setup($, opt).catch(error => showOnce($, `Setup failed: ${String(error)}`))
431    $.clock.every(opt.pollMs, () => void tick($, opt))
432    return next(e)
433  })
434
435  on('prompt.submit', async ($, e, next) => {
436    const origin = e.origin
437    const isMine = origin.kind === 'plugin' && origin.name === ME
438    const isPerson = origin.kind === 'composer' || origin.kind === 'sdk' || origin.kind === 'bridge' || origin.kind === 'unclassified'
439    const typed = N.cleanPrompt(e.text)
440    if (!isMine && isPerson && typed && !typed.startsWith('/')) {
441      firstPrompt ??= typed
442      await $.store.delete(`away:${await $.session.id()}`)
443      pushBlock(N.callout('quote', `${now().time} · You (Mac)`, N.cut(typed, 4000)))
444    }
445    return next(e)
446  })
447
448  on('turn.start', async ($, e, next) => {
449    isBusy = true
450    hasTurnText = false
451    turnFiles.clear()
452    props.status = '🟢 working'
453    return next(e)
454  })
455
456  // Each text block of Claude's reply, exactly as the conversation keeps it.
457  on('session.append', async ($, e, next) => {
458    const stored = await next(e)
459    // No message: a hook refused the row, so the conversation does not keep it.
460    const message = stored.message
461    if (e.agentId === undefined && e.door === 'response' && message?.role === 'assistant') {
462      const text = message.content
463        .map(block => (block.type === 'text' ? block.text : ''))
464        .filter(Boolean)
465        .join('\n\n')
466      if (text.trim()) {
467        hasTurnText = true
468        pushBlock(N.callout('note', `${now().time} · Claude`, N.cut(text, 50_000)))
469      }
470    }
471    return stored
472  })
473
474  on('tool.call', async ($, e, next) => {
475    if (e.agentId !== undefined) return next(e)
476    const input = e as unknown as Record<string, unknown>
477    const tool = String(e.tool)
478    const t = now()
479    if (tool === 'AskUserQuestion') {
480      const asked = N.questionsText(input)
481      if (await $.store.get(`away:${await $.session.id()}`)) {
482        pushBlock(N.callout('question', `${t.time} · Claude has a question`, `${asked}\n\n_You are away, so Claude asks in its reply. Answer under Reply to Claude._`))
483        return { deny: AWAY_DENY }
484      }
485      pushBlock(N.callout('question', `${t.time} · Claude asks on the Mac`, asked))
486      props.status = '❓ question on the Mac'
487    }
488    const ran = await next(e)
489    const isError = ran.isError === true || ran.deny !== undefined
490    if (tool === 'mcp__ccd_turn__wrap_up') {
491      // The recap that the app shows at the end of a turn is a message from Claude.
492      const said = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
493      const body = [said('recap'), said('ask') ? `**Question:** ${said('ask')}` : ''].filter(Boolean).join('\n\n')
494      if (body) pushBlock(N.callout('abstract', `${t.time} · Claude · recap`, body))
495      return ran
496    }
497    const counts = isError ? undefined : N.lineCounts(tool, input, (ran as { result?: unknown }).result)
498    const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
499    if (counts && file) {
500      const sum = turnFiles.get(file) ?? { added: 0, removed: 0 }
501      turnFiles.set(file, { added: sum.added + counts.added, removed: sum.removed + counts.removed })
502    }
503    const line = N.toolLine({ tool, input, cwd: await $.session.cwd(), home, time: t.time, counts, isError })
504    if (line) pushBlock(line, false)
505    return ran
506  }).catch(($, e, next) => next(e))
507
508  on('turn.complete', async ($, e, next) => {
509    const result = await next(e)
510    if (e.agentId !== undefined) return result
511    isBusy = false
512    const t = now()
513    // When no reply text came through session.append, log the final answer here.
514    if (!hasTurnText && e.answer.trim()) pushBlock(N.callout('note', `${t.time} · Claude`, N.cut(e.answer, 50_000)))
515    const kind = e.reason === 'answer' ? 'success' : e.reason === 'aborted' ? 'warning' : 'failure'
516    const label = e.reason === 'answer' ? 'Turn done' : e.reason === 'aborted' ? 'Turn stopped' : `Turn ended (${e.reason})`
517    pushBlock(N.callout(kind, `${t.time} · ${label} · ${N.duration(e.durationMs)}`, N.fileSummary(turnFiles)))
518    props.status = '🟡 waiting for you'
519    isTitleDue = true
520    // Write at once. A reload of the mod clears the entries that wait in memory.
521    const sessionId = await $.session.id()
522    if (await isOn($, sessionId, opt)) await serial(() => writePending($, opt, sessionId)).catch(() => undefined)
523    return result
524  })
525
526  on('session.end', async ($, e, next) => {
527    const t = now()
528    const why = e.reason === 'clear' ? 'A /clear started a new conversation. The next prompt starts a new note.' : ''
529    pushBlock(N.callout('info', `${t.time} · Session ended (${e.reason})`, why))
530    props.status = '⚪ ended'
531    await serial(() => writePending($, opt, e.sessionId)).catch(() => undefined)
532    firstPrompt = undefined
533    return next(e)
534  })
535
536  on('command.run', { command: 'obsidian' }, async ($, e) => {
537    const sessionId = await $.session.id()
538    const arg = e.args.trim().toLowerCase()
539    if (arg === 'on') {
540      await $.store.set(`mode:${sessionId}`, 'on')
541      pushBlock(N.callout('info', `${now().time} · Mirror on`))
542    } else if (arg === 'off') {
543      await serial(() => writePending($, opt, sessionId)).catch(() => undefined)
544      await $.store.set(`mode:${sessionId}`, 'off')
545    } else if (arg === 'away') {
546      await $.store.set(`away:${sessionId}`, true)
547      pushBlock(N.callout('info', `${now().time} · Away mode on`))
548    } else if (arg === 'back') {
549      await $.store.delete(`away:${sessionId}`)
550    } else if (arg) {
551      return { text: 'Use /obsidian, /obsidian on, /obsidian off, /obsidian away or /obsidian back.' }
552    }
553    const isMirrored = await isOn($, sessionId, opt)
554    const isAway = Boolean(await $.store.get(`away:${sessionId}`))
555    return { text: statusText(await notePath($, sessionId), isMirrored, isAway) }
556  })
557}
558
hooks/note.ts 484 lines
1// The pure logic of the session note. This file does not use `$`, so the
2// tests can run it directly.
3
4export const LOG_HEADING = '## 📜 Log'
5export const REPLY_HEADING = '## ✍️ Reply to Claude'
6export const SEND_BOX = '- [ ] Send'
7// The reply area after the heading line: one empty line to write on, then the box.
8export const EMPTY_ZONE = `\n${SEND_BOX}\n`
9
10// A ticked Send box, or a line with only "/send".
11const SEND_LINE = /^\s*(?:[-*+]\s+\[[xX]\]\s+send\b.*|\/send)\s*$/i
12// Lines of the template that are not part of a message.
13const TEMPLATE_LINE = /^\s*(?:[-*+]\s+\[ \]\s+send\b.*|<!--.*-->)\s*$/i
14
15export type Splice = { at: number; remove: number; insert: string }
16
17export type Reply = {
18  // The index where the reply area starts (after the heading line).
19  zoneStart: number
20  // The full reply area as it was read.
21  zone: string
22  // The text to send. Undefined when no send line is in the area.
23  message?: string
24  // The text that stays in the reply area after Claude takes the message.
25  keep: string
26}
27
28// FNV-1a over UTF-16 code units. The Obsidian side uses the same function.
29export function hash(text: string): string {
30  let x = 2166136261
31  for (let i = 0; i < text.length; i++) {
32    x ^= text.charCodeAt(i)
33    x = Math.imul(x, 16777619) >>> 0
34  }
35  return `${text.length}:${x}`
36}
37
38export function applySplices(text: string, splices: readonly Splice[]): string {
39  let out = text
40  for (const s of sortSplices(splices)) {
41    out = out.slice(0, s.at) + s.insert + out.slice(s.at + s.remove)
42  }
43  return out
44}
45
46// The last splice in the text comes first, so each index stays correct.
47export function sortSplices(splices: readonly Splice[]): Splice[] {
48  return [...splices].sort((a, b) => b.at - a.at)
49}
50
51function findReplyHeading(note: string): { start: number; zoneStart: number } | undefined {
52  let from = note.length
53  while (from >= 0) {
54    const i = note.lastIndexOf(REPLY_HEADING, from)
55    if (i < 0) return undefined
56    const end = note.indexOf('\n', i)
57    const lineEnd = end < 0 ? note.length : end
58    const isLineStart = i === 0 || note[i - 1] === '\n'
59    if (isLineStart && note.slice(i, lineEnd).trim() === REPLY_HEADING) {
60      return { start: i, zoneStart: end < 0 ? note.length : end + 1 }
61    }
62    from = i - 1
63  }
64  return undefined
65}
66
67function trimBlankLines(lines: readonly string[]): string {
68  const kept = lines.filter(line => !TEMPLATE_LINE.test(line))
69  while (kept.length > 0 && !kept[0]?.trim()) kept.shift()
70  while (kept.length > 0 && !kept[kept.length - 1]?.trim()) kept.pop()
71  return kept.join('\n')
72}
73
74// Reads the reply area. The message is the text above the first send line.
75// When nothing is above it, the message is the text below it.
76export function parseReply(note: string): Reply | undefined {
77  const heading = findReplyHeading(note)
78  if (heading === undefined) return undefined
79  const zone = note.slice(heading.zoneStart)
80  const lines = zone.split('\n')
81  const send = lines.findIndex(line => SEND_LINE.test(line))
82  if (send < 0) return { zoneStart: heading.zoneStart, zone, keep: zone }
83  const above = trimBlankLines(lines.slice(0, send))
84  const below = trimBlankLines(lines.slice(send + 1))
85  return above
86    ? { zoneStart: heading.zoneStart, zone, message: above, keep: below }
87    : { zoneStart: heading.zoneStart, zone, message: below, keep: '' }
88}
89
90function isListLine(line: string): boolean {
91  return /^\s*[-*+] /.test(line)
92}
93
94// Puts log units above the reply heading. Tool lines in a row stay one list.
95// A blank line goes between all other units. When the heading is gone, it
96// adds a new reply area at the end.
97export function logSplice(note: string, units: readonly string[]): Splice {
98  const parts = units.map(unit => unit.replace(/\n+$/, '')).filter(Boolean)
99  const heading = findReplyHeading(note)
100  const end = heading?.start ?? note.length
101  const before = note.slice(0, end).replace(/\n+$/, '')
102  let last = before.slice(before.lastIndexOf('\n') + 1)
103  let text = ''
104  for (const part of parts) {
105    const gap = before.length === 0 && text === '' ? '' : isListLine(last) && isListLine(part) ? '\n' : '\n\n'
106    text += gap + part
107    last = part.slice(part.lastIndexOf('\n') + 1)
108  }
109  const tail = heading ? '\n\n' : `\n\n${REPLY_HEADING}\n${EMPTY_ZONE}`
110  return { at: before.length, remove: end - before.length, insert: text + tail }
111}
112
113// Sets frontmatter values. It changes only the lines of these keys.
114export function propsSplices(note: string, props: Readonly<Record<string, string>>): Splice[] {
115  if (!note.startsWith('---\n')) return []
116  const close = note.indexOf('\n---', 3)
117  if (close < 0) return []
118  const frontmatter = note.slice(0, close + 1)
119  const out: Splice[] = []
120  for (const [key, value] of Object.entries(props)) {
121    const line = `${key}: ${JSON.stringify(value)}`
122    const found = new RegExp(`^${key}:.*$`, 'm').exec(frontmatter)
123    out.push(
124      found
125        ? { at: found.index, remove: found[0].length, insert: line }
126        : { at: close + 1, remove: 0, insert: `${line}\n` },
127    )
128  }
129  return out
130}
131
132// The splices that take a message: log it, clear the reply area, set the props.
133export function consumeSplices(
134  note: string,
135  reply: Reply,
136  units: readonly string[],
137  props: Readonly<Record<string, string>>,
138): Splice[] {
139  const zone = reply.keep ? `${reply.keep}\n${SEND_BOX}\n` : EMPTY_ZONE
140  return [
141    ...propsSplices(note, props),
142    ...(units.length > 0 ? [logSplice(note, units)] : []),
143    { at: reply.zoneStart, remove: reply.zone.length, insert: zone },
144  ]
145}
146
147// Escapes each "<" outside code. Obsidian reads text such as <name> as an
148// HTML tag, and a tag with no end can hide the rest of the note.
149export function escapeTags(markdown: string): string {
150  let fence: string | undefined
151  return markdown
152    .split('\n')
153    .map(line => {
154      const mark = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
155      if (fence !== undefined) {
156        if (mark && mark[0] === fence[0] && mark.length >= fence.length && line.trim() === mark) fence = undefined
157        return line
158      }
159      if (mark) {
160        fence = mark
161        return line
162      }
163      // Leave inline code as it is.
164      return line
165        .split(/(`+[^`]*`+)/)
166        .map((part, i) => (i % 2 === 1 ? part : part.replace(/</g, '&lt;')))
167        .join('')
168    })
169    .join('\n')
170}
171
172export function callout(kind: string, title: string, body = '', isFolded = false): string {
173  const head = `> [!${kind}]${isFolded ? '-' : ''} ${escapeTags(title)}`
174  const lines = body
175    ? escapeTags(body.replace(/\r\n?/g, '\n')).split('\n').map(line => (line ? `> ${line}` : '>'))
176    : []
177  return `${[head, ...lines].join('\n')}\n\n`
178}
179
180export function cut(text: string, max: number): string {
181  return text.length > max ? `${text.slice(0, max)}\n\n… (cut. The full text is in the Claude app.)` : text
182}
183
184function short(text: string, max = 100): string {
185  const one = text.replace(/\s+/g, ' ').trim()
186  return one.length > max ? `${one.slice(0, max - 1)}…` : one
187}
188
189function code(text: string): string {
190  return `\`${text.replace(/`/g, "'")}\``
191}
192
193// Tools that add no useful line to the log.
194const QUIET_TOOLS = new Set(['ToolSearch', 'mcp__ccd_turn__update_plan', 'mcp__ccd_turn__wrap_up', 'AskUserQuestion'])
195
196export type LineCounts = { added: number; removed: number; isNew: boolean }
197
198type Hunk = { lines?: unknown }
199
200function countLines(text: string): number {
201  return text ? text.replace(/\n$/, '').split('\n').length : 0
202}
203
204// How many lines a file change added and removed, from the tool result.
205// Undefined when the tool changed no file or the call failed.
206export function lineCounts(tool: string, input: Readonly<Record<string, unknown>>, result: unknown): LineCounts | undefined {
207  if (typeof result !== 'object' || result === null) return undefined
208  const r = result as Record<string, unknown>
209  const str = (value: unknown): string => (typeof value === 'string' ? value : '')
210  if (tool === 'Write' && r.type === 'create') {
211    return { added: countLines(str(r.content) || str(input.content)), removed: 0, isNew: true }
212  }
213  if ((tool === 'Edit' || tool === 'Write') && Array.isArray(r.structuredPatch)) {
214    const lines = (r.structuredPatch as Hunk[]).flatMap(h => (Array.isArray(h.lines) ? (h.lines as unknown[]) : []))
215    return {
216      added: lines.filter(line => typeof line === 'string' && line.startsWith('+')).length,
217      removed: lines.filter(line => typeof line === 'string' && line.startsWith('-')).length,
218      isNew: false,
219    }
220  }
221  if (tool === 'NotebookEdit') {
222    return { added: countLines(str(r.new_source)), removed: countLines(str(r.old_source)), isNew: false }
223  }
224  return undefined
225}
226
227function changes(counts: { added: number; removed: number }): string {
228  return `+${counts.added} −${counts.removed}`
229}
230
231// One short list line for a tool call: what happened, with no code, input or output.
232export function toolLine(o: {
233  tool: string
234  input: Readonly<Record<string, unknown>>
235  cwd: string
236  home: string
237  time: string
238  counts?: LineCounts
239  isError: boolean
240}): string | undefined {
241  if (QUIET_TOOLS.has(o.tool)) return undefined
242  const text = (key: string): string => {
243    const value = o.input[key]
244    return typeof value === 'string' ? value : ''
245  }
246  const path = (key: string): string => {
247    const value = text(key)
248    if (o.cwd && value.startsWith(`${o.cwd}/`)) return code(value.slice(o.cwd.length + 1))
249    return code(o.home && value.startsWith(`${o.home}/`) ? `~${value.slice(o.home.length)}` : value)
250  }
251  const counted = o.counts ? ` (${changes(o.counts)})` : ''
252  const what = ((): string => {
253    switch (o.tool) {
254      case 'Bash':
255        return text('description')
256          ? `🖥️ ${short(text('description'))}`
257          : `🖥️ Ran ${code(text('command').trim().split(/\s+/)[0] ?? 'a command')}`
258      case 'Read':
259        return `📖 Read ${path('file_path')}`
260      case 'Edit':
261        return `✏️ Edited ${path('file_path')}${counted}`
262      case 'Write':
263        return `📝 ${o.counts?.isNew ? 'Created' : 'Wrote'} ${path('file_path')}${counted}`
264      case 'NotebookEdit':
265        return `✏️ Edited ${path('notebook_path')}${counted}`
266      case 'Grep':
267        return '🔎 Searched the code'
268      case 'Glob':
269        return '🔎 Looked for files'
270      case 'WebFetch':
271        return `🌐 Opened ${short(text('url').replace(/^https?:\/\//, ''), 60)}`
272      case 'WebSearch':
273        return `🌐 Searched the web for ${code(short(text('query'), 60))}`
274      case 'Agent':
275      case 'Task':
276        return `🤖 Started a subagent: ${short(text('description'), 80)}`
277      case 'TodoWrite':
278        return '🗒️ Updated the task list'
279      case 'Skill':
280        return `🧩 Used the ${code(text('skill'))} skill`
281    }
282    const mcp = /^mcp__(.+?)__(.+)$/.exec(o.tool)
283    return mcp ? `🔌 ${short(mcp[1] ?? '', 30)} · ${mcp[2]}` : `🔧 ${o.tool}`
284  })()
285  return `- ${o.time} · ${escapeTags(what)}${o.isError ? ' ❌' : ''}`
286}
287
288// The files that one turn changed, in one line.
289export function fileSummary(files: ReadonlyMap<string, { added: number; removed: number }>): string {
290  if (files.size === 0) return 'No files changed.'
291  let added = 0
292  let removed = 0
293  for (const counts of files.values()) {
294    added += counts.added
295    removed += counts.removed
296  }
297  return `${files.size} ${files.size === 1 ? 'file' : 'files'} changed · ${changes({ added, removed })} lines`
298}
299
300type Question = { question?: unknown; options?: unknown }
301type Option = { label?: unknown; description?: unknown }
302
303export function questionsText(input: Readonly<Record<string, unknown>>): string {
304  const questions = Array.isArray(input.questions) ? (input.questions as Question[]) : []
305  return questions
306    .map(q => {
307      const options = Array.isArray(q.options) ? (q.options as Option[]) : []
308      const rows = options.map(o => `- ${String(o.label ?? '')}${o.description ? ` — ${String(o.description)}` : ''}`)
309      return [`**${String(q.question ?? '')}**`, ...rows].join('\n')
310    })
311    .join('\n\n')
312}
313
314function pad(n: number): string {
315  return String(n).padStart(2, '0')
316}
317
318// Local date and 12-hour time. `offsetMinutes` is the zone offset from `date +%z`.
319export function clock(ms: number, offsetMinutes: number): { date: string; time: string; fileTime: string } {
320  const d = new Date(ms + offsetMinutes * 60_000)
321  const h24 = d.getUTCHours()
322  const h = h24 % 12 || 12
323  const m = pad(d.getUTCMinutes())
324  const ampm = h24 < 12 ? 'AM' : 'PM'
325  return {
326    date: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`,
327    time: `${h}:${m} ${ampm}`,
328    fileTime: `${h}-${m} ${ampm}`,
329  }
330}
331
332export function parseOffset(text: string): number {
333  const m = /^([+-])(\d\d)(\d\d)$/.exec(text.trim())
334  if (!m) return 0
335  const minutes = Number(m[2]) * 60 + Number(m[3])
336  return m[1] === '-' ? -minutes : minutes
337}
338
339export function duration(ms: number): string {
340  const s = Math.round(ms / 1000)
341  if (s < 60) return `${s}s`
342  const m = Math.floor(s / 60)
343  if (m < 60) return `${m}m ${s % 60}s`
344  return `${Math.floor(m / 60)}h ${m % 60}m`
345}
346
347// A safe file-name part from free text.
348export function slug(text: string, maxLength = 48, maxWords = 8): string {
349  const words = text
350    .replace(/[\\/:*?"<>|#^[\]{}`]/g, ' ')
351    .replace(/\s+/g, ' ')
352    .trim()
353    .replace(/^\.+/, '')
354    .split(' ')
355    .slice(0, maxWords)
356    .join(' ')
357  return words.slice(0, maxLength).trim() || 'session'
358}
359
360// The note path: the start date and time, then the session name.
361export function notePathFor(folder: string, stamp: string, name: string, copy = 1): string {
362  return `${folder}/${stamp} - ${slug(name, 80, 20)}${copy > 1 ? ` (${copy})` : ''}.md`
363}
364
365// The start date and time at the front of a note file name.
366export function stampOf(path: string): string | undefined {
367  const name = path.split('/').pop() ?? ''
368  return /^(\d{4}-\d\d-\d\d \d{1,2}-\d\d [AP]M)/.exec(name)?.[1]
369}
370
371// The newest session name in the `grep -o` output of the transcript's
372// "customTitle" fields. The desktop app writes this name in its sidebar.
373export function parseTitle(grepOutput: string): string | undefined {
374  const last = grepOutput.trim().split('\n').pop() ?? ''
375  if (!last.startsWith('"customTitle":')) return undefined
376  try {
377    const value = (JSON.parse(`{${last}}`) as { customTitle?: unknown }).customTitle
378    return typeof value === 'string' && value.trim() ? value.trim() : undefined
379  } catch {
380    return undefined
381  }
382}
383
384// Replaces the first H1 line after the frontmatter.
385export function headingSplice(note: string, text: string): Splice | undefined {
386  const close = note.startsWith('---\n') ? note.indexOf('\n---', 3) + 4 : 0
387  const found = /^# .*$/m.exec(note.slice(close))
388  if (!found) return undefined
389  return { at: close + found.index, remove: found[0].length, insert: `# ${text}` }
390}
391
392export function newNote(o: {
393  sessionId: string
394  project: string
395  cwd: string
396  title: string
397  date: string
398  time: string
399  settleSeconds: number
400}): string {
401  return [
402    '---',
403    `claude_session: ${JSON.stringify(o.sessionId)}`,
404    `project: ${JSON.stringify(o.project)}`,
405    `cwd: ${JSON.stringify(o.cwd)}`,
406    `status: ${JSON.stringify('🟢 working')}`,
407    `started: ${JSON.stringify(`${o.date} ${o.time}`)}`,
408    `updated: ${JSON.stringify(o.time)}`,
409    'tags:',
410    '  - claude-session',
411    '---',
412    `# ${o.title}`,
413    '',
414    callout(
415      'info',
416      'How to reply from your phone',
417      [
418        `1. Write your message under **Reply to Claude**, above the **Send** box.`,
419        '2. Tick the **Send** box. You can also write a line with only `/send`.',
420        `3. Claude takes the message when the reply area stays the same for ${o.settleSeconds} seconds.`,
421        '4. Text below a ticked box stays in the area for your next message.',
422      ].join('\n'),
423      true,
424    ).trimEnd(),
425    '',
426    LOG_HEADING,
427    '',
428    REPLY_HEADING,
429    EMPTY_ZONE,
430  ].join('\n')
431}
432
433// The prompt as you typed it. The app wraps the text that it adds in tags with
434// a hyphen, such as <system-reminder> and <side-sessions-event>. This removes
435// those blocks. A prompt with only such blocks did not come from you.
436export function cleanPrompt(text: string): string {
437  return text
438    .replace(/<([a-z][a-z0-9]*-[a-z0-9-]*)(\s[^>]*)?>[\s\S]*?<\/\1>/g, '')
439    .replace(/\n{3,}/g, '\n\n')
440    .trim()
441}
442
443// The `vault=` argument for the Obsidian CLI. No name: the CLI uses its active vault.
444export function vaultArgs(name: string): string[] {
445  return name ? [`vault=${name}`] : []
446}
447
448const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
449
450export function base64(text: string): string {
451  const b = new TextEncoder().encode(text)
452  let out = ''
453  for (let i = 0; i < b.length; i += 3) {
454    const n = ((b[i] ?? 0) << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
455    out += B64[(n >> 18) & 63]
456    out += B64[(n >> 12) & 63]
457    out += i + 1 < b.length ? B64[(n >> 6) & 63] : '='
458    out += i + 2 < b.length ? B64[n & 63] : '='
459  }
460  return out
461}
462
463// The JavaScript that runs in Obsidian through `obsidian eval`. It has no
464// backslashes and no line breaks, because the CLI changes `\n` in values.
465// It applies the splices only when the note still has the expected hash.
466export function appScript(payload: unknown): string {
467  return [
468    '(async()=>{',
469    `const P=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${base64(JSON.stringify(payload))}"),c=>c.charCodeAt(0))));`,
470    'const H=s=>{let x=2166136261;for(let i=0;i<s.length;i++){x^=s.charCodeAt(i);x=Math.imul(x,16777619)>>>0}return s.length+":"+x};',
471    'const f=app.vault.getAbstractFileByPath(P.path);',
472    'if(P.rename!==undefined){if(!f)return "missing";if(app.vault.getAbstractFileByPath(P.rename))return "exists";',
473    'await app.fileManager.renameFile(f,P.rename);return "ok"}',
474    'if(P.create!==undefined){if(f)return "exists";',
475    'const d=P.path.split("/").slice(0,-1).join("/");',
476    'if(d&&!app.vault.getAbstractFileByPath(d))await app.vault.createFolder(d);',
477    'await app.vault.create(P.path,P.create);return "created"}',
478    'if(!f)return "missing";let r="conflict";',
479    'await app.vault.process(f,c=>{if(H(c)!==P.hash)return c;let o=c;',
480    'for(const s of P.splices)o=o.slice(0,s.at)+s.insert+o.slice(s.at+s.remove);r="ok";return o});',
481    'return r})()',
482  ].join('')
483}
484