Compare commits

...

5 Commits

Author SHA1 Message Date
Ralph Chang 3560868f52 release: v1.2.3 2026-04-27 02:24:48 +08:00
Ralph Chang e7c7a5cfb2 feat: add durable pending memory journal 2026-04-27 02:20:26 +08:00
Ralph Chang 026c75a5e4 feat: freeze rendered workspace memory snapshot 2026-04-27 01:57:41 +08:00
Ralph Chang eb74a9f03e docs: add workspace memory cache optimization plan 2026-04-27 01:55:48 +08:00
Ralph Chang f6f35e87c1 feat: release v1.2.2 with multilingual memory hardening 2026-04-27 00:21:18 +08:00
15 changed files with 3131 additions and 81 deletions
+25
View File
@@ -133,6 +133,31 @@ Persists across sessions within the same workspace. Automatically extracted duri
- `compaction` - Extracted during compaction (confidence: 0.75) - `compaction` - Extracted during compaction (confidence: 0.75)
- `manual` - Added programmatically (confidence: varies) - `manual` - Added programmatically (confidence: varies)
### Explicit Memory Triggers
Workspace memory is automatic, but you can also explicitly ask the agent to remember durable facts for future sessions.
Use memory triggers for preferences, decisions, project conventions, and stable references — not temporary progress updates or secrets.
| Language | Example trigger phrases |
|----------|--------------------------|
| English | `remember this`, `save to memory`, `commit to memory`, `from now on`, `my preference` |
| Chinese | `记住`, `記住`, `记得`, `記得`, `请帮我记住`, `幫我記住` |
| Japanese | `覚えて`, `覚えておいて`, `忘れないで`, `メモして` |
| Korean | `기억해`, `기억해줘`, `잊지 마`, `메모해줘` |
Negative requests are respected too, such as "don't remember this", `不要記住`, `覚えないで`, or `기억하지 마`.
**Good examples:**
- "Remember this: we prefer Vitest for new unit tests."
- "覚えておいて: API clients should use the shared retry helper."
- "기억해줘: this project uses pnpm, not npm."
**Avoid:**
- "Remember my password is hunter2." — credentials are redacted.
- "Remember Sprint 3 is 40% done." — temporary progress snapshots are filtered.
- "Remember the last command output." — session-specific details usually are not durable.
### Hot Session State (Short-term) ### Hot Session State (Short-term)
Automatically tracks current session context: Automatically tracks current session context:
+65
View File
@@ -1,5 +1,70 @@
# Release Notes # Release Notes
## 1.2.3 (2026-04-27)
### Prompt Cache Optimization — Frozen Snapshot + Ephemeral Delta
This release optimizes the plugin's impact on OpenCode's prompt cache, following Hermes-style architecture patterns.
### Key Features
- **Frozen workspace snapshot**: Workspace memory is now rendered once at session start and cached as immutable `system[1]`. No mid-session re-render that could invalidate the cache.
- **Ephemeral hot state**: Hot session state (active files, errors) is rendered in `system[2+]`, which is excluded from the first-two-system cache control.
- **Durable pending journal**: Explicit memories are written to both session state and a durable workspace-level pending journal, ensuring no data loss between compactions.
- **Safe promotion**: Explicit memories are promoted from pending to workspace memory at:
- Next session start (before frozen snapshot)
- `session.compacted`
- `session.deleted` (before cleanup)
### Architecture
```
system[0] → OpenCode / agent header (stable cached)
system[1] → Frozen workspace memory snapshot (stable cached)
system[2+] → Hot session state + pending memories (dynamic, not cached)
```
### Fixed
- **Hot state invalidating cache**: Active files / errors updating every tool call previously caused the entire workspace memory block to be re-hashed, killing cache efficiency.
- **Explicit memory loss**: Without compaction, explicit memories could be lost when sessions ended without promotion.
- **Mid-session mutation**: Explicit memories no longer mutate the running frozen snapshot; they appear as pending and are promoted safely.
### Migration
- One-time migration: `2026-04-27-p0-cleanup` removes stale pending journal entries older than 60 days.
### Tests
- **91 tests pass** (24 workspace-memory, 34 extractors, 14 plugin, 19 pending-journal)
---
## 1.2.2 (2026-04-27)
### Safer Multilingual Memory Capture
This release strengthens explicit memory handling across languages while keeping sensitive credentials out of stored workspace memory.
### Key Features
- **Always-on credential redaction**: Credentials are redacted both when memory is loaded and when it is saved
- **Multilingual memory triggers**: Added Japanese and Korean explicit-memory phrases, plus expanded Chinese coverage
- **Expanded snapshot filtering**: Rejects Wave/Sprint/Milestone/Task progress snapshots that should not become durable memory
- **Higher memory quality bar**: Extraction now focuses on durable facts that will change future behavior
### Fixed
- **Credential leakage risk**: Password/PIN-style values are now redacted with delimiter-preserving patterns, including multilingual labels such as `パスワード`, `비밀번호`, `contraseña`, `mot de passe`, and `Passwort`.
- **Missing non-English explicit memory requests**: Japanese (`覚えて`, `メモして`), Korean (`기억해`, `메모해줘`), and additional Chinese triggers are now recognized.
- **Progress snapshots polluting memory**: Wave/Sprint/Milestone/Task status updates are filtered from long-term memory unless they contain durable facts.
### Migration
- Runs one-time cleanup for legacy snapshot entries: `2026-04-26-p0-cleanup`
---
## 1.2.1 (2026-04-26) ## 1.2.1 (2026-04-26)
### Compaction Memory Quality — Four-Layer Defense ### Compaction Memory Quality — Four-Layer Defense
@@ -0,0 +1,702 @@
# Workspace Memory Cleanup Migration Plan (v2)
## Status: APPROVED (v3)
## Problem Statement
Audit of recent workspace memories found quality issues in pre-v1.2.1 stores:
### Issue 1: Snapshot Violations (P0)
| Workspace | Entry | Type |
|-----------|-------|------|
| opencode-record | `測試套件:1237 tests pass, 226 suites` | Test count |
| opencode-record | `USB 同步:37 個文件(...)` | File count (Chinese) |
| opencode-record | `pathology-playground...已完成 Phase 1-4` | Phase progress |
| pathology-agent-reports | `Waves 1-5, 7 已完成,Wave 6 deferred` | Wave progress |
**Root Cause**: These entries were created before P0c/P0d fix (08:02:32). Current code would reject them.
**Risk**: Medium. Pollutes long-term memory, wastes tokens.
### Issue 2: Sensitive Credentials (P0)
| Workspace | Entry | Risk |
|-----------|-------|------|
| opencode-record | `Admin PIN 是 456123` | **High** - Raw credential |
| Pre-cancer-atlas | `測試用戶名:shihlab,密碼:sushi` | **High** - Raw credential |
**Root Cause**: No credential redaction in compaction extraction or storage normalization.
**Risk**: High. Credentials sent to model in every compaction prompt.
### Issue 3: Wave/Sprint Not Filtered (P0)
| Pattern | Status |
|---------|--------|
| `Phase 1-4 已完成` | ✅ Filtered by P0c |
| `Wave 1-5 已完成` | ❌ Not filtered |
**Root Cause**: P0c filter only covers `Phase`, not `Wave/Sprint/Milestone/Task`.
**Risk**: Medium. New snapshots still enter memory.
### Issue 4: Duplicates (P1)
| Workspace | Entry | Issue |
|-----------|-------|-------|
| Pre-cancer-atlas | `認證使用 Basic Auth...` x2 | Exact duplicate |
| Pre-cancer-atlas | `IP 隱私...` x2 | Semantic duplicate |
| Pre-cancer-atlas | `Cloud Run...` project + reference | Cross-type duplicate |
**Root Cause**: `extractEntityKey()` only recognizes `opencode-agenthub`. Natural canonical dedup handles exact duplicates.
**Risk**: Low. Wastes tokens but not dangerous.
---
## Architect Review Failures (v1, v2)
### v1 Failures
| Issue | Problem |
|-------|---------|
| Regex | `Waves` not matched, Chinese `\b` unreliable |
| Superseded entries | Would be deleted by `enforceLongTermLimits()` |
| Credential redaction | Was migration-gated, must be always-on |
| Wave filter | Deferred to future, must be now |
| Over-broad | `Upload limit is 10 files` would be flagged |
| Rationale | Only redacted `text`, not `rationale` |
### v2 Failures
| Issue | Problem |
|-------|---------|
| File context | `upload` matches `Upload limit`, false positive |
| Explicit check | Missing `source === "explicit"` check before marking |
| Credential regex | `\S+` captures through Chinese comma tail |
| Filter location | Don't filter in `getFrozenWorkspaceMemory()` |
---
## Proposed Solution (v3)
### Architecture Principle
```
┌─────────────────────────────────┐
│ normalizeWorkspaceMemory() │
│ │
│ 1. ALWAYS redact credentials │
│ (not migration-gated) │
│ │
│ 2. Mark legacy snapshots as │
│ superseded (migration-gated)│
│ │
│ 3. Preserve superseded entries │
│ in storage, exclude from │
│ render │
└─────────────────────────────────┘
```
### Key Design Decisions
1. **Credential redaction is always-on** - runs on every normalize, independent of migration ID
2. **Snapshot marking is migration-gated** - one-time cleanup for legacy entries
3. **Superseded entries preserved in storage** - but excluded from render
4. **Type restriction for snapshots** - only `project` type, avoid false positives
5. **Wave/Sprint/Milestone filter added now** - not deferred
---
## Implementation
### 1. Add Migration Tracking to Type
```typescript
// src/types.ts
interface WorkspaceMemoryStore {
version: number;
workspace: { root: string; key: string };
limits: { maxRenderedChars: number; maxEntries: number };
entries: LongTermMemoryEntry[];
migrations?: string[]; // NEW: track applied migrations
updatedAt: string;
}
const MIGRATION_ID = "2026-04-26-p0-cleanup";
```
### 2. Snapshot Detection (Revised Regex)
```typescript
// src/workspace-memory.ts
/**
* Detect snapshot violations in text.
* Only apply to 'project' type entries with source !== 'explicit'.
*/
function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts (Chinese/English) - require sync/completion context
// And must NOT be a limit/maximum statement
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone progress
// English: Phase 1-4 completed, Waves 1-5 done
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
// Chinese: 已完成 Phase 1-4
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
```
### 3. Credential Redaction (Always-On)
```typescript
// src/workspace-memory.ts
/**
* Bounded secret value pattern - stops at delimiters and Chinese punctuation.
* Avoids capturing through Chinese commas: 密碼:sushi,用於測試
*/
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s]+`;
/**
* Multilingual credential labels.
* These are used in both detection and redaction patterns.
*/
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
/**
* Prefix patterns that capture label + delimiter together.
* This preserves the delimiter in output: 密碼:secret → 密碼:[REDACTED]
*/
const PASSWORD_PREFIX = String.raw`(${PASSWORD_LABELS.source}\s*(?:是|=|:|:)?\s*)`;
const USERNAME_PREFIX = String.raw`(${USERNAME_LABELS.source}\s*(?:是|=|:|:)?\s*)`;
/**
* Redact sensitive credentials from text.
* This runs on EVERY normalize, not just migration.
* Idempotent - [REDACTED] doesn't match patterns again.
*
* Order matters:
* 1. PIN (standalone)
* 2. Username+password pairs (must run before standalone password)
* 3. Standalone password
*/
function redactCredentials(text: string): string {
let result = text;
// 1. PIN patterns (language-neutral, supports 是, =, :, :)
result = result.replace(
new RegExp(String.raw`\b(PIN|pin)\s*(?:是|=|:|:)?\s*[`'"]?(${SECRET_VALUE})`, 'gi'),
'$1 [REDACTED]'
);
// 2. Username+Password pairs (multilingual)
// Must run BEFORE standalone password to match full pairs.
// 測試用戶名:xxx,密碼:yyy
// username: xxx, password: yyy
result = result.replace(
new RegExp(
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
'gi'
),
'$1[REDACTED]$3$4[REDACTED]'
);
// 3. Standalone password patterns (multilingual)
// Matches: password: secret, 密碼:secret, パスワード: secret, etc.
result = result.replace(
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, 'gi'),
'$1[REDACTED]'
);
return result;
}
```
### 4. Migration Function (One-Time)
```typescript
// src/workspace-memory.ts
function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string
): WorkspaceMemoryStore {
// Check if already run
if (store.migrations?.includes(MIGRATION_ID)) {
return store;
}
const entries = store.entries.map(entry => {
// Skip explicit entries - user-added memories are preserved
if (entry.source === "explicit") {
return entry;
}
// Skip non-project types for snapshot marking
// (Only project entries had snapshot pollution)
if (entry.type !== "project") {
return entry;
}
// Mark legacy snapshot violations as superseded
if (isProjectSnapshotViolation(entry.text)) {
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
};
}
return entry;
});
return {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
};
}
```
### 5. Normalize with Always-On Credential Redaction
```typescript
// src/workspace-memory.ts
// Preserve existing normalization behavior
async function normalizeWorkspaceMemory(
root: string,
store: WorkspaceMemoryStore,
): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
// Start with existing store normalization
let result: WorkspaceMemoryStore = {
...store,
workspace: { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
},
entries: Array.isArray(store.entries) ? store.entries : [],
updatedAt: nowIso,
};
// ALWAYS-ON: Redact credentials in all entries
// This must run regardless of migration status
result.entries = result.entries.map(entry => {
const text = redactCredentials(entry.text);
const rationale = entry.rationale
? redactCredentials(entry.rationale)
: undefined;
if (text === entry.text && rationale === entry.rationale) {
return entry;
}
return {
...entry,
text,
rationale,
updatedAt: nowIso,
};
});
// ONE-TIME: Mark legacy snapshots as superseded
result = runMigrationP0Cleanup(result, nowIso);
// Remove superseded from active rendering
const activeEntries = result.entries.filter(e => e.status !== "superseded");
// Apply dedup and limits to active entries only
const processed = enforceLongTermLimits(activeEntries);
// Merge back: active entries + superseded entries (preserved in storage)
const superseded = result.entries.filter(e => e.status === "superseded");
return {
...result,
entries: [...processed, ...superseded],
updatedAt: nowIso,
};
}
```
### 6. Extend P0c Snapshot Filter (Not Deferred)
```typescript
// src/extractors.ts
// Add to isProjectSnapshotViolation() or equivalent filter
// File counts - require snapshot context AND NOT limit context
const FILE_COUNT_PATTERN = /\d+\s*(?:個|个)?\s*(?:files?|文件)/i;
const FILE_SNAPSHOT_CONTEXT = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i;
const FILE_LIMIT_CONTEXT = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i;
if (FILE_COUNT_PATTERN.test(text)) {
if (FILE_SNAPSHOT_CONTEXT.test(text) && !FILE_LIMIT_CONTEXT.test(text)) {
return true; // snapshot violation
}
}
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// Phase/Wave/Sprint/Milestone progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
```
**Note**: Do NOT use bare `upload|download` as context. Use past-tense verbs or process states.
---
## Test Cases
### Credential Redaction (Always-On)
| Input | Expected Output |
|-------|-----------------|
| `Admin PIN 是 456123` | `Admin PIN 是 [REDACTED]` |
| `Admin PIN = 456123` | `Admin PIN = [REDACTED]` |
| `Admin PIN 456123` | `Admin PIN [REDACTED]` |
| `密碼:sushi` | `密碼:[REDACTED]` |
| `密码:sushi` | `密码:[REDACTED]` |
| `password: abc-123!` | `password: [REDACTED]` |
| `パスワード:secret` | `パスワード:[REDACTED]` |
| `비밀번호: secret` | `비밀번호: [REDACTED]` |
| `測試用戶名:shihlab,密碼:sushi` | `測試用戶名:[REDACTED],密碼:[REDACTED]` |
| `密碼:sushi,用於測試` | `密碼:[REDACTED],用於測試` |
| Credential in rationale | Redacted in both text and rationale |
| Explicit entry with PIN | Redacted, preserved |
| `[REDACTED]` in text | No change (idempotent) |
### Snapshot Detection
| Input | type | source | Is Violation? |
|-------|------|--------|---------------|
| `1237 tests pass, 226 suites` | project | compaction | ✅ Yes |
| `USB 同步:37 個文件` | project | compaction | ✅ Yes |
| `Phase 1-4 已完成` | project | compaction | ✅ Yes |
| `Waves 1-5 已完成` | project | compaction | ✅ Yes |
| `Upload limit is 10 files` | project | compaction | ❌ No (has "limit" context) |
| `Project supports 5 test suites` | project | compaction | ❌ No (no pass/fail) |
| `Phase 1-4 已完成` | project | explicit | ❌ No (explicit preserved) |
| Snapshot text | feedback | compaction | ❌ No (only project type) |
| Snapshot text | decision | compaction | ❌ No (only project type) |
### Migration Behavior
| Test | Description |
|------|-------------|
| Run once | Migration ID added |
| Run twice | No duplicate ID, entries unchanged |
| Non-project entry | Not marked superseded |
| Project snapshot | Marked superseded |
| Explicit project snapshot | Not marked (source check before type) |
| Credential in snapshot | Redacted, then marked superseded |
### Integration Tests
| Test | Description |
|------|-------------|
| `saveWorkspaceMemory()` | Superseded entries preserved in JSON |
| `updateWorkspaceMemory()` | Credential redaction runs on second normalize |
| New entry with PIN | Redacted on save (always-on) |
| `normalizeWorkspaceMemory()` | Preserves workspace root/key, limits, updatedAt |
| Memory render | Superseded entries excluded via `enforceLongTermLimits()` |
### Extractor Tests
| Input | Expected |
|-------|----------|
| `Upload limit is 10 files` | NOT a snapshot violation (has "limit" context) |
| `USB uploaded 37 files` | Snapshot violation (has "uploaded" process context) |
| `Project supports 5 test suites` | NOT a snapshot violation (no pass/fail context) |
| `1237 tests passed` | Snapshot violation (test count with pass) |
---
## Edge Cases
| Case | Handling |
|------|----------|
| Entry is explicit + snapshot | Not marked (source check before type check) |
| Entry has both snapshot + credential | Credential redacted, snapshot marked |
| Entry is already superseded | Keep status, still redact credentials |
| Migration runs twice | Skip if ID present |
| Store has no migrations field | Create empty array |
| `Upload limit is 10 files` | Not marked (has "limit" context) |
| Password with punctuation `abc-123!` | Captured by bounded pattern |
| Chinese comma after credential `密碼:sushi,用於測試` | Redact preserves `,用於測試` |
| Simplified Chinese `密码` | Preserved as `密码:[REDACTED]` |
---
## Implementation Order
1. Add `migrations` field to `WorkspaceMemoryStore` type
2. Add snapshot patterns to `src/extractors.ts` (not deferred)
3. Add `isProjectSnapshotViolation()` to `src/workspace-memory.ts`
4. Add `redactCredentials()` to `src/workspace-memory.ts`
5. Add `runMigrationP0Cleanup()` to `src/workspace-memory.ts`
6. Update `normalizeWorkspaceMemory()` with always-on redaction + migration
7. Do NOT add filtering to `getFrozenWorkspaceMemory()` - filtering happens in `enforceLongTermLimits()`
8. Add test cases for all patterns
---
## What We Will NOT Do
### Do NOT Add Project-Specific Entity Keys
Cloud Run, Basic Auth, IP privacy — these are project-specific. Natural canonical dedup handles exact duplicates.
### Do NOT Delete Superseded Entries
Mark as `status: "superseded"`, preserve in storage, exclude from render.
### Do NOT Gate Credential Redaction on Migration
Credential redaction is always-on. Migration only marks legacy snapshots.
---
## Summary
| Issue | Priority | Solution |
|-------|----------|----------|
| Sensitive credentials | P0 | Always-on redaction |
| Snapshot violations | P0 | Migration-gated marking (project type only) |
| Wave progress not filtered | P0 | Add to extractors.ts now |
| Project-specific duplicates | N/A | Natural dedup |
**Credential redaction runs on every normalize.**
**Snapshot marking is one-time migration for legacy entries.**
**Superseded entries preserved in storage, excluded from render.**
**Wave/Sprint/Milestone filter added now, not deferred.**
---
## Multilingual Scope
### Snapshot Detection: Chinese + English Only
Do **not** add Japanese/Korean/Spanish/French/German snapshot regexes now.
Reasons:
- False positives silently suppress valid durable memories
- Audit evidence only shows Chinese and English pollution
- Words like "completed", "terminé", "abgeschlossen" can appear in durable process descriptions
- Extraction is always-on, so every false positive becomes permanent blind spot
Add languages only after seeing real polluted memories in those languages.
### Credential Redaction: Add Multilingual Labels
For credentials, false negatives leak secrets. Add high-signal multilingual labels now.
**Password labels:**
```typescript
const PASSWORD_LABELS =
/password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
```
**Username labels:**
```typescript
const USERNAME_LABELS =
/username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
```
PIN remains language-neutral: `/\bPIN\b/i`
### Memory Trigger Patterns: Add Chinese Expansion + Japanese + Korean
#### Chinese Expansion
Add common phrases:
```typescript
// Current: 记住/記住
// Add: 记得/記得, 记下来/記下來
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim
```
#### Japanese Positive Triggers
```typescript
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/gim
```
Note: `覚えておいて` must come before `覚えて` to prevent partial match in body.
Note: `忘れないで` ("don't forget") is a positive memory request despite negative morphology.
#### Japanese Negation
```typescript
/(?:覚えないで|記憶しないで|メモしないで)\s*$/u
```
#### Korean Positive Triggers
```typescript
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim
```
Note: `기억해줘` must come before `기억해`, `메모해줘` must come before `메모해` to prevent partial match in body.
Note: `잊지 마` ("don't forget") is a positive memory request despite negative morphology.
#### Korean Negation
```typescript
/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u
```
#### Priority
1. Chinese: `记得/記得`, `记下来/記下來` (small expansion)
2. Japanese (full patterns + negation)
3. Korean (full patterns + negation)
4. Defer: Spanish/German/French (higher collision risk with normal text)
### Tests Required
**Credential redaction:**
```text
パスワード:secret → [REDACTED]
비밀번호: secret → [REDACTED]
contraseña: secret → [REDACTED]
mot de passe: secret → [REDACTED]
Passwort: secret → [REDACTED]
```
**Memory triggers (positive):**
```text
记得:这个项目使用 pnpm
記下來:这个项目使用 pnpm
覚えて: このプロジェクトは pnpm を使う
覚えておいて: このプロジェクトは pnpm を使う
忘れないで: このプロジェクトは pnpm を使う
メモして: このプロジェクトは pnpm を使う
기억해: 이 프로젝트는 pnpm을 사용한다
기억해줘: 이 프로젝트는 pnpm을 사용한다
잊지 마: 이 프로젝트는 pnpm을 사용한다
메모해: 이 프로젝트는 pnpm을 사용한다
메모해줘: 이 프로젝트는 pnpm을 사용한다
```
**Memory triggers (body extraction - must not include trigger suffix):**
```text
覚えておいて: このプロジェクトは pnpm を使う
→ body is "このプロジェクトは pnpm を使う" (not "おいて: この...")
기억해줘: 이 프로젝트는 pnpm을 사용한다
→ body is "이 프로젝트는 pnpm을 사용한다" (not "줘: 이...")
메모해줘: 이 프로젝트는 pnpm을 사용한다
→ body is "이 프로젝트는 pnpm을 사용한다" (not "줘: 이...")
```
**Memory triggers (negation - should NOT trigger):**
```text
覚えないで 覚えて: temporary note only
メモしないで メモして: temporary note only
기억하지 마 기억해: temporary note only
메모하지 마 메모해: temporary note only
```
---
## Memory Quality Bar (Prompt Improvement)
### Problem
Current extraction accepts "facts that were mentioned" instead of "facts that will change future behavior."
Examples of low-value trivia:
- `Cloud Run revision: pre-cancer-atlas-website-00066-j8c` — transient deployment state
- `UI 要統一風格:兩個表格都要 scrollable,約 20 rows` — local implementation detail
- Paths observed from code/logs without stable contract
### Solution: Prompt Quality Bar
Add to compaction memory extraction prompt:
```text
Memory quality bar:
Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.
Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.
When unsure, skip it. Fewer high-signal memories are better than many low-value ones.
```
### Example Pair (Optional)
If model still stores junk, add one example:
```text
Bad: Cloud Run revision: xyz-00066
Good: Revision xyz-00066 is the last known good deploy before the auth regression.
```
### What This Captures
| Keep | Reject |
|------|--------|
| User preferences | Transient IDs/revisions |
| Decisions with rationale | Task progress, test/file counts |
| Stable constraints | Bare status updates |
| Hard-to-rediscover references | Local UI details |
| | Rediscoverable facts |
### Why Prompt Instead of Code Filters
- Context matters: "Cloud Run revision" might be useful if framed as "last known good before regression"
- Avoid regex whack-a-mole for every trivia pattern
- Model can judge wording and context
- Easier to iterate on prompt than code
### Code Filters (Stay Minimal)
Keep only hard invariants:
- Credentials (security)
- Obvious snapshots (test counts, phase progress)
Do NOT add new filters for deployment revisions, status updates, or UI trivia. Let prompt handle those.
---
## Summary
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "opencode-working-memory", "name": "opencode-working-memory",
"version": "1.2.1", "version": "1.2.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",
+81 -16
View File
@@ -27,6 +27,16 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
return true; return true;
} }
// Japanese negative
if (/(?:覚えないで|記憶しないで|メモしないで)\s*$/u.test(prefix)) {
return true;
}
// Korean negative
if (/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u.test(prefix)) {
return true;
}
return false; return false;
} }
@@ -35,7 +45,11 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法 // Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
const patterns = [ const patterns = [
// 中文:請/幫我 + 記住 + 可選後綴 // 中文:請/幫我 + 記住 + 可選後綴
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim, /(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
// 日文(長詞優先):覚えておいて must come before 覚えて
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/gim,
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配 // 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim, /(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
// save/add to memory // save/add to memory
@@ -179,6 +193,31 @@ export function classifyCommand(command: string): OpenError["category"] | null {
return null; return null;
} }
function normalizeCandidateBody(body: string): { text: string; hadTrigger: boolean } | null {
const text = body.trim();
const triggerPatterns = [
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
/(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im,
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
];
for (const pattern of triggerPatterns) {
const match = pattern.exec(text);
if (!match) continue;
const triggerIndex = match.index + (match[0].match(/^\s*/)?.[0]?.length || 0);
if (isNegatedMemoryRequest(text, triggerIndex)) return null;
const extracted = match[1]?.trim();
return extracted ? { text: extracted, hadTrigger: true } : null;
}
return { text, hadTrigger: false };
}
function extractFirstPath(text: string): string | undefined { function extractFirstPath(text: string): string | undefined {
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0]; return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
} }
@@ -187,16 +226,22 @@ function extractFirstPath(text: string): string | undefined {
* Quality gate for workspace memory candidates. * Quality gate for workspace memory candidates.
* Rejects low-quality entries like git hashes, error messages, etc. * Rejects low-quality entries like git hashes, error messages, etc.
*/ */
function shouldAcceptWorkspaceMemoryCandidate(entry: { function shouldAcceptWorkspaceMemoryCandidate(
entry: {
type: LongTermType; type: LongTermType;
text: string; text: string;
}): boolean { },
options: {
fromMemoryTrigger?: boolean;
} = {},
): boolean {
const text = entry.text.trim(); const text = entry.text.trim();
const minLength = options.fromMemoryTrigger ? 6 : 20;
// Too short (with type-specific allowlist for stable config values) // Too short (with type-specific allowlist for stable config values)
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) { if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
// Stable config values can be short — allow below generic min length // Stable config values can be short — allow below generic min length
} else if (text.length < 20) { } else if (text.length < minLength) {
return false; return false;
} }
@@ -224,19 +269,33 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: {
// Session-specific progress snapshots for project type // Session-specific progress snapshots for project type
if (entry.type === "project") { if (entry.type === "project") {
if (/\b\d+\s+tests?\s+pass(?:ed)?\b/i.test(text)) return false; if (isProjectSnapshotViolation(text)) return false;
if (/\b\d+\s+suites?\b/i.test(text)) return false;
if (/\b\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) return false;
// Reject "Phase N completed" using semantic window (within 20 chars either direction)
if (/\bphase\s*\d+(?:\s*[-–]\s*\d+)?\b.{0,20}\b(?:completed|done|finished)\b/i.test(text)) return false;
if (/\b(?:completed|done|finished)\b.{0,20}\bphase\s*\d+(?:\s*[-–]\s*\d+)?\b/i.test(text)) return false;
if (/已完成.{0,20}Phase\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) return false;
if (/Phase\s*\d+(?:\s*[-–]\s*\d+)?.{0,20}已完成/i.test(text)) return false;
} }
return true; return true;
} }
function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts with snapshot/process context only, not static limits
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone/Task progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
/** /**
* Extract candidate block from summary using multiple formats. * Extract candidate block from summary using multiple formats.
* Supports: Plain text label, Markdown section, legacy XML. * Supports: Plain text label, Markdown section, legacy XML.
@@ -275,16 +334,22 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
); );
if (!item) continue; if (!item) continue;
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType; const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
const body = item[3].trim(); const normalizedBody = normalizeCandidateBody(item[3]);
if (body.length < 12) continue; if (!normalizedBody) continue;
const minLength = normalizedBody.hadTrigger ? 6 : 12;
if (normalizedBody.text.length < minLength) continue;
// Apply quality gate // Apply quality gate
if (!shouldAcceptWorkspaceMemoryCandidate({ type, text: body })) continue; if (!shouldAcceptWorkspaceMemoryCandidate(
{ type, text: normalizedBody.text },
{ fromMemoryTrigger: normalizedBody.hadTrigger },
)) continue;
entries.push({ entries.push({
id: id("mem"), id: id("mem"),
type, type,
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
source: "compaction", source: "compaction",
confidence: 0.75, confidence: 0.75,
status: "active", status: "active",
+4
View File
@@ -20,6 +20,10 @@ export async function workspaceMemoryPath(root: string): Promise<string> {
return join(await memoryRoot(root), "workspace-memory.json"); return join(await memoryRoot(root), "workspace-memory.json");
} }
export async function workspacePendingJournalPath(root: string): Promise<string> {
return join(await memoryRoot(root), "workspace-pending-journal.json");
}
export async function sessionStatePath(root: string, sessionID: string): Promise<string> { export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32); const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`); return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
+102
View File
@@ -0,0 +1,102 @@
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
function normalizeMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
export function memoryKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
return `${entry.type}:${normalizeMemoryText(entry.text)}`;
}
export async function emptyPendingJournal(root: string): Promise<PendingMemoryJournalStore> {
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
entries: [],
updatedAt: new Date().toISOString(),
};
}
function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const seen = new Set<string>();
const result: LongTermMemoryEntry[] = [];
for (const entry of entries) {
const key = memoryKey(entry);
if (seen.has(key)) continue;
seen.add(key);
result.push(entry);
}
return result;
}
function normalizeJournal(
root: string,
store: PendingMemoryJournalStore,
): Promise<PendingMemoryJournalStore> {
return workspaceKey(root).then(key => ({
version: 1,
workspace: { root, key },
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
updatedAt: new Date().toISOString(),
}));
}
export async function loadPendingJournal(root: string): Promise<PendingMemoryJournalStore> {
const path = await workspacePendingJournalPath(root);
const fallback = await emptyPendingJournal(root);
const loaded = await readJSON(path, () => fallback) as Partial<PendingMemoryJournalStore>;
return normalizeJournal(root, {
version: loaded.version ?? 1,
workspace: loaded.workspace ?? fallback.workspace,
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
});
}
export async function savePendingJournal(root: string, store: PendingMemoryJournalStore): Promise<void> {
await atomicWriteJSON(await workspacePendingJournalPath(root), await normalizeJournal(root, store));
}
export async function updatePendingJournal(
root: string,
updater: (store: PendingMemoryJournalStore) => PendingMemoryJournalStore | Promise<PendingMemoryJournalStore>,
): Promise<PendingMemoryJournalStore> {
const path = await workspacePendingJournalPath(root);
const fallback = await emptyPendingJournal(root);
return updateJSON(path, () => fallback, async current => {
const normalized = await normalizeJournal(root, current);
return normalizeJournal(root, await updater(normalized));
});
}
export async function appendPendingMemories(root: string, memories: LongTermMemoryEntry[]): Promise<void> {
if (memories.length === 0) return;
await updatePendingJournal(root, store => {
store.entries.push(...memories);
return store;
});
}
export async function hasPendingJournalEntries(root: string): Promise<boolean> {
const journal = await loadPendingJournal(root);
return journal.entries.length > 0;
}
export async function clearPendingMemories(root: string, keys?: Set<string>): Promise<void> {
await updatePendingJournal(root, store => {
if (!keys || keys.size === 0) {
store.entries = [];
return store;
}
store.entries = store.entries.filter(entry => !keys.has(memoryKey(entry)));
return store;
});
}
+103 -38
View File
@@ -28,6 +28,13 @@ import {
updateWorkspaceMemory, updateWorkspaceMemory,
renderWorkspaceMemory, renderWorkspaceMemory,
} from "./workspace-memory.ts"; } from "./workspace-memory.ts";
import {
appendPendingMemories,
clearPendingMemories,
hasPendingJournalEntries,
loadPendingJournal,
memoryKey,
} from "./pending-journal.ts";
import { import {
loadSessionState, loadSessionState,
updateSessionState, updateSessionState,
@@ -90,6 +97,14 @@ function buildCompactionPrompt(privateContext: string): string {
"## Relevant Files", "## Relevant Files",
"", "",
"At the end of the summary, extract durable memory entries for future sessions.", "At the end of the summary, extract durable memory entries for future sessions.",
"",
"Memory quality bar:",
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
"",
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
"",
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
"",
"Only extract facts that are likely to stay true across sessions.", "Only extract facts that are likely to stay true across sessions.",
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.", "Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
"For progress, extract the stable goal or durable milestone, not the current number.", "For progress, extract the stable goal or durable milestone, not the current number.",
@@ -154,6 +169,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
string, string,
{ {
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>; store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
renderedPrompt: string;
loadedAt: number; loadedAt: number;
} }
>(); >();
@@ -169,19 +185,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const memories = extractExplicitMemories(latestMessage.text); const memories = extractExplicitMemories(latestMessage.text);
const decisions = memories.filter(memory => memory.type === "decision"); const decisions = memories.filter(memory => memory.type === "decision");
let workspaceMemory: Awaited<ReturnType<typeof loadWorkspaceMemory>> | undefined;
if (memories.length > 0) { if (memories.length > 0) {
workspaceMemory = await updateWorkspaceMemory(directory, store => { await updateSessionState(directory, sessionID, state => {
store.entries.push(...memories); state.pendingMemories.push(...memories);
return store; return state;
}); });
await appendPendingMemories(directory, memories);
// Update frozen cache
const cached = frozenWorkspaceMemoryCache.get(sessionID);
if (cached) {
cached.store = workspaceMemory;
}
} }
if (decisions.length > 0) { if (decisions.length > 0) {
@@ -201,6 +211,45 @@ export const MemoryV2Plugin: Plugin = async (input) => {
processedUserMessages.set(sessionID, processedForSession); processedUserMessages.set(sessionID, processedForSession);
} }
async function promotePendingMemories(sessionID?: string): Promise<void> {
const [journal, sessionState] = await Promise.all([
loadPendingJournal(directory),
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
]);
const pending = [
...(sessionState?.pendingMemories ?? []),
...journal.entries,
];
if (pending.length === 0) return;
const promotedKeys = new Set<string>();
await updateWorkspaceMemory(directory, workspaceMemory => {
const existingKeys = new Set(workspaceMemory.entries.map(memory => memoryKey(memory)));
for (const memory of pending) {
const key = memoryKey(memory);
if (!existingKeys.has(key)) {
workspaceMemory.entries.push(memory);
existingKeys.add(key);
}
promotedKeys.add(key);
}
return workspaceMemory;
});
if (sessionID) {
await updateSessionState(directory, sessionID, state => {
state.pendingMemories = state.pendingMemories.filter(memory => !promotedKeys.has(memoryKey(memory)));
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
}
await clearPendingMemories(directory, promotedKeys);
}
function bashExitCode(hookOutput: unknown): number | undefined { function bashExitCode(hookOutput: unknown): number | undefined {
const output = hookOutput as { const output = hookOutput as {
exitCode?: unknown; exitCode?: unknown;
@@ -224,24 +273,28 @@ export const MemoryV2Plugin: Plugin = async (input) => {
} }
/** /**
* Get frozen workspace memory for a session. * Get frozen workspace memory snapshot for a session.
* Loads from disk once per session, then caches in memory. * Loads and renders from disk once per session, then reuses the exact rendered string.
*/ */
async function getFrozenWorkspaceMemory( async function getFrozenWorkspaceMemorySnapshot(
root: string, root: string,
sessionID: string sessionID: string
): Promise<Awaited<ReturnType<typeof loadWorkspaceMemory>>> { ): Promise<{
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
renderedPrompt: string;
}> {
const now = Date.now(); const now = Date.now();
const cached = frozenWorkspaceMemoryCache.get(sessionID); const cached = frozenWorkspaceMemoryCache.get(sessionID);
// Cache is valid for the session lifetime // Cache is valid for the session lifetime
if (cached) { if (cached) {
return cached.store; return { store: cached.store, renderedPrompt: cached.renderedPrompt };
} }
const store = await loadWorkspaceMemory(root); const store = await loadWorkspaceMemory(root);
frozenWorkspaceMemoryCache.set(sessionID, { store, loadedAt: now }); const renderedPrompt = renderWorkspaceMemory(store);
return store; frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
return { store, renderedPrompt };
} }
/** /**
@@ -260,19 +313,25 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// 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
// prior sessions. Keep this before processing latest user text so current-turn
// 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. // Process explicit user memory even on no-tool turns.
await processLatestUserMessage(sessionID); await processLatestUserMessage(sessionID);
// Get frozen workspace memory (loaded once per session) // Get frozen workspace memory snapshot (loaded and rendered once per session)
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
// Get current hot session state // Get current hot session state
const sessionState = await loadSessionState(directory, sessionID); const sessionState = await loadSessionState(directory, sessionID);
// Render and inject workspace memory // Inject frozen workspace memory snapshot
const workspacePrompt = renderWorkspaceMemory(workspaceMemory); if (workspaceSnapshot.renderedPrompt) {
if (workspacePrompt) { output.system.push(workspaceSnapshot.renderedPrompt);
output.system.push(workspacePrompt);
} }
// Render and inject hot session state // Render and inject hot session state
@@ -363,11 +422,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Build our private context (workspace memory, hot state, todos) // Build our private context (workspace memory, hot state, todos)
const contextParts: string[] = []; const contextParts: string[] = [];
// 1. Frozen workspace memory // 1. Frozen workspace memory snapshot
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory); if (workspaceSnapshot.renderedPrompt) {
if (workspacePrompt) { contextParts.push(workspaceSnapshot.renderedPrompt);
contextParts.push(workspacePrompt);
} }
// 2. Hot session state // 2. Hot session state
@@ -408,25 +466,32 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Sub-agents don't need post-compaction processing // Sub-agents don't need post-compaction processing
if (await isSubAgent(sessionID)) return; if (await isSubAgent(sessionID)) return;
// Parse latest compaction summary for memory candidates // Parse latest compaction summary for memory candidates, stage them into
// durable pending journal, then promote pending memories.
const summary = await latestCompactionSummary(client, sessionID); const summary = await latestCompactionSummary(client, sessionID);
if (summary) { const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
const candidates = parseWorkspaceMemoryCandidates(summary);
if (candidates.length > 0) { if (candidates.length > 0) {
await updateWorkspaceMemory(directory, workspaceMemory => { await appendPendingMemories(directory, candidates);
workspaceMemory.entries.push(...candidates);
return workspaceMemory;
});
// Clear frozen cache so next session reloads with new memories
clearFrozenWorkspaceMemoryCache(sessionID);
} }
try {
await promotePendingMemories(sessionID);
} catch {
// Keep pending memories in session/journal for retry on next event/session.
} }
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionID = (event.properties as { info?: { id?: string } })?.info?.id; const sessionID = (event.properties as { info?: { id?: string } })?.info?.id;
if (sessionID) { if (sessionID) {
// Promote pending memories before deleting per-session state.
// If promotion fails, leave session state and journal intact.
try {
await promotePendingMemories(sessionID);
} catch {
return;
}
// Clean up caches // Clean up caches
frozenWorkspaceMemoryCache.delete(sessionID); frozenWorkspaceMemoryCache.delete(sessionID);
processedUserMessages.delete(sessionID); processedUserMessages.delete(sessionID);
+29 -2
View File
@@ -1,8 +1,9 @@
import { relative } from "path"; import { relative } from "path";
import { sessionStatePath } from "./paths.ts"; import { sessionStatePath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import type { ActiveFile, OpenError, SessionDecision, SessionState } from "./types.ts"; import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
import { HOT_STATE_LIMITS } from "./types.ts"; import { HOT_STATE_LIMITS } from "./types.ts";
import { memoryKey } from "./pending-journal.ts";
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = { const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
edit: 50, edit: 50,
@@ -20,6 +21,7 @@ export function createEmptySessionState(sessionID: string): SessionState {
activeFiles: [], activeFiles: [],
openErrors: [], openErrors: [],
recentDecisions: [], recentDecisions: [],
pendingMemories: [],
}; };
} }
@@ -30,6 +32,7 @@ export async function loadSessionState(root: string, sessionID: string): Promise
loaded.activeFiles = Array.isArray(loaded.activeFiles) ? loaded.activeFiles : []; loaded.activeFiles = Array.isArray(loaded.activeFiles) ? loaded.activeFiles : [];
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : []; loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : []; loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : [];
return loaded; return loaded;
} }
@@ -48,6 +51,7 @@ export async function updateSessionState(
current.activeFiles = Array.isArray(current.activeFiles) ? current.activeFiles : []; current.activeFiles = Array.isArray(current.activeFiles) ? current.activeFiles : [];
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : []; current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : []; current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : [];
return normalizeSessionState(await updater(current)); return normalizeSessionState(await updater(current));
}); });
} }
@@ -57,9 +61,23 @@ function normalizeSessionState(state: SessionState): SessionState {
state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored); state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored); state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored); state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored);
state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : [])
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored);
return state; return state;
} }
function dedupePendingMemories(memories: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const seen = new Set<string>();
const deduped: LongTermMemoryEntry[] = [];
for (const memory of memories) {
const key = memoryKey(memory);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(memory);
}
return deduped;
}
export function touchActiveFile(state: SessionState, filePath: string, action: ActiveFile["action"]): void { export function touchActiveFile(state: SessionState, filePath: string, action: ActiveFile["action"]): void {
const now = Date.now(); const now = Date.now();
const existing = state.activeFiles.find(item => item.path === filePath); const existing = state.activeFiles.find(item => item.path === filePath);
@@ -177,8 +195,10 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string
.sort((a, b) => b.lastSeen - a.lastSeen) .sort((a, b) => b.lastSeen - a.lastSeen)
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered); .slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored); const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
const pendingMemories = dedupePendingMemories(state.pendingMemories)
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0) return ""; if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
const lines: string[] = ["Hot session state (current session):"]; const lines: string[] = ["Hot session state (current session):"];
@@ -204,6 +224,13 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string
} }
} }
if (pendingMemories.length > 0) {
lines.push("pending_memories:");
for (const memory of pendingMemories) {
lines.push(`- [${memory.type}] ${memory.text}`);
}
}
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars); return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
} }
+14
View File
@@ -28,6 +28,17 @@ export type WorkspaceMemoryStore = {
maxEntries: number; maxEntries: number;
}; };
entries: LongTermMemoryEntry[]; entries: LongTermMemoryEntry[];
migrations?: string[];
updatedAt: string;
};
export type PendingMemoryJournalStore = {
version: 1;
workspace: {
root: string;
key: string;
};
entries: LongTermMemoryEntry[];
updatedAt: string; updatedAt: string;
}; };
@@ -68,6 +79,7 @@ export type SessionState = {
activeFiles: ActiveFile[]; activeFiles: ActiveFile[];
openErrors: OpenError[]; openErrors: OpenError[];
recentDecisions: SessionDecision[]; recentDecisions: SessionDecision[];
pendingMemories: LongTermMemoryEntry[];
}; };
export const LONG_TERM_LIMITS = { export const LONG_TERM_LIMITS = {
@@ -85,4 +97,6 @@ export const HOT_STATE_LIMITS = {
maxOpenErrorsStored: 5, maxOpenErrorsStored: 5,
maxOpenErrorsRendered: 3, maxOpenErrorsRendered: 3,
maxRecentDecisionsStored: 8, maxRecentDecisionsStored: 8,
maxPendingMemoriesStored: 12,
maxPendingMemoriesRendered: 6,
} as const; } as const;
+169 -10
View File
@@ -5,6 +5,16 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory> // Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80; const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
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 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+(?![是=::])))`;
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> { export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
return { return {
@@ -15,20 +25,53 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
maxEntries: LONG_TERM_LIMITS.maxEntries, maxEntries: LONG_TERM_LIMITS.maxEntries,
}, },
entries: [], entries: [],
migrations: [],
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
} }
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> { export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const path = await workspaceMemoryPath(root);
const fallback = await emptyWorkspaceMemory(root); const fallback = await emptyWorkspaceMemory(root);
const loaded = await readJSON(await workspaceMemoryPath(root), () => fallback); const loaded = await readJSON(path, () => fallback) as Partial<WorkspaceMemoryStore>;
loaded.workspace = { root, key: await workspaceKey(root) };
loaded.limits = { const store: WorkspaceMemoryStore = {
version: loaded.version ?? 1,
workspace: loaded.workspace ?? { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
},
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
}; };
loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : [];
return loaded; // Always normalize on load so redaction/migrations are always-on.
const normalized = await normalizeWorkspaceMemory(root, store);
// Persist only when meaningful content changed (ignore timestamps).
if (didStoreMeaningfullyChange(store, normalized)) {
await atomicWriteJSON(path, normalized);
}
return normalized;
}
function didStoreMeaningfullyChange(
before: WorkspaceMemoryStore,
after: WorkspaceMemoryStore,
): boolean {
const sanitize = (store: WorkspaceMemoryStore) => ({
...store,
updatedAt: "",
entries: store.entries.map(entry => ({
...entry,
updatedAt: "",
})),
});
return JSON.stringify(sanitize(before)) !== JSON.stringify(sanitize(after));
} }
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> { export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
@@ -52,14 +95,130 @@ async function normalizeWorkspaceMemory(
root: string, root: string,
store: WorkspaceMemoryStore, store: WorkspaceMemoryStore,
): Promise<WorkspaceMemoryStore> { ): Promise<WorkspaceMemoryStore> {
store.workspace = { root, key: await workspaceKey(root) }; const nowIso = new Date().toISOString();
store.limits = {
let result: WorkspaceMemoryStore = {
...store,
workspace: { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
},
entries: Array.isArray(store.entries) ? store.entries : [],
migrations: Array.isArray(store.migrations) ? store.migrations : [],
updatedAt: nowIso,
}; };
store.entries = enforceLongTermLimits(store.entries);
store.updatedAt = new Date().toISOString(); // Always-on credential redaction
result.entries = result.entries.map(entry => {
const text = redactCredentials(entry.text);
const rationale = entry.rationale ? redactCredentials(entry.rationale) : undefined;
if (text === entry.text && rationale === entry.rationale) {
return entry;
}
return {
...entry,
text,
rationale,
updatedAt: nowIso,
};
});
// One-time migration for legacy snapshot violations
result = runMigrationP0Cleanup(result, nowIso);
// Enforce limits on active entries while preserving superseded entries in storage
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
const processedActive = enforceLongTermLimits(activeEntries);
return {
...result,
entries: [...processedActive, ...supersededEntries],
updatedAt: nowIso,
};
}
export function redactCredentials(text: string): string {
let result = text;
// 1. PIN
result = result.replace(
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
// 2. Username+password pair
result = result.replace(
new RegExp(
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
"gi",
),
"$1[REDACTED]$3$4[REDACTED]",
);
// 3. Standalone password
result = result.replace(
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
return result;
}
export function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts with snapshot context, excluding limit statements
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone/Task progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): WorkspaceMemoryStore {
if (store.migrations?.includes(MIGRATION_ID)) {
return store; return store;
}
const entries = store.entries.map(entry => {
if (entry.source === "explicit") return entry;
if (entry.type !== "project") return entry;
if (isProjectSnapshotViolation(entry.text)) {
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
};
}
return entry;
});
return {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
};
} }
function sourcePriority(source: LongTermMemoryEntry["source"]): number { function sourcePriority(source: LongTermMemoryEntry["source"]): number {
@@ -181,7 +340,7 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
// Phase 1: filter active, prune by age // Phase 1: filter active, prune by age
const phase1 = entries const phase1 = entries
.filter(entry => entry.status === "active") .filter(entry => entry.status !== "superseded")
.filter(entry => !isPrunableByAge(entry, now)) .filter(entry => !isPrunableByAge(entry, now))
.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) })); .map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
+74
View File
@@ -304,6 +304,31 @@ Memory candidates:
assert.equal(items.length, 0, "Phase progress is session snapshot, not durable milestone"); assert.equal(items.length, 0, "Phase progress is session snapshot, not durable milestone");
}); });
test("parseWorkspaceMemoryCandidates rejects wave/sprint/milestone/task progress snapshots", () => {
const summary = `
Memory candidates:
- project Waves 1-5 已完成,Wave 6 deferred
- project Sprint 3 completed
- project Milestone 2 done
- project Task 8 finished
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Wave/Sprint/Milestone/Task progress should be rejected as snapshots");
});
test("parseWorkspaceMemoryCandidates keeps file limits but rejects file sync snapshots", () => {
const summary = `
Memory candidates:
- project Upload limit is 10 files per request
- project USB uploaded 37 files for sync verification
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1, "Should keep static file-limit facts and reject processed file-count snapshots");
assert.match(items[0].text, /Upload limit is 10 files/);
});
test("parseWorkspaceMemoryCandidates accepts durable project facts", () => { test("parseWorkspaceMemoryCandidates accepts durable project facts", () => {
const summary = ` const summary = `
Memory candidates: Memory candidates:
@@ -361,3 +386,52 @@ Memory candidates:
const items = parseWorkspaceMemoryCandidates(summary); const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Phase snapshot mid-description should still be rejected"); assert.equal(items.length, 0, "Phase snapshot mid-description should still be rejected");
}); });
test("parseWorkspaceMemoryCandidates extracts Japanese triggers", () => {
const summary = `
Memory candidates:
- project 覚えて: このプロジェクトは pnpm を使う
- project 覚えておいて: 日本語でメモ
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 2);
assert.match(items[0].text, /pnpm/);
});
test("parseWorkspaceMemoryCandidates extracts Korean triggers", () => {
const summary = `
Memory candidates:
- project 기억해: 이 프로젝트는 pnpm을 사용한다
- project 메모해줘: 한국어 메모
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 2);
});
test("parseWorkspaceMemoryCandidates rejects negated Japanese triggers", () => {
const summary = `
Memory candidates:
- project 覚えないで 覚えて: 一時的なメモ
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Negated Japanese trigger should be rejected");
});
test("parseWorkspaceMemoryCandidates rejects negated Korean triggers", () => {
const summary = `
Memory candidates:
- project 기억하지 마 기억해: 일시적인 메모
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Negated Korean trigger should be rejected");
});
test("parseWorkspaceMemoryCandidates body extraction excludes trigger suffix", () => {
const summary = `
Memory candidates:
- project 覚えておいて: このプロジェクトは pnpm を使う
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items[0].text, "このプロジェクトは pnpm を使う");
assert.equal(items[0].text.includes("おいて"), false);
});
+281 -2
View File
@@ -1,18 +1,39 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { MemoryV2Plugin } from "../src/plugin.ts"; import { MemoryV2Plugin } from "../src/plugin.ts";
import { loadSessionState, saveSessionState } from "../src/session-state.ts"; import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { OpenError } from "../src/types.ts"; import type { OpenError } from "../src/types.ts";
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { loadPendingJournal, savePendingJournal } from "../src/pending-journal.ts";
// Mock client for root session (not a sub-agent) // Mock client for root session (not a sub-agent)
function mockRootClient() { function mockRootClient() {
return { return {
session: { session: {
get: async () => ({ data: { parentID: null } }), get: async () => ({ data: { parentID: null } }),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
};
}
function mockClientWithLatestUser(text: string, messageID: string) {
return {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async () => ({
data: [
{
info: { role: "user", id: messageID },
parts: [{ type: "text", text }],
},
],
}),
todo: async () => ({ data: [] }),
}, },
}; };
} }
@@ -27,6 +48,7 @@ function createSessionWithError(sessionID: string, error: OpenError) {
activeFiles: [], activeFiles: [],
openErrors: [error], openErrors: [error],
recentDecisions: [], recentDecisions: [],
pendingMemories: [],
}; };
} }
@@ -346,3 +368,260 @@ Next steps: continue development.
assert.equal(candidates.length, 1, "Should parse legacy XML format"); assert.equal(candidates.length, 1, "Should parse legacy XML format");
assert.equal(candidates[0].type, "feedback"); assert.equal(candidates[0].type, "feedback");
}); });
test("chat system transform reuses frozen rendered workspace snapshot", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "snapshot-session", model: {} },
output1,
);
const firstWorkspacePrompt = output1.system.find((part: string) =>
part.startsWith("Workspace memory")
);
assert.equal(firstWorkspacePrompt, undefined,
"empty workspace memory should not render a prompt before any memories exist");
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "snapshot-session", model: {} },
output2,
);
assert.deepEqual(output2.system, ["base header"],
"no compaction summary means no workspace memory prompt is added");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("no compaction: explicit memory is promoted on next session start from durable journal", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const firstClient = mockClientWithLatestUser("remember this: Prefer boring cache boundaries.", "msg-remember-1");
const firstPlugin = await MemoryV2Plugin({ directory: tmpDir, client: firstClient });
await (firstPlugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "session-without-compaction", model: {} },
{ system: ["base header"] },
);
const secondPlugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { system: ["base header"] };
await (secondPlugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "new-session", model: {} },
output,
);
const workspacePrompt = output.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Prefer boring cache boundaries/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.deleted promotes pending memories before deleting session state", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockClientWithLatestUser("remember this: Promote pending memories before delete.", "msg-delete-1");
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "delete-session", model: {} },
{ system: ["base header"] },
);
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.deleted",
properties: { info: { id: "delete-session" } },
},
});
const nextPlugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { system: ["base header"] };
await (nextPlugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "after-delete-session", model: {} },
output,
);
const workspacePrompt = output.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Promote pending memories before delete/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("duplicate explicit memories dedupe by normalized type and text, not generated id", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const pluginA = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser("remember this: Prefer stable cache boundaries.", "msg-a"),
});
await (pluginA as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "dedupe-session", model: {} },
{ system: ["base header"] },
);
const pluginB = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser("remember this: prefer stable cache boundaries.", "msg-b"),
});
await (pluginB as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "dedupe-session", model: {} },
{ system: ["base header"] },
);
await (pluginB as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "dedupe-session" } },
});
const output = { system: ["base header"] };
const pluginC = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (pluginC as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "dedupe-next", model: {} },
output,
);
const joined = output.system.join("\n");
assert.equal((joined.match(/stable cache boundaries/gi) ?? []).length, 1);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted promotes pending memories to workspace memory and clears pending list", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
await saveSessionState(tmpDir, {
version: 1,
sessionID: "promote-session",
turn: 1,
updatedAt: new Date().toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [{
id: "mem_pending_1",
type: "decision",
text: "Use frozen rendered snapshots for cache stability.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}],
});
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.compacted",
properties: { sessionID: "promote-session" },
},
});
const state = await loadSessionState(tmpDir, "promote-session");
assert.equal(state.pendingMemories.length, 0,
"pending memories should be cleared after promotion");
const after = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "new-session-after-promotion", model: {} },
after,
);
const workspacePrompt = after.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Use frozen rendered snapshots for cache stability/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("promotion failure does not clear pending memories in session or journal", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
const now = new Date().toISOString();
await saveSessionState(tmpDir, {
version: 1,
sessionID: "failure-session",
turn: 0,
updatedAt: now,
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [{
id: "mem_pending_failure",
type: "decision",
text: "Keep pending when promotion fails",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
});
const journalPath = await workspacePendingJournalPath(tmpDir);
await mkdir(dirname(journalPath), { recursive: true });
const journal = await loadPendingJournal(tmpDir);
journal.entries = [{
id: "mem_pending_failure_journal",
type: "decision",
text: "Keep pending when promotion fails",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}];
await savePendingJournal(tmpDir, journal);
const wmPath = await workspaceMemoryPath(tmpDir);
await rm(wmPath, { force: true }).catch(() => undefined);
await mkdir(wmPath, { recursive: true });
let didThrow = false;
try {
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.compacted",
properties: { sessionID: "failure-session" },
},
});
} catch {
didThrow = true;
}
assert.equal(didThrow, false,
"promotion failure should not throw from session.compacted handler");
const state = await loadSessionState(tmpDir, "failure-session");
assert.equal(state.pendingMemories.length, 1,
"session pending memories should remain when promotion fails");
const pendingAfter = await loadPendingJournal(tmpDir);
assert.equal(pendingAfter.entries.length, 1,
"journal pending memories should remain when promotion fails");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
+240 -1
View File
@@ -1,7 +1,20 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { renderWorkspaceMemory, enforceLongTermLimits } from "../src/workspace-memory.ts"; import { LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import {
renderWorkspaceMemory,
enforceLongTermLimits,
redactCredentials,
isProjectSnapshotViolation,
runMigrationP0Cleanup,
loadWorkspaceMemory,
saveWorkspaceMemory,
} from "../src/workspace-memory.ts";
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -414,3 +427,229 @@ test("enforceLongTermLimits feedback: supersession prefers newer shorter over ol
assert.equal(feedbackEntries.length, 1, "Newer shorter feedback should supersede older longer"); assert.equal(feedbackEntries.length, 1, "Newer shorter feedback should supersede older longer");
assert.ok(feedbackEntries[0].text.includes("template replacement"), "Kept entry should be the newer fix"); assert.ok(feedbackEntries[0].text.includes("template replacement"), "Kept entry should be the newer fix");
}); });
// ============================================
// Workspace cleanup migration tests
// ============================================
test("redactCredentials preserves PIN delimiter variants", () => {
assert.equal(redactCredentials("Admin PIN 是 456123"), "Admin PIN 是 [REDACTED]");
assert.equal(redactCredentials("Admin PIN = 456123"), "Admin PIN = [REDACTED]");
assert.equal(redactCredentials("Admin PIN 456123"), "Admin PIN [REDACTED]");
});
test("redactCredentials handles multilingual passwords", () => {
assert.equal(redactCredentials("パスワード:secret"), "パスワード:[REDACTED]");
assert.equal(redactCredentials("비밀번호: secret"), "비밀번호: [REDACTED]");
assert.equal(redactCredentials("contraseña: secret"), "contraseña: [REDACTED]");
});
test("redactCredentials handles username+password pair and punctuation boundary", () => {
assert.equal(
redactCredentials("測試用戶名:shihlab,密碼:sushi"),
"測試用戶名:[REDACTED],密碼:[REDACTED]",
);
assert.equal(
redactCredentials("密碼:sushi,用於測試"),
"密碼:[REDACTED],用於測試",
);
});
test("redactCredentials is idempotent and also redacts rationale text", () => {
assert.equal(redactCredentials("password: [REDACTED]"), "password: [REDACTED]");
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
migrations: [],
entries: [
{
id: "cred",
type: "reference",
text: "Admin PIN 是 456123",
rationale: "password: sushi",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
},
],
updatedAt: now,
};
const migrated = runMigrationP0Cleanup(
{
...store,
entries: store.entries.map(entry => ({
...entry,
text: redactCredentials(entry.text),
rationale: entry.rationale ? redactCredentials(entry.rationale) : undefined,
})),
},
now,
);
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
});
test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => {
assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true);
assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true);
assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
});
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
const now = new Date().toISOString();
const later = new Date(Date.now() + 1000).toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
migrations: [],
entries: [
{
id: "project-snapshot",
type: "project",
text: "Phase 1-4 已完成",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "project-explicit",
type: "project",
text: "Waves 1-5 已完成",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "feedback-snapshot-like",
type: "feedback",
text: "1237 tests pass",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
],
updatedAt: now,
};
const once = runMigrationP0Cleanup(store, now);
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
const twice = runMigrationP0Cleanup(once, later);
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
});
test("renderWorkspaceMemory excludes superseded entries", () => {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
migrations: ["2026-04-26-p0-cleanup"],
entries: [
{
id: "active-1",
type: "decision",
text: "Use pnpm for this workspace",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "sup-1",
type: "project",
text: "Waves 1-5 已完成",
source: "compaction",
confidence: 0.75,
status: "superseded",
createdAt: now,
updatedAt: now,
},
],
updatedAt: now,
};
const rendered = renderWorkspaceMemory(store);
assert.match(rendered, /Use pnpm/);
assert.doesNotMatch(rendered, /Waves 1-5 已完成/);
});
test("loadWorkspaceMemory normalizes and persists credentials from legacy unredacted store", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-redact-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const now = new Date().toISOString();
// Write UNREDACTED JSON directly to disk (simulating legacy store)
const unredactedStore: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [
{
id: "cred-1",
text: "Admin PIN 是 456123",
type: "project",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
],
migrations: [],
updatedAt: now,
};
// Write directly to disk WITHOUT using saveWorkspaceMemory (which would redact)
const { mkdir, writeFile } = await import("node:fs/promises");
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify(unredactedStore, null, 2), "utf-8");
// Load should normalize and redact
const loaded = await loadWorkspaceMemory(root);
assert.equal(loaded.entries[0].text, "Admin PIN 是 [REDACTED]");
// Verify persisted to disk (not just in-memory)
const { readFile } = await import("node:fs/promises");
const persistedRaw = await readFile(storePath, "utf-8");
const persisted = JSON.parse(persistedRaw);
assert.equal(persisted.entries[0].text, "Admin PIN 是 [REDACTED]");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
await rm(sandbox, { recursive: true, force: true });
}
});