Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
66b62ac
refactor: convert canvas.rs to canvas/mod.rs (prep for split)
WKJBryan Jul 10, 2026
75cd4eb
refactor: extract canvas::shared
WKJBryan Jul 10, 2026
8458c89
refactor: extract canvas::context
WKJBryan Jul 10, 2026
97fc878
refactor: extract canvas::streaming
WKJBryan Jul 10, 2026
68ecce5
refactor: extract canvas::debate
WKJBryan Jul 10, 2026
0b26864
refactor: extract canvas::session
WKJBryan Jul 10, 2026
e0b50cf
fix: drop redundant #![cfg(test)] in canvas::test_support
WKJBryan Jul 10, 2026
c152548
refactor: convert twin_store.rs to twin/mod.rs (prep for split)
WKJBryan Jul 10, 2026
50641d0
refactor: extract twin::shared
WKJBryan Jul 10, 2026
07272e4
refactor: extract twin::traces
WKJBryan Jul 10, 2026
9b63a4f
refactor: extract twin::records
WKJBryan Jul 10, 2026
3e747ad
refactor: extract twin::constitution
WKJBryan Jul 10, 2026
5528016
refactor: extract twin::decisions
WKJBryan Jul 10, 2026
2027302
refactor: extract twin::digest
WKJBryan Jul 10, 2026
9ed29fd
refactor: extract twin::export
WKJBryan Jul 10, 2026
0e961b4
chore: trim unused imports across twin/ submodules
WKJBryan Jul 10, 2026
c162a4e
fix: take write lock for twin store read-modify-write commands
WKJBryan Jul 10, 2026
e106a4e
fix: restore #[derive(Debug)] dropped from TwinStore in the split
WKJBryan Jul 10, 2026
444fbd4
refactor: add stores/twin.js scaffold for Twin Workspace decomposition
WKJBryan Jul 10, 2026
f044815
refactor: extract twin/TwinGuideTab component
WKJBryan Jul 10, 2026
8b031f0
refactor: wire TwinReviewView onto the twin store; extract twin/TwinC…
WKJBryan Jul 10, 2026
471bb3c
refactor: extract twin/TwinSetupTab and twin/SetupField components
WKJBryan Jul 10, 2026
b69ad25
refactor: extract twin/TwinConstitutionTab and shared twin/ReviewActions
WKJBryan Jul 10, 2026
ea217b7
refactor: extract twin/TwinActionGapsTab and twin/ActionGapRow compon…
WKJBryan Jul 10, 2026
5fb8433
refactor: extract twin/TwinDecisionsTab and twin/DecisionRow components
WKJBryan Jul 10, 2026
52a64e7
refactor: extract twin/TwinMemoryTab component
WKJBryan Jul 10, 2026
f72f35e
refactor: extract twin/TwinOverviewTab and twin/EvidenceDrawer compon…
WKJBryan Jul 10, 2026
edeea37
docs: CLAUDE.md reflects post-refactor structure and true optimizer b…
WKJBryan Jul 10, 2026
4917406
ci: fail lint job when a source file exceeds 2,500 lines
WKJBryan Jul 10, 2026
68a5d45
docs: record owner-accepted 2,500-line tripwire in plan acceptance; c…
WKJBryan Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ jobs:
- name: Run frontend linting
run: cd frontend && npm run lint

- name: Check source file sizes
run: cd frontend && npm run check:file-sizes

- name: Run Rust clippy
run: cd frontend/src-tauri && cargo clippy --all-targets -- -D warnings
continue-on-error: true # TODO: fix 26 pre-existing warnings, then remove this
Expand Down
30 changes: 21 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ Grafyn is a **desktop-only** app — a single Tauri binary with a Vue frontend a

### Tauri IPC Commands

16 modules in `frontend/src-tauri/src/commands/`. Enumerate exact command names with `grep -rn "#\[tauri::command\]" -A1 src/commands/` — purposes only below, to avoid drift.
16 modules in `frontend/src-tauri/src/commands/`. `canvas` is a directory module (split — see below); every other module is a single file. Enumerate exact command names with `grep -rn "#\[tauri::command\]" -A1 src/commands/` — purposes only below, to avoid drift.

