-A local-first, privacy-focused second brain. Capture anything — text, images, URLs. Process locally with your own LLM. Search semantically and by keyword. Your data never leaves your machine.
+A local-first, privacy-focused second brain. Capture anything — text, images, URLs, PDFs, voice. Process locally with your own LLM: tags, summaries, entities, and proactive connections that resurface what you've forgotten. Search semantically and by keyword, or ask AI questions over your own notes. Your data never leaves your machine.
## How It Works
@@ -42,14 +44,20 @@ Khayal and Obsidian are complementary. Khayal is a capture and retrieval layer
## Features
-- **Capture** — Text, images, URLs, articles with zero friction
-- **Process** — Tags, summaries, key ideas, entities via local LLM
+- **Capture** — Text, images, URLs, articles with zero friction (voice notes and PDF ingestion on the roadmap)
+- **Process** — Tags, summaries, key ideas, entities (people, amounts, dates, places, orgs, URLs) via local LLM
+- **Proactive connections** — after every capture, khayal resurfaces related thoughts, shared people, matching amounts, **contradictions of things you wrote**, unfinished follow-ups, and ideas you keep revisiting
+- **Capture intelligence** — relative dates resolved at capture; an LLM-maintained memory file keeps naming consistent across months
- **Search** — Keyword (FTS5) + semantic (chunk-level embeddings) hybrid, with passage-level excerpts
-- **Store** — Plain markdown in your vault, yours forever
+- **AI answers** — on-demand answers above search results, grounded in your own notes with `[n]` citations; explicit CTA, never automatic, never breaks search
+- **Store** — Plain markdown in your vault, yours forever (Obsidian-friendly, with connection wikilinks written into frontmatter)
+- **Vault care** — health report, broken-link repair, orphaned-media cleanup, duplicate detection, soft-delete with trash
+- **Backup** — encrypted (age) vault/database/config backups with additive-merge restore
- **PWA** — Web interface, works offline, update notifications
+ - Live queue over WebSocket (job status streams in; polling fallback)
+ - AI answer with skeleton loading, connection flares on the queue, linked notes with reasons, entity chips, image previews
- Offline capture queue (syncs when server is back)
- Works as installable PWA on iOS and desktop
- - Live pipeline visualization for queued notes
- Optional Face ID / Touch ID app lock (WebAuthn PRF) that encrypts the token at rest
- **CLI** — Full client (`kl`) + server admin (`khayal`)
- **Updates** — Built-in update checker via GitHub releases
@@ -74,6 +82,9 @@ curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text
ollama pull qwen2.5:3b
ollama pull moondream
+
+# optional: a larger model just for memory consolidation (recommended)
+ollama pull qwen2.5:7b
```
### 2. Install Khayal
@@ -175,6 +186,20 @@ search:
chunk_min_words: 50
chunk_overlap_words: 35
+connections:
+ enabled: true # proactive connections after every capture
+ similarity_threshold: 0.72
+ types: # toggle each detector independently
+ similar: true
+ person: true
+ amount: true
+ contradiction: true
+ follow_up: true
+ revisit: true
+
+memory:
+ enabled: true # LLM context memory + memory.md consolidation
+
log:
level: info
file: logs/khayal.log
@@ -188,11 +213,14 @@ See [config.example.yaml](config.example.yaml) for all options.
| Location | Content |
|---|---|
+| `~/Documents/brain/khayal/` | Your notes (plain markdown + media) |
+| `~/Documents/brain/khayal/memory.md` | LLM-maintained memory (editable by hand) |
+| `~/Documents/brain/khayal/.khayal-trash/` | Soft-deleted notes (recoverable) |
| `~/.config/khayal/khayal.db` | Search index + embeddings |
| `~/.config/khayal/config.yaml` | Server configuration |
| `~/.config/khayal/logs/` | Server logs |
-All on your machine. Back up the vault directory — it's plain markdown.
+All on your machine. Back up the vault directory — it's plain markdown (or use `khayal backup --encrypt`).
## Commands
@@ -207,6 +235,12 @@ All on your machine. Back up the vault directory — it's plain markdown.
| `khayal status` | Server status + update check |
| `khayal reindex` | Rebuild search index (FTS + chunk embeddings) |
| `khayal config` | View config (token redacted) |
+| `khayal vault health` | Vault health report (notes, indexed %, orphans, broken links) |
+| `khayal vault fix-links` | Remove broken wikilinks (dry-run by default) |
+| `khayal vault clean-media` | Move orphaned media files to trash |
+| `khayal vault show-duplicates` | Show potential duplicate notes |
+| `khayal backup --dest
-
-
this.bindToMotionValue(o,i)),this.reducedMotionConfig==="never"?this.shouldReduceMotion=!1:this.reducedMotionConfig==="always"?this.shouldReduceMotion=!0:(I0.current||LP(),this.shouldReduceMotion=Ef.current),this.shouldSkipAnimations=this.skipAnimationsConfig??!1,(r=this.parent)==null||r.addChild(this),this.update(this.props,this.presenceContext),this.hasBeenMounted=!0}unmount(){var t;this.projection&&this.projection.unmount(),Xn(this.notifyUpdate),Xn(this.render),this.valueSubscriptions.forEach(n=>n()),this.valueSubscriptions.clear(),this.removeFromVariantTree&&this.removeFromVariantTree(),(t=this.parent)==null||t.removeChild(this);for(const n in this.events)this.events[n].clear();for(const n in this.features){const r=this.features[n];r&&(r.unmount(),r.isMounted=!1)}this.current=null}addChild(t){this.children.add(t),this.enteringChildren??(this.enteringChildren=new Set),this.enteringChildren.add(t)}removeChild(t){this.children.delete(t),this.enteringChildren&&this.enteringChildren.delete(t)}bindToMotionValue(t,n){if(this.valueSubscriptions.has(t)&&this.valueSubscriptions.get(t)(),n.accelerate&&h0.has(t)&&this.current instanceof HTMLElement){const{factory:s,keyframes:a,times:l,ease:u,duration:c}=n.accelerate,f=new f0({element:this.current,name:t,keyframes:a,times:l,ease:u,duration:ht(c)}),h=s(f);this.valueSubscriptions.set(t,()=>{h(),f.cancel()});return}const r=Ri.has(t);r&&this.onBindTransform&&this.onBindTransform();const i=n.on("change",s=>{this.latestValues[t]=s,this.props.onUpdate&&se.preRender(this.notifyUpdate),r&&this.projection&&(this.projection.isTransformDirty=!0),this.scheduleRender()});let o;typeof window<"u"&&window.MotionCheckAppearSync&&(o=window.MotionCheckAppearSync(this,t,n)),this.valueSubscriptions.set(t,()=>{i(),o&&o(),n.owner&&n.stop()})}sortNodePosition(t){return!this.current||!this.sortInstanceNodePosition||this.type!==t.type?0:this.sortInstanceNodePosition(this.current,t.current)}updateFeatures(){let t="animation";for(t in Ga){const n=Ga[t];if(!n)continue;const{isEnabled:r,Feature:i}=n;if(!this.features[t]&&i&&r(this.props)&&(this.features[t]=new i(this)),this.features[t]){const o=this.features[t];o.isMounted?o.update():(o.mount(),o.isMounted=!0)}}}triggerBuild(){this.build(this.renderState,this.latestValues,this.props)}measureViewportBox(){return this.current?this.measureInstanceViewportBox(this.current,this.props):je()}getStaticValue(t){return this.latestValues[t]}setStaticValue(t,n){this.latestValues[t]=n}update(t,n){(t.transformTemplate||this.props.transformTemplate)&&this.scheduleRender(),this.prevProps=this.props,this.props=t,this.prevPresenceContext=this.presenceContext,this.presenceContext=n;for(let r=0;r this.bindToMotionValue(o,i)),this.reducedMotionConfig==="never"?this.shouldReduceMotion=!1:this.reducedMotionConfig==="always"?this.shouldReduceMotion=!0:(O0.current||VP(),this.shouldReduceMotion=Ef.current),this.shouldSkipAnimations=this.skipAnimationsConfig??!1,(r=this.parent)==null||r.addChild(this),this.update(this.props,this.presenceContext),this.hasBeenMounted=!0}unmount(){var t;this.projection&&this.projection.unmount(),Xn(this.notifyUpdate),Xn(this.render),this.valueSubscriptions.forEach(n=>n()),this.valueSubscriptions.clear(),this.removeFromVariantTree&&this.removeFromVariantTree(),(t=this.parent)==null||t.removeChild(this);for(const n in this.events)this.events[n].clear();for(const n in this.features){const r=this.features[n];r&&(r.unmount(),r.isMounted=!1)}this.current=null}addChild(t){this.children.add(t),this.enteringChildren??(this.enteringChildren=new Set),this.enteringChildren.add(t)}removeChild(t){this.children.delete(t),this.enteringChildren&&this.enteringChildren.delete(t)}bindToMotionValue(t,n){if(this.valueSubscriptions.has(t)&&this.valueSubscriptions.get(t)(),n.accelerate&&v0.has(t)&&this.current instanceof HTMLElement){const{factory:s,keyframes:a,times:l,ease:u,duration:c}=n.accelerate,f=new g0({element:this.current,name:t,keyframes:a,times:l,ease:u,duration:pt(c)}),h=s(f);this.valueSubscriptions.set(t,()=>{h(),f.cancel()});return}const r=Ri.has(t);r&&this.onBindTransform&&this.onBindTransform();const i=n.on("change",s=>{this.latestValues[t]=s,this.props.onUpdate&&le.preRender(this.notifyUpdate),r&&this.projection&&(this.projection.isTransformDirty=!0),this.scheduleRender()});let o;typeof window<"u"&&window.MotionCheckAppearSync&&(o=window.MotionCheckAppearSync(this,t,n)),this.valueSubscriptions.set(t,()=>{i(),o&&o(),n.owner&&n.stop()})}sortNodePosition(t){return!this.current||!this.sortInstanceNodePosition||this.type!==t.type?0:this.sortInstanceNodePosition(this.current,t.current)}updateFeatures(){let t="animation";for(t in qa){const n=qa[t];if(!n)continue;const{isEnabled:r,Feature:i}=n;if(!this.features[t]&&i&&r(this.props)&&(this.features[t]=new i(this)),this.features[t]){const o=this.features[t];o.isMounted?o.update():(o.mount(),o.isMounted=!0)}}}triggerBuild(){this.build(this.renderState,this.latestValues,this.props)}measureViewportBox(){return this.current?this.measureInstanceViewportBox(this.current,this.props):je()}getStaticValue(t){return this.latestValues[t]}setStaticValue(t,n){this.latestValues[t]=n}update(t,n){(t.transformTemplate||this.props.transformTemplate)&&this.scheduleRender(),this.prevProps=this.props,this.props=t,this.prevPresenceContext=this.presenceContext,this.presenceContext=n;for(let r=0;r