Compare commits

...

2 Commits

Author SHA1 Message Date
Ralph Chang dbc5c01818 fix(package): publish JavaScript plugin entry 2026-05-31 14:44:25 +08:00
Ralph Chang 01bda7c134 fix(memory): freeze hot session prompt epoch 2026-05-20 12:38:16 +08:00
12 changed files with 784 additions and 96 deletions
+23
View File
@@ -5,6 +5,29 @@ 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.7] - 2026-05-31
### Fixed
- Fixed published OpenCode plugin loading under Node-based plugin loaders by compiling the runtime plugin entry to JavaScript and pointing package entry exports at `dist/`.
- Added package smoke coverage for bare, `./server`, and `./tui` imports from an installed tarball so TypeScript entry points cannot regress under `node_modules`.
## [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
+5 -5
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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`
+118
View File
@@ -0,0 +1,118 @@
# Plan: Package JS Entry for OpenCode Node Loader
## Goal
Fix GitHub issue #6 by making the published `opencode-working-memory` package loadable through OpenCode's Node-based plugin loader. The package must not expose TypeScript source files as runtime entry points under `node_modules`.
## Background
`opencode-working-memory@1.6.6` currently publishes:
- `main: "index.ts"`
- `exports["."]: "./index.ts"`
- `exports["./server"]: "./index.ts"`
- `exports["./tui"]: "./src/tui-plugin.ts"`
Node can import the local repo `index.ts`, but refuses to strip TypeScript types for files under `node_modules`. This means local path testing passes while npm/opencode package-cache loading fails.
## Proposed Changes
1. Expand the existing dist build config into a full package build.
- Update `tsconfig.memory-diag.json` to include `index.ts`, `src/**/*.ts`, `scripts/memory-diag.ts`, and `scripts/memory-diag/**/*.ts`.
- Remove the current exclusions for runtime plugin dependencies: `src/plugin.ts`, `src/tui-plugin.ts`, `src/opencode.ts`, `src/session-state.ts`, `src/extractors.ts`, `src/pending-journal.ts`, `src/promotion-accounting.ts`, and `src/memory-visibility.ts`.
- Keep `rootDir: "."` and `outDir: "dist"` so `index.ts` emits to `dist/index.js` and `src/tui-plugin.ts` emits to `dist/src/tui-plugin.js`.
- Keep `rewriteRelativeImportExtensions: true` so emitted ESM imports point at `.js`.
2. Update `package.json` runtime entry points.
- `main` should point to `dist/index.js`.
- `exports["."]` and `exports["./server"]` should point to `./dist/index.js`.
- `exports["./tui"]` should point to `./dist/src/tui-plugin.js`.
3. Update build scripts.
- Keep one clean build path that emits both plugin runtime JS and `memory-diag` JS.
- Preserve the existing `memory-diag` binary wrapper behavior.
- Rename or alias the script so the unified dist build is not hidden behind a memory-diag-only name.
4. Update packaging tests.
- Add a smoke test that builds/packs/installs the tarball into a temporary prefix and imports `opencode-working-memory` from `node_modules`.
- Assert the default export id is `working-memory`.
- Assert `opencode-working-memory/server` imports and exposes the same default plugin id.
- Assert `opencode-working-memory/tui` imports and exposes the TUI plugin id.
- Assert package manifest entry points do not point at `.ts`.
- Use an isolated npm cache under `/private/tmp` or the test temp root so local `~/.npm` permissions do not affect the smoke test.
5. Update package file allowlist if needed.
- Ensure `dist/`, `scripts/memory-diag-bin.cjs`, `README.md`, and `LICENSE` are included.
- Keeping TypeScript source files in the tarball is acceptable only if runtime entry points resolve to JS.
## Affected Files
- `package.json`
- `tsconfig.memory-diag.json` or a new build tsconfig
- `tests/smoke/memory-diag-packaging.test.ts` or a new smoke test under `tests/smoke/`
- `CHANGELOG.md`
- Possibly `package-lock.json` if package metadata changes require npm to refresh it
## Verification
Run:
```bash
npm run build
test -f dist/index.js
test -f dist/src/tui-plugin.js
node -e "import('./dist/index.js').then(m => console.log(m.default.id))"
node -e "import('./dist/src/tui-plugin.js').then(m => console.log(m.default.id))"
rg 'from\s+\".*\\.ts\"|import\s*\\(.*\\.ts' dist
npm run typecheck
npm test
npm run check:package-integrity
```
Package-path smoke:
```bash
rm -rf /private/tmp/owm-pack /private/tmp/owm-install /private/tmp/npm-cache
mkdir -p /private/tmp/owm-pack /private/tmp/owm-install /private/tmp/npm-cache
npm pack --cache /private/tmp/npm-cache --pack-destination /private/tmp/owm-pack
npm install --cache /private/tmp/npm-cache --prefix /private/tmp/owm-install /private/tmp/owm-pack/opencode-working-memory-*.tgz
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory').then(m => console.log(m.default.id))"
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory/server').then(m => console.log(m.default.id))"
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory/tui').then(m => console.log(m.default.id))"
```
Expected output for both import checks:
```text
working-memory
```
For the TUI import, expected output is:
```text
working-memory-tui
```
Also inspect the installed package manifest and assert `main`, `exports["."]`, `exports["./server"]`, and `exports["./tui"]` all point to `.js` files.
## Release Preparation
After implementation and verification:
1. Inspect `git diff`.
2. Confirm the working tree contains only this release fix plus the pre-existing unrelated untracked plan.
3. Add a `CHANGELOG.md` entry for `1.6.7` describing the Node loader/package entry fix.
4. Run `npm version patch` or otherwise bump `package.json` and `package-lock.json` to `1.6.7` consistently.
5. If publishing from `main`, prepare the verified package for publish after the final git diff review.
## Risks
- Build config may expose TypeScript type-check errors in files that were previously excluded from `tsconfig.memory-diag.json`.
- Package smoke tests that call `npm pack` can fail on the developer machine if `~/.npm` has permission problems; tests should use an isolated cache under `/private/tmp`.
- OpenCode may resolve `./server` or `./tui` differently from bare package import, so both exports should be checked.
- `npm run check:package-integrity` currently checks package-lock version alignment only; entry-point assertions must live in packaging tests or a new entry-point check.
- Publishing both `src/` and `dist/` increases tarball size, but runtime correctness depends on the JS entry points rather than removing sources.
## Rollback
Revert the package entry/build changes and publish a corrected package if the JS entry causes a runtime regression. Existing local-path development remains unaffected by reverting because it can still import TypeScript from outside `node_modules`.
+8 -7
View File
@@ -1,13 +1,13 @@
{
"name": "opencode-working-memory",
"version": "1.6.5",
"version": "1.6.7",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
"main": "dist/index.js",
"exports": {
".": "./index.ts",
"./server": "./index.ts",
"./tui": "./src/tui-plugin.ts"
".": "./dist/index.js",
"./server": "./dist/index.js",
"./tui": "./dist/src/tui-plugin.js"
},
"bin": {
"memory-diag": "./scripts/memory-diag-bin.cjs"
@@ -24,8 +24,9 @@
],
"scripts": {
"clean:dist": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"build:memory-diag": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
"build": "npm run build:memory-diag && node -e \"console.log('BUILD_PASS')\"",
"build:dist": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
"build:memory-diag": "npm run build:dist",
"build": "npm run build:dist && node -e \"console.log('BUILD_PASS')\"",
"prepack": "npm run build",
"diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs",
"test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts",
+110 -42
View File
@@ -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 {
function clearFrozenPromptEpoch(sessionID: string): void {
frozenWorkspaceMemoryCache.delete(sessionID);
frozenHotSessionStateCache.delete(sessionID);
}
function pruneFrozenPromptEpochCaches(): void {
const lastAccessedAtBySession = new Map<string, number>();
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
frozenWorkspaceMemoryCache.delete(sessionID);
}
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),
);
}
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);
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);
}
+1 -1
View File
@@ -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
View File
@@ -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 });
}
+5 -4
View File
@@ -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");
+86 -5
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -15,18 +15,26 @@ function executable(name: "npm" | "npx"): string {
return process.platform === "win32" ? `${name}.cmd` : name;
}
function binPath(root: string, name: string): string {
return join(root, "node_modules", ".bin", process.platform === "win32" ? `${name}.cmd` : name);
}
test("packed memory-diag bin runs from a temp consumer project", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-packaging-"));
const packDir = join(tempRoot, "pack");
const consumerDir = join(tempRoot, "consumer");
const cacheDir = join(tempRoot, "npm-cache");
try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
await mkdir(cacheDir, { recursive: true });
const packResult = await execFileAsync(executable("npm"), [
"pack",
repoRoot,
"--cache",
cacheDir,
"--pack-destination",
packDir,
"--silent",
@@ -35,11 +43,18 @@ test("packed memory-diag bin runs from a temp consumer project", async () => {
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
const tarballPath = join(packDir, tarballName);
const runResult = await execFileAsync(executable("npx"), [
"--yes",
"--package",
await execFileAsync(executable("npm"), [
"install",
"--cache",
cacheDir,
tarballPath,
"memory-diag",
"--legacy-peer-deps",
"--prefix",
consumerDir,
"--silent",
], { cwd: tempRoot, maxBuffer });
const runResult = await execFileAsync(binPath(consumerDir, "memory-diag"), [
"--help",
], { cwd: consumerDir, maxBuffer });
@@ -49,3 +64,69 @@ test("packed memory-diag bin runs from a temp consumer project", async () => {
await rm(tempRoot, { recursive: true, force: true });
}
});
test("packed plugin runtime imports from node_modules JavaScript entries", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-working-memory-packaging-"));
const packDir = join(tempRoot, "pack");
const consumerDir = join(tempRoot, "consumer");
const cacheDir = join(tempRoot, "npm-cache");
try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
await mkdir(cacheDir, { recursive: true });
const packResult = await execFileAsync(executable("npm"), [
"pack",
repoRoot,
"--cache",
cacheDir,
"--pack-destination",
packDir,
"--silent",
], { cwd: tempRoot, maxBuffer });
const tarballName = packResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
const tarballPath = join(packDir, tarballName);
await execFileAsync(executable("npm"), [
"install",
"--cache",
cacheDir,
"--prefix",
consumerDir,
tarballPath,
"--legacy-peer-deps",
"--silent",
], { cwd: tempRoot, maxBuffer });
const packageJsonPath = join(consumerDir, "node_modules", "opencode-working-memory", "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as {
main?: unknown;
exports?: Record<string, unknown>;
};
assert.equal(packageJson.main, "dist/index.js");
assert.equal(packageJson.exports?.["."], "./dist/index.js");
assert.equal(packageJson.exports?.["./server"], "./dist/index.js");
assert.equal(packageJson.exports?.["./tui"], "./dist/src/tui-plugin.js");
const importResult = await execFileAsync(process.execPath, [
"-e",
[
"const plugin = await import('opencode-working-memory');",
"const server = await import('opencode-working-memory/server');",
"const tui = await import('opencode-working-memory/tui');",
"console.log([plugin.default.id, server.default.id, tui.default.id].join('\\n'));",
].join(" "),
], { cwd: consumerDir, maxBuffer });
assert.deepEqual(importResult.stdout.trim().split(/\r?\n/), [
"working-memory",
"working-memory",
"working-memory-tui",
]);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
+2 -10
View File
@@ -10,18 +10,10 @@
"rewriteRelativeImportExtensions": true,
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
"include": ["index.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
"exclude": [
"node_modules",
"dist",
"tests",
"src/plugin.ts",
"src/tui-plugin.ts",
"src/opencode.ts",
"src/session-state.ts",
"src/extractors.ts",
"src/pending-journal.ts",
"src/promotion-accounting.ts",
"src/memory-visibility.ts"
"tests"
]
}