Knowledge Graph Personale con Claude e Obsidian: Pattern Wiki LLM

2026-04-13 · 23 min read · gen:2m 52s · tok:15041
#claude-api #obsidian #knowledge-graph #nodejs #devops #intermediate-tutorial #italiano

Implementa il pattern LLM Wiki di Karpathy: comandi /wiki, /save e /autoresearch per sincronizzare conversazioni Claude con Obsidian e costruire un secondo cervello.

Building a Personal Knowledge Graph with Claude e Obsidian: Implementare il Pattern LLM Wiki di Karpathy

Ogni sviluppatore conosce la frustrazione: hai una conversazione brillante con Claude, risolvi un problema complesso, e due settimane dopo ti ritrovi a cercare la stessa soluzione. Le chat con gli LLM sono effimere per design, ma la conoscenza che generi dovrebbe essere permanente.

Andrej Karpathy ha proposto un pattern elegante: trasformare l’LLM in un assistente wiki personale che non solo risponde alle tue domande, ma struttura, persiste e arricchisce automaticamente la conoscenza nel tuo vault Obsidian. Il risultato? Un secondo cervello che cresce con ogni interazione.

In questo articolo implementeremo da zero i comandi /wiki, /save e /autoresearch — un sistema che sincronizza bidirezionalmente le tue conversazioni con file markdown, mantiene contesto tra sessioni e arricchisce automaticamente ogni entry con riferimenti correlati.

Prerequisiti

Prima di iniziare, assicurati di avere:

  • Node.js 20+ con supporto ES modules
  • Obsidian con un vault esistente (anche vuoto)
  • Claude API key con accesso al modello claude-sonnet-4-20250514
  • Git per il versioning del vault (opzionale ma raccomandato)
1
2
3
4
5
6
7
8
9
# Verifica le versioni
node --version  # v20.0.0 o superiore
npm --version   # 10.0.0 o superiore

# Crea la struttura del progetto
mkdir claude-obsidian-wiki && cd claude-obsidian-wiki
npm init -y
npm install @anthropic-ai/sdk chokidar gray-matter marked glob dotenv
npm install -D typescript @types/node tsx

💡 Usiamo chokidar per il file watching e gray-matter per parsare il frontmatter YAML di Obsidian — entrambi sono standard de facto nell’ecosistema.

Architettura e Concetti Chiave

Il sistema si basa su tre componenti principali che comunicano attraverso un event bus centrale:

flowchart TD
    subgraph CLI["CLI Interface"]
        WIKI["/wiki comando"]
        SAVE["/save comando"]
        AUTO["/autoresearch comando"]
    end

    subgraph CORE["Core Engine"]
        PARSER["Command Parser"]
        CONTEXT["Context Manager"]
        SYNC["Sync Engine"]
    end

    subgraph STORAGE["Persistence Layer"]
        VAULT["Obsidian Vault"]
        INDEX["Knowledge Index"]
        CACHE["Session Cache"]
    end

    subgraph CLAUDE["Claude API"]
        CONV["Conversation Handler"]
        RESEARCH["Research Pipeline"]
    end

    WIKI --> PARSER
    SAVE --> PARSER
    AUTO --> PARSER
    
    PARSER --> CONTEXT
    CONTEXT --> SYNC
    CONTEXT --> CONV
    
    SYNC -->|"write"| VAULT
    VAULT -->|"watch"| SYNC
    SYNC --> INDEX
    
    CONV --> RESEARCH
    RESEARCH -->|"enrich"| SYNC
    
    INDEX --> CONTEXT
    CACHE --> CONTEXT

Command Parser: Intercetta i comandi /wiki, /save, /autoresearch e li trasforma in azioni strutturate.

Context Manager: Mantiene lo stato della sessione e carica contesto rilevante dal vault esistente. Questo è il cuore della cross-session awareness.

Sync Engine: Gestisce la sincronizzazione bidirezionale — scrive nuove entry nel vault e reagisce alle modifiche manuali in Obsidian.

📝 Il pattern bidirezionale è fondamentale: puoi modificare le note in Obsidian e il sistema le reindicizza automaticamente, mantenendo coerenza tra le due interfacce.

Implementazione Passo-Passo

Configurazione del Progetto e Client Claude

Iniziamo con la struttura base e la configurazione del client Anthropic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/config.ts
import { config } from 'dotenv';
import { join, resolve } from 'path';

config();

