Compare commits

..

5 Commits

Author SHA1 Message Date
Ralph Chang 17258ce60f fix: use SDK API instead of sqlite3 CLI for cross-platform compatibility
- Replace sqlite3 CLI with client.session.messages() and client.session.todo()
- Fix message scan order: API returns oldest-first, now scans backwards
- Include cache.read in total token calculation
- Skip in-progress messages with zero tokens
- Silent error handling (no console.error for expected failures)
2026-02-19 13:43:26 +08:00
Ralph Chang 6fad91953c docs: improve token-usage section readability 2026-02-18 16:59:47 +08:00
Ralph Chang 7b33b51c1e docs: add token-usage guidance for session patterns 2026-02-18 16:57:40 +08:00
Ralph Chang 2edb643667 chore: bump version to v1.1.0 2026-02-18 16:44:17 +08:00
Ralph Chang d7302e531a perf: move core memory guidelines from system prompt to tool description 2026-02-18 16:39:21 +08:00
3 changed files with 148 additions and 116 deletions
+13
View File
@@ -176,6 +176,19 @@ The plugin exposes these tools to your OpenCode agent:
- ✅ Pressure-aware compression
- ✅ Zero configuration, works immediately
## Does working-memory system increase token usage? It depends.
It depends on your workflow.
- 🧹 **Clean Slate user** (for example, using DCP and frequently restarting sessions)
- ⚠️ Yes, it might add slight overhead.
- Because you keep starting fresh, automated memory persistence does not get enough time to pay off.
- 🚀 **Long Haul user** (staying in one session until token limits/compaction hit)
- ✅ This plugin is a token saver.
- Without it, compaction can cause the agent to lose the goal, forget active files, or make wrong assumptions, which creates correction loops.
- By preserving high-value context (Goals, Progress, Active Files), the agent inherits its previous state quickly. The small memory-prompt cost avoids the larger cost of the agent getting lost.
## Configuration (Optional)
The plugin works great with zero configuration. To customize behavior, modify the constants at the top of `index.ts`. See the [Configuration Guide](docs/configuration.md) for all tunable options.
+134 -115
View File
@@ -1122,7 +1122,7 @@ Recent session context (auto-managed, sorted by relevance):
${sections.join("\n\n")}
(${totalItems} items shown, updated: ${new Date(memory.updatedAt).toLocaleTimeString()})
(${totalItems} items shown)
</working_memory>
`.trim();
}
@@ -1319,28 +1319,34 @@ async function loadModelPressureInfo(
}
/**
* Calculate total tokens by querying OpenCode's session database.
* Calculate total tokens by querying session messages via OpenCode SDK.
* Cross-platform compatible (no sqlite3 CLI dependency).
*/
async function calculateTotalTokensFromDB(sessionID: string): Promise<number> {
async function calculateTotalTokensFromDB(
client: ReturnType<typeof import("@opencode-ai/sdk").createOpencodeClient>,
sessionID: string
): Promise<number> {
try {
const { execSync } = await import("child_process");
const dbPath = join(process.env.HOME || "~", ".local/share/opencode/opencode.db");
const result = await client.session.messages({
path: { id: sessionID }
});
// Get tokens.total from most recent assistant message
const query = `
SELECT json_extract(data, '$.tokens.total') as total
FROM message
WHERE session_id = '${sessionID}'
AND json_extract(data, '$.role') = 'assistant'
AND json_extract(data, '$.tokens.total') IS NOT NULL
ORDER BY time_created DESC
LIMIT 1;
`;
if (!result.data) return 0;
const result = execSync(`sqlite3 "${dbPath}" "${query}"`, { encoding: "utf-8" }).trim();
return parseInt(result) || 0;
// Find the most recent assistant message with non-zero tokens.
// API returns messages oldest-first, so we scan backwards.
for (let i = result.data.length - 1; i >= 0; i--) {
const { info } = result.data[i];
if (info.role !== "assistant") continue;
// Total = input + output + reasoning + cache.read
const total = info.tokens.input + info.tokens.output + info.tokens.reasoning + info.tokens.cache.read;
if (total > 0) return total;
}
return 0;
} catch (error) {
console.error("[working-memory] Failed to query tokens from DB:", error);
// Silent — API call failed
return 0;
}
}
@@ -1463,18 +1469,9 @@ ${context.value || "[No project context set - add relevant file paths, conventio
</context>
IMPORTANT: These blocks persist across conversation resets and compaction.
Update them regularly using core_memory_update tool when:
- Goals change or new objectives are identified
- Significant progress is made or tasks are completed
- Important project context is discovered (file structures, patterns, conventions)
Update them regularly using core_memory_update tool. When blocks approach their character limits, compress or rephrase content.
When memory blocks approach their character limits, compress or rephrase content.
**Usage Discipline** (see Core Memory Usage Guidelines above for details):
- goal: ONE specific task, not project-wide goals
- progress: Checklist format, NO line numbers/commit hashes/API signatures
- context: ONLY files you're currently working on, NO type definitions/function signatures
- NEVER store: API docs, library types, function signatures (read source instead)
To mark decisions for automatic capture into working memory, write inline: [Decision: chose X over Y because Z]
</core_memory>
`.trim();
}
@@ -1488,75 +1485,35 @@ export default async function WorkingMemoryPlugin(
): Promise<ReturnType<Plugin>> {
const { directory, client } = input;
// Cache for sub-agent detection — avoids repeated API calls per session.
// Maps sessionID → parentID (string) or null (root session).
const sessionParentCache = new Map<string, string | null>();
async function isSubAgent(sessionID: string): Promise<boolean> {
if (sessionParentCache.has(sessionID)) {
return sessionParentCache.get(sessionID) !== null;
}
try {
const result = await client.session.get({ path: { id: sessionID } });
const parentID = result.data?.parentID ?? null;
sessionParentCache.set(sessionID, parentID);
return parentID !== null;
} catch {
// If we can't determine, assume it's NOT a sub-agent (safe default).
sessionParentCache.set(sessionID, null);
return false;
}
}
return {
// Inject core memory usage guidelines, pressure warnings, core memory,
// and working memory into the system prompt each turn.
// Inject pressure warnings, core memory, and working memory into the system prompt each turn.
// Core memory usage guidelines are in the core_memory_update tool description instead.
"experimental.chat.system.transform": async (hookInput, output) => {
const { sessionID, model } = hookInput;
if (!sessionID) return;
// Inject core memory usage guidelines.
// Inserted before <core_memory> so the agent reads guidance first.
const coreMemoryGuidelines = `
# Core Memory Usage Guidelines
The Working Memory Plugin provides persistent core_memory blocks. **USE THEM CORRECTLY**:
## goal block (1000 chars)
**Purpose**: ONE specific task you're working on RIGHT NOW
✅ **GOOD Examples**:
- "Fix pruning bug where items with relevanceScore <0.01 are incorrectly excluded"
- "Add new tool: working_memory_search to query pool items by keyword"
- "Investigate why pressure warnings not showing in system prompt"
❌ **BAD Examples**:
- "Complete Phase 1-4 development and testing" (too broad, likely already done)
- "Build a working memory system for OpenCode" (project-level goal, not task-level)
## progress block (2000 chars)
**Purpose**: Checklist of done/in-progress/blocked items + key decisions
✅ **GOOD Examples**:
- "✅ Found bug in applyDecay() line 856\\n⏳ Testing fix with gamma=0.85\\n❓ Need to verify edge case: score=0"
- "✅ Phase 1-3 complete\\n⏳ Phase 4 intervention testing\\n⚠️ BLOCKED: Need promptAsync docs"
❌ **BAD Examples**:
- "Function sendPressureInterventionMessage() @ working-memory.ts:L1286-1354" (line numbers useless after edits)
- "Commit 2f42f1b implemented promptAsync integration" (commit hash irrelevant)
- "API: client.session.promptAsync({ path: {id}, body: {...} })" (API signature, not progress)
## context block (1500 chars)
**Purpose**: Files you're CURRENTLY editing + key patterns/conventions
✅ **GOOD Examples**:
- "Editing: .opencode/plugins/working-memory.ts (main plugin, 1706 lines)\\nRelated: WORKING_MEMORY.md, TEST_PHASE4.md"
- "Key paths: .opencode/memory-core/ (persistent blocks), memory-working/ (session data)"
- "Pattern: All async file ops use mkdir({recursive:true}) before writeFile"
❌ **BAD Examples**:
- "OpenCode SDK types: TextPartInput = { type: 'text', text: string, synthetic?: boolean }" (type definition)
- "Function signature: async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null>" (function signature)
- "Method client.session.promptAsync() returns 204 No Content" (API behavior, read docs instead)
## ⚠️ NEVER Store in Core Memory
- API documentation (read source/docs when needed)
- Type definitions from libraries (import them)
- Function signatures (read source code)
- Implementation details (belong in code comments)
- Completed goals (clear them immediately)
## ✅ Update Core Memory Immediately When
- **Starting new task**: Clear old goal, set new specific goal
- **Making progress**: Update progress checklist (keep concise)
- **Switching files**: Update context with current working files
- **Task completed**: Clear goal/progress, set next task
- **Approaching char limit**: Compress or remove outdated info
**Remember**: Core Memory is your **working scratchpad**, not a reference manual.
`.trim();
output.system.push(coreMemoryGuidelines);
// Sub-agents are short-lived — skip entire memory system.
if (await isSubAgent(sessionID)) return;
// Check for memory pressure and inject warning into system prompt.
// Skip if model just changed (avoids false alarms with different limits).
@@ -1572,7 +1529,7 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
// Calculate current token usage from DB and update pressure info.
if (model) {
const totalTokens = await calculateTotalTokensFromDB(sessionID);
const totalTokens = await calculateTotalTokensFromDB(client, sessionID);
// Calculate pressure with current model
const updatedPressure = calculateModelPressure(
@@ -1630,6 +1587,9 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
const { sessionID, callID, tool: toolName, args } = hookInput;
const { output: toolOutput } = hookOutput;
// Sub-agents don't need working memory tracking.
if (await isSubAgent(sessionID)) return;
await cacheToolOutput(directory, {
callID,
sessionID,
@@ -1654,6 +1614,9 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
"experimental.chat.messages.transform": async (hookInput, output) => {
const sessionID = output.messages[0]?.info?.sessionID || "";
// Sub-agents don't need smart pruning.
if (sessionID && await isSubAgent(sessionID)) return;
// Load current pressure info to get pressure-aware pruning config
const currentPressure = await loadModelPressureInfo(directory, sessionID);
const pressureLevel = currentPressure?.current?.pressure || 0;
@@ -1690,6 +1653,30 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
}
},
// Auto-capture [Decision: ...] markers from agent responses.
"experimental.text.complete": async (hookInput, output) => {
const { sessionID } = hookInput;
if (!sessionID) return;
// Sub-agents are short-lived — skip decision tracking.
if (await isSubAgent(sessionID)) return;
// Extract all [Decision: ...] markers from the completed text.
const matches = output.text.matchAll(/\[Decision:\s*([^\]]+)\]/gi);
for (const match of matches) {
const description = match[1].trim();
if (!description) continue;
await addToWorkingMemory(directory, sessionID, {
type: "decision",
content: description,
source: "auto:text",
timestamp: Date.now(),
mentions: 1,
});
}
},
// Clean up all session artifacts on session deletion.
event: async ({ event }) => {
if (event.type === "session.deleted") {
@@ -1704,6 +1691,9 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
"experimental.session.compacting": async (hookInput, output) => {
const { sessionID } = hookInput;
// Sub-agents don't need compaction support.
if (await isSubAgent(sessionID)) return;
// Preserve only the most relevant working memory items
const preservedItems = await preserveRelevantItems(directory, sessionID, 0.5);
@@ -1741,26 +1731,14 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
// Inject pending OpenCode todos into compaction context.
try {
const { execSync } = await import("child_process");
const dbPath = join(process.env.HOME || "~", ".local/share/opencode/opencode.db");
const result = await client.session.todo({
path: { id: sessionID }
});
const query = `
SELECT content, status, priority
FROM todo
WHERE session_id = '${sessionID}'
AND status != 'completed'
ORDER BY position ASC;
`;
const result = execSync(`sqlite3 "${dbPath}" "${query.replace(/\n/g, ' ')}"`, {
encoding: "utf-8"
}).trim();
if (result) {
const todos = result.split('\n').map(line => {
const [content, status, priority] = line.split('|');
return `- [${status}] ${content} (${priority})`;
});
if (result.data) {
const todos = result.data
.filter(todo => todo.status !== "completed")
.map(todo => `- [${todo.status}] ${todo.content} (${todo.priority})`);
if (todos.length > 0) {
output.context.push(
@@ -1769,7 +1747,7 @@ The Working Memory Plugin provides persistent core_memory blocks. **USE THEM COR
}
}
} catch (error) {
console.error("[working-memory] Failed to inject todos from DB:", error);
// Silent — API call failed
}
// Inform about preserved working memory
@@ -1795,7 +1773,48 @@ Operations:
- append: Add content to the end of the block (automatically adds newline)
These blocks are ALWAYS visible to you in every message, even after compaction.
Update them regularly to maintain continuity across long conversations.`,
Update them regularly to maintain continuity across long conversations.
---
## Usage Guidelines
### goal block (1000 chars)
**Purpose**: ONE specific task you're working on RIGHT NOW
✅ GOOD: "Fix pruning bug where items with relevanceScore <0.01 are incorrectly excluded"
✅ GOOD: "Add new tool: working_memory_search to query pool items by keyword"
❌ BAD: "Complete Phase 1-4 development and testing" (too broad, likely already done)
❌ BAD: "Build a working memory system for OpenCode" (project-level goal, not task-level)
### progress block (2000 chars)
**Purpose**: Checklist of done/in-progress/blocked items + key decisions
✅ GOOD: "✅ Found bug in applyDecay()\\n⏳ Testing fix with gamma=0.85\\n❓ Need to verify edge case"
✅ GOOD: "✅ Phase 1-3 complete\\n⏳ Phase 4 intervention testing\\n⚠️ BLOCKED: Need promptAsync docs"
❌ BAD: "Function foo() @ file.ts:L1286-1354" (line numbers useless after edits)
❌ BAD: "Commit 2f42f1b implemented X" (commit hash irrelevant)
❌ BAD: "API: client.session.promptAsync({ ... })" (API signatures belong in source)
### context block (1500 chars)
**Purpose**: Files you're CURRENTLY editing + key patterns/conventions
✅ GOOD: "Editing: src/plugin.ts\\nKey paths: .opencode/memory-core/ (persistent blocks)"
✅ GOOD: "Pattern: All async file ops use mkdir({recursive:true}) before writeFile"
❌ BAD: Type definitions, function signatures, API docs (read source/docs instead)
### NEVER store in core memory
- API documentation (read source/docs when needed)
- Type definitions from libraries (import them)
- Function signatures (read source code)
- Completed goals (clear them immediately)
### Update core memory immediately when
- Starting new task: clear old goal, set new specific goal
- Making progress: update progress checklist (keep concise)
- Switching files: update context with current working files
- Task completed: clear goal/progress, set next task
- Approaching char limit: compress or remove outdated info`,
args: {
block: tool.schema.enum(["goal", "progress", "context"]).describe(
"Which memory block to update (goal/progress/context)"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.0.1",
"version": "1.1.1",
"description": "Advanced four-tier memory architecture for OpenCode with intelligent pressure monitoring and auto-storage governance",
"type": "module",
"main": "index.ts",