diff --git a/README.md b/README.md index 87f935e..c6e6f07 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,31 @@ Persists across sessions within the same workspace. Automatically extracted duri - `compaction` - Extracted during compaction (confidence: 0.75) - `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) Automatically tracks current session context: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f800396..e0004ac 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,30 @@ # Release Notes +## 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) ### Compaction Memory Quality — Four-Layer Defense diff --git a/docs/superpowers/plans/2026-04-26-workspace-memory-cleanup-migration.md b/docs/superpowers/plans/2026-04-26-workspace-memory-cleanup-migration.md new file mode 100644 index 0000000..4be4717 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-workspace-memory-cleanup-migration.md @@ -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 { + 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 \ No newline at end of file diff --git a/package.json b/package.json index d6c8ef7..acec170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.2.1", + "version": "1.2.2", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", diff --git a/src/extractors.ts b/src/extractors.ts index b44a8f8..7296bf8 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -27,6 +27,16 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean { return true; } + // Japanese negative + if (/(?:覚えないで|記憶しないで|メモしないで)\s*$/u.test(prefix)) { + return true; + } + + // Korean negative + if (/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u.test(prefix)) { + return true; + } + return false; } @@ -35,7 +45,11 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { // Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法 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" 非指令匹配 /(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim, // save/add to memory @@ -179,6 +193,31 @@ export function classifyCommand(command: string): OpenError["category"] | 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 { 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. * Rejects low-quality entries like git hashes, error messages, etc. */ -function shouldAcceptWorkspaceMemoryCandidate(entry: { - type: LongTermType; - text: string; -}): boolean { +function shouldAcceptWorkspaceMemoryCandidate( + entry: { + type: LongTermType; + text: string; + }, + options: { + fromMemoryTrigger?: boolean; + } = {}, +): boolean { const text = entry.text.trim(); + const minLength = options.fromMemoryTrigger ? 6 : 20; // 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)) { // Stable config values can be short — allow below generic min length - } else if (text.length < 20) { + } else if (text.length < minLength) { return false; } @@ -224,19 +269,33 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: { // Session-specific progress snapshots for project type if (entry.type === "project") { - if (/\b\d+\s+tests?\s+pass(?:ed)?\b/i.test(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; + if (isProjectSnapshotViolation(text)) return false; } 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. * Supports: Plain text label, Markdown section, legacy XML. @@ -275,16 +334,22 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE ); if (!item) continue; const type = (item[1] ?? item[2]).toLowerCase() as LongTermType; - const body = item[3].trim(); - if (body.length < 12) continue; + const normalizedBody = normalizeCandidateBody(item[3]); + if (!normalizedBody) continue; + + const minLength = normalizedBody.hadTrigger ? 6 : 12; + if (normalizedBody.text.length < minLength) continue; // Apply quality gate - if (!shouldAcceptWorkspaceMemoryCandidate({ type, text: body })) continue; + if (!shouldAcceptWorkspaceMemoryCandidate( + { type, text: normalizedBody.text }, + { fromMemoryTrigger: normalizedBody.hadTrigger }, + )) continue; entries.push({ id: id("mem"), type, - text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), source: "compaction", confidence: 0.75, status: "active", diff --git a/src/plugin.ts b/src/plugin.ts index 4c8e80d..5447274 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -90,6 +90,14 @@ function buildCompactionPrompt(privateContext: string): string { "## Relevant Files", "", "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.", "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.", diff --git a/src/types.ts b/src/types.ts index ed97498..1a5e58d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,7 @@ export type WorkspaceMemoryStore = { maxEntries: number; }; entries: LongTermMemoryEntry[]; + migrations?: string[]; updatedAt: string; }; diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 624bd18..24b90e5 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -5,6 +5,16 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; // Minimum length for workspace_memory envelope: \n...\n 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 { return { @@ -15,20 +25,53 @@ export async function emptyWorkspaceMemory(root: string): Promise { + const path = await workspaceMemoryPath(root); const fallback = await emptyWorkspaceMemory(root); - const loaded = await readJSON(await workspaceMemoryPath(root), () => fallback); - loaded.workspace = { root, key: await workspaceKey(root) }; - loaded.limits = { - maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, - maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, + const loaded = await readJSON(path, () => fallback) as Partial; + + const store: WorkspaceMemoryStore = { + version: loaded.version ?? 1, + workspace: loaded.workspace ?? { root, key: await workspaceKey(root) }, + limits: { + maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, + 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 { @@ -52,14 +95,130 @@ async function normalizeWorkspaceMemory( root: string, store: WorkspaceMemoryStore, ): Promise { - store.workspace = { root, key: await workspaceKey(root) }; - store.limits = { - maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, - maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, + const nowIso = new Date().toISOString(); + + 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 : [], + migrations: Array.isArray(store.migrations) ? store.migrations : [], + updatedAt: nowIso, + }; + + // 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; + } + + 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, }; - store.entries = enforceLongTermLimits(store.entries); - store.updatedAt = new Date().toISOString(); - return store; } function sourcePriority(source: LongTermMemoryEntry["source"]): number { @@ -181,7 +340,7 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM // Phase 1: filter active, prune by age const phase1 = entries - .filter(entry => entry.status === "active") + .filter(entry => entry.status !== "superseded") .filter(entry => !isPrunableByAge(entry, now)) .map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) })); diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index c2ffd69..40a6f2c 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -304,6 +304,31 @@ Memory candidates: 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", () => { const summary = ` Memory candidates: @@ -360,4 +385,53 @@ Memory candidates: const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0, "Phase snapshot mid-description should still be rejected"); -}); \ No newline at end of file +}); + +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); +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 371af6c..e48931d 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -1,7 +1,20 @@ import test from "node:test"; 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 { 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 { const now = new Date().toISOString(); @@ -413,4 +426,230 @@ test("enforceLongTermLimits feedback: supersession prefers newer shorter over ol const feedbackEntries = kept.filter(e => e.type === "feedback"); 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"); -}); \ No newline at end of file +}); + +// ============================================ +// 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 }); + } +});