// Configurazione centralizzata del sistema
export const CONFIG = {
  // Path al vault Obsidian - usa variabile d'ambiente o default
  vaultPath: resolve(process.env.OBSIDIAN_VAULT_PATH || './vault'),
  
  // Sottocartella per le entry generate da Claude
  wikiFolder: 'claude-wiki',
  
  // Modello Claude da utilizzare
  claudeModel: 'claude-sonnet-4-20250514',
  
  // Numero massimo di entry correlate da includere nel contesto
  maxContextEntries: 10,
  
  // Pattern per identificare file wiki
  wikiPattern: '**/*.md',
  
  // Frontmatter tags per categorizzazione
  defaultTags: ['claude-generated', 'wiki'],
} as const;

// Percorsi derivati
export const PATHS = {
  wiki: join(CONFIG.vaultPath, CONFIG.wikiFolder),
  index: join(CONFIG.vaultPath, '.claude-index.json'),
  sessionCache: join(CONFIG.vaultPath, '.claude-sessions'),
} as const;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// src/claude-client.ts
import Anthropic from '@anthropic-ai/sdk';
import { CONFIG } from './config.js';

// Il client viene inizializzato automaticamente con ANTHROPIC_API_KEY
const client = new Anthropic();

// Interfaccia per messaggi strutturati
interface WikiMessage {
  role: 'user' | 'assistant';
  content: string;
}

// System prompt che definisce il comportamento wiki
const WIKI_SYSTEM_PROMPT = `Sei un assistente wiki personale. Il tuo compito è aiutare l'utente a costruire una knowledge base strutturata.

Quando ricevi comandi speciali, rispondi in formati specifici:

/wiki <topic>: Genera una entry wiki completa in markdown con:
- Frontmatter YAML (title, tags, created, related)
- Definizione concisa
- Esempi pratici con codice quando appropriato
- Sezione "Vedi anche" con link wiki-style [[topic]]

/save: Conferma il salvataggio dell'ultima entry discussa

/autoresearch <topic>: Genera la entry principale PIÙ una lista JSON di 3-5 concetti correlati da esplorare automaticamente

Usa sempre italiano per le spiegazioni, inglese per codice e termini tecnici.`;

export async function queryWiki(
  messages: WikiMessage[],
  systemOverride?: string
): Promise<string> {
  // Chiamata all'API Claude con gestione automatica degli errori
  const response = await client.messages.create({
    model: CONFIG.claudeModel,
    max_tokens: 4096,
    system: systemOverride || WIKI_SYSTEM_PROMPT,
    messages: messages.map(m => ({
      role: m.role,
      content: m.content,
    })),
  });

  // Estrai il testo dalla risposta
  const textBlock = response.content.find(block => block.type === 'text');
  if (!textBlock || textBlock.type !== 'text') {
    throw new Error('Risposta Claude non contiene testo');
  }
  
  return textBlock.text;
}

export { client };

⚠️ Non committare mai la tua API key. Usa sempre variabili d’ambiente e aggiungi .env al .gitignore.

Implementazione del Command Parser e Context Manager

Il parser riconosce i comandi e il context manager carica le entry rilevanti dal vault:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// src/command-parser.ts
import { readFileSync, existsSync } from 'fs';
import { glob } from 'glob';
import matter from 'gray-matter';
import { PATHS, CONFIG } from './config.js';

// Tipi per i comandi supportati
type CommandType = 'wiki' | 'save' | 'autoresearch' | 'chat';

interface ParsedCommand {
  type: CommandType;
  topic: string | null;
  rawInput: string;
}

interface WikiEntry {
  path: string;
  title: string;
  content: string;
  tags: string[];
  related: string[];
  created: string;
}

// Parser per riconoscere i comandi nell'input utente
export function parseCommand(input: string): ParsedCommand {
  const trimmed = input.trim();
  
  // Pattern matching per i comandi
  const wikiMatch = trimmed.match(/^\/wiki\s+(.+)$/i);
  if (wikiMatch) {
    return { type: 'wiki', topic: wikiMatch[1].trim(), rawInput: input };
  }
  
  const saveMatch = trimmed.match(/^\/save$/i);
  if (saveMatch) {
    return { type: 'save', topic: null, rawInput: input };
  }
  
  const researchMatch = trimmed.match(/^\/autoresearch\s+(.+)$/i);
  if (researchMatch) {
    return { type: 'autoresearch', topic: researchMatch[1].trim(), rawInput: input };
  }
  
  // Default: conversazione normale
  return { type: 'chat', topic: null, rawInput: input };
}

// Carica e indicizza tutte le entry wiki esistenti
export async function loadWikiIndex(): Promise<Map<string, WikiEntry>> {
  const index = new Map<string, WikiEntry>();
  
  // Trova tutti i file markdown nel vault wiki
  const files = await glob(`${PATHS.wiki}/**/*.md`);
  
  for (const filePath of files) {
    try {
      const content = readFileSync(filePath, 'utf-8');
      const { data, content: body } = matter(content);
      
      // Estrai il titolo normalizzato per lookup veloce
      const title = (data.title || filePath.split('/').pop()?.replace('.md', '') || '')
        .toLowerCase();
      
      index.set(title, {
        path: filePath,
        title: data.title || title,
        content: body,
        tags: data.tags || [],
        related: data.related || [],
        created: data.created || new Date().toISOString(),
      });
    } catch (err) {
      // File corrotto o non leggibile, skip silenzioso
      console.warn(`Skipping malformed file: ${filePath}`);
    }
  }
  
  return index;
}