| Module | Purpose |
|--------|---------|
| `notes.rs` | Note CRUD |
| `search.rs` | Full-text search, find-similar, reindex |
| `graph.rs` | Link graph: backlinks, outgoing, neighbors, unlinked, full graph, rebuild |
| `canvas.rs` | Multi-LLM canvas (18 commands) with note context; streaming via `canvas-stream` Tauri events |
| `canvas/` | Multi-LLM canvas (18 commands) with note context; streaming via `canvas-stream` Tauri events. Split across `session.rs` (session/tile CRUD), `streaming.rs` (`send_prompt`/`add_models_to_tile`/`regenerate_response`), `debate.rs` (`start_debate`/`continue_debate`), `context.rs` (retrieval + twin-context prompt assembly, incl. `build_twin_context_prompt()`), `shared.rs` (common helpers), and `mod.rs` (re-exports) |
| `distill.rs` | LLM + rules-based distillation, tag normalization |
| `settings.rs` | Settings, first-run setup, OpenRouter key validation, Ollama status/models |
| `feedback.rs` | Feedback with offline queue |
Expand All @@ -114,8 +114,9 @@ Grafyn is a **desktop-only** app — a single Tauri binary with a Vue frontend a
### Frontend

- `src/api/client.js` — all backend calls go through Tauri `invoke()`; exports one namespace per command module plus `optimizer` and `isDesktopApp`
- **Pinia stores (3):** `canvas.js`, `theme.js`, `boot.js` — there is no twin store; twin UI state lives in `TwinReviewView.vue` and `stores/canvas.js`
- **Routes:** `/` (notes), `/canvas`, `/canvas/:id`, `/import`, `/twin` (component: `TwinReviewView.vue`), plus catch-all → `NotFoundView.vue`
- **Pinia stores (4):** `canvas.js`, `theme.js`, `boot.js`, `twin.js` — twin state (records, review, Constitution, action gaps, decisions, Decision Mirror config, setup) lives in the store; components read/act through it rather than holding local copies
- `components/twin/` — the Twin Workspace's tab components (`TwinOverviewTab.vue`, `TwinSetupTab.vue`, `TwinConfigTab.vue`, `TwinConstitutionTab.vue`, `TwinActionGapsTab.vue`, `TwinDecisionsTab.vue`, `TwinMemoryTab.vue`, `TwinGuideTab.vue`) plus shared pieces (`ReviewActions.vue`, `SetupField.vue`, `ActionGapRow.vue`, `DecisionRow.vue`, `EvidenceDrawer.vue`) and `twin-workspace.css`. `views/TwinReviewView.vue` is now a ~150-line shell that wires the store to these tabs — do not add Twin Workspace logic directly to the view.
- **Routes:** `/` (notes), `/canvas`, `/canvas/:id`, `/import`, `/twin` (component: `TwinReviewView.vue`, thin shell over `components/twin/`), plus catch-all → `NotFoundView.vue`
- **Tests:** `src/__tests__/{unit,integration,fixtures}/` + `setup.js` (Vitest)

