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