// Trova entry correlate basandosi su tags e link
export function findRelatedEntries(
  topic: string,
  index: Map<string, WikiEntry>,
  limit: number = CONFIG.maxContextEntries
): WikiEntry[] {
  const normalizedTopic = topic.toLowerCase();
  const results: Array<{ entry: WikiEntry; score: number }> = [];
  
  for (const [key, entry] of index) {
    let score = 0;
    
    // Match diretto nel titolo
    if (key.includes(normalizedTopic) || normalizedTopic.includes(key)) {
      score += 10;
    }
    
    // Match nei tags
    for (const tag of entry.tags) {
      if (tag.toLowerCase().includes(normalizedTopic)) {
        score += 5;
      }
    }
    
    // Match nei related
    for (const rel of entry.related) {
      if (rel.toLowerCase().includes(normalizedTopic)) {
        score += 3;
      }
    }
    
    // Match nel contenuto (peso minore)
    if (entry.content.toLowerCase().includes(normalizedTopic)) {
      score += 1;
    }
    
    if (score > 0) {
      results.push({ entry, score });
    }
  }
  
  // Ordina per score e limita
  return results
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map(r => r.entry);
}

💡 Il sistema di scoring è volutamente semplice. In produzione potresti integrare embeddings vettoriali per semantic search, ma per vault sotto le 1000 note questo approccio è più che sufficiente.

Implementazione del Sync Engine Bidirezionale

Il sync engine è il componente più critico — gestisce la persistenza delle entry e reagisce alle modifiche esterne:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// src/sync-engine.ts
import { watch, FSWatcher } from 'chokidar';
import { 
  writeFileSync, 
  readFileSync, 
  mkdirSync, 
  existsSync 
} from 'fs';
import { join, dirname } from 'path';
import matter from 'gray-matter';
import { PATHS, CONFIG } from './config.js';
import { loadWikiIndex, WikiEntry } from './command-parser.js';

// Event emitter semplice per notifiche
type SyncEventHandler = (entry: WikiEntry) => void;

class SyncEngine {
  private watcher: FSWatcher | null = null;
  private index: Map<string, WikiEntry> = new Map();
  private handlers: Map<string, SyncEventHandler[]> = new Map();
  private debounceTimers: Map<string, NodeJS.Timeout> = new Map();

  // Inizializza il sync engine e carica l'indice
  async initialize(): Promise<void> {
    // Crea la cartella wiki se non esiste
    if (!existsSync(PATHS.wiki)) {
      mkdirSync(PATHS.wiki, { recursive: true });
    }
    
    // Carica l'indice esistente
    this.index = await loadWikiIndex();
    console.log(`📚 Indicizzate ${this.index.size} entry wiki esistenti`);
    
    // Avvia il file watcher per sync bidirezionale
    this.startWatcher();
  }

  // Avvia il watcher per modifiche esterne (da Obsidian)
  private startWatcher(): void {
    this.watcher = watch(PATHS.wiki, {
      persistent: true,
      ignoreInitial: true,
      awaitWriteFinish: {
        stabilityThreshold: 500,
        pollInterval: 100,
      },
    });

    // Gestisci modifiche ai file
    this.watcher.on('change', (filePath) => {
      this.handleFileChange(filePath);
    });

    // Gestisci nuovi file creati in Obsidian
    this.watcher.on('add', (filePath) => {
      this.handleFileChange(filePath);
    });

    // Gestisci eliminazioni
    this.watcher.on('unlink', (filePath) => {
      this.handleFileDelete(filePath);
    });

    console.log('👁️ File watcher attivo per sync bidirezionale');
  }

  // Gestisci modifiche con debounce per evitare eventi multipli
  private handleFileChange(filePath: string): void {
    // Cancella timer esistente per questo file
    const existingTimer = this.debounceTimers.get(filePath);
    if (existingTimer) {
      clearTimeout(existingTimer);
    }

    // Debounce di 300ms
    const timer = setTimeout(() => {
      this.reindexFile(filePath);
      this.debounceTimers.delete(filePath);
    }, 300);

    this.debounceTimers.set(filePath, timer);
  }

