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
Problema: Link Obsidian non risolti
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 };
}
|
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:
- Il formato è semplice: Markdown con frontmatter YAML basta
- La sincronizzazione bidirezionale richiede attenzione ai conflitti
- Il vector store abilita ricerca semantica che il grep non può fare
- 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.
Problema 2: Parsing Errato dei Link Obsidian
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:
| Metrica | Prima | Dopo |
|---|
| Tempo per trovare informazioni | 5-15 min | 30 sec |
| Note orfane | 40%+ | < 5% |
| Connessioni per nota | 1-2 | 5-8 |
| Riutilizzo della conoscenza | Occasionale | Sistematico |
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