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
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@
<!-- lore:019c91ad-4d47-7afc-90e0-239a9eda57a4 -->
* **Stuck compaction loops leave orphaned user+assistant message pairs in DB**: When OpenCode compaction overflows, it creates paired user+assistant messages per retry (assistant has error.name:'ContextOverflowError', mode:'compaction'). These accumulate and worsen the session. Recovery: find last good assistant message (has tokens, no error), delete all messages after it from both \`message\` and \`part\` tables. Use json\_extract(data, '$.error.name') to identify compaction debris.
<!-- lore:019c8f4f-67ca-7212-a8c4-8a75b230ceea -->
* **Lore test suite uses live DB — no test isolation for db.test.ts**: The lore test suite (test/db.test.ts, test/ltm.test.ts) uses the live DB at ~/.local/share/opencode-lore/lore.db — no LORE\_DB\_PATH override. Test fixtures create entries with 019c9026-\* UUIDs that persist and leak into AGENTS.md exports. Known leaked entries: 'Kubernetes deployment pattern', 'TypeScript strict mode caveat', 'React useState async pitfall', 'Fine entry'. These require periodic manual cleanup from the DB. Fix needed: set LORE\_DB\_PATH to a temp file in tests.
* **Lore test suite uses live DB — no test isolation for db.test.ts**: Lore test suite (test/db.test.ts, test/ltm.test.ts) uses the live DB at ~/.local/share/opencode-lore/lore.db — no LORE\_DB\_PATH override. Test fixtures create entries with 019c9026-\* UUIDs that persist and leak into AGENTS.md exports. Known leaked entries: 'Kubernetes deployment pattern', 'TypeScript strict mode caveat', 'React useState async pitfall', 'Fine entry'. Require periodic manual cleanup. Fix needed: LORE\_DB\_PATH temp file in tests.

### Preference
### Pattern

<!-- lore:019ca19d-fc02-7657-b2e9-7764658c01a5 -->
* **Code style**: User prefers no backwards-compat shims — fix callers directly. Prefer explicit error handling over silent failures. Derive thresholds from existing constants rather than hardcoding magic numbers (e.g., use \`raw.length <= COL\_COUNT\` instead of \`n < 10\_000\`). In CI, define shared env vars at workflow level, not per-job.
<!-- lore:019cb050-ef48-7cbe-8e58-802f17c34591 -->
* **Lore logging: LORE\_DEBUG gating for info/warn, always-on for errors**: src/log.ts provides three levels: log.info() and log.warn() are suppressed unless LORE\_DEBUG=1 or LORE\_DEBUG=true; log.error() always emits. All write to stderr with \[lore] prefix. This exists because OpenCode TUI renders all stderr as red error text — routine status messages (distillation counts, pruning stats, consolidation) were alarming users. Rule: use log.info() for successful operations and status, log.warn() for non-actionable oddities (e.g. dropping trailing messages), log.error() only in catch blocks for real failures. Never use console.error directly in plugin source files.

### Preference

<!-- lore:019ca190-0001-7000-8000-000000000001 -->
* **Always dry-run before bulk DB deletes**: Never execute bulk DELETE/destructive operations without first running the equivalent SELECT to verify row count and inspect affected rows. A hardcoded timestamp off by one year caused deletion of all 1638 messages + 5927 parts instead of 5 debris rows. Pattern: (1) SELECT with same WHERE, (2) verify count, (3) then DELETE. Applies to any destructive op — DB mutations, git reset, file deletion.
<!-- lore:019ca19d-fc02-7657-b2e9-7764658c01a5 -->
* **Code style**: User prefers no backwards-compat shims — fix callers directly. Prefer explicit error handling over silent failures. Derive thresholds from existing constants rather than hardcoding magic numbers (e.g., use \`raw.length <= COL\_COUNT\` instead of \`n < 10\_000\`). In CI, define shared env vars at workflow level, not per-job.
<!-- End lore-managed section -->
5 changes: 3 additions & 2 deletions src/distillation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk";
import { db, ensureProject } from "./db";
import { config } from "./config";
import * as temporal from "./temporal";
import * as log from "./log";
import {
DISTILLATION_SYSTEM,
distillationUser,
Expand Down Expand Up @@ -273,8 +274,8 @@ export async function run(input: {
// Reset orphaned messages (marked distilled by a deleted/migrated distillation)
const orphans = resetOrphans(input.projectPath, input.sessionID);
if (orphans > 0) {
console.error(
`[lore] Reset ${orphans} orphaned messages for re-observation`,
log.info(
`Reset ${orphans} orphaned messages for re-observation`,
);
}

Expand Down
53 changes: 27 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { formatKnowledge, formatDistillations } from "./prompt";
import { createRecallTool } from "./reflect";
import { shouldImport, importFromFile, exportToFile } from "./agents-file";
import * as log from "./log";

/**
* Detect whether an error from session.error is a context overflow ("prompt too long").
Expand Down Expand Up @@ -85,9 +86,9 @@ export const LorePlugin: Plugin = async (ctx) => {
if (shouldImport({ projectPath, filePath })) {
try {
importFromFile({ projectPath, filePath });
console.error("[lore] imported knowledge from", cfg.agentsFile.path);
log.info("imported knowledge from", cfg.agentsFile.path);
} catch (e) {
console.error("[lore] agents-file import error:", e);
log.error("agents-file import error:", e);
}
}
}
Expand All @@ -99,7 +100,7 @@ export const LorePlugin: Plugin = async (ctx) => {
if (config().knowledge.enabled) {
const pruned = ltm.pruneOversized(1200);
if (pruned > 0) {
console.error(`[lore] pruned ${pruned} oversized knowledge entries (confidence set to 0)`);
log.info(`pruned ${pruned} oversized knowledge entries (confidence set to 0)`);
}
}

Expand Down Expand Up @@ -168,7 +169,7 @@ export const LorePlugin: Plugin = async (ctx) => {
});
}
} catch (e) {
console.error("[lore] distillation error:", e);
log.error("distillation error:", e);
} finally {
distilling = false;
}
Expand All @@ -185,7 +186,7 @@ export const LorePlugin: Plugin = async (ctx) => {
model: cfg.model,
});
} catch (e) {
console.error("[lore] curator error:", e);
log.error("curator error:", e);
}
}

Expand Down Expand Up @@ -240,8 +241,8 @@ export const LorePlugin: Plugin = async (ctx) => {
) {
const pending = temporal.undistilledCount(projectPath, msg.sessionID);
if (pending >= config().distillation.maxSegment) {
console.error(
`[lore] incremental distillation: ${pending} undistilled messages in ${msg.sessionID.substring(0, 16)}`,
log.info(
`incremental distillation: ${pending} undistilled messages in ${msg.sessionID.substring(0, 16)}`,
);
backgroundDistill(msg.sessionID);
}
Expand Down Expand Up @@ -271,11 +272,11 @@ export const LorePlugin: Plugin = async (ctx) => {

// Detect "prompt is too long" API errors and auto-recover.
const rawError = (event.properties as Record<string, unknown>).error;
console.error("[lore] session.error received:", JSON.stringify(rawError, null, 2));
log.info("session.error received:", JSON.stringify(rawError, null, 2));

if (isContextOverflow(rawError) && errorSessionID) {
console.error(
`[lore] detected context overflow — auto-recovering (session: ${errorSessionID.substring(0, 16)})`,
log.info(
`detected context overflow — auto-recovering (session: ${errorSessionID.substring(0, 16)})`,
);

// 1. Force layer 2 on next transform (persisted to DB — survives restarts).
Expand All @@ -294,23 +295,23 @@ export const LorePlugin: Plugin = async (ctx) => {
summaries.map(s => ({ observations: s.observations, generation: s.generation })),
);

console.error(
`[lore] sending auto-recovery message to session ${errorSessionID.substring(0, 16)}`,
log.info(
`sending auto-recovery message to session ${errorSessionID.substring(0, 16)}`,
);
await ctx.client.session.prompt({
path: { id: errorSessionID },
body: {
parts: [{ type: "text", text: recoveryText, synthetic: true }],
},
});
console.error(
`[lore] auto-recovery message sent successfully`,
log.info(
`auto-recovery message sent successfully`,
);
} catch (recoveryError) {
// Recovery is best-effort — don't let it crash the event handler.
// The persisted forceMinLayer will still help on the user's next message.
console.error(
`[lore] auto-recovery failed (forceMinLayer still persisted):`,
log.error(
`auto-recovery failed (forceMinLayer still persisted):`,
recoveryError,
);
}
Expand Down Expand Up @@ -343,8 +344,8 @@ export const LorePlugin: Plugin = async (ctx) => {
if (cfg.knowledge.enabled) try {
const allEntries = ltm.forProject(projectPath);
if (allEntries.length > cfg.curator.maxEntries) {
console.error(
`[lore] entry count ${allEntries.length} exceeds maxEntries ${cfg.curator.maxEntries} — running consolidation`,
log.info(
`entry count ${allEntries.length} exceeds maxEntries ${cfg.curator.maxEntries} — running consolidation`,
);
const { updated, deleted } = await curator.consolidate({
client: ctx.client,
Expand All @@ -353,11 +354,11 @@ export const LorePlugin: Plugin = async (ctx) => {
model: cfg.model,
});
if (updated > 0 || deleted > 0) {
console.error(`[lore] consolidation: ${updated} updated, ${deleted} deleted`);
log.info(`consolidation: ${updated} updated, ${deleted} deleted`);
}
}
} catch (e) {
console.error("[lore] consolidation error:", e);
log.error("consolidation error:", e);
}

// Prune temporal messages after distillation and curation have run.
Expand All @@ -371,12 +372,12 @@ export const LorePlugin: Plugin = async (ctx) => {
maxStorageMB: cfg.pruning.maxStorage,
});
if (ttlDeleted > 0 || capDeleted > 0) {
console.error(
`[lore] pruned temporal messages: ${ttlDeleted} by TTL, ${capDeleted} by size cap`,
log.info(
`pruned temporal messages: ${ttlDeleted} by TTL, ${capDeleted} by size cap`,
);
}
} catch (e) {
console.error("[lore] pruning error:", e);
log.error("pruning error:", e);
}

// Export curated knowledge to AGENTS.md after distillation + curation.
Expand All @@ -387,7 +388,7 @@ export const LorePlugin: Plugin = async (ctx) => {
exportToFile({ projectPath, filePath });
}
} catch (e) {
console.error("[lore] agents-file export error:", e);
log.error("agents-file export error:", e);
}
}
},
Expand Down Expand Up @@ -508,8 +509,8 @@ export const LorePlugin: Plugin = async (ctx) => {
break;
}
const dropped = result.messages.pop()!;
console.error(
"[lore] WARN: dropping trailing pure-text",
log.warn(
"dropping trailing pure-text",
dropped.info.role,
"message to prevent prefill error. id:",
dropped.info.id,
Expand Down
27 changes: 27 additions & 0 deletions src/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Lightweight logger that suppresses informational messages by default.
*
* In TUI mode, all stderr output renders as red "error" text — confusing
* for routine status messages like "incremental distillation" or "pruned
* temporal messages". Only actual errors should be visible by default.
*
* Set LORE_DEBUG=1 to see informational messages (useful when debugging
* the plugin itself).
*/

const isDebug = !!process.env.LORE_DEBUG;

/** Log an informational status message. Suppressed unless LORE_DEBUG=1. */
export function info(...args: unknown[]): void {
if (isDebug) console.error("[lore]", ...args);
}

/** Log a warning. Suppressed unless LORE_DEBUG=1. */
export function warn(...args: unknown[]): void {
if (isDebug) console.error("[lore] WARN:", ...args);
}

/** Log an error. Always visible — these indicate real failures. */
export function error(...args: unknown[]): void {
console.error("[lore]", ...args);
}