Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
84 changes: 63 additions & 21 deletions CLAUDE.md

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The first usable twin mode is a **native RAG twin**:
- Full-text search powered by Tantivy.
- Backlinks, outgoing links, and graph-aware retrieval.
- Conversation import from ChatGPT, Claude, Grok, Gemini, and Codex-style exports.
- Structured vault migration with preview, apply, and rollback.

### Knowledge Graph And Hub Clustering

Expand All @@ -72,6 +73,12 @@ The first usable twin mode is a **native RAG twin**:
- Twin context mode using reviewed user records.
- Smart web search detection for prompts that need current information.
- Save Canvas sessions as notes.
- **Local model support** via Ollama — run Canvas and twin answers on local models alongside OpenRouter cloud models.

### Vault Automation

- **Background link discovery** — scans the vault continuously for potential `[[wikilinks]]` using keyword extraction and similarity, surfaces suggestions in the Link Suggestion inbox.
- **Vault Optimizer** — background service that queues and applies structural improvements to notes (tag cleanup, sidecar metadata, full rewrites). Budget caps and per-change rollback keep it safe to enable.

### Twin Capture And Review

Expand Down Expand Up @@ -165,6 +172,27 @@ Grafyn's data can later support stronger personal models, but those are not v1:
- **Local adapters or fine-tuning** - adjusts a capable base model using reviewed examples.
- **Scratch-trained personal model** - research path only. Prompts alone are not enough; it would require large volumes of personal writing, decisions, outcomes, corrections, and domain evidence.

## Project Status

| Area | Status |
|------|--------|
| Knowledge vault (notes, wikilinks, full-text search) | ✅ Stable |
| Knowledge graph + topic hub clustering | ✅ Stable |
| Multi-LLM Canvas with semantic note context | ✅ Stable |
| Conversation import (ChatGPT, Claude, Grok, Gemini) | ✅ Stable |
| Native RAG twin (Advisor + Simulation modes) | ✅ Stable |
| Twin Identity, Constitution, Decision Mirror | ✅ Stable |
| Twin evidence capture and review dashboard | ✅ Stable |
| Local model support via Ollama | ✅ Stable |
| Background link discovery | ✅ Stable |
| Vault Optimizer (background vault improvements) | ✅ Stable |
| Structured vault migration (preview/apply/rollback) | ✅ Stable |
| MCP server (`grafyn-mcp`) for Claude Desktop / Codex Desktop | ✅ Stable |
| Preference / ranking model from export bundles | 🔲 Not started |
| Local adapters or fine-tuning from reviewed evidence | 🔲 Not started |

Current version: see [Releases](https://github.com/WKJBryan/Grafyn/releases/latest).

## Quick Start

### Download
Expand Down Expand Up @@ -260,7 +288,7 @@ Tauri Desktop App
| Backend | Rust |
| Search | Tantivy |
| Graph | petgraph + local graph algorithms |
| LLM Runtime | OpenRouter via reqwest |
| LLM Runtime | OpenRouter via reqwest; Ollama for local models |
| MCP | rmcp over stdio |
| Storage | Local markdown vault + JSON data files |
| Updates | Cloudflare R2 + Workers |
Expand Down
260 changes: 260 additions & 0 deletions TWIN_ACCURACY_ROADMAP.md

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions frontend/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 15 additions & 12 deletions frontend/src-tauri/src/commands/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2930,7 +2930,7 @@ fn build_twin_context_prompt(
),
TwinAnswerMode::Simulation => prompt.push_str(
"Answer in first person from the configured Twin Identity. Use approved records as stronger style and preference evidence; mention candidate influence as tentative when relevant. \
Write as a natural continuation of my documented reasoning pattern, not a report. Lead with my likely reasoning or judgment, show the tradeoff logic, and ask sharp reflective questions when useful. \
Write as a natural continuation of my documented reasoning pattern, not a report. Lead with my likely reasoning or judgment, show the tradeoff logic, and do not append questions unless the user's request asks for them. \
If the evidence packet does not contain enough basis, say so naturally in first person. Use light citations or brief source mentions only where they help; avoid turning the answer into an evidence workflow.\n",
),
}
Expand All @@ -2956,14 +2956,7 @@ fn build_twin_context_prompt(
In Constitution Check, separate stated values, revealed behavior, taste, somatic signal, and constraints. In Action Gap Risk, state whether past intention-action gaps could change the next step. \
Recommendation must be derived after the Constitution Check and Evidence From Grafyn sections, not before them.\n",
),
TwinAnswerMode::Simulation => prompt.push_str(
"\n## Decision Mirror Simulation Style\n\n\
This is a Decision Mirror simulation session. Do not return numbered headings or a card template. \
Return a natural first-person reflection that feels like my reasoning pattern thinking through the decision. \
Prefer concise paragraphs over sections. Start with my likely judgment or hesitation, then explain the filters, tradeoffs, and next question. \
Keep light citations or parenthetical source mentions where helpful, but keep evidence backstage. \
Treat every self-model claim as evidence-constrained. If a useful claim is weakly supported, say so naturally.\n",
),
TwinAnswerMode::Simulation => {}
}

