mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 034dfe8d91 | |||
| e3ebdbfe9f | |||
| fdebd304f6 | |||
| 77d60abf5f | |||
| 560f63f96b | |||
| 11361abc91 | |||
| e071095422 | |||
| 909d6c7767 | |||
| c697f63c67 | |||
| 25b673fbb7 | |||
| acaa829df4 |
@@ -0,0 +1,32 @@
|
||||
name: compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
|
||||
jobs:
|
||||
locked:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
|
||||
opencode-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm install --no-save @opencode-ai/plugin@latest
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.3.2] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Compatibility CI now installs dependencies with `npm install` so it works in this no-lockfile repository.
|
||||
- Compatibility CI now runs on Node 24, matching the test command's `--experimental-strip-types` requirement.
|
||||
|
||||
## [1.3.1] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- Pending journal retention: max 50 entries, 30-day TTL, automatic pruning on save.
|
||||
- Plugin capability test to catch missing OpenCode hooks before release.
|
||||
- CI workflow for weekly OpenCode plugin API compatibility testing.
|
||||
- Indirect prompt-injection filtering for workspace memory candidates.
|
||||
- Expanded credential redaction for common API key, token, secret, credential, auth, and private-key labels.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pending memory journal entries are now bounded and pruned instead of growing indefinitely.
|
||||
- Adversarial memory candidates that try to override system instructions are rejected before storage.
|
||||
- Broader credential-like labels are redacted from workspace memory text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Memory dedupe is now repo-agnostic: project/reference entries use exact canonical text plus generic URL/path identity, while decision/feedback entries no longer use repository-specific topic heuristics.
|
||||
- OpenCode plugin compatibility is documented and declared as `>=1.2.0 <2.0.0`.
|
||||
- README limitations now concisely document compatibility, secret handling, semantic-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Compatibility is tested against OpenCode plugin API `>=1.2.0 <2.0.0`.
|
||||
- Credential redaction is best-effort; do not store secrets.
|
||||
- This is working memory, not semantic search.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multi-process writes to the same workspace are not fully serialized.
|
||||
|
||||
## [1.3.0] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -214,9 +214,17 @@ npm run typecheck
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenCode >= 1.0.0
|
||||
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||
- Node.js >= 18.0.0
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires OpenCode plugin API `>=1.2.0 <2.0.0`; OpenCode hook changes may break compatibility.
|
||||
- Not a secret manager. Credential redaction is best-effort. Do not store secrets.
|
||||
- Working memory only. No semantic search, embeddings, or vector knowledge base.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multiple OpenCode processes on the same workspace may race on local files.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.3.2 (2026-04-27)
|
||||
|
||||
### CI Compatibility Patch
|
||||
|
||||
- Fixed the compatibility workflow so dependency installation works without a committed lockfile.
|
||||
- Moved compatibility CI to Node 24 so TypeScript-stripping tests run correctly.
|
||||
- No runtime or storage changes.
|
||||
|
||||
---
|
||||
|
||||
## 1.3.1 (2026-04-27)
|
||||
|
||||
### Security and Reliability Patch
|
||||
|
||||
This patch release keeps the v1.3 memory-consolidation model intact while tightening storage safety, compatibility checks, and repository-agnostic dedupe behavior.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Bounded pending journal**: pending memories are capped at 50 entries and pruned after 30 days.
|
||||
- **Security hardening**: workspace memory candidates now reject indirect prompt-injection attempts, and redaction covers broader token, secret, credential, auth, and private-key labels.
|
||||
- **Compatibility coverage**: plugin capability tests and weekly OpenCode plugin API compatibility CI help catch hook drift before release.
|
||||
- **Repo-agnostic dedupe**: long-term memory dedupe no longer depends on hardcoded project-specific topic rules; project/reference memories use generic URL/path identity plus exact canonical matching.
|
||||
- **Clearer limitations**: README and changelog now document compatibility, best-effort secret redaction, working-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Thanks @StevenChoo for the security hardening contribution in #3.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No user migration is required.
|
||||
- Existing workspace memory and pending journal files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
|
||||
---
|
||||
|
||||
## 1.3.0 (2026-04-27)
|
||||
|
||||
### Better Memory Consolidation
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
@@ -16,7 +16,8 @@
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
@@ -37,7 +38,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.0"
|
||||
"@opencode-ai/plugin": ">=1.2.0 <2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
||||
@@ -263,6 +263,12 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
|
||||
|
||||
// Indirect Prompt Injection / Adversarial Instructions
|
||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||
// comparative "instead of" is allowed.
|
||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||
|
||||
// Path-heavy facts (rediscoverable from repo)
|
||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||
if (pathCount > 2) return false;
|
||||
|
||||
+70
-5
@@ -2,6 +2,20 @@ import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts"
|
||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
/**
|
||||
* Retention limits for the pending memory journal.
|
||||
*
|
||||
* The journal is a scratchpad for memories that haven't been promoted to
|
||||
* workspace memory yet. It should not grow unboundedly:
|
||||
* - maxEntries: Hard cap on number of pending entries
|
||||
* - maxAgeDays: Prune entries older than this (compaction candidates that
|
||||
* were never promoted)
|
||||
*/
|
||||
export const PENDING_JOURNAL_LIMITS = {
|
||||
maxEntries: 50,
|
||||
maxAgeDays: 30,
|
||||
} as const;
|
||||
|
||||
function normalizeMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
@@ -37,6 +51,57 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective timestamp for an entry, preferring updatedAt over createdAt.
|
||||
* Returns 0 if both are invalid/missing.
|
||||
*/
|
||||
function entryTime(entry: LongTermMemoryEntry): number {
|
||||
const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN;
|
||||
if (!Number.isNaN(updatedAt)) return updatedAt;
|
||||
|
||||
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN;
|
||||
if (!Number.isNaN(createdAt)) return createdAt;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
|
||||
const time = entryTime(entry);
|
||||
|
||||
// If timestamp is 0 (both invalid), treat as stale
|
||||
if (time === 0) return true;
|
||||
|
||||
const ageMs = Date.now() - time;
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
return ageMs > maxAgeMs;
|
||||
}
|
||||
|
||||
function applyRetention(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxEntries: number,
|
||||
maxAgeDays: number,
|
||||
): LongTermMemoryEntry[] {
|
||||
// 1. Dedupe first
|
||||
const deduped = dedupeByText(entries);
|
||||
|
||||
// 2. Remove stale entries
|
||||
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||
|
||||
// 3. Sort by entryTime descending (newest first) for cap, using updatedAt then createdAt
|
||||
const sorted = [...freshEntries].sort((a, b) => {
|
||||
return entryTime(b) - entryTime(a);
|
||||
});
|
||||
|
||||
// 4. Keep maxEntries newest
|
||||
const capped = sorted.slice(0, maxEntries);
|
||||
|
||||
// 5. Restore stable order (oldest-to-newest) for consistency with existing code
|
||||
return capped.sort((a, b) => {
|
||||
return entryTime(a) - entryTime(b);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJournal(
|
||||
root: string,
|
||||
store: PendingMemoryJournalStore,
|
||||
@@ -44,11 +109,11 @@ function normalizeJournal(
|
||||
return workspaceKey(root).then(key => ({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
// TODO(memory-consolidation follow-up): add the deferred pending journal
|
||||
// safety cap (max entries and old compaction pruning). P0 currently relies
|
||||
// on promotion accounting to clear terminal compaction candidates without
|
||||
// changing journal capacity behavior.
|
||||
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
|
||||
entries: applyRetention(
|
||||
Array.isArray(store.entries) ? store.entries : [],
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
+75
-68
@@ -11,10 +11,12 @@ const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||
|
||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
@@ -231,6 +233,12 @@ export function redactCredentials(text: string): string {
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 4. Standalone sensitive keys/tokens
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -305,72 +313,80 @@ export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type"
|
||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
/** Extract entity/destination keys for project and reference dedup */
|
||||
function extractEntityKey(text: string): string | null {
|
||||
const normalized = canonicalMemoryText(text);
|
||||
// Check known key phrases (bilingual-friendly)
|
||||
// opencode + agenthub plugin system
|
||||
if (/opencode.*agenthub/i.test(normalized)) {
|
||||
return "opencode-agenthub plugin system";
|
||||
function normalizeUrlIdentity(raw: string): string | null {
|
||||
const cleaned = raw.replace(/[),.;:!?]+$/g, "");
|
||||
try {
|
||||
const url = new URL(cleaned);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.protocol = url.protocol.toLowerCase();
|
||||
url.hostname = url.hostname.toLowerCase();
|
||||
url.hash = "";
|
||||
if (url.pathname.length > 1) {
|
||||
url.pathname = url.pathname.replace(/\/+$/g, "");
|
||||
}
|
||||
return `url:${url.toString()}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// For generic config references, fall back to canonical text dedup — no entity key
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract decision topic key for supersession detection */
|
||||
function decisionTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Parser format versions
|
||||
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
|
||||
return "parser-supported-formats";
|
||||
}
|
||||
// Compaction template replacement
|
||||
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
|
||||
return "compaction-template-replacement";
|
||||
}
|
||||
// Plugin loading
|
||||
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
|
||||
return "plugin-loading-config";
|
||||
}
|
||||
// Output format changes (purple/italic, YAML frontmatter, etc)
|
||||
if (/purple.*italic|markup|markdown.*render|frontmatter/i.test(normalized)) {
|
||||
return "output-format-rendering";
|
||||
}
|
||||
return null;
|
||||
function normalizePathIdentity(raw: string): string | null {
|
||||
const unwrapped = raw
|
||||
.trim()
|
||||
.replace(/^[`"']+|[`"']+$/g, "")
|
||||
.replace(/[),.;:!?]+$/g, "")
|
||||
.replace(/\\+/g, "/");
|
||||
|
||||
if (!unwrapped) return null;
|
||||
const collapsed = unwrapped.startsWith("/")
|
||||
? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}`
|
||||
: unwrapped.replace(/\/+/g, "/");
|
||||
const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed;
|
||||
return `path:${withoutTrailingSlash}`;
|
||||
}
|
||||
|
||||
/** Extract feedback topic key for supersession detection */
|
||||
function feedbackTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Purple/italic rendering issue
|
||||
if (/purple.*italic/i.test(normalized)) {
|
||||
return "purple-italic-rendering";
|
||||
function isConcretePathIdentity(pathIdentity: string): boolean {
|
||||
const path = pathIdentity.slice("path:".length);
|
||||
if (!path || path === "." || path === "..") return false;
|
||||
|
||||
if (path.startsWith("/")) return true;
|
||||
if (/^\.\.?\//.test(path)) return true;
|
||||
if (/^\.[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
if (/^[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
return /\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config)$/i.test(path);
|
||||
}
|
||||
|
||||
function normalizeConcretePathIdentity(raw: string): string | null {
|
||||
const pathIdentity = normalizePathIdentity(raw);
|
||||
if (!pathIdentity) return null;
|
||||
return isConcretePathIdentity(pathIdentity) ? pathIdentity : null;
|
||||
}
|
||||
|
||||
function extractConcreteIdentityKey(text: string): string | null {
|
||||
const urlMatch = text.match(/https?:\/\/[^\s`"'<>]+/i);
|
||||
if (urlMatch) {
|
||||
const urlIdentity = normalizeUrlIdentity(urlMatch[0]);
|
||||
if (urlIdentity) return urlIdentity;
|
||||
}
|
||||
// Browser login/server errors (500 internal_error)
|
||||
if (/login.*500|500.*internal|internal_error|server.*error/i.test(normalized)) {
|
||||
return "server-error";
|
||||
|
||||
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||||
for (const match of text.matchAll(wrappedPathPattern)) {
|
||||
const pathIdentity = normalizeConcretePathIdentity(match[1]);
|
||||
if (pathIdentity) return pathIdentity;
|
||||
}
|
||||
// Port occupied / environment issues
|
||||
if (/port.*occup|9473|端口|舊進程|旧进程/i.test(normalized)) {
|
||||
return "port-occupied-environment";
|
||||
}
|
||||
// Theme preferences
|
||||
if (/theme|dark.*light|prefer.*theme/i.test(normalized)) {
|
||||
return "theme-preference";
|
||||
}
|
||||
return null;
|
||||
|
||||
const pathMatch = text.match(/(?:\/[^ | ||||