Skip to content
Open
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
34 changes: 34 additions & 0 deletions docs/llm-enrichment.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ what Pass A produced.
turns it on; it has no effect until AI-based semantic analysis is also
enabled and has produced semantic edges to label.

## Cost and data handling

Pass B sends text to whatever AI provider you have set up in Joplin's own AI
configuration, so the cost and privacy behavior follow that configuration,
not this plugin. Note Graph never stores or forwards note content anywhere
of its own accord; its only outbound traffic is the `joplin.ai.chat()` call
described below. Joplin routes that call to the provider you chose in
Joplin's Configuration screen (**AI** page). The plugin has no separate
server, no separate terms and no third-party destination of its own.

What actually leaves your machine and what it costs therefore depends
entirely on that provider:

- **Which provider.** `joplin.ai.chat()` uses whichever chat model Joplin is
configured to talk to. If you point Joplin at a local or self-hosted
model, note content stays on your machine; if you use a cloud provider,
the excerpts go to that provider's servers and are handled under its
terms. The plugin does not select or influence the provider.

- **How much is sent.** Only notes and edges that already carry a semantic
edge are ever sent, in batches of 4, with each note body truncated to 300
characters (`MAX_BODY_EXCERPT_LENGTH`). Unchanged notes and edges are
served from the in-memory cache and never re-sent, so re-running
enrichment on a mostly unchanged graph sends very little new text.

- **Credentials and billing.** Any API key, account, rate limit, or billing
relationship belongs to Joplin's AI setup, not to Note Graph. The plugin
neither reads nor manages credentials and has no usage meter or cost
estimate of its own.

In short, treat Pass B as an extension of Joplin's AI chat. Its cost and
privacy posture are whatever you already accepted when you enabled AI in
Joplin. The plugin adds nothing on top of that.

## Where it runs: `LLMEnricher`

`LLMEnricher` (`src/services/llm/LLMEnricher.ts`) is called from
Expand Down
133 changes: 128 additions & 5 deletions src/data/Database/GraphCacheRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@ import { Note } from '../Types';
class FakeConnection implements IVectorDatabase {
public opened = false;
private graphRow: { notes_json: string; graph_json: string } | null = null;
private syncStateRow: { events_cursor: string | null; embeddings_cursor: string | null } | null =
null;
private syncStateRow: {
events_cursor: string | null;
embeddings_cursor: string | null;
} | null = null;
private scopeStateRow: { scope_key: string | null } | null = null;
private enrichmentRows: {
kind: string;
id: string;
updated_time: number;
enrichment_json: string;
}[] = [];

public async open(): Promise<void> {
this.opened = true;
}

public async run(sql: string, params: unknown[]): Promise<void> {
if (sql.includes('INTO graph_cache')) {
if (sql.includes('DELETE FROM graph_cache')) {
this.graphRow = null;
} else if (sql.includes('INTO graph_cache')) {
const [notesJson, graphJson] = params as [string, string, number];
this.graphRow = { notes_json: notesJson, graph_json: graphJson };
} else if (sql.includes('embeddings_cursor')) {
Expand All @@ -29,6 +40,28 @@ class FakeConnection implements IVectorDatabase {
events_cursor: cursor,
embeddings_cursor: this.syncStateRow?.embeddings_cursor ?? null,
};
} else if (sql.includes('INTO scope_state')) {
const [scopeKey] = params as [string];
this.scopeStateRow = { scope_key: scopeKey };
} else if (sql.includes('INTO enrichment_cache')) {
const [kind, id, updatedTime, enrichmentJson] = params as [
string,
string,
number,
string
];
const existing = this.enrichmentRows.find((row) => row.kind === kind && row.id === id);
if (existing) {
existing.updated_time = updatedTime;
existing.enrichment_json = enrichmentJson;
} else {
this.enrichmentRows.push({
kind,
id,
updated_time: updatedTime,
enrichment_json: enrichmentJson,
});
}
}
}

Expand All @@ -39,6 +72,12 @@ class FakeConnection implements IVectorDatabase {
if (sql.includes('FROM sync_state')) {
return (this.syncStateRow ? [this.syncStateRow] : []) as unknown as T[];
}
if (sql.includes('FROM scope_state')) {
return (this.scopeStateRow ? [this.scopeStateRow] : []) as unknown as T[];
}
if (sql.includes('FROM enrichment_cache')) {
return this.enrichmentRows as unknown as T[];
}
return [];
}
}
Expand All @@ -53,7 +92,9 @@ const note: Note = {
};

const graphData: GraphData = {
nodes: [{ data: { id: 'n1', label: 'Note 1', noteId: 'n1', degree: 0, community: 0, size: 1 } }],
nodes: [
{ data: { id: 'n1', label: 'Note 1', noteId: 'n1', degree: 0, community: 0, size: 1 } },
],
edges: [],
};

Expand Down Expand Up @@ -136,6 +177,85 @@ describe('GraphCacheRepository', () => {
});
});

