Commit graph

160 commits

Author SHA1 Message Date
Mike Thicke
2b1e079355 Fix MarkdownView freezing streamed text at the first chunk
The original signature destructured props in the function args:

    export const MarkdownView = ({ markdown, sourcePath }) => …

Solid only delivers reactive updates through `props.*`; destructuring
strips the getter, so the closed-over `markdown` was whatever string
arrived in the first text-delta event. createEffect read the local
const, tracked no signals, ran once, and never re-rendered when the
text-part grew. The full message appeared only after the chat note
was closed and reopened — at that point a fresh MarkdownView mounted
with the complete text from the sidecar.

Worked under the old single-blob renderer because ChatInterface
rebuilt the {role, content} message array on every store touch, so
For tore down and remounted the BotMessage tree for each chunk. The
Phase 5 parts-aware renderer reuses the same component instance and
relies on per-prop reactivity, so the freeze surfaced.

Switch to `Component<MarkdownViewProps>` form, read `props.markdown`
inside the createEffect, and let the effect re-fire on every change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:01:30 -04:00
Mike Thicke
c5c7f39194 Phase 5: per-session settings header (max steps, approval mode)
Adds a compact, collapsible header above the chat history with two
per-session overrides: the agent-loop step ceiling and the approval
mode (ask / auto / readonly). Both persist via the session sidecar
so choices stick across reloads.

- src/session/types.ts: SessionSettings + ApprovalMode types,
  Session.settings optional field. Defaults stay external.
- src/session/session-store.ts: `updateSettings(patch)` shallow-
  merges into session.settings and touches updatedAt.
- src/agent/tool-registry.ts: toAiSdkTools options gain
  `approvalMode`. `auto` skips the broker; `readonly` throws
  ToolApprovalDeniedError without prompting; `ask` (default) keeps
  the current broker-mediated behaviour.
- src/services/model-service.ts: `generateChatResponse` now
  forwards a `maxSteps` arg to runAgentLoop.
- useChatController: derives `maxSteps` + `approvalMode` from the
  session store (memos), passes them through to the tools and the
  loop. The Phase 5 step indicator now reads the live ceiling, not
  the static default.
- src/components/SessionSettingsHeader.tsx: the UI — collapsed by
  default, summary line shows the active values, expanded body has
  a number input and a select. Clamped to [1, 50] steps.
- ChatInterface mounts the header above ChatHistory.
- Tests cover updateSettings, approval-mode behaviour in the
  registry, and the header component.

Closes #69.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:26:38 -04:00
Mike Thicke
fe3f1bee62 Phase 5: step indicator + cancel pairing
Surfaces the agent-loop step counter alongside the cancel button so
the user can see how deep a multi-step run has gone. The step ceiling
is still the static DEFAULT_MAX_STEPS — Phase 5 #69 wires per-session
overrides into the same accessor.

- src/agent/types.ts + agent-loop.ts: add `{ type: "start-step" }` to
  AgentEvent and translate the AI SDK's `start-step` chunk into it.
- useChatController:
  - track `currentStep` (incremented on each start-step) and expose
    a `maxSteps` accessor wired from DEFAULT_MAX_STEPS.
  - reset both at send() start, in finally, and on cancel().
- ChatInterface → ChatHistory → ProcessingIndicator: thread the new
  accessors through. The indicator renders a `Step N / M` badge in
  the same row as the cancel button when N > 0; idle state is
  unchanged.

Closes #68.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:22:19 -04:00
Mike Thicke
c71af3c20a Phase 5: diff card for edit_note (pre-write preview + result)
edit_note's approval card now shows a pre-write diff preview of the
edit, and its result card renders the returned diff via the same
DiffView. Approval-time and result-time use the exact same edit
logic, so what the user clicks Allow on is byte-for-byte what gets
written.

- src/utils/edit-note-diff.ts: extracts `attemptEdit` (replacement
  + ambiguity refusal) and `makeUnifiedDiff` out of edit_note.ts.
  edit_note.ts is rewritten to call them, dropping the duplicated
  local helpers.
- src/components/parts/DiffView.tsx: per-line classification
  (header / added / removed / context) so themes can colour them.
  Marker char is stripped from the rendered text so adds and
  removes line up with context lines.
- src/components/parts/EditNoteApprovalPrompt.tsx: edit_note-specific
  approval card. Loads the target file through createResource and
  computes the diff in the same call site as the tool. Surfaces
  "Note not found" and ambiguous-match errors inline so the user
  can deny without guessing.
- ApprovalPrompt: gains a `children` slot that replaces the default
  args block when provided.
- MessageParts router: for `edit_note` tool calls under approval,
  routes through EditNoteApprovalPrompt.
- ToolResultCard: detects the edit_note success shape ({path,
  replacements, diff}) and renders the diff instead of pretty-
  printed JSON.

Tests cover the diff/attempt helpers, DiffView classification, and
the ToolResultCard edit_note branch.

