mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c7019820 | |||
| 1847f63480 | |||
| 8b21325469 | |||
| b846b34e30 | |||
| 47905921ca | |||
| ca88193f9f | |||
| 1927cc8828 | |||
| 64f86ef39c | |||
| 39d27e8d3c | |||
| 77bf8af3fe | |||
| 6eb341f43c | |||
| 6a1fa525dc | |||
| d6875aac1b | |||
| c2ee245620 | |||
| 15c0c8a45d | |||
| 909fec9abb | |||
| ef1248f23a | |||
| c8c7dbed3b | |||
| bfa2972353 | |||
| 5fe4955057 | |||
| 55e163adef | |||
| 5ed57943d2 | |||
| 2fc2172d59 |
@@ -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,53 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.3] - 2026-04-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added atomic cross-process storage writes with stale-lock recovery and heartbeat refresh to prevent concurrent memory-file corruption.
|
||||||
|
- Scoped pending-memory promotion by owner/session so global unowned cleanup no longer removes active owned entries.
|
||||||
|
- Retained source-aware pending memories until they are actually promoted, absorbed, superseded, or rejected.
|
||||||
|
- Persisted load-time security redaction and expanded Bearer-token redaction to reduce secret retention risk.
|
||||||
|
- Hardened workspace normalization, cache bounds, rejected-entry retention, and session cleanup behavior.
|
||||||
|
|
||||||
|
## [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
|
## [1.3.0] - 2026-04-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -214,9 +214,17 @@ npm run typecheck
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- OpenCode >= 1.0.0
|
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||||
- Node.js >= 18.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
|
## License
|
||||||
|
|
||||||
MIT License. See [LICENSE](LICENSE) for details.
|
MIT License. See [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
# Release Notes
|
# 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)
|
## 1.3.0 (2026-04-27)
|
||||||
|
|
||||||
### Better Memory Consolidation
|
### Better Memory Consolidation
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"name": "opencode-working-memory",
|
||||||
"version": "1.3.0",
|
"version": "1.3.3",
|
||||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode-ai/plugin": "^1.2.0"
|
"@opencode-ai/plugin": ">=1.2.0 <2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
"@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 (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
||||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.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)
|
// Path-heavy facts (rediscoverable from repo)
|
||||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||||
if (pathCount > 2) return false;
|
if (pathCount > 2) return false;
|
||||||
|
|||||||
+135
-8
@@ -1,7 +1,22 @@
|
|||||||
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
|
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
|
||||||
|
import { PROMOTION_RETRY_LIMITS } from "./types.ts";
|
||||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.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 {
|
function normalizeMemoryText(text: string): string {
|
||||||
return text
|
return text
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
@@ -28,7 +43,7 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
|||||||
const result: LongTermMemoryEntry[] = [];
|
const result: LongTermMemoryEntry[] = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const key = memoryKey(entry);
|
const key = `${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
result.push(entry);
|
result.push(entry);
|
||||||
@@ -37,6 +52,53 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
|||||||
return result;
|
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);
|
||||||
|
|
||||||
|
// Invalid timestamps are corruption safety and apply to every source.
|
||||||
|
if (time === 0) return true;
|
||||||
|
|
||||||
|
// TTL policy applies only to compaction candidates. Explicit/manual entries
|
||||||
|
// represent user intent and should survive age while under the hard cap.
|
||||||
|
if (entry.source !== "compaction") return false;
|
||||||
|
|
||||||
|
return Date.now() - time > maxAgeDays * 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRetention(
|
||||||
|
entries: LongTermMemoryEntry[],
|
||||||
|
maxEntries: number,
|
||||||
|
maxAgeDays: number,
|
||||||
|
): LongTermMemoryEntry[] {
|
||||||
|
const deduped = dedupeByText(entries);
|
||||||
|
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||||
|
const sorted = [...freshEntries].sort((a, b) => {
|
||||||
|
const timeDiff = entryTime(b) - entryTime(a);
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
const capped = sorted.slice(0, maxEntries);
|
||||||
|
return capped.sort((a, b) => {
|
||||||
|
const timeDiff = entryTime(a) - entryTime(b);
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeJournal(
|
function normalizeJournal(
|
||||||
root: string,
|
root: string,
|
||||||
store: PendingMemoryJournalStore,
|
store: PendingMemoryJournalStore,
|
||||||
@@ -44,11 +106,11 @@ function normalizeJournal(
|
|||||||
return workspaceKey(root).then(key => ({
|
return workspaceKey(root).then(key => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
workspace: { root, key },
|
workspace: { root, key },
|
||||||
// TODO(memory-consolidation follow-up): add the deferred pending journal
|
entries: applyRetention(
|
||||||
// safety cap (max entries and old compaction pruning). P0 currently relies
|
Array.isArray(store.entries) ? store.entries : [],
|
||||||
// on promotion accounting to clear terminal compaction candidates without
|
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||||
// changing journal capacity behavior.
|
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||||
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
|
),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -94,13 +156,78 @@ export async function hasPendingJournalEntries(root: string): Promise<boolean> {
|
|||||||
return journal.entries.length > 0;
|
return journal.entries.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingMemories(root: string, keys?: Set<string>): Promise<void> {
|
export async function clearPendingMemories(
|
||||||
|
root: string,
|
||||||
|
keys?: Set<string>,
|
||||||
|
options: { ownerSessionID?: string; clearUnowned?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
await updatePendingJournal(root, store => {
|
await updatePendingJournal(root, store => {
|
||||||
if (!keys || keys.size === 0) {
|
if (!keys || keys.size === 0) {
|
||||||
store.entries = [];
|
store.entries = [];
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
store.entries = store.entries.filter(entry => !keys.has(memoryKey(entry)));
|
|
||||||
|
store.entries = store.entries.filter(entry => {
|
||||||
|
if (!keys.has(memoryKey(entry))) return true;
|
||||||
|
|
||||||
|
if (options.ownerSessionID) {
|
||||||
|
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
|
||||||
|
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.clearUnowned) {
|
||||||
|
return Boolean(entry.pendingOwnerSessionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
return store;
|
return store;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recordPromotionRejections(
|
||||||
|
root: string,
|
||||||
|
keys: Set<string>,
|
||||||
|
reason: string,
|
||||||
|
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const exhausted = new Set<string>();
|
||||||
|
if (keys.size === 0) return exhausted;
|
||||||
|
|
||||||
|
await updatePendingJournal(root, store => {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const exhaustedEntries = new Set<string>();
|
||||||
|
|
||||||
|
store.entries = store.entries.map(entry => {
|
||||||
|
const key = memoryKey(entry);
|
||||||
|
if (!keys.has(key)) return entry;
|
||||||
|
if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) return entry;
|
||||||
|
if (!options.ownerSessionID && options.includeUnownedOnly && entry.pendingOwnerSessionID) return entry;
|
||||||
|
|
||||||
|
const promotionAttempts = (entry.promotionAttempts ?? 0) + 1;
|
||||||
|
const max = entry.source === "manual"
|
||||||
|
? PROMOTION_RETRY_LIMITS.maxManualAttempts
|
||||||
|
: PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
|
||||||
|
|
||||||
|
if (promotionAttempts >= max) {
|
||||||
|
exhausted.add(key);
|
||||||
|
exhaustedEntries.add(`${key}\u0000${entry.pendingOwnerSessionID ?? ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
promotionAttempts,
|
||||||
|
lastPromotionAttemptAt: nowIso,
|
||||||
|
lastPromotionFailureReason: reason,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
store.entries = store.entries.filter(entry => (
|
||||||
|
!exhaustedEntries.has(`${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`)
|
||||||
|
));
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
|
||||||
|
return exhausted;
|
||||||
|
}
|
||||||
|
|||||||
+123
-21
@@ -43,6 +43,7 @@ import {
|
|||||||
hasPendingJournalEntries,
|
hasPendingJournalEntries,
|
||||||
loadPendingJournal,
|
loadPendingJournal,
|
||||||
memoryKey,
|
memoryKey,
|
||||||
|
recordPromotionRejections,
|
||||||
} from "./pending-journal.ts";
|
} from "./pending-journal.ts";
|
||||||
import {
|
import {
|
||||||
loadSessionState,
|
loadSessionState,
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
pendingTodos,
|
pendingTodos,
|
||||||
} from "./opencode.ts";
|
} from "./opencode.ts";
|
||||||
import { accountPendingPromotions } from "./promotion-accounting.ts";
|
import { accountPendingPromotions } from "./promotion-accounting.ts";
|
||||||
|
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the complete compaction prompt.
|
* Build the complete compaction prompt.
|
||||||
@@ -203,13 +205,67 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||||
const processedUserMessages = new Map<string, Set<string>>();
|
const processedUserMessages = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||||
|
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||||
|
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||||
|
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||||
|
const oldest = [...frozenWorkspaceMemoryCache.entries()]
|
||||||
|
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
|
||||||
|
if (!oldest) break;
|
||||||
|
frozenWorkspaceMemoryCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneProcessedUserMessagesCache(): void {
|
||||||
|
for (const [sessionID, messages] of processedUserMessages) {
|
||||||
|
while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||||
|
const oldest = messages.values().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
messages.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.size === 0) {
|
||||||
|
processedUserMessages.delete(sessionID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) {
|
||||||
|
const oldestSessionID = processedUserMessages.keys().next().value as string | undefined;
|
||||||
|
if (!oldestSessionID) break;
|
||||||
|
processedUserMessages.delete(oldestSessionID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set<string>): void {
|
||||||
|
processedForSession.add(messageID);
|
||||||
|
while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||||
|
const oldest = processedForSession.values().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
processedForSession.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedUserMessages.has(sessionID)) {
|
||||||
|
processedUserMessages.delete(sessionID);
|
||||||
|
}
|
||||||
|
processedUserMessages.set(sessionID, processedForSession);
|
||||||
|
pruneProcessedUserMessagesCache();
|
||||||
|
}
|
||||||
|
|
||||||
async function processLatestUserMessage(sessionID: string): Promise<void> {
|
async function processLatestUserMessage(sessionID: string): Promise<void> {
|
||||||
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
|
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
|
||||||
const latestMessage = await latestUserText(client, sessionID);
|
const latestMessage = await latestUserText(client, sessionID);
|
||||||
|
|
||||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||||
|
|
||||||
const memories = extractExplicitMemories(latestMessage.text);
|
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
|
||||||
|
...memory,
|
||||||
|
pendingOwnerSessionID: sessionID,
|
||||||
|
pendingMessageID: latestMessage.id,
|
||||||
|
}));
|
||||||
const decisions = memories.filter(memory => memory.type === "decision");
|
const decisions = memories.filter(memory => memory.type === "decision");
|
||||||
|
|
||||||
if (memories.length > 0) {
|
if (memories.length > 0) {
|
||||||
@@ -233,19 +289,29 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processedForSession.add(latestMessage.id);
|
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
|
||||||
processedUserMessages.set(sessionID, processedForSession);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promotePendingMemories(sessionID?: string): Promise<void> {
|
async function promotePendingMemories(
|
||||||
|
sessionID?: string,
|
||||||
|
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const includeUnownedJournal = options.includeUnownedJournal ?? !sessionID;
|
||||||
|
const includeOwnedJournal = options.includeOwnedJournal ?? Boolean(sessionID);
|
||||||
const [journal, sessionState] = await Promise.all([
|
const [journal, sessionState] = await Promise.all([
|
||||||
loadPendingJournal(directory),
|
loadPendingJournal(directory),
|
||||||
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
|
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const journalPending = journal.entries.filter(memory => {
|
||||||
|
if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true;
|
||||||
|
if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
const pending = [
|
const pending = [
|
||||||
...(sessionState?.pendingMemories ?? []),
|
...(sessionState?.pendingMemories ?? []),
|
||||||
...journal.entries,
|
...journalPending,
|
||||||
];
|
];
|
||||||
if (pending.length === 0) return;
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
@@ -277,16 +343,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
events: updateResult.events,
|
events: updateResult.events,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exhaustedRejectedKeys = await recordPromotionRejections(
|
||||||
|
directory,
|
||||||
|
accounting.retryableRejectedKeys,
|
||||||
|
"rejected_capacity",
|
||||||
|
{
|
||||||
|
ownerSessionID: sessionID,
|
||||||
|
includeUnownedOnly: !sessionID,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionRemovalKeys = new Set([
|
||||||
|
...accounting.clearableKeys,
|
||||||
|
...exhaustedRejectedKeys,
|
||||||
|
]);
|
||||||
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
await updateSessionState(directory, sessionID, state => {
|
await updateSessionState(directory, sessionID, state => {
|
||||||
state.pendingMemories = state.pendingMemories.filter(memory => !accounting.clearableKeys.has(memoryKey(memory)));
|
state.pendingMemories = state.pendingMemories.filter(memory => {
|
||||||
|
const key = memoryKey(memory);
|
||||||
|
if (!sessionRemovalKeys.has(key)) return true;
|
||||||
|
|
||||||
|
if (accounting.clearableKeys.has(key)) return false;
|
||||||
|
if (exhaustedRejectedKeys.has(key)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounting.clearableKeys.size > 0) {
|
if (accounting.clearableKeys.size > 0) {
|
||||||
await clearPendingMemories(directory, accounting.clearableKeys);
|
await clearPendingMemories(directory, accounting.clearableKeys, {
|
||||||
|
ownerSessionID: sessionID,
|
||||||
|
clearUnowned: !sessionID || includeUnownedJournal === true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +416,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
renderedPrompt: string;
|
renderedPrompt: string;
|
||||||
}> {
|
}> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
pruneFrozenWorkspaceMemoryCache(now);
|
||||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||||
|
|
||||||
// Cache is valid for the current session cache epoch.
|
// Cache is valid for the current session cache epoch.
|
||||||
@@ -336,6 +429,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
const store = await loadWorkspaceMemory(root);
|
const store = await loadWorkspaceMemory(root);
|
||||||
const renderedPrompt = renderWorkspaceMemory(store);
|
const renderedPrompt = renderWorkspaceMemory(store);
|
||||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||||
|
pruneFrozenWorkspaceMemoryCache(now);
|
||||||
return { store, renderedPrompt };
|
return { store, renderedPrompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,19 +451,23 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
const { sessionID } = hookInput;
|
const { sessionID } = hookInput;
|
||||||
if (!sessionID) return;
|
if (!sessionID) return;
|
||||||
|
|
||||||
|
pruneFrozenWorkspaceMemoryCache();
|
||||||
|
pruneProcessedUserMessagesCache();
|
||||||
|
|
||||||
// Sub-agents are short-lived - skip memory system
|
// Sub-agents are short-lived - skip memory system
|
||||||
if (await isSubAgent(sessionID)) return;
|
if (await isSubAgent(sessionID)) return;
|
||||||
|
|
||||||
// Before first snapshot in this session, promote durable pending memories from
|
// Process explicit user memory even on no-tool turns. Keep this after the
|
||||||
// prior sessions. Keep this before processing latest user text so current-turn
|
// sub-agent guard so child sessions never append to the parent journal.
|
||||||
// explicit memory remains pending (not immediately frozen into system[1]).
|
|
||||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
|
||||||
await promotePendingMemories();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process explicit user memory even on no-tool turns.
|
|
||||||
await processLatestUserMessage(sessionID);
|
await processLatestUserMessage(sessionID);
|
||||||
|
|
||||||
|
// Before first snapshot in this session, promote durable unowned backlog from
|
||||||
|
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||||
|
// appears in hot state for this transform.
|
||||||
|
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||||
|
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||||
|
}
|
||||||
|
|
||||||
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
||||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||||
|
|
||||||
@@ -521,7 +619,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await promotePendingMemories(sessionID);
|
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||||
} catch {
|
} catch {
|
||||||
// Keep pending memories in session/journal for retry on next event/session.
|
// Keep pending memories in session/journal for retry on next event/session.
|
||||||
}
|
}
|
||||||
@@ -532,16 +630,20 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
// Promote pending memories before deleting per-session state.
|
// Promote pending memories before deleting per-session state.
|
||||||
// If promotion fails, leave session state and journal intact.
|
// If promotion fails, leave session state and journal intact.
|
||||||
|
let promoted = false;
|
||||||
try {
|
try {
|
||||||
await promotePendingMemories(sessionID);
|
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||||
|
promoted = true;
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
if (promoted) {
|
||||||
|
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||||
|
processedUserMessages.delete(sessionID);
|
||||||
|
sessionParentCache.delete(sessionID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up caches
|
|
||||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
|
||||||
processedUserMessages.delete(sessionID);
|
|
||||||
sessionParentCache.delete(sessionID);
|
|
||||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-14
@@ -8,6 +8,7 @@ export type PendingPromotionAccounting = {
|
|||||||
absorbedKeys: Set<string>;
|
absorbedKeys: Set<string>;
|
||||||
supersededKeys: Set<string>;
|
supersededKeys: Set<string>;
|
||||||
rejectedKeys: Set<string>;
|
rejectedKeys: Set<string>;
|
||||||
|
retryableRejectedKeys: Set<string>;
|
||||||
clearableKeys: Set<string>;
|
clearableKeys: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,24 +73,38 @@ export function accountPendingPromotions(input: {
|
|||||||
rejectedKeys.add(key);
|
rejectedKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearableKeys = new Set([
|
||||||
|
...promotedKeys,
|
||||||
|
...absorbedKeys,
|
||||||
|
...supersededKeys,
|
||||||
|
...input.pending
|
||||||
|
.filter(memory => {
|
||||||
|
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||||
|
return memory.source === "compaction" && (
|
||||||
|
terminal?.reason === "rejected_capacity" ||
|
||||||
|
terminal?.reason === "rejected_stale"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(memory => memoryKey(memory)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const retryableRejectedKeys = new Set(
|
||||||
|
input.pending
|
||||||
|
.filter(memory => {
|
||||||
|
const key = memoryKey(memory);
|
||||||
|
return rejectedKeys.has(key) &&
|
||||||
|
!clearableKeys.has(key) &&
|
||||||
|
(memory.source === "explicit" || memory.source === "manual");
|
||||||
|
})
|
||||||
|
.map(memory => memoryKey(memory)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
promotedKeys,
|
promotedKeys,
|
||||||
absorbedKeys,
|
absorbedKeys,
|
||||||
supersededKeys,
|
supersededKeys,
|
||||||
rejectedKeys,
|
rejectedKeys,
|
||||||
clearableKeys: new Set([
|
retryableRejectedKeys,
|
||||||
...promotedKeys,
|
clearableKeys,
|
||||||
...absorbedKeys,
|
|
||||||
...supersededKeys,
|
|
||||||
...input.pending
|
|
||||||
.filter(memory => {
|
|
||||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
|
||||||
return memory.source === "compaction" && (
|
|
||||||
terminal?.reason === "rejected_capacity" ||
|
|
||||||
terminal?.reason === "rejected_stale"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map(memory => memoryKey(memory)),
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-5
@@ -1,9 +1,13 @@
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
||||||
|
import type { FileHandle } from "fs/promises";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
|
|
||||||
const fileLocks = new Map<string, Promise<unknown>>();
|
const fileLocks = new Map<string, Promise<unknown>>();
|
||||||
|
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
||||||
|
const LOCK_STALE_MS = 30_000;
|
||||||
|
const LOCK_HEARTBEAT_MS = 1_000;
|
||||||
|
|
||||||
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||||
if (!existsSync(path)) return fallback();
|
if (!existsSync(path)) return fallback();
|
||||||
@@ -14,6 +18,82 @@ export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readJSONStrict<T>(path: string, fallback: () => T): Promise<T> {
|
||||||
|
if (!existsSync(path)) return fallback();
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLockStale(lockPath: string, now = Date.now()): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stats = await stat(lockPath);
|
||||||
|
|
||||||
|
if (now - stats.mtimeMs > LOCK_STALE_MS) return true;
|
||||||
|
|
||||||
|
const content = await readFile(lockPath, "utf8");
|
||||||
|
const [, createdText] = content.split("\n");
|
||||||
|
const createdAt = Number(createdText);
|
||||||
|
|
||||||
|
return Number.isFinite(createdAt) && now - createdAt > LOCK_STALE_MS;
|
||||||
|
} catch (error) {
|
||||||
|
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLockInfo(handle: FileHandle): Promise<void> {
|
||||||
|
const content = `${process.pid}\n${Date.now()}\n`;
|
||||||
|
await handle.truncate(0);
|
||||||
|
await handle.write(content, 0, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const lockPath = `${path}.lock`;
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const handle = await open(lockPath, "wx", 0o600);
|
||||||
|
let heartbeat: NodeJS.Timeout | undefined;
|
||||||
|
let heartbeatWrite: Promise<void> = Promise.resolve();
|
||||||
|
const queueHeartbeat = (): void => {
|
||||||
|
heartbeatWrite = heartbeatWrite
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => writeLockInfo(handle))
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeLockInfo(handle);
|
||||||
|
heartbeat = setInterval(queueHeartbeat, LOCK_HEARTBEAT_MS);
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
await heartbeatWrite.catch(() => undefined);
|
||||||
|
await handle.close();
|
||||||
|
await rm(lockPath, { force: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== "EEXIST") throw error;
|
||||||
|
|
||||||
|
if (await isLockStale(lockPath)) {
|
||||||
|
await rm(lockPath, { force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
|
||||||
|
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
|
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
|
||||||
await mkdir(dirname(path), { recursive: true });
|
await mkdir(dirname(path), { recursive: true });
|
||||||
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||||
@@ -36,10 +116,12 @@ export async function updateJSON<T>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await previous.catch(() => undefined);
|
await previous.catch(() => undefined);
|
||||||
const current = await readJSON(path, fallback);
|
return await withFileLock(path, async () => {
|
||||||
const updated = await updater(current);
|
const current = await readJSONStrict(path, fallback);
|
||||||
await atomicWriteJSON(path, updated);
|
const updated = await updater(current);
|
||||||
return updated;
|
await atomicWriteJSON(path, updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
release();
|
release();
|
||||||
if (fileLocks.get(path) === queued) {
|
if (fileLocks.get(path) === queued) {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export type LongTermMemoryEntry = {
|
|||||||
staleAfterDays?: number;
|
staleAfterDays?: number;
|
||||||
supersedes?: string[];
|
supersedes?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
pendingOwnerSessionID?: string;
|
||||||
|
pendingMessageID?: string;
|
||||||
|
promotionAttempts?: number;
|
||||||
|
lastPromotionAttemptAt?: string;
|
||||||
|
lastPromotionFailureReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceMemoryStore = {
|
export type WorkspaceMemoryStore = {
|
||||||
@@ -100,3 +105,15 @@ export const HOT_STATE_LIMITS = {
|
|||||||
maxPendingMemoriesStored: 12,
|
maxPendingMemoriesStored: 12,
|
||||||
maxPendingMemoriesRendered: 6,
|
maxPendingMemoriesRendered: 6,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const PROMOTION_RETRY_LIMITS = {
|
||||||
|
maxExplicitAttempts: 3,
|
||||||
|
maxManualAttempts: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const WORKSPACE_MEMORY_CACHE_LIMITS = {
|
||||||
|
maxFrozenSessions: 50,
|
||||||
|
maxProcessedSessionIDs: 200,
|
||||||
|
maxProcessedMessagesPerSession: 50,
|
||||||
|
frozenTtlMs: 60 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|||||||
+104
-91
@@ -11,10 +11,13 @@ const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
|||||||
|
|
||||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/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 PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\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 USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
|
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||||
|
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
||||||
|
|
||||||
export type MemoryConsolidationReason =
|
export type MemoryConsolidationReason =
|
||||||
| "promoted"
|
| "promoted"
|
||||||
@@ -77,30 +80,33 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Always normalize on load so redaction/migrations are always-on.
|
// Always normalize on load so redaction/migrations are always-on.
|
||||||
const normalized = await normalizeWorkspaceMemory(root, store);
|
const normalized = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||||
|
|
||||||
// Persist only when meaningful content changed (ignore timestamps).
|
// Persist security/correctness mutations, but avoid read-time maintenance
|
||||||
if (didStoreMeaningfullyChange(store, normalized)) {
|
// writes for ordering/capacity/timestamp-only normalization.
|
||||||
await atomicWriteJSON(path, normalized);
|
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||||||
|
await atomicWriteJSON(path, normalized.store);
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized.store;
|
||||||
}
|
}
|
||||||
|
|
||||||
function didStoreMeaningfullyChange(
|
function hasSecurityOrMigrationChange(
|
||||||
before: WorkspaceMemoryStore,
|
before: WorkspaceMemoryStore,
|
||||||
after: WorkspaceMemoryStore,
|
after: WorkspaceMemoryStore,
|
||||||
): boolean {
|
): boolean {
|
||||||
const sanitize = (store: WorkspaceMemoryStore) => ({
|
const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry]));
|
||||||
...store,
|
for (const afterEntry of after.entries ?? []) {
|
||||||
updatedAt: "",
|
const beforeEntry = beforeById.get(afterEntry.id);
|
||||||
entries: store.entries.map(entry => ({
|
if (!beforeEntry) continue;
|
||||||
...entry,
|
if (beforeEntry.text !== afterEntry.text) return true;
|
||||||
updatedAt: "",
|
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||||||
})),
|
if (beforeEntry.status !== afterEntry.status) return true;
|
||||||
});
|
}
|
||||||
|
|
||||||
return JSON.stringify(sanitize(before)) !== JSON.stringify(sanitize(after));
|
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
||||||
|
const afterMigrations = JSON.stringify(after.migrations ?? []);
|
||||||
|
return beforeMigrations !== afterMigrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
||||||
@@ -231,6 +237,17 @@ export function redactCredentials(text: string): string {
|
|||||||
"$1[REDACTED]",
|
"$1[REDACTED]",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4. Standalone sensitive keys/tokens
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,72 +322,80 @@ export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type"
|
|||||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract entity/destination keys for project and reference dedup */
|
function normalizeUrlIdentity(raw: string): string | null {
|
||||||
function extractEntityKey(text: string): string | null {
|
const cleaned = raw.replace(/[),.;:!?]+$/g, "");
|
||||||
const normalized = canonicalMemoryText(text);
|
try {
|
||||||
// Check known key phrases (bilingual-friendly)
|
const url = new URL(cleaned);
|
||||||
// opencode + agenthub plugin system
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||||
if (/opencode.*agenthub/i.test(normalized)) {
|
url.protocol = url.protocol.toLowerCase();
|
||||||
return "opencode-agenthub plugin system";
|
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 normalizePathIdentity(raw: string): string | null {
|
||||||
function decisionTopicKey(text: string): string | null {
|
const unwrapped = raw
|
||||||
const normalized = text.toLowerCase();
|
.trim()
|
||||||
// Parser format versions
|
.replace(/^[`"']+|[`"']+$/g, "")
|
||||||
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
|
.replace(/[),.;:!?]+$/g, "")
|
||||||
return "parser-supported-formats";
|
.replace(/\\+/g, "/");
|
||||||
}
|
|
||||||
// Compaction template replacement
|
if (!unwrapped) return null;
|
||||||
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
|
const collapsed = unwrapped.startsWith("/")
|
||||||
return "compaction-template-replacement";
|
? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}`
|
||||||
}
|
: unwrapped.replace(/\/+/g, "/");
|
||||||
// Plugin loading
|
const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed;
|
||||||
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
|
return `path:${withoutTrailingSlash}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract feedback topic key for supersession detection */
|
function isConcretePathIdentity(pathIdentity: string): boolean {
|
||||||
function feedbackTopicKey(text: string): string | null {
|
const path = pathIdentity.slice("path:".length);
|
||||||
const normalized = text.toLowerCase();
|
if (!path || path === "." || path === "..") return false;
|
||||||
// Purple/italic rendering issue
|
|
||||||
if (/purple.*italic/i.test(normalized)) {
|
if (path.startsWith("/")) return true;
|
||||||
return "purple-italic-rendering";
|
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)) {
|
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||||||
return "server-error";
|
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)) {
|
const pathMatch = text.match(/(?:\/[^ | ||||||