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