  // Reindicizza un singolo file modificato
  private reindexFile(filePath: string): void {
    try {
      const content = readFileSync(filePath, 'utf-8');
      const { data, content: body } = matter(content);
      
      const title = (data.title || '').toLowerCase();
      const entry: WikiEntry = {
        path: filePath,
        title: data.title || title,
        content: body,
        tags: data.tags || [],
        related: data.related || [],
        created: data.created || new Date().toISOString(),
      };

      this.index.set(title, entry);
      this.emit('update', entry);
      console.log(`🔄 Reindicizzato: ${entry.title}`);
    } catch (err) {
      console.warn(`Errore reindicizzazione ${filePath}:`, err);
    }
  }

  // Gestisci eliminazione file
  private handleFileDelete(filePath: string): void {
    for (const [key, entry] of this.index) {
      if (entry.path === filePath) {
        this.index.delete(key);
        console.log(`🗑️ Rimosso dall'indice: ${key}`);
        break;
      }
    }
  }

  // Salva una nuova entry nel vault
  async saveEntry(
    title: string,
    content: string,
    metadata: Partial<WikiEntry> = {}
  ): Promise<string> {
    // Genera nome file sicuro
    const safeFilename = title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/-+/g, '-')
      .replace(/^-|-$/g, '');
    
    const filePath = join(PATHS.wiki, `${safeFilename}.md`);
    
    // Costruisci il frontmatter
    const frontmatter = {
      title,
      tags: [...CONFIG.defaultTags, ...(metadata.tags || [])],
      created: new Date().toISOString(),
      related: metadata.related || [],
      source: 'claude-wiki',
    };

    // Assembla il documento completo
    const document = matter.stringify(content, frontmatter);
    
    // Assicurati che la directory esista
    mkdirSync(dirname(filePath), { recursive: true });
    
    // Scrivi il file
    writeFileSync(filePath, document, 'utf-8');
    
    // Aggiorna l'indice locale
    const entry: WikiEntry = {
      path: filePath,
      title,
      content,
      tags: frontmatter.tags,
      related: frontmatter.related,
      created: frontmatter.created,
    };
    this.index.set(title.toLowerCase(), entry);
    
    console.log(`💾 Salvato: ${filePath}`);
    return filePath;
  }

  // Ottieni entry correlate per contesto
  getRelatedContext(topic: string): string {
    const related = [];
    const normalizedTopic = topic.toLowerCase();
    
    for (const [key, entry] of this.index) {
      if (key.includes(normalizedTopic) || 
          entry.tags.some(t => t.toLowerCase().includes(normalizedTopic))) {
        related.push(`## ${entry.title}\n${entry.content.slice(0, 500)}...`);
      }
      
      if (related.length >= CONFIG.maxContextEntries) break;
    }
    
    return related.length > 0 
      ? `\n---\nCONTESTO ESISTENTE:\n${related.join('\n\n')}\n---\n`
      : '';
  }

  // Sistema eventi semplice
  on(event: string, handler: SyncEventHandler): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event)!.push(handler);
  }

  private emit(event: string, entry: WikiEntry): void {
    const handlers = this.handlers.get(event) || [];
    handlers.forEach(h => h(entry));
  }

  // Cleanup
  async shutdown(): Promise<void> {
    if (this.watcher) {
      await this.watcher.close();
    }
    this.debounceTimers.forEach(timer => clearTimeout(timer));
  }

  // Getter per l'indice
  getIndex(): Map<string, WikiEntry> {
    return this.index;
  }
}

// Export singleton
export const syncEngine = new SyncEngine();

⚠️ Il file watcher consuma risorse. Se hai un vault con migliaia di file, considera di limitare il watching alla sola cartella claude-wiki come facciamo qui, anziché monitorare l’intero vault.

Configurazione per Produzione

Per un deployment robusto, serve una configurazione centralizzata che gestisca tutti gli aspetti del sistema.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# config/production.yaml
sync:
  vault_path: /Users/nome/Documents/Obsidian/MainVault
  wiki_folder: claude-wiki
  debounce_ms: 500
  max_file_size_kb: 500
  
claude:
  api_key: ${ANTHROPIC_API_KEY}
  model: claude-sonnet-4-20250514
  max_tokens: 4096
  temperature: 0.3
  
embedding:
  enabled: true
  model: text-embedding-3-small
  batch_size: 100
  
vector_store:
  type: qdrant
  url: http://localhost:6333
  collection: knowledge_graph
  
logging:
  level: info
  file: /var/log/wiki-sync/app.log
  max_size_mb: 100
  retention_days: 30

Il loader della configurazione con validazione:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// config/loader.ts
import { z } from 'zod';
import yaml from 'js-yaml';
import { readFileSync } from 'fs';