## Key Concepts
Expand Down Expand Up @@ -186,7 +187,9 @@ Streaming commands: `send_prompt`, `start_debate`, `continue_debate`, `add_model

### Twin Identity, Constitution, And Decision Mirror

Twin context mode is a native RAG path, not model-weight training. `frontend/src-tauri/src/commands/canvas.rs` assembles the model-facing prompt through `build_twin_context_prompt()`.
Twin context mode is a native RAG path, not model-weight training. `frontend/src-tauri/src/commands/canvas/context.rs` assembles the model-facing prompt through `build_twin_context_prompt()`.

`TwinStore` itself is defined in `services/twin/mod.rs` (struct + shared state) with its methods split by concern across sibling files in `services/twin/`: `records.rs` (user records, inference, promotion), `constitution.rs` (Constitution items + review), `decisions.rs` (decision episodes, outcomes, Decision Mirror), `digest.rs` (memory digest), `traces.rs` (session traces, reflection cards), `export.rs` (JSONL export bundles), and `shared.rs` (corrupt-file quarantine + common helpers). There is no single `twin_store.rs` file — treat `services/twin/` as one logical module split across files, the same pattern as `commands/canvas/`.

The prompt order is:

Expand Down Expand Up @@ -231,13 +234,21 @@ Configurable via `RetrievalConfig` (persisted in `data/retrieval_config.json`):

Hub clustering rules: label-propagation over linked note groups; noise filtering suppresses model names, provider names, and transcript artifacts from becoming hubs; minor themes are grouped under a parent hub's `Subtopics` section rather than creating new hubs.

### Note Write & Delete Chokepoints

Any note write or delete that must stay consistent with the search index, chunk index, topic hubs, and vault-optimizer queue goes through two chokepoints in `commands/mod.rs`: **`commit_note_write`/`commit_note_writes`** (single/batch write — indexes the note, syncs the chunk index, runs `sync_topic_hubs`, enqueues the note in the vault optimizer) and **`commit_note_delete`** (the delete-side equivalent). **New cross-cutting write paths must call these** rather than writing a note and re-indexing ad hoc.

Underlying atomic file I/O is `services/atomic_io.rs::write_atomic` (temp-file-then-rename, so a crash mid-write never leaves a half-written note on disk). `services/index_commit.rs` holds the shared search-index-write helpers (`index_note_for_search`, `commit_search`) used by both chokepoints and any other direct index writer.

`commands/mod.rs`'s module-level doc comment documents the **canonical lock order**: `knowledge_store` must always be acquired before `vault_optimizer` (order doesn't matter between read/write, but `vault_optimizer` must come second) — reversing it risks an ABBA deadlock between the background vault-optimizer worker and any command that touches both locks.

### Background Services

These services run automatically in the background and have dedicated inbox/decision/rollback APIs. Do not re-implement any of these — they already exist.

**`services/link_discovery.rs`** — background link-discovery worker. Distinct from on-demand zettelkasten discovery (`discover_links` command). Uses YAKE keyword extraction (`services/yake.rs`) and TF-IDF cosine similarity (`services/similarity.rs`) to find wikilink candidates without LLM calls. Optional LLM pass controlled by `background_link_discovery_llm_enabled`. Results surface via `list_link_suggestion_queue` / `dismiss_link_suggestion` commands.

**`services/vault_optimizer.rs`** — background vault optimizer. Queued via `enqueue_vault_optimizer_note()` whenever notes are created or migrated. Processes the queue and applies structural improvements in two modes: `sidecar_first` (overlay metadata) or `full_rewrite`. Budget caps and daily write limits prevent runaway LLM spend. Decisions are auditable via `list_vault_optimizer_decisions`; rollbacks are per-change via `rollback_vault_optimizer_change`.
**`services/vault_optimizer.rs`** — background vault optimizer. Queued via `enqueue_vault_optimizer_note()` whenever notes are created or migrated. Processes the queue and applies structural improvements in two modes: `sidecar_first` (overlay metadata) or `full_rewrite`. **The proposals are rule-based, not LLM-based** — title-alias normalization and topic tagging computed directly from vault content; there is no `OpenRouterService`/network call anywhere in this file. `background_vault_optimizer_llm_enabled` is a reserved no-op today (a characterization test, `run_next_ignores_llm_enabled_because_no_llm_path_exists`, locks in that toggling it changes nothing); it exists as the gate a future LLM-backed enrichment step must check before making any network call. The budget/daily-write settings (`background_vault_optimizer_max_daily_writes`, etc.) cap **disk writes to the vault** (`daily_write_count`), not LLM spend. Decisions are auditable via `list_vault_optimizer_decisions`; rollbacks are per-change via `rollback_vault_optimizer_change`.

**`services/markdown_migration.rs`** — one-shot structured vault migration. Preview → apply → rollback workflow. `apply_markdown_migration` runs `sync_topic_hubs` and rebuilds all indexes after applying, then enqueues touched notes in the vault optimizer. Rollback restores pre-migration state and rebuilds.

Expand All @@ -256,8 +267,8 @@ First-run setup wizard and persistent settings. Manages vault path, OpenRouter A
- **`twin_llm_provider`** — selects the LLM runtime for twin context answers: `"openrouter"` (default) or `"ollama"`. Gates whether `OllamaService` or `OpenRouterService` is used in `build_twin_context_prompt()`.
- **`ollama_base_url`** / **`ollama_model`** — endpoint and model for local inference. `get_ollama_status` probes the Ollama daemon; `list_ollama_models` enumerates pulled models. Synced via `ollama.set_base_url()` on settings change.
- **`background_link_discovery_enabled`** / **`background_link_discovery_llm_enabled`** — controls the background link-discovery worker. When enabled, `LinkDiscoveryService` processes notes in the background using YAKE keyword extraction and TF-IDF similarity.
- **`background_vault_optimizer_enabled`** — controls the `VaultOptimizerService`. When enabled, optimizer processes queued notes and applies structural improvements (sidecar overlay or full rewrite mode). Budget and max daily writes cap LLM costs.
- **Vault optimizer sub-settings** (all on `UserSettings`): `background_vault_optimizer_llm_enabled`, `_budget_monthly`, `_max_daily_writes`, `_edit_mode`, `_program_enabled`, and `vault_optimizer_program_path` — the last two enable a **vault-local `program.md` policy file** that steers optimizer behavior per-vault.
- **`background_vault_optimizer_enabled`** — controls the `VaultOptimizerService`. When enabled, optimizer processes queued notes and applies structural improvements (sidecar overlay or full rewrite mode) using rule-based proposals only (no LLM call today). Max daily writes cap disk writes to the vault, not LLM spend — see Background Services above.
- **Vault optimizer sub-settings** (all on `UserSettings`): `background_vault_optimizer_llm_enabled` (reserved no-op — no LLM path exists yet), `_budget_monthly` (unused until an LLM path exists), `_max_daily_writes` (real — caps disk writes), `_edit_mode`, `_program_enabled`, and `vault_optimizer_program_path` — the last two enable a **vault-local `program.md` policy file** that steers optimizer behavior per-vault.
- **`canvas_model_presets`** — saved canvas model combinations (`CanvasModelPreset` struct in `models/settings.rs`).

**Runtime sync pattern:** When settings change via `update_settings`, dependent services are updated in-place — no restart required. The pattern (in `commands/settings.rs`): capture changed fields before moving the update, apply settings, then sync each affected service:
Expand Down Expand Up @@ -307,7 +318,7 @@ cargo build --release --bin grafyn-mcp --no-default-features --features mcp

### Test Pipeline

`.github/workflows/test.yml` — runs on push to main and PRs. Jobs: `release-preflight` (version + Cargo.lock alignment), `rust-tests` (ubuntu-22.04), `frontend-tests` (Vitest), `lint` (eslint + `cargo clippy -D warnings`), `security` (`npm audit --audit-level=high`), `build` (Vite), `test-summary`. Clippy and npm audit **block** PRs.
`.github/workflows/test.yml` — runs on push to main and PRs. Jobs: `release-preflight` (version + Cargo.lock alignment), `rust-tests` (ubuntu-22.04), `frontend-tests` (Vitest), `lint` (eslint + file-size tripwire + `cargo clippy -D warnings`), `security` (`npm audit --audit-level=high`), `build` (Vite), `test-summary`. npm audit **blocks** PRs; clippy currently runs with `continue-on-error: true` pending cleanup of pre-existing warnings (see the TODO in the `lint` job). The file-size tripwire (`npm run check:file-sizes`, `scripts/check-file-sizes.cjs`) blocks PRs when any non-test `.rs`/`.vue`/`.js` source file exceeds 2,500 lines.

### Release Pipeline

Expand Down Expand Up @@ -412,3 +423,4 @@ From `frontend/`:
- **Read before editing.** Always Read a file in-session before Edit/Write; "File has not been read yet" failures were the most repeated tool error in this repo's sessions.
- **Discussion-first.** When the user is exploring a design ("lets chat more", strategic questions), discuss — do not start implementing until explicitly told to build. Tool-use rejections are usually redirects back to discussion, not vetoes.
- **Local models:** before benchmarking a new Ollama tag, smoke-test it first (prompt-echo check, choice-extraction check, max-token truncation check) — these three failure modes consumed entire past sessions.
- **Source file size.** Keep new/edited source files under ~1,500 lines as an authoring target; CI hard-fails the `lint` job at 2,500 lines (`npm run check:file-sizes`, excludes `__tests__/`, `*.spec.js`, and Rust test-only files by path convention). New cross-cutting write paths must go through `commit_note_write`/`commit_note_delete` (`commands/mod.rs`) and `services/atomic_io.rs::write_atomic` — see "Note Write & Delete Chokepoints" above — not ad hoc file writes. When a file needs splitting, follow the mod-facade pattern already established by `commands/canvas/` (and `services/twin/`): one directory module, a `mod.rs` that re-exports, and sibling files split by concern (not by arbitrary line count).
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
- [ ] Add convention to CLAUDE.md: source files should stay under ~1,500 lines; new cross-cutting write paths must go through `commit_note_write`/`write_atomic`.
- [ ] Optional CI tripwire: a 10-line script failing the lint job when any `src-tauri/src/**/*.rs` or `src/**/*.{vue,js}` exceeds 2,000 lines (excluding tests). Decide with owner at PR time.

**Phase 4 acceptance:** no public behavior change (full Rust + Vitest + e2e suites green before/after); largest source file < 2,000 lines; twin state has a real store.
**Phase 4 acceptance:** no public behavior change (full Rust + Vitest + e2e suites green before/after); source files target ~1,500 lines with a 2,500-line CI hard-fail (owner decision 2026-07-10: `commands/canvas/context.rs` at 2,279 accepted — keeping the twin Simulation gate and its 21 tests together outweighs the original < 2,000 line goal); twin state has a real store.

---

Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"prepare:sidecar": "node scripts/prepare-sidecar.cjs",
"check:file-sizes": "node scripts/check-file-sizes.cjs",
"tauri": "tauri",
"tauri:dev": "node scripts/run-tauri.cjs dev",
"tauri:build": "node scripts/run-tauri.cjs build",
Expand Down
120 changes: 120 additions & 0 deletions frontend/scripts/check-file-sizes.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node

// Size tripwire: fails when a source file grows past a line-count threshold.
// Catches the "one file does everything" drift this refactor split apart
// (the old single-file commands/canvas.rs and services/twin_store.rs, now
// the commands/canvas/ and services/twin/ module directories) before it recurs. See
// CLAUDE.md "Working conventions" for the ~1,500-line authoring target —
// this threshold (2,500) is a hard CI failure with headroom above that.

const fs = require('node:fs')
const path = require('node:path')

const projectRoot = path.resolve(__dirname, '..')

const DEFAULT_MAX_LINES = 2500

const SCAN_ROOTS = [
{ dir: path.join(projectRoot, 'src-tauri', 'src'), extensions: ['.rs'] },
{ dir: path.join(projectRoot, 'src'), extensions: ['.vue', '.js'] },
]

// Path-convention exclusions only — we do not parse Rust to find
// `#[cfg(test)]`-only files, and we do not parse JS to find describe/it
// blocks. A file is excluded solely by where it lives or how it's named.
function isExcluded(relativePath) {
const segments = relativePath.split('/')

if (segments.includes('__tests__')) {
return true
}

const basename = path.basename(relativePath)

if (/\.(spec|test)\.js$/.test(basename)) {
return true
}

// Rust test-only files by naming convention (e.g. test_support.rs,
// foo_test.rs) — not files that merely contain an inline `mod tests {}`.
if (relativePath.endsWith('.rs') && /(^|_)test(s)?(_|\.rs$)/.test(basename)) {
return true
}

return false
}