Closes #67.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:11:27 -04:00
Mike Thicke
6b2782d5eb Phase 5: in-chat ApprovalPrompt, retire the Obsidian Modal
Approval-required tools used to pop a native Obsidian Modal —
useful as Phase 3 scaffolding but it pulled the user out of the
chat, hid the surrounding tool-call context, and made bulk-approve
flows awkward. Replace it with an inline card that renders in
place of the tool-call card while the broker holds a pending
approval for that toolCallId.

- src/components/parts/ApprovalPrompt.tsx: card with tool name,
  pretty-printed args, and Allow / Deny / Always buttons.
- src/components/parts/MessageParts.tsx: the tool-call branch now
  consults `plugin.permissionBroker.pending()` and routes through
  ApprovalPrompt when a matching entry exists; once the user picks
  a decision the broker resolves and the JSX naturally swaps back
  to the static ToolCallCard.
- CoIntelligencePlugin: drops the mountApprovalModalBroker plumbing
  and the disposeApprovalModal field.
- src/agent/approval/: deleted along with its (orphan) test
  directory.
- styles.css: replaces the modal-era .coi-approval-* rules with
  the in-chat card layout.
- Tests: ApprovalPrompt unit tests + a MessageParts integration
  test that exercises the broker-driven swap.

Closes #66.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:26:27 -04:00
Mike Thicke
47998ddf70 Phase 5: message-parts rendering — tool calls + results visible in chat
ChatHistory now receives the rich SessionMessage[] instead of a
collapsed { role, content: text } list, and a new parts router
walks each message and renders each part with a dedicated component.
The single-blob text rendering that hid tool calls behind their
final assistant prose is gone.

- src/components/parts/MessageParts.tsx: router. Switches on
  part.type → TextPart / ReasoningBlock / ToolCallCard /
  ToolResultCard. Attachments stub to null pairing with the
  deferred attachment-chip work.
- TextPart: splits inline `<think>`…`</think>` markers (still how
  stream-consumer surfaces reasoning) into a ReasoningBlock above
  the body so the markdown renderer doesn't see literal tags. The
  splitter is exported and unit-tested.
- ReasoningBlock: collapsible disclosure with a markdown body.
  Replaces the inline `<details>` that used to live in BotMessage.
- ToolCallCard: collapsible card showing tool name, status pill,
  and pretty-printed JSON args. Status is reflected as a class so
  pending / awaiting-approval / running / success / error / denied
  can style differently.
- ToolResultCard: collapsible result, error variant opens by
  default and uses an error border + status colour.
- BotMessage / UserMessage: thin wrappers that delegate to
  MessageParts inside their styled containers. Both still capture
  link clicks via the new useLinkClicks hook so internal
  `[[wikilinks]]` and tag-search links keep working.
- ChatHistory: takes `Accessor<readonly SessionMessage[]>`.
- ChatInterface: drops the messageText() collapse.
- ChatMessage.tsx is removed (no remaining callers).

Closes #62, #63, #64, #65.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:50:03 -04:00
Mike Thicke
2ef1b76f48 Phase 4: auto-expose tools as /<tool-name>
Slash palette now lists every registered tool below the built-in
commands, and submit-time routing falls through from the command
registry to the tool registry. Typing /read_note {"path":"a.md"}
runs the tool directly (skipping the model) and writes a synthetic
user message + tool-call / tool-result pair into the session, so
the resulting transcript looks the same as a model-driven call.

- src/input/run-tool-direct.ts: orchestrates the direct invocation.
  Args are JSON, validated against the tool's zod input schema;
  bad shapes surface as Obsidian Notices without touching the
  session. Approval-required tools still route through the broker
  (denials write a `denied` tool-call + isError tool-result).
- UserInput: routes /-prefixed submits through commands first, then
  tools; slash palette items merge both with a `<json args>` hint
  on tool entries.

Closes #58.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:35:51 -04:00
Mike Thicke
036bce16c2 Phase 4: slash-trigger palette + command routing
Wires up the slash-command flow end to end. Typing `/` at the start
of the composer now opens an inline palette (built on the same CM6
tooltip pattern as the note suggester) listing every command in the
plugin's command registry, ranked by name prefix. Enter / Tab runs
the selected command immediately and clears the editor; Esc closes
without consuming the text.

- src/input/extensions/slash-trigger.ts: new CM6 extension. Detects
  a doc-leading `/`, renders a tooltip via requestMeasure, fires
  onAccept(name, args) with the matched item and the rest of the
  line.
- CoIntelligencePlugin: owns `commands: CommandRegistry<ChatCommandHost>`,
  populated at onload with builtInCommands.
- ChatInterface: passes the session store down to UserInput so
  /clear can act on it.
- UserInput: builds a ChatCommandHost per invocation (so model /
  system / web signals are read fresh), routes /-prefixed submits
  through the registry before falling through to the model, and
  exposes the same routing via the palette's onAccept.