// Schema di validazione rigoroso
const ConfigSchema = z.object({
  sync: z.object({
    vault_path: z.string().min(1),
    wiki_folder: z.string().default('claude-wiki'),
    debounce_ms: z.number().min(100).max(5000).default(500),
    max_file_size_kb: z.number().min(10).max(10000).default(500),
  }),
  claude: z.object({
    api_key: z.string().min(1),
    model: z.string().default('claude-sonnet-4-20250514'),
    max_tokens: z.number().default(4096),
    temperature: z.number().min(0).max(1).default(0.3),
  }),
  embedding: z.object({
    enabled: z.boolean().default(true),
    model: z.string().default('text-embedding-3-small'),
    batch_size: z.number().min(1).max(1000).default(100),
  }),
  vector_store: z.object({
    type: z.enum(['qdrant', 'pinecone', 'local']),
    url: z.string().url().optional(),
    collection: z.string().default('knowledge_graph'),
  }),
  logging: z.object({
    level: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
    file: z.string().optional(),
    max_size_mb: z.number().default(100),
    retention_days: z.number().default(30),
  }),
});

type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(path: string): Config {
  const raw = readFileSync(path, 'utf-8');
  
  // Espandi variabili d'ambiente
  const expanded = raw.replace(/\$\{(\w+)\}/g, (_, key) => {
    const value = process.env[key];
    if (!value) {
      throw new Error(`Variabile d'ambiente mancante: ${key}`);
    }
    return value;
  });
  
  const parsed = yaml.load(expanded);
  return ConfigSchema.parse(parsed);
}

💡 Usa sempre variabili d’ambiente per le API key. Mai committarle nel repository, nemmeno in file di configurazione.

Il flusso completo di inizializzazione in produzione:

flowchart TD
    A[Avvio Applicazione] --> B[Carica Config YAML]
    B --> C{Validazione Schema}
    C -->|Fallita| D[Exit con Errore]
    C -->|Successo| E[Inizializza Logger]
    E --> F[Connetti Vector Store]
    F --> G{Connessione OK?}
    G -->|No| H[Retry con Backoff]
    H --> G
    G -->|Sì| I[Avvia File Watcher]
    I --> J[Carica Indice Esistente]
    J --> K[Sistema Pronto]
    K --> L[Health Check Periodico]
    L --> M{Tutti i Servizi OK?}
    M -->|Sì| L
    M -->|No| N[Alert + Recovery]
    N --> L

Errori Comuni e Troubleshooting

Quando Claude genera link a note che non esistono ancora:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// utils/link-validator.ts
interface LinkValidation {
  valid: string[];
  broken: string[];
  suggestions: Map<string, string[]>;
}

export function validateWikiLinks(
  content: string, 
  index: Map<string, WikiEntry>
): LinkValidation {
  // Estrai tutti i [[wiki links]]
  const linkPattern = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
  const links = [...content.matchAll(linkPattern)].map(m => m[1]);
  
  const result: LinkValidation = {
    valid: [],
    broken: [],
    suggestions: new Map(),
  };
  
  for (const link of links) {
    const normalized = link.toLowerCase().trim();
    
    // Verifica esistenza diretta
    if (index.has(normalized)) {
      result.valid.push(link);
      continue;
    }
    
    // Cerca suggerimenti con fuzzy matching
    const candidates = [...index.keys()]
      .filter(key => {
        // Levenshtein distance < 3 o substring match
        return key.includes(normalized) || 
               normalized.includes(key) ||
               levenshtein(key, normalized) < 3;
      })
      .slice(0, 3);
    
    result.broken.push(link);
    if (candidates.length > 0) {
      result.suggestions.set(link, candidates);
    }
  }
  
  return result;
}

// Levenshtein semplificato
function levenshtein(a: string, b: string): number {
  const matrix: number[][] = [];
  
  for (let i = 0; i <= b.length; i++) {
    matrix[i] = [i];
  }
  for (let j = 0; j <= a.length; j++) {
    matrix[0][j] = j;
  }
  
  for (let i = 1; i <= b.length; i++) {
    for (let j = 1; j <= a.length; j++) {
      const cost = a[j - 1] === b[i - 1] ? 0 : 1;
      matrix[i][j] = Math.min(
        matrix[i - 1][j] + 1,
        matrix[i][j - 1] + 1,
        matrix[i - 1][j - 1] + cost
      );
    }
  }
  
  return matrix[b.length][a.length];
}

Problema: Rate Limiting Claude API

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// utils/rate-limiter.ts
class TokenBucketLimiter {
  private tokens: number;
  private lastRefill: number;
  
  constructor(
    private maxTokens: number = 10,
    private refillRate: number = 1, // token per secondo
  ) {
    this.tokens = maxTokens;
    this.lastRefill = Date.now();
  }
  
