mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
fix(memory): freeze hot session prompt epoch
This commit is contained in:
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.6.6] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Froze hot session state with the existing prompt-epoch model to reduce pre-history prompt churn for better prefix KV-cache reuse.
|
||||
- Switched frozen prompt cache pressure eviction to recency-aware tracking.
|
||||
- Updated README and architecture docs for the new frozen hot snapshot behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed KV prefix-cache instability caused by per-turn hot session prompt changes.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Thanks to @nilo85 for opening PR #5 and surfacing the local-LLM KV cache hit-rate issue that led to this release.
|
||||
|
||||
## [1.6.5] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -132,22 +132,22 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
||||
┌──────────────────────────────────────┐
|
||||
│ ⚡ Prompt Context │
|
||||
│ system[1]*: frozen workspace memory │
|
||||
│ system[2+]*: hot session state │
|
||||
│ system[2+]*: frozen hot snapshot │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
||||
\* Conceptually, frozen workspace memory is pushed first when it is non-empty, and the frozen hot snapshot is pushed after workspace memory. If workspace memory is empty, the hot snapshot may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
||||
|
||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||
|
||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||
**Cache-friendly layout:** durable workspace memory and hot session state are rendered as separate frozen prompts that share the same epoch lifecycle. Hot state is an epoch-start snapshot: active files and open errors can change after it is created, and the conversation/tool transcript is the source of truth for newer events. The plugin intentionally does not invalidate the hot snapshot on active-file, open-error, recent-decision, or pending-memory changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories remain durable and promote safely at compaction, but after the current epoch caches exist they do not force a prompt refresh.
|
||||
|
||||
The runtime context has three layers:
|
||||
|
||||
| Layer | Purpose | Lifetime |
|
||||
|---|---|---|
|
||||
| Workspace Memory | Durable decisions, preferences, project facts, references | Cross-session |
|
||||
| Hot Session State | Active files, open errors, recent context | Current session |
|
||||
| Hot Session State | Active files, open errors, recent context, pending memories | Current session storage; frozen prompt refreshes at epoch boundaries |
|
||||
| Native OpenCode State | Todos and built-in state | OpenCode-managed |
|
||||
|
||||
## Workspace Memory
|
||||
@@ -261,7 +261,7 @@ Default behavior:
|
||||
|
||||
- Workspace memory budget: 3600 characters (~900 tokens)
|
||||
- Workspace memory limit: 28 entries
|
||||
- Hot session state budget: 700 characters (~175 tokens)
|
||||
- Hot session state budget: 700 characters (~175 tokens) per frozen hot snapshot
|
||||
- Active files shown: 8
|
||||
- Open errors shown: 3
|
||||
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.6.6 (2026-05-20)
|
||||
|
||||
### KV Cache Stability
|
||||
|
||||
This patch release reduces pre-history prompt churn by freezing hot session state with the existing prompt-epoch model, improving prefix KV-cache reuse for local LLMs.
|
||||
|
||||
Thanks to @nilo85 for opening PR #5 and surfacing the cache hit-rate issue.
|
||||
|
||||
### What Changed
|
||||
|
||||
- Hot session state now uses a frozen epoch snapshot instead of changing on every normal turn.
|
||||
- Frozen prompt caches use recency-aware cache pressure eviction.
|
||||
- The hot-state prompt now labels itself as an epoch snapshot so conversation/tool history remains the source of truth for newer events.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files, session state files, and evidence logs remain compatible.
|
||||
|
||||
### Validation
|
||||
|
||||
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/session-state.test.ts` — 14 tests passing
|
||||
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/plugin.test.ts` — 67 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 509 tests passing, `TEST_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.6.5 (2026-05-19)
|
||||
|
||||
### Code Health and Release Hygiene
|
||||
|
||||
+17
-7
@@ -18,7 +18,8 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
|
||||
│ LAYER 2: HOT SESSION STATE (Short-term, per-session) │
|
||||
│ • Session-scoped tracking: active files, open errors │
|
||||
│ • Storage: sessions/{sessionID}.json │
|
||||
│ • Auto-extracted from tool usage patterns │
|
||||
│ • Frozen prompt snapshot shares the workspace epoch │
|
||||
│ • Auto-extracted from tool usage and explicit remembers │
|
||||
│ • Cleared: on new session start │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
@@ -182,6 +183,9 @@ Track current session context automatically:
|
||||
- What files are you working on?
|
||||
- What errors are currently open?
|
||||
- What decisions were made recently?
|
||||
- Which explicit memories are pending promotion?
|
||||
|
||||
Hot session state is stored continuously during a session, but it is not rendered as a per-turn dynamic prompt. The prompt layer uses a frozen hot snapshot created or refreshed at the same epoch boundary as frozen workspace memory. Active files and open errors are current at epoch boundaries, not on every normal turn. After epoch start, the conversation/tool transcript is the source of truth for newer events.
|
||||
|
||||
### Storage
|
||||
|
||||
@@ -195,7 +199,8 @@ Track current session context automatically:
|
||||
updatedAt: string,
|
||||
activeFiles: ActiveFile[],
|
||||
openErrors: OpenError[],
|
||||
recentDecisions: SessionDecision[]
|
||||
recentDecisions: SessionDecision[],
|
||||
pendingMemories: LongTermMemoryEntry[]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -242,12 +247,17 @@ Short-term decisions made this session. Candidates for promotion to workspace me
|
||||
|
||||
### System Prompt Injection
|
||||
|
||||
Hot session state is injected after workspace memory:
|
||||
Workspace memory and hot session state are separate cached prompt layers that share a prompt epoch lifecycle:
|
||||
|
||||
```text
|
||||
system[1]*: frozen workspace memory
|
||||
system[2+]*: frozen hot snapshot
|
||||
```
|
||||
|
||||
The hot state example below is included in a frozen hot snapshot when the epoch is created or refreshed, not rendered again on every normal turn. Active files and open errors are current at epoch boundaries, not on every normal turn; the plugin intentionally does not invalidate the hot snapshot on active-file or open-error changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories persist in session state and the pending journal, then promote safely at compaction; once the current epoch caches exist, new pending memories do not force pre-history prompt refresh. After epoch start, the conversation/tool transcript is the source of truth for newer events.
|
||||
|
||||
```
|
||||
---
|
||||
|
||||
Hot session state (current session):
|
||||
Hot session state snapshot (epoch start; conversation history may be newer):
|
||||
|
||||
active_files:
|
||||
- src/plugin.ts (edit, 18x)
|
||||
@@ -280,7 +290,7 @@ OpenCode Working Memory hooks into OpenCode lifecycle events:
|
||||
|
||||
### `experimental.chat.system.transform`
|
||||
|
||||
Injects workspace memory and hot session state into system prompt.
|
||||
Injects cached frozen workspace memory and cached frozen hot snapshot prompts into the system prompt. Normal tool/user churn updates storage but does not mutate these pre-history prompts until a new epoch starts.
|
||||
|
||||
### `tool.execute.after`
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
|
||||
+110
-42
@@ -3,21 +3,24 @@
|
||||
*
|
||||
* Architecture:
|
||||
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
|
||||
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
|
||||
* - Layer 2: Frozen Hot Session State (active files, open errors, recent decisions, pending memories)
|
||||
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
|
||||
*
|
||||
* Cache Epoch Model:
|
||||
* - Each session creates a frozen workspace memory snapshot on first transform.
|
||||
* - Normal turns reuse the exact rendered string (system[1] remains stable).
|
||||
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
|
||||
* and the next transform re-renders workspace memory.
|
||||
* - Each session creates frozen workspace memory and hot session snapshots on first transform.
|
||||
* - Normal turns reuse the exact rendered strings (pre-history system prompts remain stable).
|
||||
* - Normal tool/user churn updates session storage but does not mutate pre-history prompts
|
||||
* until compaction, session restart, or process restart starts a new epoch; conversation
|
||||
* and tool history are the source of truth for newer events after epoch start.
|
||||
* - Compaction starts a new cache epoch: pending memories are promoted, caches are cleared,
|
||||
* and the next transform re-renders workspace memory and hot session state.
|
||||
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
|
||||
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
|
||||
* visible in the hot snapshot only if processed before epoch creation, promoted after compaction.
|
||||
*
|
||||
* This plugin:
|
||||
* - Caches frozen workspace memory per sessionID
|
||||
* - Caches frozen workspace memory and hot session state per sessionID epoch
|
||||
* - Processes explicit memory from latest user text once per message id
|
||||
* - Injects frozen workspace memory and dynamic hot session state into system prompt
|
||||
* - Injects frozen workspace memory and frozen hot session state into system prompt
|
||||
* - Updates session state after tool execution
|
||||
* - Augments compaction context with numbered memory refs, todos, and instruction
|
||||
* - Parses compaction summaries for memory candidates and merges them
|
||||
@@ -258,6 +261,18 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
lastAccessedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache for frozen hot session state per session epoch.
|
||||
// Lifecycle is unified with frozenWorkspaceMemoryCache; do not clear independently.
|
||||
const frozenHotSessionStateCache = new Map<
|
||||
string,
|
||||
{
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
lastAccessedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -539,18 +554,39 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
})));
|
||||
}
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
function clearFrozenPromptEpoch(sessionID: string): void {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
frozenHotSessionStateCache.delete(sessionID);
|
||||
}
|
||||
|
||||
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const oldest = [...frozenWorkspaceMemoryCache.entries()]
|
||||
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
|
||||
if (!oldest) break;
|
||||
frozenWorkspaceMemoryCache.delete(oldest);
|
||||
function pruneFrozenPromptEpochCaches(): void {
|
||||
const lastAccessedAtBySession = new Map<string, number>();
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
lastAccessedAtBySession.set(
|
||||
sessionID,
|
||||
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
|
||||
);
|
||||
}
|
||||
for (const [sessionID, cached] of frozenHotSessionStateCache) {
|
||||
lastAccessedAtBySession.set(
|
||||
sessionID,
|
||||
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...lastAccessedAtBySession.entries()].sort((a, b) => a[1] - b[1]);
|
||||
while (lastAccessedAtBySession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const [oldestSessionID] = sorted.shift() ?? [];
|
||||
if (!oldestSessionID) break;
|
||||
lastAccessedAtBySession.delete(oldestSessionID);
|
||||
clearFrozenPromptEpoch(oldestSessionID);
|
||||
}
|
||||
|
||||
for (const sessionID of frozenWorkspaceMemoryCache.keys()) {
|
||||
if (!lastAccessedAtBySession.has(sessionID)) frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
for (const sessionID of frozenHotSessionStateCache.keys()) {
|
||||
if (!lastAccessedAtBySession.has(sessionID)) frozenHotSessionStateCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,7 +802,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
});
|
||||
return state;
|
||||
});
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
clearFrozenPromptEpoch(sessionID);
|
||||
}
|
||||
|
||||
if (accounting.clearableKeys.size > 0) {
|
||||
@@ -811,13 +847,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
renderedPrompt: string;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||
|
||||
// Cache is valid for the current session cache epoch.
|
||||
// It is intentionally invalidated after compaction so promoted memories
|
||||
// become visible in the next compacted context (new epoch starts).
|
||||
if (cached) {
|
||||
cached.lastAccessedAt = now;
|
||||
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
|
||||
}
|
||||
|
||||
@@ -828,16 +864,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now, lastAccessedAt: now });
|
||||
pruneFrozenPromptEpochCaches();
|
||||
return { store, renderedPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear frozen workspace memory cache (e.g., after compaction).
|
||||
* Get frozen hot session state snapshot for a session.
|
||||
* Loads and renders from disk once per prompt epoch, then reuses the exact rendered string.
|
||||
*/
|
||||
function clearFrozenWorkspaceMemoryCache(sessionID: string): void {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
async function getFrozenHotSessionStateSnapshot(
|
||||
root: string,
|
||||
sessionID: string,
|
||||
): Promise<{ renderedPrompt: string }> {
|
||||
const now = Date.now();
|
||||
const cached = frozenHotSessionStateCache.get(sessionID);
|
||||
if (cached) {
|
||||
cached.lastAccessedAt = now;
|
||||
return { renderedPrompt: cached.renderedPrompt };
|
||||
}
|
||||
|
||||
const sessionState = await loadSessionState(root, sessionID);
|
||||
const renderedPrompt = renderHotSessionState(sessionState, root);
|
||||
frozenHotSessionStateCache.set(sessionID, { renderedPrompt, loadedAt: now, lastAccessedAt: now });
|
||||
pruneFrozenPromptEpochCaches();
|
||||
return { renderedPrompt };
|
||||
}
|
||||
|
||||
async function promoteUnownedBacklogForEpochSnapshot(sessionID: string): Promise<void> {
|
||||
if (frozenWorkspaceMemoryCache.has(sessionID) || frozenHotSessionStateCache.has(sessionID)) return;
|
||||
if (!await hasPendingJournalEntries(directory)) return;
|
||||
|
||||
try {
|
||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform.promote_unowned", error, directory);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIDFromEventProperties(properties: unknown): string | undefined {
|
||||
@@ -853,13 +915,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Inject workspace memory and hot session state into system prompt
|
||||
// Inject frozen workspace memory and frozen hot session state into system prompt
|
||||
"experimental.chat.system.transform": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
try {
|
||||
pruneFrozenWorkspaceMemoryCache();
|
||||
pruneFrozenPromptEpochCaches();
|
||||
pruneProcessedUserMessagesCache();
|
||||
|
||||
// Sub-agents are short-lived - skip memory system
|
||||
@@ -869,28 +931,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// sub-agent guard so child sessions never append to the parent journal.
|
||||
await processLatestUserMessage(sessionID);
|
||||
|
||||
// Before first snapshot in this session, promote durable unowned backlog from
|
||||
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||
// appears in hot state for this transform.
|
||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||
// Before first snapshot in this session, best-effort promote durable
|
||||
// unowned backlog from prior sessions. Current-turn owned explicit memory
|
||||
// remains pending and appears in hot state only if the epoch snapshot is new.
|
||||
await promoteUnownedBacklogForEpochSnapshot(sessionID);
|
||||
|
||||
let workspaceSnapshot: Awaited<ReturnType<typeof getFrozenWorkspaceMemorySnapshot>> | undefined;
|
||||
try {
|
||||
workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform.workspace_snapshot", error, directory);
|
||||
}
|
||||
|
||||
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
|
||||
// Get current hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
let hotSnapshot: Awaited<ReturnType<typeof getFrozenHotSessionStateSnapshot>> | undefined;
|
||||
try {
|
||||
hotSnapshot = await getFrozenHotSessionStateSnapshot(directory, sessionID);
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform.hot_snapshot", error, directory);
|
||||
}
|
||||
|
||||
// Inject frozen workspace memory snapshot
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
if (workspaceSnapshot?.renderedPrompt) {
|
||||
output.system.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// Render and inject hot session state
|
||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||
if (hotPrompt) {
|
||||
output.system.push(hotPrompt);
|
||||
// Inject frozen hot session state snapshot
|
||||
if (hotSnapshot?.renderedPrompt) {
|
||||
output.system.push(hotSnapshot.renderedPrompt);
|
||||
}
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform", error, directory);
|
||||
@@ -1060,6 +1127,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} finally {
|
||||
await clearCompactionMemoryRefs(sessionID);
|
||||
clearFrozenPromptEpoch(sessionID);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
@@ -1077,7 +1145,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||
promoted = true;
|
||||
if (promoted) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
clearFrozenPromptEpoch(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ type HotStateRenderSection = {
|
||||
items: HotStateRenderItem[];
|
||||
};
|
||||
|
||||
const HOT_STATE_PREFIX = "Hot session state (current session):";
|
||||
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
|
||||
|
||||
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
|
||||
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
|
||||
|
||||
+381
-15
@@ -8,7 +8,7 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts";
|
||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
import type { CompactionMemoryRef, LongTermMemoryEntry, OpenError } from "../src/types.ts";
|
||||
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
|
||||
import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { sessionStatePath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
|
||||
import { loadWorkspaceMemory, updateWorkspaceMemory, workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
|
||||
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
||||
@@ -758,6 +758,46 @@ test("explicit memory appended from user message is owned by session and not pro
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit memory before first cache miss appears in frozen hot snapshot", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
let latestMessages: Array<Record<string, unknown>> = [{
|
||||
info: { role: "user", id: "msg-before-cache-1" },
|
||||
parts: [{ type: "text", text: "remember this: First epoch captures pending memory." }],
|
||||
}];
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { parentID: null } }),
|
||||
messages: async () => ({ data: latestMessages }),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
};
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "first-cache-explicit-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
const joined = output.system.join("\n");
|
||||
assert.match(joined, /Hot session state/);
|
||||
assert.match(joined, /pending_memories:/);
|
||||
assert.match(joined, /First epoch captures pending memory/);
|
||||
|
||||
const state = await loadSessionState(tmpDir, "first-cache-explicit-session");
|
||||
assert.ok(
|
||||
state.pendingMemories.some(memory => /First epoch captures pending memory/.test(memory.text)),
|
||||
"first-turn explicit memory should remain durable in session pending state",
|
||||
);
|
||||
|
||||
latestMessages = [];
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session promotion does not clear another session's same-key pending journal entry", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
@@ -880,12 +920,21 @@ test("session.deleted clears caches even when session state file is already gone
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "deleted-missing-state-session",
|
||||
args: { filePath: join(tmpDir, "src/delete-before.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
const beforeOutput = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "deleted-missing-state-session", model: {} },
|
||||
beforeOutput,
|
||||
);
|
||||
assert.match(beforeOutput.system.join("\n"), /Workspace memory before delete cleanup/);
|
||||
assert.match(beforeOutput.system.join("\n"), /src\/delete-before\.ts/);
|
||||
|
||||
const ownedPending = {
|
||||
id: "mem_delete_owned_journal",
|
||||
@@ -917,6 +966,15 @@ test("session.deleted clears caches even when session state file is already gone
|
||||
assert.equal(pendingAfter.entries.length, 0,
|
||||
"clearable owned journal entry should be removed even when session state file is absent");
|
||||
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "deleted-missing-state-session",
|
||||
args: { filePath: join(tmpDir, "src/delete-after.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const afterOutput = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "deleted-missing-state-session", model: {} },
|
||||
@@ -925,6 +983,11 @@ test("session.deleted clears caches even when session state file is already gone
|
||||
const workspacePrompt = afterOutput.system.find((part: string) => part.startsWith("Workspace memory"));
|
||||
assert.match(workspacePrompt ?? "", /Owned journal memory promotes during delete cleanup/,
|
||||
"session.deleted should clear frozen cache after successful promotion");
|
||||
const afterJoined = afterOutput.system.join("\n");
|
||||
assert.match(afterJoined, /src\/delete-after\.ts/,
|
||||
"session.deleted should clear the paired frozen hot cache after successful promotion");
|
||||
assert.equal(afterJoined.includes("src/delete-before.ts"), false,
|
||||
"new epoch after session deletion should not reuse the deleted session's hot snapshot");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -2352,7 +2415,64 @@ test("integration: next session promotes prior unowned journal and leaves journa
|
||||
}
|
||||
});
|
||||
|
||||
test("same-session explicit memory does not mutate frozen system[1]", async () => {
|
||||
test("unowned pending promotion failure does not block epoch snapshot creation", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
let lockPath: string | undefined;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push({
|
||||
id: "mem_existing_promotion_failure",
|
||||
type: "project",
|
||||
text: "Existing stable workspace memory survives promotion failure.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return store;
|
||||
});
|
||||
await savePendingJournal(tmpDir, {
|
||||
version: 1,
|
||||
workspace: { root: tmpDir, key: await workspaceKey(tmpDir) },
|
||||
updatedAt: now,
|
||||
entries: [{
|
||||
id: "mem_unowned_lock_failure",
|
||||
type: "feedback",
|
||||
text: "unowned pending memory text should not be treated as promoted after lock failure.",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
});
|
||||
|
||||
const workspacePath = await workspaceMemoryPath(tmpDir);
|
||||
lockPath = `${workspacePath}.lock`;
|
||||
await mkdir(dirname(workspacePath), { recursive: true });
|
||||
await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`);
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "promotion-failure-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
const joined = output.system.join("\n");
|
||||
assert.match(joined, /Existing stable workspace memory survives promotion failure|Hot session state/);
|
||||
assert.equal(joined.includes("unowned pending memory text"), false,
|
||||
"failed unowned backlog promotion should not be silently treated as promoted");
|
||||
} finally {
|
||||
if (lockPath) await rm(lockPath, { force: true });
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("same-session explicit memory after epoch creation persists without refreshing frozen prompts", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
@@ -2394,10 +2514,10 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
|
||||
assert.match(firstSystem1 ?? "", /Existing stable workspace memory/,
|
||||
"first transform should create a frozen workspace memory system[1]");
|
||||
|
||||
// 3. User says "remember X" in the same session.
|
||||
// 3. User says "remember X" in the same session after the epoch exists.
|
||||
latestMessages = [{
|
||||
info: { role: "user", id: "msg-explicit-1" },
|
||||
parts: [{ type: "text", text: "remember this: Same-session memory stays ephemeral." }],
|
||||
parts: [{ type: "text", text: "remember this: Same-session memory stays durable but frozen prompt does not refresh." }],
|
||||
}];
|
||||
|
||||
const output2 = { system: ["base header"] };
|
||||
@@ -2406,25 +2526,28 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
|
||||
output2,
|
||||
);
|
||||
|
||||
// 4. Assert: workspace system[1] unchanged (frozen snapshot).
|
||||
// 4. Assert: all plugin-added pre-history prompts are unchanged.
|
||||
const secondSystem1 = output2.system.find((part: string) => part.startsWith("Workspace memory"));
|
||||
assert.equal(secondSystem1, firstSystem1,
|
||||
"frozen system[1] must not change after explicit memory in same session");
|
||||
"frozen workspace prompt should remain unchanged after explicit memory in same session");
|
||||
assert.deepEqual(output2.system, output1.system,
|
||||
"same-session explicit memory after epoch creation must not mutate pre-history prompts");
|
||||
|
||||
// 5. Assert: hot state (system[2+]) contains the pending memory.
|
||||
const hotState = output2.system.find((part: string) => part.includes("Hot session state"));
|
||||
assert.ok(hotState, "hot session state should be rendered");
|
||||
assert.match(hotState, /pending_memories:/,
|
||||
"hot state should contain pending_memories section");
|
||||
assert.match(hotState, /Same-session memory stays ephemeral/,
|
||||
"hot state should contain the explicit memory text");
|
||||
// 5. Assert: explicit memory remains durable without forcing prompt refresh.
|
||||
const state = await loadSessionState(tmpDir, "frozen-cache-session");
|
||||
assert.ok(
|
||||
state.pendingMemories.some(memory => /Same-session memory stays durable/.test(memory.text)),
|
||||
"explicit memory should remain durable in session pending state",
|
||||
);
|
||||
assert.equal(output2.system.join("\n").includes("Same-session memory stays durable"), false,
|
||||
"new pending memory is already in conversation history and should not force system prompt refresh");
|
||||
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("chat system transform reloads frozen workspace snapshot after cache TTL expires", async () => {
|
||||
test("chat system transform does not reload frozen epoch snapshots after TTL time passes", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
const originalNow = Date.now;
|
||||
let now = originalNow();
|
||||
@@ -2447,12 +2570,21 @@ test("chat system transform reloads frozen workspace snapshot after cache TTL ex
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "ttl-session",
|
||||
args: { filePath: join(tmpDir, "src/before-ttl.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
const output1 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "ttl-session", model: {} },
|
||||
output1,
|
||||
);
|
||||
assert.match(output1.system.join("\n"), /Workspace memory before TTL expiry/);
|
||||
assert.match(output1.system.join("\n"), /src\/before-ttl\.ts/);
|
||||
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push({
|
||||
@@ -2470,13 +2602,228 @@ test("chat system transform reloads frozen workspace snapshot after cache TTL ex
|
||||
|
||||
now += WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs + 1;
|
||||
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "ttl-session",
|
||||
args: { filePath: join(tmpDir, "src/after-ttl.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const output2 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "ttl-session", model: {} },
|
||||
output2,
|
||||
);
|
||||
|
||||
assert.match(output2.system.join("\n"), /Workspace memory after TTL expiry/);
|
||||
const joined = output2.system.join("\n");
|
||||
assert.match(joined, /Workspace memory before TTL expiry/);
|
||||
assert.equal(joined.includes("Workspace memory after TTL expiry"), false);
|
||||
assert.deepEqual(output2.system, output1.system,
|
||||
"TTL time passage and hot-state churn must not refresh active frozen epoch prompts");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("chat system transform keeps frozen prompts stable across active file churn", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "active-file-churn-session",
|
||||
args: { filePath: join(tmpDir, "src/test.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const output1 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "active-file-churn-session", model: {} },
|
||||
output1,
|
||||
);
|
||||
assert.match(output1.system.join("\n"), /src\/test\.ts \(edit, 1x\)/);
|
||||
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "active-file-churn-session",
|
||||
args: { filePath: join(tmpDir, "src/test.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const output2 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "active-file-churn-session", model: {} },
|
||||
output2,
|
||||
);
|
||||
|
||||
assert.deepEqual(output2.system, output1.system,
|
||||
"active-file churn must not mutate pre-history prompts during the active epoch");
|
||||
assert.equal(output2.system.join("\n").includes("Hot state deltas"), false);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session compaction starts a new frozen prompt epoch including refreshed hot state", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "epoch-refresh-session",
|
||||
args: { filePath: join(tmpDir, "src/before.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const output1 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "epoch-refresh-session", model: {} },
|
||||
output1,
|
||||
);
|
||||
assert.match(output1.system.join("\n"), /src\/before\.ts/);
|
||||
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "epoch-refresh-session",
|
||||
args: { filePath: join(tmpDir, "src/after.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const output2 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "epoch-refresh-session", model: {} },
|
||||
output2,
|
||||
);
|
||||
assert.equal(output2.system.join("\n").includes("src/after.ts"), false,
|
||||
"normal turns should keep using the frozen hot prompt before compaction");
|
||||
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "epoch-refresh-session" } },
|
||||
});
|
||||
|
||||
const output3 = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "epoch-refresh-session", model: {} },
|
||||
output3,
|
||||
);
|
||||
assert.match(output3.system.join("\n"), /src\/after\.ts/,
|
||||
"compaction should clear the frozen hot cache so the next epoch includes refreshed hot state");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("chat system transform keeps recently accessed frozen epoch under cache pressure", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
const originalNow = Date.now;
|
||||
let now = originalNow();
|
||||
Date.now = () => now;
|
||||
|
||||
try {
|
||||
const timestamp = new Date(now).toISOString();
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push({
|
||||
id: "mem_recency_cache_before",
|
||||
type: "project",
|
||||
text: "Workspace memory before recency cache pressure.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return store;
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "active-session-0",
|
||||
args: { filePath: join(tmpDir, "src/recency-before.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
let activeOutput = { system: ["base header"] };
|
||||
for (let i = 0; i < WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
|
||||
const sessionID = i === 0 ? "active-session-0" : `inactive-session-${i}`;
|
||||
now += 1;
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID, model: {} },
|
||||
output,
|
||||
);
|
||||
if (sessionID === "active-session-0") activeOutput = output;
|
||||
}
|
||||
|
||||
assert.match(activeOutput.system.join("\n"), /Workspace memory before recency cache pressure/);
|
||||
assert.match(activeOutput.system.join("\n"), /src\/recency-before\.ts/);
|
||||
|
||||
now += 1;
|
||||
const activeHitOutput = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "active-session-0", model: {} },
|
||||
activeHitOutput,
|
||||
);
|
||||
assert.deepEqual(activeHitOutput.system, activeOutput.system,
|
||||
"cache hit should update access recency without changing frozen prompt text");
|
||||
|
||||
now += 1;
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "pressure-session-extra", model: {} },
|
||||
{ system: ["base header"] },
|
||||
);
|
||||
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push({
|
||||
id: "mem_recency_cache_after",
|
||||
type: "project",
|
||||
text: "Workspace memory after recency cache pressure.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return store;
|
||||
});
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "active-session-0",
|
||||
args: { filePath: join(tmpDir, "src/recency-after.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
now += 1;
|
||||
const outputAfterPressure = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "active-session-0", model: {} },
|
||||
outputAfterPressure,
|
||||
);
|
||||
|
||||
const joined = outputAfterPressure.system.join("\n");
|
||||
assert.deepEqual(outputAfterPressure.system, activeOutput.system,
|
||||
"recently accessed active session should keep its original frozen workspace and hot prompts under cache pressure");
|
||||
assert.match(joined, /Workspace memory before recency cache pressure/);
|
||||
assert.equal(joined.includes("Workspace memory after recency cache pressure"), false);
|
||||
assert.match(joined, /src\/recency-before\.ts/);
|
||||
assert.equal(joined.includes("src/recency-after.ts"), false);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
@@ -2503,6 +2850,14 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "cache-size-session-0",
|
||||
args: { filePath: join(tmpDir, "src/cache-before.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
for (let i = 0; i <= WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: `cache-size-session-${i}`, model: {} },
|
||||
@@ -2524,6 +2879,15 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
|
||||
return store;
|
||||
});
|
||||
|
||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
||||
{
|
||||
tool: "edit",
|
||||
sessionID: "cache-size-session-0",
|
||||
args: { filePath: join(tmpDir, "src/cache-after.ts") },
|
||||
},
|
||||
{ output: "", exitCode: 0 },
|
||||
);
|
||||
|
||||
const output = { system: ["base header"] };
|
||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
||||
{ sessionID: "cache-size-session-0", model: {} },
|
||||
@@ -2531,6 +2895,8 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
|
||||
);
|
||||
|
||||
assert.match(output.system.join("\n"), /Workspace memory after cache pressure/);
|
||||
assert.match(output.system.join("\n"), /src\/cache-after\.ts/,
|
||||
"cache pressure should evict the paired frozen hot snapshot for the oldest session");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const accountHotSessionStateRender = (
|
||||
const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule;
|
||||
|
||||
const root = "/repo";
|
||||
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
|
||||
|
||||
function state(overrides: Partial<SessionState> = {}): SessionState {
|
||||
return {
|
||||
@@ -113,7 +114,7 @@ test("accountHotSessionStateRender renders hot-state sections in stable order",
|
||||
pendingMemories: [memory("mem-1", "Promote useful fact")],
|
||||
}), root);
|
||||
|
||||
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
|
||||
assert.ok(accounting.prompt.startsWith(HOT_STATE_PREFIX));
|
||||
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
|
||||
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
|
||||
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
|
||||
@@ -165,7 +166,7 @@ test("accountHotSessionStateRender omits over-budget entries without cutting ren
|
||||
}), root);
|
||||
|
||||
assert.equal(accounting.prompt, [
|
||||
"Hot session state (current session):",
|
||||
HOT_STATE_PREFIX,
|
||||
"active_files:",
|
||||
"- src/short.ts (read, 1x)",
|
||||
].join("\n"));
|
||||
@@ -177,7 +178,7 @@ test("accountHotSessionStateRender omits over-budget entries without cutting ren
|
||||
|
||||
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
|
||||
const fixedPrompt = [
|
||||
"Hot session state (current session):",
|
||||
HOT_STATE_PREFIX,
|
||||
"pending_memories:",
|
||||
"- [decision] ",
|
||||
].join("\n");
|
||||
@@ -223,7 +224,7 @@ test("renderHotSessionState delegates to accounted renderer prompt for empty and
|
||||
|
||||
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
|
||||
const fixedPrompt = [
|
||||
"Hot session state (current session):",
|
||||
HOT_STATE_PREFIX,
|
||||
"recent_decisions:",
|
||||
"- ",
|
||||
].join("\n");
|
||||
|
||||
Reference in New Issue
Block a user