Closes #54.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:33:29 -04:00
Mike Thicke
c8ff7fc78d Pills: explicit Backspace/Delete keymap (atomicRanges wasn't enough)
EditorView.atomicRanges is documented as covering "cursor motion
and deletion" but CM6's default contenteditable delete path still
chips away one bracket at a time — confirmed via test. Add an
explicit, high-precedence Backspace and Delete keymap on the pill
extension:

- Backspace immediately after `]]` (or `#tag`) deletes the whole
  pill range and fires onRemove → the linked-notes context entry
  drops in lockstep with the visible text.
- Delete immediately before `[[` does the same for the other side.
- Both also swallow a single adjacent space so we don't leave a
  double-space hole where the pill was, matching the existing × UX.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 10:04:21 -04:00
Mike Thicke
d35b0a083f Fix suggester (defer layout reads) + atomic pill ranges
Two bugs surfaced once a test exercised the suggester end to end.

CM6 throws "Reading the editor layout isn't allowed during an
update" when `coordsAtPos` is called from a ViewPlugin's update
phase, and CM6 swallows the throw — that's why the popup silently
never showed in Obsidian, even though detection + provider lookup
were running. Move the layout read into a `view.requestMeasure` so
the read happens in the next measure phase. Also drop Obsidian's
HTMLElement helpers (`el.empty`, `createDiv`) from the tooltip
construction in favour of plain DOM, so tests can exercise the
render without requiring Obsidian's prototype augmentation.

Backspacing through `[[Note]]` was eating one bracket at a time
because Decoration.replace alone doesn't make the range atomic —
the cursor could still land inside it. Register the same decoration
ranges via `EditorView.atomicRanges` so caret navigation skips
across a pill and Backspace deletes it whole. The caret-inside
exemption already drops decorations when the user is editing the
range, so the atomicity automatically lifts in that case too.

Adds a vitest covering the suggester (open / filter / close /
accept / tag detect / Esc) so the layout-read regression can't
sneak back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:47:33 -04:00
Mike Thicke
c743e64215 Inline note/tag suggester instead of the autocomplete modal
The Obsidian-modal flow had bugs that came from listening to DOM
input events that fire before CM6 commits the doc, plus a mismatch
between the modal's own search box and the text the user typed:

- typing [[ took two extra chars to pop the modal, and the modal
  opened with an empty search even though the user had typed [[l
- backspacing through [[partial repeatedly re-opened the modal
- × on a pill removed the text but not the linked-note context
- pills weren't applied until the user pressed space after the link

Move to an inline CM6 suggester that matches Obsidian's native note
autocomplete: a small tooltip anchored to the caret, fuzzy-ranked
results, arrow-key navigation, Enter/Tab to select, Esc to dismiss.
Reading the active query off the post-update doc inside a
ViewPlugin removes the input-event timing issue, and only inserting
on explicit selection removes the backspace re-trigger.

- src/input/extensions/note-suggest.ts: new SuggestController +
  high-precedence keymap. Providers are passed in so UserInput can
  also receive onInsertNote / onInsertTag for context tracking.
- src/input/extensions/mention-pill.ts: factory now takes an
  onRemove callback so × can also drop the file from linked-notes.
- src/components/UserInput.tsx: removes the modal-driven flow,
  wires note-suggest + mention-pill, threads onRemoveNote /
  onRemoveTag through the props.
- src/components/ChatInterface.tsx: passes the remove handlers.
- Pill CSS: now reads as native [[Note]] link tokens (bracket
  glyphs in muted colour, body in accent), × is a plain glyph, no
  oval. Caret colour follows --caret-color so the theme's white
  caret shows again.
- Suggest popup CSS: matches the layout in the user's screenshot
  (label + faded sub-label, selected row highlighted).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:06:33 -04:00
Mike Thicke
0828d10ab5 Defer CM6 mount to onMount so the host is attached when EditorView reads it
After externalising CM6, Obsidian's flavour of EditorView crashed in
its constructor with "Cannot read properties of null (reading
'CSSStyleSheet')" — it dereferences `parent.ownerDocument.defaultView`
during style mounting, and Solid's `ref={...}` callback fires before
the element is appended to the document, so defaultView was null.

Move the mountComposer call from a ref callback into onMount and
capture the host element through a separate ref assignment. The host
is in the document by the time onMount runs, so EditorView's style
mounting finds a real window. Also fixes the "failed to open" notice
that came from the failed view construction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 23:14:29 -04:00
Mike Thicke
1cc9edf63c Phase 4: rewrite UserInput.tsx onto the CM6 composer
Replaces the <textarea> with a mounted composer. The composer owns
the keymap (Enter / Shift-Enter / Esc / ArrowUp); UserInput keeps
the surrounding chrome (model picker, system-prompt selector, web
toggle) and the [[/# Obsidian-modal autocomplete flow.

Notable changes from the textarea version:
- mention-pill extension renders finished [[Note]] and #tag ranges
  as click-to-remove pills.
- Suggestion-modal insertion goes through view.dispatch on the
  current selection range instead of textarea.setSelectionRange.
- The textarea's auto-]] insertion when the user types [[ is gone
  for now — the suggestion modal still pops on [[ and the pill
  renders once the user closes the bracket themselves. Worth
  revisiting via a transactionFilter if it's missed in practice.
- Composer bundles CM6 (~200 kB), which bumps the plugin from
  ~620 kB to ~820 kB.

Closes #59.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:36:50 -04:00
Mike Thicke
d76325f8cc Phase 4: built-in commands
src/input/commands.ts — the seven /-commands plus a ChatCommandHost
shape commands operate against. Four real ones (/clear, /model,
/system, /web) act immediately; three (/approve, /checkpoint, /agent)
are stubs that surface a Notice naming the phase that owns them, so
the palette can advertise them today.

The host is built fresh per invocation by the chat UI (next commit
wires this through the slash trigger), keeping per-chat signals
(model accessor, web-search toggle, store) out of the registry's
interface.

/model fuzzy-matches against available models, refusing ambiguous
matches so the user doesn't accidentally swap providers.

Closes #57.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:34:00 -04:00
Mike Thicke
f8cd83586d Phase 4: command-registry
src/input/command-registry.ts — append-only registry mirroring the
tool-registry shape, generic over a per-application HOST type so
commands can pull whatever wiring they need (model accessors, store
actions) off a single context object instead of growing the registry
interface as commands appear.

parseCommandLine() splits "/cmd args" into { name, args }; returns
null for non-commands and bare slashes so the slash-trigger can
distinguish "in progress" typing from a runnable line.

Closes #56.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:32:17 -04:00
Mike Thicke
f736673fea Phase 4: mention-pill extension
src/input/extensions/mention-pill.ts — CM6 ViewPlugin that scans
the doc for [[Note]] and #tag ranges and replaces them with atomic
pill widgets via Decoration.replace. Each pill renders a label + ×
button; clicking removes the range (and swallows the leading space
when one is present so the doc doesn't end up with double spaces).

The range the caret is currently inside is left untouched so the
user can keep editing — it pillifies once the caret moves away.

findPillMatches is exported as a pure function for unit testing;
the wikilink/tag patterns live there. Tag rule matches at line
start or after whitespace so URL fragments like `foo#bar` don't
pillify.

CSS uses Obsidian colour variables; tag and note get distinct
backgrounds so the user can tell them apart at a glance.

Closes #53.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:31:16 -04:00
Mike Thicke
5624e005a3 Phase 4: CM6 composer skeleton
src/input/composer.ts — mountComposer({ parent, initialDoc,
onSubmit, onCancel, onRecallLast, placeholder, extensions }) wraps
EditorView with the base keymap and paste-as-plaintext rule. Caller
hands in the callbacks; the composer owns the editor lifecycle.

Keymap:
- Enter        submit (consumed when onSubmit returns true)
- Shift-Enter  newline at caret
- Escape       cancel (delegated to onCancel)
- ArrowUp      at doc start, recall last message via onRecallLast

`extensions` is the slot Phase 4's mention-pill / slash-trigger /
attachment-chip plug into; the skeleton stays decoration-free.

Closes #52.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:30:01 -04:00
Mike Thicke
57ee503940 Cancel: silence abort notice, mark running tool-calls as cancelled
Two cancel-path fixes:

- AbortError from the AI SDK was leaking out as a Notice ("request
  aborted ...") after the user cancelled. Track a `cancelled` flag
  set by cancel() and reset per send(), and add an isAbortError
  helper. Both the in-stream error event and the for-await catch now
  skip Notice + streamError handling when cancellation is the cause.
- Running tool-call parts were left at status="running" forever after
  a cancel, wedging the session. cancel() now walks the in-flight
  assistant message, flips any running tool-calls to "error", and
  records a tool-result part with "Tool call cancelled by user" so
  the chat reflects what actually happened. assistantId is tracked
  via a signal so cancel() can target the right message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:18:52 -04:00
Mike Thicke
4646f6b370 Remove get_active_note tool
Drops the get_active_note tool and its lastUserFile plumbing on the
plugin. Active-note tracking added real complexity (workspace event
handler, file filtering, "last user file" state) for a tool whose
use case wasn't clear — the model can already get the same info by
reading whatever note the user mentions, and keeping the tool roster
tight reduces the surface the model has to reason over.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:20:07 -04:00
Mike Thicke
362b1de77f Manual-verification fixes: errors, active-note tracking, search defaults
Three findings from manual verification of Phase 3.

- Errors after an error event would still trip the "No response
  received from the model." fallback, hiding the real cause. Track
  streamError from both the in-stream `error` event and the catch
  around the for-await; when set, write a "*Error: <msg>*" assistant
  part instead of the misleading fallback, and surface the actual
  message in the Notice.
- get_active_note used to return the chat note itself (it called
  workspace.getActiveFile() from inside the chat view). Plugin now
  tracks `lastUserFile` via the workspace `file-open` event, skipping
  COI notes and non-markdown files. Tool reads from that.
- search_vault now scans content by default — the model shouldn't
  have to think to ask for full-text search. Each hit carries a
  `wikilink` field, and the description tells the assistant to cite
  results with `[[Note]]` links so references in the chat are
  clickable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:05:08 -04:00
Mike Thicke
5813964b8e search_web: add Tavily as an alternative provider
Tavily is purpose-built for agent search and returns cleaner
per-result snippets, so when both keys are configured it wins.
Perplexity stays as the fallback because users already configure
it as a chat provider. Neither key configured → the same
tool-error path we just landed surfaces in the chat.

- src/agent/tools/web/search_web.ts: dispatcher (pickProvider) +
  searchViaTavily / searchViaPerplexity backends. Output now
  carries `provider` so the chat UI can label the source.
- src/settings.ts: tavily added to ApiKeyProvider + a settings
  field for the key.
- CoIntelligencePlugin.migrateLegacyApiKeys: legacyFields is now
  Partial since Tavily never had a pre-1.1 inline-key path to
  migrate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:38:05 -04:00
Mike Thicke
113a993d52 Always register search_web; surface 'not configured' at execute
Hiding search_web when no Perplexity key is set meant the model
silently lacked the capability — users saw "I can't search the web"
with no visible cause. Register it unconditionally so the execute
wrapper's helpful "set a Perplexity API key" error surfaces as a
tool-error event the user can act on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:09:26 -04:00
Mike Thicke
de91b23c3d Register Phase 3 tools in the default registry
Wires all nine vault tools + fetch_url into createDefaultToolRegistry.
search_web is conditionally registered via isSearchWebAvailable so it
only shows up to the model when a Perplexity API key is set.

Closes #40.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:58 -04:00
Mike Thicke
7821fa7871 search_web tool
Provider-agnostic web search via Perplexity's sonar model: the agent
calls the tool with a query and gets back a synthesized answer plus
cited URLs, even when the conversation model is OpenAI / Anthropic /
Google without its native search. Goes through requestUrl directly
(no SDK roundtrip) since we just need one POST.

Gated on a configured Perplexity API key — isSearchWebAvailable
returns false otherwise, and the registry skips registration so the
model doesn't see a tool that can't run.

Closes #50.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:55 -04:00
Mike Thicke
6f878b50ea fetch_url tool
Fetches a URL via Obsidian's requestUrl (mobile-safe; no Node fetch)
and runs the body through htmlToMarkdown when the response is HTML.
JSON / plaintext is returned raw. Extracts <title> for HTML so the
model can label sources without a second round-trip.

Closes #49.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:51 -04:00
Mike Thicke
5185625103 edit_note tool
Replaces exact oldText -> newText via Vault.process. Refuses
ambiguous matches unless replaceAll=true — a single search/replace
that hits multiple lines should be intentional. Returns a unified-
style diff (--- / +++ / -/+ lines) so the Phase 5 approval card
(#67) can render it directly. Requires approval.

Closes #45.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:43 -04:00
Mike Thicke
1926f296e6 create_note tool
Creates a new note at a vault path. Fails if a file already exists
there — the model should reach for edit_note / append_to_note for
existing notes. Frontmatter is rendered via Obsidian's stringifyYaml.
Requires approval.

Closes #44.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:42 -04:00
Mike Thicke
e172b29e92 append_to_note tool
Appends content to an existing note via Vault.process. Inserts a
blank-line separator only when the existing content doesn't already
end with one, so we don't accumulate extra blank lines on repeated
appends. Requires approval.

Closes #46.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:40 -04:00
Mike Thicke
bdc7cc1f06 search_vault tool
Filename match first (cheap), optional content scan with a ~120-char
snippet around the first hit. Sorts folder hits before file hits in
the same alphabetical ordering; truncates at `limit` and reports it.
Read-only; mobile-safe.

Closes #43.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:26 -04:00
Mike Thicke
0f1374a8e3 read_frontmatter + set_frontmatter tools
- read_frontmatter: returns a note's YAML frontmatter as an object
  (read-only; no approval).
- set_frontmatter: merges key/value updates into the YAML block via
  fileManager.processFrontMatter. `null` values delete keys, other
  values overwrite. Requires approval.

Closes #47.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:23 -04:00
Mike Thicke
5ce547d955 list_folder tool
Lists the immediate children of a vault folder. Returns names +
paths partitioned into files vs folders by `kind`; non-recursive so
agents traverse explicitly. Empty path / "/" lists the vault root.
Read-only; mobile-safe.

Closes #42.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:20 -04:00
Mike Thicke
b458d07e37 get_active_note tool
Returns the path + content of the currently focused note, or null
fields when no note is active. Read-only; mobile-safe.

Closes #48.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:13 -04:00
Mike Thicke
a4703596fb Tool foundations: schema variance + mock surface for Phase 3 tools
- CoiTool.inputSchema typed as `z.ZodType<INPUT, ZodTypeDef, unknown>`
  so schemas with `.default()` / `.optional()` (input ≠ output type)
  stay assignable. Previously only schemas without defaults compiled.
- Obsidian test mock gains Workspace.getActiveFile, Vault.getRoot /
  getFolderByPath / getFiles, and stringifyYaml / parseYaml — surface
  the new vault tools and their tests need.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:11:06 -04:00
Mike Thicke
1cb5faf710 Phase 3 vertical slice: read_note tool + tool runtime wiring
Closes #41. Lands the first end-to-end agent tool — registry-gated
approval flow, plugin lifecycle, controller persistence — with
read_note as the read-only vertical slice that validates the path.

- src/agent/tools/vault/read_note.ts: first tool. Mobile-safe, reads
  via Vault.cachedRead, normalizes paths, throws clean errors.
- src/agent/tool-registry.ts: execute wrapper now gates approval
  through the broker (decision=deny throws ToolApprovalDeniedError,
  surfacing as tool-error). Removes the SDK needsApproval handoff,
  which is tied to the UIMessage approval lifecycle we don't use.
  Registry also receives ToolDependencies (app, plugin) once at
  construction; merged into every execute call's ctx.
- src/agent/approval/obsidian-modal-broker.ts: interim Modal-based
  approval UI driven by a Solid effect over broker.pending(). Phase
  5 replaces this with the in-chat ApprovalPrompt card (#66).
- src/agent/tools/index.ts: createDefaultToolRegistry() + platform
  helper; registers read_note for now.
- CoIntelligencePlugin: owns the tool registry, permission broker,
  and mounted approval modal; tears down on onunload.
- useChatController: passes registered tools to generateChatResponse
  and persists tool-call/tool-result/tool-error events into the
  session store (addToolCallPart / updateToolCallStatus /
  addToolResultPart). No-text-only-tools responses no longer trip
  the "No response received" fallback.
- test/mocks/obsidian.ts: adds Modal and Platform mocks the new
  modules need.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:39:13 -04:00
Mike Thicke
3cf74357fd Integration tests: round-trip + migration + markdown regen (#39)
src/session/__tests__/integration.test.ts covers the seams between session
storage, the session store, migration, and markdown regen:

- write → read returns the exact same Session (including all message-part
  variants: text, reasoning, tool-call, tool-result).
- A store-driven edit cycle (appendUserMessage → beginAssistantMessage →
  appendAssistantText → addSources → setContextItems → setLastModelId)
  round-trips through writeSession / readSession with the new state intact.
- First open migrates a legacy markdown chat: writes the sidecar and stamps
  coi-session-id into frontmatter.
- A migrate → edit → reload cycle preserves the new edit (ensureSessionForNote
  is idempotent on the second open).
- renderSessionIntoNote produces markdown that reflects the latest session
  state, not the previous note body — the sidecar wins on conflict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:18:35 -04:00
Mike Thicke
262f36da36 ChatView is now a thin adapter over the session store (#37)
The whole load/save flow is rewired around the session store:

- ChatView.onLoadFile / onOpen runs ensureSessionForNote() (creating /
  migrating on demand), readSession() into a Session, and renders the
  Solid app with that initial session.
- CoiChatApp creates the SessionStore from initialSession and pipes a
  defer'd createEffect on session.updatedAt back to ChatView via
  onSessionChange. ChatView debounces and persists (writeSession +
  renderSessionIntoNote into this.data + requestSave).
- ChatInterface no longer holds per-state signals — it reads everything
  from store.session via createMemo. ContextItems is derived: store
  holds path-backed SerializedContextItems, the memo resolves paths to
  TFile[] for the UI. Mutations route through store.setContextItems.
- ContextList API tightens: setContextItems is replaced with explicit
  onRemoveNote / onRemoveTag callbacks so the storage shape doesn't leak
  into the component.
- useChatController is refactored to take the store + an
  onAssistantResponseComplete callback. It mutates via store actions
  (appendUserMessage, beginAssistantMessage, appendAssistantText,
  replaceLastTextPart, addSources). Title regen moved to ChatView.

ChatHistory and ChatRequest still consume the legacy { role, content }
shape — ChatInterface collapses session messages via messageText() at
the boundary. Phase 5's parts-aware history view replaces that adapter.

session/types.ts gains sessionMessagesToModelMessages() as a stub for
sending — drops tool-call/result/attachment parts, which the AI SDK
call boundary doesn't accept yet. Phase 3+ revisits this when tools
land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:17:00 -04:00
Mike Thicke
b2b4981ae5 Centralized createStore for session state (#36)
src/session/session-store.ts exposes createSessionStore(initial) returning
{ session, appendUserMessage, beginAssistantMessage, appendAssistantText,
replaceLastTextPart, addToolCallPart, updateToolCallStatus, addToolResultPart,
addSources, setContextItems, setLastModelId, replaceSession }.

Uses Solid's createStore + produce so fine-grained reactivity is preserved
(downstream readers only re-run on the slice they touched). Every mutating
action bumps updatedAt so persistence layers can debounce on the timestamp.

appendAssistantText is the streaming-friendly action: appends to the trailing
text part when present, starts a new text part when the trailing part isn't
text (e.g. came right after a tool-result). replaceLastTextPart supports the
source-processor's rewrite-with-numbered-references step.

The hook + ChatView wire-up that swaps from per-signal state to this store
lands in #37.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:11:11 -04:00
Mike Thicke
2f311ea655 Auto-migrate legacy chat notes to sessions (#38)
src/session/migration.ts exposes:

- migrateLegacyToSession(noteContent, metadata, app, sessionId, now?) —
  pure conversion of the legacy `## user:` / `## assistant:` markdown
  shape into a structured Session. Each legacy turn becomes a single
  text part; sources and linked-notes/tags pass through. No tool-call /
  reasoning / attachment surface existed in the legacy format so there's
  nothing to recover beyond text.

- ensureSessionForNote(note, app, plugin, generateId?, now?) — idempotent
  high-level helper. Returns null for non-Coi notes, returns the existing
  id when already linked, and otherwise generates a uuid, writes the
  sidecar, and stamps coi-session-id into the frontmatter. Safe to call
  on every open.

The full ChatView wire-up that calls ensureSessionForNote in onLoadFile
lands in #37.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:08:21 -04:00
Mike Thicke
ae5735506d Render Session to markdown for the note body (#35)
src/session/render-markdown.ts exports renderSessionIntoNote(noteContent,
session) and renderChatSection(session). The markdown view is regenerated
from the sidecar on every save — the sidecar is the source of truth.

Text parts render as the message body (with header bumping so role headers
stay top-level, matching the legacy serializer). Reasoning parts wrap in
<think>. Tool-call / tool-result / attachment parts render as collapsed
Obsidian callouts:

  > [!info]- tool-call: read_note (success)
  > ```json
  > { ... }
  > ```

The `-` suffix on the callout name collapses by default so even a chat
with heavy tool use stays readable. Tool errors render as `[!error]-`,
successful results as `[!success]-`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:06:34 -04:00
Mike Thicke
1a91d678db Cross-reference notes to sessions via coi-session-id (#34)
CoiNoteFrontmatter gains an optional coi-session-id key. New helpers in
src/session/session-link.ts: getSessionId(note) reads the frontmatter id
from the cache, setSessionId(note, id) writes it (idempotent — skips
processFrontMatter when the id is already current), and findNoteBySessionId(id)
walks the markdown index for the rare "session orphaned from note"
recovery path.

This is the wiring layer between a chat note (markdown body, frontmatter)
and its sidecar (JSON source of truth). The full migration / load flow
that uses these helpers lands in #37 (ChatView adapter) and #38 (migration).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:04:48 -04:00
Mike Thicke
ba053b1f4a Implement sidecar JSON storage (#33)
src/session/session-storage.ts exports writeSession / readSession /
deleteSession plus sessionsDir / sessionPath helpers. Sidecars live at
.obsidian/plugins/co-intelligence/sessions/<id>.json — under the Obsidian
config dir so they're outside the visible vault but still travel with sync.

writeSession lazily creates the sessions directory and bumps updatedAt on
every write. readSession returns null when the sidecar doesn't exist (caller
decides whether that means "fresh session" or "needs migration"); a malformed
or shape-mismatched sidecar throws rather than silently nuking real chat
history. Validates against SESSION_FILE_VERSION so future downgrades fail loudly.

Adds an in-memory DataAdapter to the obsidian mock (exists/read/write/mkdir/
remove/list) plus Vault.configDir = ".obsidian", so storage round-trips
naturally in tests without per-test stubbing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:58 -04:00
Mike Thicke
428bed5a5e Define Session domain types (#32)
src/session/types.ts introduces the Session, SessionMessage, and MessagePart
types that replace ModelChatMessage as the primary domain shape. Mirrors
AI SDK v6 UIMessage parts (text / reasoning / tool-call / tool-result /
attachment) so AgentEvent → SessionMessage.parts is a near-direct mapping.

Tool calls carry a ToolCallStatus lifecycle (pending → awaiting-approval →
running → success/error/denied) so the future agentic UI can render state
correctly mid-flight.

Context items get a SerializedContextItems shape that stores note paths
instead of TFile objects, since the latter can't go in a JSON sidecar —
in-memory ContextItems is still TFile-backed, the conversion happens at
the storage seam.

Helpers: createEmptySession(id, now?) and messageText(message) for the
common "give me the plain text" path (used by legacy code paths like
title generation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:00:51 -04:00
Mike Thicke
ca644d052e generateChatResponse emits AgentEventStream (#29)
model-service.generateChatResponse no longer returns a raw StreamTextResult.
It now accepts an optional tools map, runs the request through runAgentLoop,
and returns the normalized AgentEventStream. Provider-specific config
(anthropic browser-access header, openai responses API reasoningSummary,
provider-native web search tools) is unchanged — the switch just produces a
ProviderConfig that's fed to runAgentLoop instead of streamText directly.

consumeChatStream now consumes AgentEvent directly (was a loose ChatStreamChunk
shape). Reasoning events still fold into the text channel as <think> tags;
tool / approval / finish events pass through unchanged for the Phase 5
agentic UI to render.

useChatController iterates responseStream.events (was .fullStream) and
ignores non-text events for now — Phase 5 will wire ToolCallCard / ApprovalPrompt
to these passthrough events.

Updates the pre-existing model-service mock to include stepCountIs +
fullStream + sources on the streamText return value so the agent loop's
internals are satisfied by the mock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:28:48 -04:00
Mike Thicke
a9915eb364 Add agent loop wrapping streamText (#27, #30)
runAgentLoop wraps streamText with stopWhen: stepCountIs(maxSteps) (default
10) and translates each TextStreamPart into a normalized AgentEvent. The
loop's only logic lives in the translate() generator — exported separately
so tests can drive it with canned chunk sequences instead of mocking the
full streamText surface.

Approval-request chunks are mapped through but not yet wired to the
permission broker — that integration lands when the first approval-gated
tool ships in Phase 3. The agent loop's job here is just to surface the
event so the UI / broker can react.

19 tests: 13 against translate() covering every supported chunk type, sequence
ordering, abort handling, and iteration-error propagation; 6 against
runAgentLoop covering maxSteps wiring, param forwarding, sources passthrough,
and onError unwrapping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:25:31 -04:00
Mike Thicke
5c62fed02e Add agent permission broker (#28)
src/agent/permission-broker.ts exposes createPermissionBroker() →
{ pending, requestApproval, resolve, denyAll, hasStandingApproval,
grantStandingApproval, revokeStandingApproval, clear }.

requestApproval returns a Promise that resolves when the user (via a
future UI handler calling resolve()) decides. Standing approvals
short-circuit subsequent calls for the same tool name so the user only
gets prompted once per tool until they revoke. denyAll/clear resolve
in-flight requests with "deny" so cancelling a request doesn't leak
hanging promises.

pending() is a SolidJS Accessor so the future approval-prompt UI can
just subscribe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:21:56 -04:00
Mike Thicke
b3c07c64b2 Add agent tool registry (#26)
src/agent/tool-registry.ts exposes createToolRegistry() returning an
append-only map of CoiTool entries. Supports platform (desktop/mobile) and
scope (vault/web/mcp) filtering and a toAiSdkTools() converter that wraps
each tool into the AI SDK Tool shape streamText accepts (including
needsApproval mapping). Re-registering an existing name throws so
collisions surface early.

Shared agent types (CoiTool, ToolScope, ToolPlatform, ToolExecutionContext,
AgentEvent) live in src/agent/types.ts. AgentEvent is wired in subsequent
commits (#27 emits it; #29 consumes it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:19:32 -04:00
Mike Thicke
b282eb06e4 Move chat orchestration into useChatController + slim ChatInterface (#24, #25)
useChatController owns the in-flight orchestration (send, cancel,
isProcessing) and the message/source state that the active request mutates.
Caller provides the model + contextItems accessors so the dropdown and
ContextList stay in charge of UI state. The triggerChange + auto-save effect
moves into the hook so the change firing stays co-located with the state
that drives it.

context-actions extracts the link-note / add-tag dedupe helpers as small
pure functions so the component is purely composition.

ChatInterface drops from 387 → 96 lines and is now layout-only: contexts,
state wiring, child components. Sets up Phase 2's session createStore
swap-in — the hook is the seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 22:50:59 -04:00
Mike Thicke
bcf5f4d0c9 Extract stream-consumer out of ChatInterface (#23)
Normalizes the AI SDK fullStream into a flat sequence of text/error events
that the caller can iterate without knowing about chunk-type variants or
reasoning markers. Reasoning blocks are wrapped inline with <think> tags so
the loop in handleSendMessage only ever sees text and error events.

The consumer is intentionally chunk-shape-agnostic (loose ChatStreamChunk
type) — when the agent loop in #29 swaps the stream for an AgentEventStream
we'll add tool-call / tool-result / approval events here without forcing
ChatInterface to change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 22:45:41 -04:00
Mike Thicke
938637bdf8 Extract source-processor out of ChatInterface (#22)
Splits the Perplexity-style numbered-reference rewriting and the
markdown-link extraction out of handleSendMessage into two pure functions:
processNumberedSources and extractMarkdownLinkSources. Both take their
inputs explicitly and return the rewritten content / new Source[] for the
caller to apply, keeping all signal access on the component side.

Defines a local RawSource shape rather than importing the AI SDK's
internal Source type, which isn't exported from "ai".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 22:43:12 -04:00
Mike Thicke
dc44c68b59 Extract buildChatRequest out of ChatInterface (#21)
Pulls the inline ChatRequest assembly into a stateless helper in src/chat/.
Snapshots messages on build so post-send mutations by the caller don't
leak into the in-flight request. Unit tests cover field passthrough, uuid
generation, and the snapshot guarantee.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 22:37:24 -04:00