  async acquire(): Promise<void> {
    this.refill();
    
    if (this.tokens < 1) {
      // Calcola tempo di attesa
      const waitTime = (1 - this.tokens) / this.refillRate * 1000;
      console.log(`Rate limit: attendo ${waitTime}ms`);
      await this.sleep(waitTime);
      this.refill();
    }
    
    this.tokens -= 1;
  }
  
  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;
  }
  
  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

export const claudeRateLimiter = new TokenBucketLimiter(5, 0.5);

⚠️ Il rate limiting di Claude varia per tier. Con il tier gratuito, mantieni massimo 1 richiesta ogni 2 secondi per evitare errori 429.

Problema: Conflitti di Sincronizzazione

Quando modifichi un file mentre Claude sta ancora processando:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// utils/conflict-resolver.ts
interface ConflictResolution {
  strategy: 'keep-local' | 'keep-remote' | 'merge';
  result: string;
}

export async function resolveConflict(
  localContent: string,
  remoteContent: string,
  baseContent: string | null
): Promise<ConflictResolution> {
  // Se abbiamo la versione base, prova three-way merge
  if (baseContent) {
    const merged = threeWayMerge(baseContent, localContent, remoteContent);
    if (!merged.hasConflicts) {
      return { strategy: 'merge', result: merged.content };
    }
  }
  
  // Fallback: mantieni la versione più recente (locale)
  // ma salva il contenuto remoto come backup
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const backupPath = `conflicts/${timestamp}-remote.md`;
  
  console.warn(`Conflitto rilevato. Backup salvato in: ${backupPath}`);
  
  return { strategy: 'keep-local', result: localContent };
}

Performance e Scalabilità

Per vault con centinaia di entry, l’indicizzazione incrementale è fondamentale:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// performance/incremental-indexer.ts
import { createHash } from 'crypto';
import { LRUCache } from 'lru-cache';

class IncrementalIndexer {
  // Cache degli hash per rilevare cambiamenti
  private hashCache: Map<string, string> = new Map();
  
  // LRU cache per embedding già calcolati
  private embeddingCache: LRUCache<string, number[]>;
  
  constructor() {
    this.embeddingCache = new LRUCache({
      max: 1000, // massimo 1000 embedding in cache
      ttl: 1000 * 60 * 60 * 24, // 24 ore
    });
  }
  
  async indexIfChanged(entry: WikiEntry): Promise<boolean> {
    const contentHash = this.computeHash(entry.content);
    const cachedHash = this.hashCache.get(entry.title);
    
    // Skip se non è cambiato nulla
    if (cachedHash === contentHash) {
      return false;
    }
    
    // Calcola embedding solo per contenuto nuovo
    const embedding = this.embeddingCache.get(contentHash) 
      || await this.computeEmbedding(entry.content);
    
    // Aggiorna caches
    this.hashCache.set(entry.title, contentHash);
    this.embeddingCache.set(contentHash, embedding);
    
    // Upsert nel vector store
    await this.vectorStore.upsert({
      id: entry.title,
      vector: embedding,
      metadata: {
        title: entry.title,
        tags: entry.tags,
        updated: entry.updated.toISOString(),
      },
    });
    
    return true;
  }
  
  private computeHash(content: string): string {
    return createHash('sha256').update(content).digest('hex').slice(0, 16);
  }
  
  private async computeEmbedding(content: string): Promise<number[]> {
    // Chunking intelligente per documenti lunghi
    const chunks = this.splitIntoChunks(content, 512);
    const embeddings = await Promise.all(
      chunks.map(chunk => this.embeddingModel.embed(chunk))
    );
    
    // Media dei vettori per documento
    return this.averageVectors(embeddings);
  }
  
  private splitIntoChunks(text: string, maxTokens: number): string[] {
    // Splitta su paragrafi, poi raggruppa fino a maxTokens
    const paragraphs = text.split(/\n\n+/);
    const chunks: string[] = [];
    let current = '';
    
    for (const para of paragraphs) {
      if ((current + para).length / 4 > maxTokens) {
        if (current) chunks.push(current);
        current = para;
      } else {
        current += (current ? '\n\n' : '') + para;
      }
    }
    
    if (current) chunks.push(current);
    return chunks;
  }
  
  private averageVectors(vectors: number[][]): number[] {
    const dim = vectors[0].length;
    const result = new Array(dim).fill(0);
    
    for (const vec of vectors) {
      for (let i = 0; i < dim; i++) {
        result[i] += vec[i] / vectors.length;
      }
    }
    
    return result;
  }
}

📝 Con 500 note e embedding da 1536 dimensioni, l’indice occupa circa 3MB in memoria. Scala linearmente, quindi 5000 note richiedono ~30MB.

Conclusioni e Next Steps

