Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Once Lore is active, you should notice several changes:
All data lives locally in `~/.local/share/opencode-lore/lore.db`:

- **Session observations** — timestamped event log of each conversation: what was asked, what was done, decisions made, errors found
- **Long-term knowledge** — patterns, gotchas, and architectural decisions curated across sessions and projects
- **Long-term knowledge** — patterns, gotchas, and architectural decisions curated across sessions and projects. Entries can reference each other with `[[entry-id]]` wiki links, forming a navigable knowledge graph. Dead references are automatically cleaned up when entries are deleted or consolidated.
- **Raw messages** — full message history in FTS5-indexed SQLite for the `recall` tool

## The `recall` tool
Expand All @@ -211,6 +211,18 @@ The assistant gets a `recall` tool that searches across stored messages and know
- "What was the error from the migration?"
- "What's my database schema convention?"

## lat.md compatibility

If your project uses [lat.md](https://github.com/1st1/lat.md) to maintain a knowledge graph, Lore automatically indexes the `lat.md/` directory and includes its sections in recall results. No configuration needed — if the directory exists, Lore parses the markdown files, extracts sections, and ranks them alongside its own knowledge entries using BM25 + RRF fusion.

This means the `recall` tool searches both:
- Lore's LLM-curated memory (distillations, knowledge entries, raw messages)
- lat.md's human-authored design documentation (architecture, specs, decisions)

lat.md sections also participate in LTM injection — the most relevant sections for the current session are included in the system prompt alongside Lore's own knowledge entries, ranked by session-context relevance.

Lore re-scans the `lat.md/` directory periodically (on session idle), so changes made by the agent or by hand are picked up automatically.

## Standing on the shoulders of

- [How we solved the agent memory problem](https://www.sanity.io/blog/how-we-solved-the-agent-memory-problem) — Simen Svale at Sanity on the Nuum memory architecture: three-tier storage, distillation not summarization, recursive compression. The foundation this plugin is built on.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-lore",
"version": "0.7.1",
"version": "0.8.0",
"type": "module",
"license": "MIT",
"description": "Three-tier memory architecture for OpenCode — distillation, not summarization",
Expand Down
11 changes: 10 additions & 1 deletion src/curator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export async function run(input: {
let updated = 0;
let deleted = 0;

const idsToSync: string[] = [];

for (const op of ops) {
if (op.op === "create") {
// Truncate oversized content — the model should stay within the prompt's
Expand All @@ -143,7 +145,7 @@ export async function run(input: {
? op.content.slice(0, MAX_ENTRY_CONTENT_LENGTH) +
" [truncated — entry too long]"
: op.content;
ltm.create({
const id = ltm.create({
projectPath: op.scope === "project" ? input.projectPath : undefined,
category: op.category,
title: op.title,
Expand All @@ -152,6 +154,7 @@ export async function run(input: {
scope: op.scope,
crossProject: op.crossProject ?? true,
});
idsToSync.push(id);
created++;
} else if (op.op === "update") {
const entry = ltm.get(op.id);
Expand All @@ -162,6 +165,7 @@ export async function run(input: {
" [truncated — entry too long]"
: op.content;
ltm.update(op.id, { content, confidence: op.confidence });
if (op.content !== undefined) idsToSync.push(op.id);
updated++;
}
} else if (op.op === "delete") {
Expand All @@ -173,6 +177,11 @@ export async function run(input: {
}
}

// Sync cross-references for created/updated entries
for (const id of idsToSync) {
ltm.syncRefs(id);
}

lastCuratedAt.set(input.sessionID, Date.now());
return { created, updated, deleted };
}
Expand Down
55 changes: 54 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { join, dirname } from "path";
import { mkdirSync } from "fs";

const SCHEMA_VERSION = 9;
const SCHEMA_VERSION = 10;

const MIGRATIONS: string[] = [
`
Expand Down Expand Up @@ -228,6 +228,59 @@ const MIGRATIONS: string[] = [
-- or via explicit backfill when embeddings are first enabled.
ALTER TABLE distillations ADD COLUMN embedding BLOB;
`,
`
-- Version 10: lat.md section cache + knowledge cross-references.

-- lat.md section cache for recall integration.
-- Parsed from lat.md/ directory markdown files, FTS5-indexed for search.
CREATE TABLE IF NOT EXISTS lat_sections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
file TEXT NOT NULL,
heading TEXT NOT NULL,
depth INTEGER NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
first_paragraph TEXT,
updated_at INTEGER NOT NULL
);

CREATE VIRTUAL TABLE IF NOT EXISTS lat_sections_fts USING fts5(
heading,
content,
content=lat_sections,
content_rowid=rowid,
tokenize='porter unicode61'
);

CREATE TRIGGER IF NOT EXISTS lat_fts_insert AFTER INSERT ON lat_sections BEGIN
INSERT INTO lat_sections_fts(rowid, heading, content)
VALUES (new.rowid, new.heading, new.content);
END;

CREATE TRIGGER IF NOT EXISTS lat_fts_delete AFTER DELETE ON lat_sections BEGIN
INSERT INTO lat_sections_fts(lat_sections_fts, rowid, heading, content)
VALUES('delete', old.rowid, old.heading, old.content);
END;

CREATE TRIGGER IF NOT EXISTS lat_fts_update AFTER UPDATE ON lat_sections BEGIN
INSERT INTO lat_sections_fts(lat_sections_fts, rowid, heading, content)
VALUES('delete', old.rowid, old.heading, old.content);
INSERT INTO lat_sections_fts(rowid, heading, content)
VALUES (new.rowid, new.heading, new.content);
END;

CREATE INDEX IF NOT EXISTS idx_lat_sections_project ON lat_sections(project_id);
CREATE INDEX IF NOT EXISTS idx_lat_sections_file ON lat_sections(project_id, file);

-- Knowledge cross-references via [[entry-id]] wiki links.
-- ON DELETE CASCADE: when either entry is deleted, the ref row is auto-removed.
CREATE TABLE IF NOT EXISTS knowledge_refs (
from_id TEXT NOT NULL REFERENCES knowledge(id) ON DELETE CASCADE,
to_id TEXT NOT NULL REFERENCES knowledge(id) ON DELETE CASCADE,
PRIMARY KEY (from_id, to_id)
);
`,
];

function dataDir() {
Expand Down
33 changes: 33 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { formatKnowledge, formatDistillations } from "./prompt";
import { createRecallTool } from "./reflect";
import { shouldImport, importFromFile, exportToFile } from "./agents-file";
import * as latReader from "./lat-reader";
import * as embedding from "./embedding";
import * as log from "./log";

Expand Down Expand Up @@ -124,6 +125,16 @@ export const LorePlugin: Plugin = async (ctx) => {
}
}

// Index lat.md/ directory sections at startup (if the directory exists).
// Content-hash-based — skips unchanged files, so this is cheap on repeat runs.
if (isValidProjectPath(projectPath)) {
try {
latReader.refresh(projectPath);
} catch (e) {
log.error("lat-reader startup refresh error:", e);
}
}

// Track user turns for periodic curation
let turnsSinceCuration = 0;

Expand Down Expand Up @@ -461,6 +472,28 @@ export const LorePlugin: Plugin = async (ctx) => {
} catch (e) {
log.error("agents-file export error:", e);
}

// Clean dead knowledge cross-references (entries deleted by curation/consolidation).
if (cfg.knowledge.enabled) {
try {
const cleaned = ltm.cleanDeadRefs();
if (cleaned > 0) {
log.info(`cleaned ${cleaned} dead knowledge cross-references`);
invalidateLtmCache();
}
} catch (e) {
log.error("dead-ref cleanup error:", e);
}
}

// Re-scan lat.md/ directory to pick up changes made by the agent.
if (isValidProjectPath(projectPath)) {
try {
latReader.refresh(projectPath);
} catch (e) {
log.error("lat-reader idle refresh error:", e);
}
}
}
},

Expand Down
Loading
Loading