describe('scope key', () => {
it('returns null when no scope has ever been saved', async () => {
expect(await repo.loadScopeKey()).toBeNull();
});

it('round-trips the scope key through save/load', async () => {
await repo.saveScopeKey('current:folder-1');
expect(await repo.loadScopeKey()).toBe('current:folder-1');
});

it('overwrites the previous scope key on a second save', async () => {
await repo.saveScopeKey('all');
await repo.saveScopeKey('current:folder-2');
expect(await repo.loadScopeKey()).toBe('current:folder-2');
});
});

describe('clearGraph', () => {
it('removes the cached graph so a later load returns null', async () => {
await repo.saveGraph([note], graphData);

await repo.clearGraph();

expect(await repo.loadGraph()).toBeNull();
});

it('leaves the events and embeddings cursors untouched', async () => {
await repo.saveGraph([note], graphData);
await repo.saveEventsCursor('cursor-1');
await repo.saveEmbeddingsCursor('embeddings-cursor-1');

await repo.clearGraph();

expect(await repo.loadEventsCursor()).toBe('cursor-1');
expect(await repo.loadEmbeddingsCursor()).toBe('embeddings-cursor-1');
});
});

describe('enrichment cache', () => {
it('returns an empty list when nothing has been persisted', async () => {
expect(await repo.loadEnrichments()).toEqual([]);
});

it('round-trips node and edge enrichments through save/load', async () => {
await repo.saveEnrichments([
{ kind: 'node', id: 'n1', updatedTime: 1, enrichment: { category: 'Cat' } },
{
kind: 'edge',
id: 'a::b::semantic',
updatedTime: 2,
enrichment: { relationshipLabel: 'links' },
},
]);

expect(await repo.loadEnrichments()).toEqual([
{ kind: 'node', id: 'n1', updatedTime: 1, enrichment: { category: 'Cat' } },
{
kind: 'edge',
id: 'a::b::semantic',
updatedTime: 2,
enrichment: { relationshipLabel: 'links' },
},
]);
});

it('upserts on a repeated save for the same kind and id', async () => {
await repo.saveEnrichments([
{ kind: 'node', id: 'n1', updatedTime: 1, enrichment: { category: 'Old' } },
]);
await repo.saveEnrichments([
{ kind: 'node', id: 'n1', updatedTime: 3, enrichment: { category: 'New' } },
]);

expect(await repo.loadEnrichments()).toEqual([
{ kind: 'node', id: 'n1', updatedTime: 3, enrichment: { category: 'New' } },
]);
});
});