Il pattern LLM Wiki di Karpathy diventa potente quando integrato con strumenti esistenti. Ricapitolando:

  1. Il formato è semplice: Markdown con frontmatter YAML basta
  2. La sincronizzazione bidirezionale richiede attenzione ai conflitti
  3. Il vector store abilita ricerca semantica che il grep non può fare
  4. Il debouncing è essenziale per non saturare le API

Prossimi step per evolvere il sistema:

  • Multi-vault: sincronizza più vault Obsidian verso un singolo knowledge graph
  • Versioning semantico: traccia come evolve la tua comprensione di un concetto
  • Graph visualization: usa D3.js o Obsidian Canvas per visualizzare le connessioni
  • Query naturali: “Cosa so sui transformer che si collega a Python?” tradotto in query vettoriale + graph traversal

Risorse Aggiuntive

Errori Comuni e Troubleshooting

Problema 1: Rate Limiting di Claude API

L’errore più frequente quando processi vault di grandi dimensioni:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ❌ Codice problematico - troppe chiamate parallele
const results = await Promise.all(
  notes.map(note => processWithClaude(note))
);

// ✅ Soluzione con rate limiting e retry
import pLimit from 'p-limit';
import { setTimeout } from 'timers/promises';

const limit = pLimit(3); // massimo 3 chiamate concorrenti

async function processWithRetry(
  note: ObsidianNote,
  maxRetries = 3
): Promise<ProcessedNote> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await claudeClient.process(note);
    } catch (error) {
      if (error.status === 429) {
        // Rate limit: attendi con backoff esponenziale
        const delay = Math.pow(2, attempt) * 1000;
        console.log(`Rate limit hit, retry in ${delay}ms...`);
        await setTimeout(delay);
        continue;
      }
      throw error;
    }
  }
  throw new Error(`Fallito dopo ${maxRetries} tentativi`);
}

// Processa con limite di concorrenza
const results = await Promise.all(
  notes.map(note => limit(() => processWithRetry(note)))
);

⚠️ Attenzione ai costi: Con vault di 1000+ note, una singola sincronizzazione può costare $5-15. Implementa sempre un sistema di cache per le note già processate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ❌ Regex naive che fallisce con alias e heading links
const linkRegex = /\[\[(.*?)\]\]/g;

// ✅ Parser completo per tutti i formati Obsidian
interface ObsidianLink {
  raw: string;           // [[nota#heading|alias]]
  target: string;        // nota
  heading?: string;      // heading
  alias?: string;        // alias
  isEmbed: boolean;      // true se inizia con !
}

