mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
feat: release v1.2.2 with multilingual memory hardening
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+83
-18
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,7 @@ export type WorkspaceMemoryStore = {
|
||||
maxEntries: number;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
migrations?: string[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
|
||||
+174
-15
@@ -5,6 +5,16 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
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> {
|
||||
return {
|
||||
@@ -15,20 +25,53 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
|
||||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
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<WorkspaceMemoryStore>;
|
||||
|
||||
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<void> {
|
||||
@@ -52,14 +95,130 @@ async function normalizeWorkspaceMemory(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
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) }));
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user