function walk(dir, extensions, results) {
const entries = fs.readdirSync(dir, { withFileTypes: true })

for (const entry of entries) {
const fullPath = path.join(dir, entry.name)

if (entry.isDirectory()) {
walk(fullPath, extensions, results)
continue
}

if (extensions.includes(path.extname(entry.name))) {
results.push(fullPath)
}
}
}

function countLines(filePath) {
const contents = fs.readFileSync(filePath, 'utf8')
if (contents.length === 0) {
return 0
}
return contents.split('\n').length
}

function main() {
const maxLinesArg = process.argv.find((arg) => arg.startsWith('--max-lines='))
const maxLines = maxLinesArg ? Number(maxLinesArg.split('=')[1]) : DEFAULT_MAX_LINES

if (!Number.isFinite(maxLines) || maxLines <= 0) {
console.error(`check-file-sizes: invalid --max-lines value: ${maxLinesArg}`)
process.exit(1)
}

const files = []
for (const { dir, extensions } of SCAN_ROOTS) {
if (fs.existsSync(dir)) {
walk(dir, extensions, files)
}
}

const offenders = []

for (const filePath of files) {
const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/')
if (isExcluded(relativePath)) {
continue
}

const lines = countLines(filePath)
if (lines > maxLines) {
offenders.push({ relativePath, lines })
}
}

offenders.sort((a, b) => b.lines - a.lines)

if (offenders.length > 0) {
console.error(`check-file-sizes: ${offenders.length} file(s) exceed ${maxLines} lines:`)
for (const { relativePath, lines } of offenders) {
console.error(` ${lines.toString().padStart(6)} ${relativePath}`)
}
console.error('')
console.error(
'Split oversized files using the mod-facade pattern (see commands/canvas/ for the exemplar) ' +
'instead of raising this threshold.',
)
process.exit(1)
}

console.log(`check-file-sizes: OK — no source file exceeds ${maxLines} lines.`)
}

main()
Loading
Loading