function parseObsidianLinks(content: string): ObsidianLink[] {
  // Cattura: embed opzionale, target, heading opzionale, alias opzionale
  const fullRegex = /(!?)\[\[([^\]#|]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g;
  const links: ObsidianLink[] = [];
  
  let match;
  while ((match = fullRegex.exec(content)) !== null) {
    links.push({
      raw: match[0],
      isEmbed: match[1] === '!',
      target: match[2].trim(),
      heading: match[3]?.trim(),
      alias: match[4]?.trim()
    });
  }
  
  return links;
}

// Test con casi edge
const testContent = `
Vedi [[Nota Semplice]] per dettagli.
Approfondisci in [[Altra Nota#Sezione Specifica]].
Come dice [[Autore Famoso|il professore]].
![[immagine.png]]
`;

console.log(parseObsidianLinks(testContent));
// Output corretto con tutti i casi gestiti

Problema 3: Grafo con Nodi Orfani

flowchart TD
    subgraph Problema["❌ Grafo Disconnesso"]
        A1[Nota A] --> B1[Nota B]
        C1[Nota Orfana 1]
        D1[Nota Orfana 2]
        E1[Nota Orfana 3]
    end
    
    subgraph Soluzione["✅ Grafo Connesso"]
        A2[Nota A] --> B2[Nota B]
        A2 --> HUB[Hub Tematico]
        HUB --> C2[Ex Orfana 1]
        HUB --> D2[Ex Orfana 2]
        HUB --> E2[Ex Orfana 3]
    end
    
    Problema -.->|"Claude suggerisce connessioni"| Soluzione
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Script per identificare e risolvere nodi orfani
interface OrphanAnalysis {
  orphans: string[];
  suggestedConnections: Map<string, string[]>;
}

async function analyzeAndFixOrphans(
  graph: KnowledgeGraph
): Promise<OrphanAnalysis> {
  // Trova nodi senza connessioni in entrata o uscita
  const orphans = graph.nodes.filter(node => {
    const hasIncoming = graph.edges.some(e => e.target === node.id);
    const hasOutgoing = graph.edges.some(e => e.source === node.id);
    return !hasIncoming && !hasOutgoing;
  });

  if (orphans.length === 0) {
    return { orphans: [], suggestedConnections: new Map() };
  }

  // Chiedi a Claude di suggerire connessioni semantiche
  const prompt = `Analizza questi contenuti orfani e suggerisci connessioni:

NODI ORFANI:
${orphans.map(o => `- ${o.title}: ${o.summary}`).join('\n')}

NODI ESISTENTI NEL GRAFO:
${graph.nodes.filter(n => !orphans.includes(n))
  .map(n => `- ${n.title}: ${n.summary}`)
  .join('\n')}

Per ogni orfano, suggerisci 1-3 nodi esistenti a cui collegarlo.
Rispondi in JSON: { "orfano": ["collegamento1", "collegamento2"] }`;

  const response = await claude.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 2000,
    messages: [{ role: 'user', content: prompt }]
  });

  const suggestions = JSON.parse(
    response.content[0].type === 'text' 
      ? response.content[0].text 
      : '{}'
  );

  return {
    orphans: orphans.map(o => o.id),
    suggestedConnections: new Map(Object.entries(suggestions))
  };
}

💡 Tip: Esegui l’analisi degli orfani settimanalmente. Un grafo sano dovrebbe avere meno del 5% di nodi orfani.

Problema 4: Conflitti di Sincronizzazione

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# config/sync-rules.yaml
# Regole per gestire conflitti tra vault e knowledge graph

conflict_resolution:
  # Strategia quando il contenuto locale e remoto divergono
  strategy: "local_wins"  # oppure: "remote_wins", "manual", "merge"
  
  # Backup automatico prima di ogni sync
  backup:
    enabled: true
    retention_days: 30
    path: ".kg-backups/"
  
  # File da ignorare sempre
  ignore_patterns:
    - "**/.obsidian/**"
    - "**/node_modules/**"
    - "**/*.excalidraw.md"
    - "**/Daily Notes/**"
  
  # Lock file per evitare sync concorrenti
  lock:
    enabled: true
    timeout_seconds: 300
    stale_threshold_minutes: 10

# Notifiche in caso di conflitti irrisolti
notifications:
  on_conflict:
    type: "obsidian_notice"  # oppure: "system", "webhook"
    webhook_url: "${DISCORD_WEBHOOK_URL}"

📝 Nota: Se usi Obsidian Sync insieme al knowledge graph, disabilita la sincronizzazione automatica durante le operazioni di bulk update per evitare race condition.


Conclusioni e Next Steps

Hai costruito un sistema che trasforma un vault Obsidian statico in un knowledge graph dinamico e interrogabile. Ricapitoliamo l’architettura finale:

sequenceDiagram
    participant U as Utente
    participant O as Obsidian
    participant W as Watcher
    participant C as Claude API
    participant G as Graph DB
    participant Q as Query Interface

    U->>O: Crea/modifica nota
    O->>W: File change event
    W->>W: Debounce + validate
    W->>C: Estrai entità e relazioni
    C-->>W: Structured JSON
    W->>G: Upsert nodi e edges
    
    U->>Q: "Cosa so su X?"
    Q->>G: Graph traversal
    G-->>Q: Subgraph rilevante
    Q->>C: Sintetizza con contesto
    C-->>Q: Risposta contestuale
    Q-->>U: Insight + link alle note

Metriche di Successo

Dopo 3 mesi di utilizzo, dovresti osservare:

MetricaPrimaDopo
Tempo per trovare informazioni5-15 min30 sec
Note orfane40%+< 5%
Connessioni per nota1-25-8
Riutilizzo della conoscenzaOccasionaleSistematico

Roadmap di Evoluzione

Fase 2 — Multi-modal (mese 2-3):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Aggiungi supporto per immagini e PDF
interface MultiModalNote extends ObsidianNote {
  attachments: {
    type: 'image' | 'pdf' | 'audio';
    path: string;
    extractedText?: string;
    embedding?: number[];
  }[];
}

// Claude può analizzare immagini direttamente
const imageAnalysis = await claude.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1000,
  messages: [{
    role: 'user',
    content: [
      { type: 'image', source: { type: 'base64', data: imageBase64 } },
      { type: 'text', text: 'Estrai concetti chiave da questo diagramma' }
    ]
  }]
});

Fase 3 — Collaborativo (mese 4+):

  • Sincronizzazione tra vault di team
  • Merge di knowledge graph multipli
  • Permessi granulari su sezioni del grafo

Fase 4 — Agenti Autonomi:

  • Claude che suggerisce proattivamente connessioni
  • Identificazione automatica di gap nella conoscenza
  • Generazione di “note ponte” tra cluster isolati

💡 Consiglio finale: Non cercare di automatizzare tutto subito. Il valore del pattern Karpathy sta nell’interazione tra pensiero umano e assistenza AI. Mantieni sempre il controllo editoriale sulle connessioni più importanti.


Risorse Aggiuntive