describe('write serialization', () => {
it('serializes interleaved graph and cursor writes instead of racing them', async () => {
const order: string[] = [];
Expand All @@ -145,7 +265,10 @@ describe('GraphCacheRepository', () => {
await originalRun(sql, params);
};

await Promise.all([repo.saveGraph([note], graphData), repo.saveEventsCursor('cursor-1')]);
await Promise.all([
repo.saveGraph([note], graphData),
repo.saveEventsCursor('cursor-1'),
]);

expect(order).toHaveLength(2);
expect(await repo.loadGraph()).not.toBeNull();
Expand Down
110 changes: 110 additions & 0 deletions src/data/Database/GraphCacheRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ const SYNC_STATE_SCHEMA = `
)
`;

const SCOPE_STATE_SCHEMA = `
CREATE TABLE IF NOT EXISTS scope_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
scope_key TEXT
)
`;

const ENRICHMENT_CACHE_SCHEMA = `
CREATE TABLE IF NOT EXISTS enrichment_cache (
kind TEXT NOT NULL,
id TEXT NOT NULL,
updated_time INTEGER NOT NULL,
enrichment_json TEXT NOT NULL,
PRIMARY KEY (kind, id)
)
`;

interface GraphCacheRow {
notes_json: string;
graph_json: string;
Expand All @@ -31,13 +48,33 @@ interface SyncStateRow {
embeddings_cursor: string | null;
}

interface ScopeStateRow {
scope_key: string | null;
}

interface EnrichmentCacheRow {
kind: string;
id: string;
updated_time: number;
enrichment_json: string;
}

export interface PersistedEnrichment {
kind: 'node' | 'edge';
id: string;
updatedTime: number;
enrichment: Record<string, unknown>;
}

export class GraphCacheRepository {
private writeLock: Promise<void> = Promise.resolve();

public constructor(
private readonly db: IVectorDatabase = new VectorDatabase(DB_FILE_NAME, [
GRAPH_CACHE_SCHEMA,
SYNC_STATE_SCHEMA,
SCOPE_STATE_SCHEMA,
ENRICHMENT_CACHE_SCHEMA,
])
) {}

Expand Down Expand Up @@ -113,6 +150,79 @@ export class GraphCacheRepository {
});
}

public async loadScopeKey(): Promise<string | null> {
await this.db.open();
const rows = await this.db.all<ScopeStateRow>(
'SELECT scope_key FROM scope_state WHERE id = 1',
[]
);
return rows[0]?.scope_key ?? null;
}

public saveScopeKey(scopeKey: string): Promise<void> {
return this.enqueueWrite(async () => {
await this.db.open();
await this.db.run(
`INSERT INTO scope_state (id, scope_key)
VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET scope_key = excluded.scope_key`,
[scopeKey]
);
});
}

public clearGraph(): Promise<void> {
return this.enqueueWrite(async () => {
await this.db.open();
await this.db.run('DELETE FROM graph_cache WHERE id = 1', []);
});
}

public async loadEnrichments(): Promise<PersistedEnrichment[]> {
await this.db.open();
const rows = await this.db.all<EnrichmentCacheRow>(
'SELECT kind, id, updated_time, enrichment_json FROM enrichment_cache',
[]
);
return rows.map((row) => ({
kind: row.kind === 'node' ? 'node' : 'edge',
id: row.id,
updatedTime: row.updated_time,
enrichment: JSON.parse(row.enrichment_json) as Record<string, unknown>,
}));
}

public saveEnrichments(records: PersistedEnrichment[]): Promise<void> {
if (records.length === 0) return Promise.resolve();
return this.enqueueWrite(async () => {
await this.db.open();
await this.db.run('BEGIN TRANSACTION', []);
try {
for (const record of records) {
await this.db.run(
`INSERT INTO enrichment_cache (kind, id, updated_time, enrichment_json)
VALUES (?, ?, ?, ?)
ON CONFLICT(kind, id) DO UPDATE SET
updated_time = excluded.updated_time,
enrichment_json = excluded.enrichment_json`,
[record.kind, record.id, record.updatedTime, JSON.stringify(record.enrichment)]
);
}
await this.db.run('COMMIT', []);
} catch (e) {
try {
await this.db.run('ROLLBACK', []);
} catch (rollbackError) {
console.error(
'Enrichment cache rollback failed after a write error:',
rollbackError
);
}
throw e;
}
});
}

private enqueueWrite(write: () => Promise<void>): Promise<void> {
const task = this.writeLock.then(write);
this.writeLock = task.then(
Expand Down
Loading