if let Some(metadata) = decision_metadata {
Expand Down Expand Up @@ -3555,6 +3548,11 @@ mod tests {
assert!(prompt.contains("My role/context is founder deciding from product evidence."));
assert!(prompt.contains("Use reviewed notes and uploaded interviews only."));
assert!(prompt.contains("Continue my documented reasoning pattern"));
assert!(prompt.contains(
"do not append questions unless the user's request asks for them"
));
assert!(!prompt.contains("reflective questions"));
assert!(!prompt.contains("next question"));
assert!(!prompt.contains("not the user's actual view"));
assert!(prompt.contains("## Reviewed Constitution"));
}
Expand Down Expand Up @@ -3602,7 +3600,7 @@ mod tests {
}

#[test]
fn decision_simulation_prompt_uses_natural_reflection_instructions() {
fn decision_simulation_prompt_uses_base_simulation_without_decision_style_block() {
let metadata = DecisionPromptMetadata {
decision: "Should Grafyn build Decision Mirror first?".into(),
options: vec!["Decision Mirror".into(), "Topology layer".into()],
Expand All @@ -3626,8 +3624,13 @@ mod tests {
assert!(!prompt.contains("Reflection Card"));
assert!(!prompt.contains("Evidence From Grafyn"));
assert!(!prompt.contains("Blind Spot Hypothesis"));
assert!(prompt.contains("natural first-person reflection"));
assert!(prompt.contains("light citations"));
assert!(!prompt.contains("Decision Mirror Simulation Style"));
assert!(!prompt.contains("Decision Mirror simulation session"));
assert!(!prompt.contains("natural first-person reflection"));
assert!(!prompt.contains("numbered headings"));
assert!(prompt.contains(
"do not append questions unless the user's request asks for them"
));
assert!(!prompt.contains("not the user's actual view"));
assert!(prompt.contains("Decision: Should Grafyn build Decision Mirror first?"));
}
Expand Down
71 changes: 69 additions & 2 deletions frontend/src/__tests__/unit/components/CanvasContainer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import CanvasContainer from '@/components/canvas/CanvasContainer.vue'

const { store, getOpenRouterStatus, getStatus, getSettings, updateSettings, listOllamaModels, getConstitutionSetup, toastSuccess } = vi.hoisted(() => ({
const { store, getOpenRouterStatus, getStatus, getSettings, updateSettings, listOllamaModels, getConstitutionSetup, saveConstitutionSetup, toastSuccess } = vi.hoisted(() => ({
store: {
currentSession: {
id: 'session-1',
Expand Down Expand Up @@ -58,6 +58,7 @@ const { store, getOpenRouterStatus, getStatus, getSettings, updateSettings, list
updateSettings: vi.fn(),
listOllamaModels: vi.fn(),
getConstitutionSetup: vi.fn(),
saveConstitutionSetup: vi.fn(),
toastSuccess: vi.fn()
}))

Expand All @@ -74,7 +75,8 @@ vi.mock('@/api/client', () => ({
listOllamaModels
},
twin: {
getConstitutionSetup
getConstitutionSetup,
saveConstitutionSetup
},
isDesktopApp: () => true
}))
Expand Down Expand Up @@ -186,6 +188,21 @@ function mountContainer() {
twinLlmProvider: 'ollama',
webSearch: false
})" />
<button class="identity-submit-stub" @click="$emit('submit', {
prompt: 'Simulate my likely response',
promptType: 'standard',
models: ['llama3.1:8b'],
systemPrompt: null,
temperature: 0.4,
contextMode: 'twin',
twinAnswerMode: 'simulation',
twinLlmProvider: 'ollama',
webSearch: false,
twinIdentitySetup: {
twin_name: 'Alex Chen',
twin_role: 'founder deciding from product evidence'
}
})" />
</div>
`
}
Expand Down Expand Up @@ -280,6 +297,7 @@ describe('CanvasContainer', () => {
twin_name: 'Alex Chen',
twin_role: 'founder deciding from product evidence'
})
saveConstitutionSetup.mockResolvedValue({})
toastSuccess.mockReset()
})

Expand All @@ -295,6 +313,55 @@ describe('CanvasContainer', () => {
expect(wrapper.text()).not.toContain('OpenRouter API Key Required')
})

it('saves inline Twin Identity before submitting a simulation prompt', async () => {
getOpenRouterStatus.mockResolvedValue({ has_key: true, is_configured: true })
getConstitutionSetup.mockResolvedValueOnce({
values: ['evidence-backed work'],
tastes: [],
constraints: [],
somatic_cues: [],
action_tendencies: []
}).mockResolvedValue({
twin_name: 'Alex Chen',
twin_role: 'founder deciding from product evidence',
values: ['evidence-backed work'],
tastes: [],
constraints: [],
somatic_cues: [],
action_tendencies: []
})
const wrapper = mountContainer()
await flushPromises()

await wrapper.find('[data-guide="canvas-prompt-btn"]').trigger('click')
await flushPromises()
await wrapper.find('.identity-submit-stub').trigger('click')
await flushPromises()

expect(saveConstitutionSetup).toHaveBeenCalledWith(expect.objectContaining({
twin_name: 'Alex Chen',
twin_role: 'founder deciding from product evidence',
values: ['evidence-backed work']
}))
expect(store.sendPrompt).toHaveBeenCalledWith(
'Simulate my likely response',
['llama3.1:8b'],
null,
0.4,
null,
null,
null,
'twin',
'simulation',
false,
undefined,
'standard',
null,
'none',
'ollama'
)
})

it('blocks API submit if OpenRouter becomes unavailable after the dialog opens', async () => {
getOpenRouterStatus
.mockResolvedValueOnce({ has_key: true, is_configured: true })
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/__tests__/unit/components/PromptDialog.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('PromptDialog', () => {
expect(wrapper.find('.btn-primary').attributes('disabled')).toBeDefined()
})

it('blocks Twin Simulation until identity name and role are configured', async () => {
it('asks for Twin Identity inline before running Simulation', async () => {
const wrapper = mountDialog({
twinLlmProvider: 'ollama',
ollamaModel: 'llama3.1:8b',
Expand All @@ -127,8 +127,23 @@ describe('PromptDialog', () => {
await wrapper.find('#prompt').setValue('Simulate my likely response')
await wrapper.find('#contextMode').setValue('twin')

expect(wrapper.text()).toContain('Set up Twin Identity with a name and role before running Simulation')
expect(wrapper.text()).toContain('Twin Identity')
expect(wrapper.text()).toContain('Who should this twin be?')
expect(wrapper.text()).toContain('What work or role should this twin reason from?')
expect(wrapper.find('.btn-primary').attributes('disabled')).toBeDefined()

await wrapper.find('#inlineTwinName').setValue('Alex Chen')
await wrapper.find('#inlineTwinRole').setValue('founder deciding from product evidence')
await wrapper.find('.btn-primary').trigger('click')

expect(wrapper.emitted('submit')[0][0]).toMatchObject({
contextMode: 'twin',
twinAnswerMode: 'simulation',
twinIdentitySetup: {
twin_name: 'Alex Chen',
twin_role: 'founder deciding from product evidence'
}
})
})

it('allows Twin Simulation after identity name and role are configured', async () => {
Expand Down
37 changes: 36 additions & 1 deletion frontend/src/components/canvas/CanvasContainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,26 @@ async function refreshTwinIdentity() {
}
}

async function saveInlineTwinIdentity(identitySetup) {
const existing = await twinApi.getConstitutionSetup()
const nextSetup = {
...existing,
twin_name: identitySetup.twin_name,
twin_role: identitySetup.twin_role,
source_boundaries: existing?.source_boundaries || [],
values: existing?.values || [],
tastes: existing?.tastes || [],
constraints: existing?.constraints || [],
somatic_cues: existing?.somatic_cues || [],
action_tendencies: existing?.action_tendencies || []
}
await twinApi.saveConstitutionSetup(nextSetup)
twinIdentity.value = {
name: nextSetup.twin_name || '',
role: nextSetup.twin_role || ''
}
}

async function loadCanvasPreferences() {
try {
const settingsData = await settingsApi.get()
Expand Down Expand Up @@ -1358,7 +1378,8 @@ async function handlePromptSubmit({
twinAnswerMode = 'simulation',
webSearch,
reasoningEffort = 'none',
twinLlmProvider: selectedTwinLlmProvider = null
twinLlmProvider: selectedTwinLlmProvider = null,
twinIdentitySetup = null
}) {
const usesLocalTwinRuntime = (promptType === 'decision' || contextMode === 'twin') &&
selectedTwinLlmProvider === 'ollama'
Expand All @@ -1376,6 +1397,20 @@ async function handlePromptSubmit({
}
}

if (twinIdentitySetup) {
try {
await saveInlineTwinIdentity(twinIdentitySetup)
} catch (err) {
console.error('Failed to save Twin Identity before submit:', err)
saveMessage.value = {
type: 'error',
text: err.message || 'Failed to save Twin Identity'
}
setTimeout(() => { saveMessage.value = null }, 5000)
return
}
}

showPromptDialog.value = false
const activeBranchContext = branchContext.value

Expand Down
Loading
Loading