hffmnnj.autocompact 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://unpkg.com/hffmnnj.autocompact/assets/banner.png" alt="Autocompact: pack idle context down." width="100%">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # hffmnnj.autocompact
6
+
7
+ An [OpenCode](https://opencode.ai) V2 plugin that compacts a primary session after it has been idle for a short window, before the configured prompt-cache window expires. It is the OpenCode equivalent of Claude Code's idle auto-compact.
8
+
9
+ ## Install
10
+
11
+ Reference the published package in the V2 config, keeping any existing plugin entries:
12
+
13
+ ```json
14
+ {
15
+ "plugins": [
16
+ { "package": "hffmnnj.autocompact" }
17
+ ]
18
+ }
19
+ ```
20
+
21
+ Options are optional and can be passed alongside the package:
22
+
23
+ ```json
24
+ {
25
+ "plugins": [
26
+ {
27
+ "package": "hffmnnj.autocompact",
28
+ "options": { "idleSeconds": 290, "cacheTtlSeconds": 300 }
29
+ }
30
+ ]
31
+ }
32
+ ```
33
+
34
+ ## Behavior
35
+
36
+ - A run that settles (`session.execution.succeeded`, `failed`, or `interrupted`) starts an idle countdown. The default is **290 seconds**, just under the configured five-minute cache window.
37
+ - **Warming comes first.** The native `opencode.warming` plugin sends keep-alive requests while a session is idle. Each keep-alive restarts the countdown, so compaction fires **290 seconds after the final keep-alive**, once warming has stopped. Keep-alives are recognised by their prompt, which must match `warmingPrompt` (default: the native prompt). With warming disabled, the countdown runs from the settled run.
38
+ - A new user prompt, model call, or execution cancels the pending countdown.
39
+ - Compaction is requested with `session.compact({ delivery: "queue" })`. An idle session runs it immediately, and a racing user message is queued rather than injected into a running turn.
40
+ - **At most one automatic compaction per idle stretch.** The compaction run's own settlement does not start another countdown. A new user prompt starts a fresh stretch.
41
+ - **Only sessions this plugin instance has seen active.** Session events reach every plugin instance, but hooks reach only the instance whose location owns the session. A session becomes eligible only through a `prompt` or `context` hook in its own instance, so sessions that were already idle when the plugin loaded are left untouched.
42
+ - **Primary sessions only** by default. Child sessions (subagents) are skipped unless `includeSubagents` is set.
43
+ - **OpenAI-provider sessions are exempt.** At fire time the plugin re-reads the session and skips it when the current model's `providerID` is `openai`.
44
+ - **Cache-expiry guard.** Compaction fires only while the elapsed idle time is below `cacheTtlSeconds` (default **300 seconds**). If the process was suspended and the timer wakes after this configured window, the plugin skips the attempt rather than risk a cold compaction. This does not guarantee a provider's actual cache retention or a cache hit.
45
+ - **A compaction by anyone ends the stretch.** A manual `/compact` during the countdown cancels it, and no automatic one follows until the next user prompt.
46
+ - **Location check at fire time.** Before compacting, the instance re-reads the session and refuses unless the session's directory is its own.
47
+ - **Every decision is logged** when the decision log is enabled. The host records these compactions as `reason: "manual"`, so with the log enabled the plugin's decision log is the only way to tell them from a user's.
48
+ - **Fail-open runtime observers.** Every hook and the event loop are wrapped. A bug in those observers is reported once per error class and swallowed; it cannot fail the session it watches.
49
+
50
+ ## Warming
51
+
52
+ The native warming plugin sends a keep-alive as a generate request whose final message is exactly its prompt. Keep-alives emit no session events, so the request shape is the only signal that warming is still running.
53
+
54
+ For default settings, keep the warming `interval` below `idleSeconds` (native default: 4 minutes against 290 seconds). A longer interval would let the countdown expire between keep-alives, so compaction could fire while warming is still running. If you customise the native `warming.prompt`, set `warmingPrompt` to the same text so keep-alives are still recognised.
55
+
56
+ ## Options
57
+
58
+ | Option | Default | Meaning |
59
+ | --- | --- | --- |
60
+ | `idleSeconds` | `290` | Idle time after a settled run before compacting. Must be a positive integer less than `cacheTtlSeconds`. |
61
+ | `cacheTtlSeconds` | `300` | Prompt-cache lifetime used by the expiry guard. Use `3600` for accounts on the 1-hour tier and set `idleSeconds` accordingly (for example `3300`). |
62
+ | `includeSubagents` | `false` | Also compact child sessions. |
63
+ | `warmingPrompt` | native keep-alive prompt | Must match a customised `warming.prompt` in the host config. The default is `This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK`. |
64
+ | `logFile` | `$XDG_STATE_HOME/opencode-autocompact/decisions.jsonl` | Absolute path for the decision log, or `false` to disable it. Falls back to `~/.local/state` when `XDG_STATE_HOME` is unset or empty. |
65
+
66
+ Unknown options and invalid values are rejected when the plugin loads, so a typo cannot silently fall back to a default.
67
+
68
+ ## Decision log
69
+
70
+ One JSON object per line, appended to `$XDG_STATE_HOME/opencode-autocompact/decisions.jsonl` (default `~/.local/state/opencode-autocompact/decisions.jsonl`). Set `logFile` to an absolute path to move it, or `false` to turn it off. Past 5 MB the active log rotates to `decisions.jsonl.1`, replacing any previous `.1`. Lines hold session IDs, timings, event names, option values and error class names, never conversation text or native error messages.
71
+
72
+ Every line has `at`, `instance` (the location whose plugin instance decided) and `pid`, plus:
73
+
74
+ | `event` | Meaning |
75
+ | --- | --- |
76
+ | `loaded` / `disposed` | Instance start and stop, with the effective options. |
77
+ | `owned` | A prompt or model call reached this instance's hooks (`via`). Only then is a session a candidate. |
78
+ | `armed` / `disarmed` | A countdown started (`via`: `settled` run or `keepalive`), or a pending countdown was cancelled (`why`). |
79
+ | `keepalive-ignored`, `settle-ignored` | A signal changed nothing, and `why`. |
80
+ | `skipped` | The countdown ended without compacting: `cache-expired`, `ineligible`, or `activity-during-lookup`. |
81
+ | `compacting` / `compact-result` | The request was sent, and whether it was accepted (`ok`, `errorName`). |
82
+ | `compaction-seen` | The host reported a compaction: `by: "plugin"` or `"other"` (a user's). |
83
+
84
+ The plugin does not use `ctx.storage`; eligibility is held in memory, and the optional decision log is a local file.
85
+
86
+ ## Limitations
87
+
88
+ - Compaction is **lossy** and is a model call. It runs whether or not you intended to come back, and there is no context-size floor, so small sessions can be compacted too.
89
+ - Eligibility is in memory and resets on every host restart. A session becomes eligible again only after it runs or receives a prompt under the new host.
90
+ - A run that began before the plugin loaded and settles afterwards is never compacted, because no hook of this instance saw it start.
91
+ - A session run only by something that fires neither the `prompt` nor the `context` hook is never owned, so it is never compacted. That fails safe.
92
+ - A failed compaction request is logged and not retried until the next user prompt.
93
+ - The native host records these compactions as `reason: "manual"`, because they go through the API.
94
+ - The timer uses wall-clock time. A suspended machine that wakes inside the configured TTL window can still compact; a wake at or after the TTL is skipped.
Binary file
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "hffmnnj.autocompact",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./index.ts"
7
+ },
8
+ "files": [
9
+ "index.ts",
10
+ "src",
11
+ "assets/banner.png"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/hffmnnj/opencode-autocompact.git"
19
+ },
20
+ "homepage": "https://github.com/hffmnnj/opencode-autocompact#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/hffmnnj/opencode-autocompact/issues"
23
+ },
24
+ "dependencies": {
25
+ "@opencode/plugin": "2.0.25"
26
+ },
27
+ "devDependencies": {
28
+ "@opencode/sdk": "2.0.25",
29
+ "@types/bun": "1.3.5",
30
+ "typescript": "5.9.3"
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "bun test tests/unit",
35
+ "check": "bun run typecheck && bun run test",
36
+ "test:host": "bun tests/host/run.ts"
37
+ }
38
+ }
@@ -0,0 +1,196 @@
1
+ import type { Trace } from "./trace";
2
+
3
+ export interface CompactorOptions {
4
+ readonly idleMs: number;
5
+ readonly cacheTtlMs: number;
6
+ readonly includeSubagents: boolean;
7
+ }
8
+
9
+ export interface CompactorDeps {
10
+ readonly now: () => number;
11
+ /** Returns a cancel function. */
12
+ readonly schedule: (run: () => void, delayMs: number) => () => void;
13
+ readonly isEligible: (sessionID: string) => Promise<boolean>;
14
+ readonly compact: (sessionID: string) => Promise<void>;
15
+ /** Failures only; never include conversation content. */
16
+ readonly report: (message: string) => void;
17
+ /** Every decision is traced, so an unexpected compaction can be explained from the log. */
18
+ readonly trace: (trace: Trace) => void;
19
+ }
20
+
21
+ /**
22
+ * Session events are delivered to every plugin instance, but hooks only reach the instance whose location owns the
23
+ * session. A keep-alive is therefore visible only to the owner. State is created solely from owner-scoped signals
24
+ * (`userPrompted`, `modelCalled`); every event-driven method ignores sessions it has no state for. Without that rule,
25
+ * each other instance counts down on its own, never hears a keep-alive, and compacts a still-warming session.
26
+ */
27
+ export interface IdleCompactor {
28
+ /** A model call seen through a hook, so this instance owns the session and it is running now. */
29
+ modelCalled(sessionID: string): void;
30
+ /** A compaction request by anyone (manual or automatic): this stretch has had its compaction. */
31
+ compactionRequested(sessionID: string): void;
32
+ executionStarted(sessionID: string): void;
33
+ executionSettled(sessionID: string): void;
34
+ /** A keep-alive ping: the countdown restarts from here, so compaction waits until warming stops. */
35
+ warmingPinged(sessionID: string): void;
36
+ userPrompted(sessionID: string): void;
37
+ forget(sessionID: string): void;
38
+ dispose(): void;
39
+ }
40
+
41
+ /** One idle stretch: from a settled run until the next user prompt or execution. */
42
+ interface Stretch {
43
+ busy: boolean;
44
+ /** At most one automatic compaction per stretch, so our own compaction cannot re-arm the next one. */
45
+ compacted: boolean;
46
+ /**
47
+ * Set when this plugin asked for a compaction, cleared when the host's `compaction` hook reports it. That hook runs
48
+ * after `compact()` has already returned (the request is queued), so the flag cannot be tied to the call's lifetime.
49
+ */
50
+ requesting: boolean;
51
+ /** Bumped on every transition; a callback scheduled or started under an older value is stale. */
52
+ generation: number;
53
+ cancel?: () => void;
54
+ }
55
+
56
+ export function createIdleCompactor(options: CompactorOptions, deps: CompactorDeps): IdleCompactor {
57
+ const stretches = new Map<string, Stretch>();
58
+ let disposed = false;
59
+
60
+ /** Creates state, which marks this instance as the owner. Only owner-scoped hooks may call this. */
61
+ const own = (sessionID: string, via: "prompt" | "context"): Stretch => {
62
+ const existing = stretches.get(sessionID);
63
+ if (existing) return existing;
64
+ const created: Stretch = { busy: false, compacted: false, requesting: false, generation: 0 };
65
+ stretches.set(sessionID, created);
66
+ deps.trace({ event: "owned", sessionID, via });
67
+ return created;
68
+ };
69
+
70
+ const disarm = (sessionID: string, stretch: Stretch, why: Extract<Trace, { event: "disarmed" }>["why"]): void => {
71
+ // Only a countdown that was actually pending is worth a log line.
72
+ if (stretch.cancel) deps.trace({ event: "disarmed", sessionID, why });
73
+ stretch.generation++;
74
+ stretch.cancel?.();
75
+ stretch.cancel = undefined;
76
+ };
77
+
78
+ const arm = (sessionID: string, stretch: Stretch, via: "settled" | "keepalive"): void => {
79
+ stretch.generation++;
80
+ stretch.cancel?.();
81
+ const generation = stretch.generation;
82
+ const armedAt = deps.now();
83
+ deps.trace({ event: "armed", sessionID, via, fireInMs: options.idleMs });
84
+
85
+ const tick = (): void => {
86
+ stretch.cancel = undefined;
87
+ if (disposed || stretch.generation !== generation) return;
88
+
89
+ const elapsed = deps.now() - armedAt;
90
+ if (elapsed < options.idleMs) {
91
+ stretch.cancel = deps.schedule(tick, options.idleMs - elapsed);
92
+ return;
93
+ }
94
+ // A late wake (for example after laptop sleep) means the cache has already expired. Compacting cold
95
+ // re-reads the full history and then pays for the summary as well, which costs more than it saves.
96
+ if (elapsed >= options.cacheTtlMs) {
97
+ deps.trace({ event: "skipped", sessionID, why: "cache-expired", idleMs: elapsed });
98
+ return;
99
+ }
100
+
101
+ stretch.compacted = true;
102
+ void compactIdle(sessionID, generation, stretch, elapsed);
103
+ };
104
+
105
+ stretch.cancel = deps.schedule(tick, options.idleMs);
106
+ };
107
+
108
+ const compactIdle = async (sessionID: string, generation: number, stretch: Stretch, idleMs: number): Promise<void> => {
109
+ try {
110
+ if (!(await deps.isEligible(sessionID))) {
111
+ deps.trace({ event: "skipped", sessionID, why: "ineligible", idleMs });
112
+ return;
113
+ }
114
+ // Re-check after the awaited lookup: a prompt or new execution may have arrived in the meantime.
115
+ if (disposed || stretch.generation !== generation || stretch.busy) {
116
+ deps.trace({ event: "skipped", sessionID, why: "activity-during-lookup", idleMs });
117
+ return;
118
+ }
119
+ deps.trace({ event: "compacting", sessionID, idleMs });
120
+ stretch.requesting = true;
121
+ await deps.compact(sessionID);
122
+ deps.trace({ event: "compact-result", sessionID, ok: true });
123
+ } catch (error) {
124
+ // Never forward the native error text: it can contain conversation content. The class name is safe.
125
+ stretch.requesting = false;
126
+ deps.trace({ event: "compact-result", sessionID, ok: false, errorName: error instanceof Error ? error.name : "unknown" });
127
+ deps.report("idle compaction request failed; the session was left unchanged");
128
+ }
129
+ };
130
+
131
+ /** Event-driven methods only touch sessions that an owner-scoped signal already registered. */
132
+ const known = (sessionID: string): Stretch | undefined => stretches.get(sessionID);
133
+
134
+ return {
135
+ modelCalled(sessionID) {
136
+ const stretch = own(sessionID, "context");
137
+ stretch.busy = true;
138
+ disarm(sessionID, stretch, "model-call");
139
+ },
140
+ compactionRequested(sessionID) {
141
+ const stretch = known(sessionID);
142
+ if (!stretch) return;
143
+ deps.trace({ event: "compaction-seen", sessionID, by: stretch.requesting ? "plugin" : "other" });
144
+ stretch.requesting = false;
145
+ stretch.compacted = true;
146
+ disarm(sessionID, stretch, "compaction-seen");
147
+ },
148
+ executionStarted(sessionID) {
149
+ const stretch = known(sessionID);
150
+ if (!stretch) return;
151
+ stretch.busy = true;
152
+ disarm(sessionID, stretch, "execution-started");
153
+ },
154
+ executionSettled(sessionID) {
155
+ const stretch = known(sessionID);
156
+ if (!stretch) return;
157
+ stretch.busy = false;
158
+ if (disposed) return;
159
+ if (stretch.compacted) {
160
+ deps.trace({ event: "settle-ignored", sessionID, why: "already-compacted" });
161
+ return;
162
+ }
163
+ arm(sessionID, stretch, "settled");
164
+ },
165
+ warmingPinged(sessionID) {
166
+ const stretch = known(sessionID);
167
+ if (!stretch || disposed) return;
168
+ if (stretch.busy || stretch.compacted) {
169
+ deps.trace({ event: "keepalive-ignored", sessionID, why: stretch.busy ? "running" : "already-compacted" });
170
+ return;
171
+ }
172
+ arm(sessionID, stretch, "keepalive");
173
+ },
174
+ userPrompted(sessionID) {
175
+ const stretch = own(sessionID, "prompt");
176
+ stretch.compacted = false;
177
+ stretch.requesting = false;
178
+ disarm(sessionID, stretch, "prompt");
179
+ },
180
+ forget(sessionID) {
181
+ const stretch = stretches.get(sessionID);
182
+ if (!stretch) return;
183
+ disarm(sessionID, stretch, "forgotten");
184
+ stretches.delete(sessionID);
185
+ },
186
+ dispose() {
187
+ disposed = true;
188
+ for (const stretch of stretches.values()) {
189
+ stretch.generation++;
190
+ stretch.cancel?.();
191
+ stretch.cancel = undefined;
192
+ }
193
+ stretches.clear();
194
+ },
195
+ };
196
+ }
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { isAbsolute } from "node:path";
2
+
3
+ /** Default idle window: just under the 5-minute prompt-cache lifetime, so compaction is dispatched while the cache is still warm. */
4
+ export const DEFAULT_IDLE_SECONDS = 290;
5
+ /** Anthropic's default prompt-cache lifetime. Set to 3600 for accounts on the 1-hour tier. */
6
+ export const DEFAULT_CACHE_TTL_SECONDS = 300;
7
+
8
+ /** Must match the native `opencode.warming` default, or a customised warming prompt must be set here too. */
9
+ export const DEFAULT_WARMING_PROMPT =
10
+ "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK";
11
+
12
+ export interface AutoCompactConfig {
13
+ readonly idleSeconds: number;
14
+ readonly cacheTtlSeconds: number;
15
+ readonly includeSubagents: boolean;
16
+ readonly warmingPrompt: string;
17
+ /** Decision log path; `false` disables the file. `undefined` means the default state-directory path. */
18
+ readonly logFile: string | false | undefined;
19
+ }
20
+
21
+ const KNOWN_KEYS = new Set(["idleSeconds", "cacheTtlSeconds", "includeSubagents", "warmingPrompt", "logFile"]);
22
+
23
+ function positiveInteger(value: unknown, name: string, fallback: number): number {
24
+ if (value === undefined) return fallback;
25
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
26
+ throw new Error(`opencode-autocompact: ${name} must be a positive integer`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ /** Strict on purpose: an unknown key is almost always a typo that would otherwise silently keep the default. */
32
+ export function parseConfig(raw: unknown): AutoCompactConfig {
33
+ if (raw === undefined) return parseConfig({});
34
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
35
+ throw new Error("opencode-autocompact: options must be an object");
36
+ }
37
+ const unknown = Object.keys(raw).filter(key => !KNOWN_KEYS.has(key));
38
+ if (unknown.length > 0) {
39
+ throw new Error(`opencode-autocompact: unknown option(s): ${unknown.join(", ")}`);
40
+ }
41
+ const { idleSeconds, cacheTtlSeconds, includeSubagents, warmingPrompt, logFile } = raw as Record<string, unknown>;
42
+ if (logFile !== undefined && logFile !== false && (typeof logFile !== "string" || !isAbsolute(logFile))) {
43
+ throw new Error("opencode-autocompact: logFile must be an absolute path or false");
44
+ }
45
+ if (includeSubagents !== undefined && typeof includeSubagents !== "boolean") {
46
+ throw new Error("opencode-autocompact: includeSubagents must be a boolean");
47
+ }
48
+ if (warmingPrompt !== undefined && (typeof warmingPrompt !== "string" || warmingPrompt.length === 0)) {
49
+ throw new Error("opencode-autocompact: warmingPrompt must be a non-empty string");
50
+ }
51
+ const config: AutoCompactConfig = {
52
+ idleSeconds: positiveInteger(idleSeconds, "idleSeconds", DEFAULT_IDLE_SECONDS),
53
+ cacheTtlSeconds: positiveInteger(cacheTtlSeconds, "cacheTtlSeconds", DEFAULT_CACHE_TTL_SECONDS),
54
+ includeSubagents: includeSubagents ?? false,
55
+ warmingPrompt: warmingPrompt ?? DEFAULT_WARMING_PROMPT,
56
+ logFile,
57
+ };
58
+ // Compaction must be dispatched before the cache expires; an idle window at or past the TTL can never fire warm.
59
+ if (config.idleSeconds >= config.cacheTtlSeconds) {
60
+ throw new Error("opencode-autocompact: idleSeconds must be less than cacheTtlSeconds");
61
+ }
62
+ return config;
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { createIdleCompactor } from "./compactor";
3
+ import { parseConfig } from "./config";
4
+ import { createDecisionLog, defaultLogPath } from "./log";
5
+ import { failOpen } from "./safe";
6
+ import { isWarmingPing } from "./warming";
7
+
8
+ export default Plugin.define({
9
+ id: "hffmnnj.autocompact",
10
+ async setup(ctx) {
11
+ const config = parseConfig(ctx.options);
12
+ const warn = (message: string): void => console.warn(`opencode-autocompact: ${message}`);
13
+ const log =
14
+ config.logFile === false
15
+ ? undefined
16
+ : createDecisionLog(
17
+ config.logFile ?? defaultLogPath(),
18
+ { instance: ctx.location.directory, now: () => Date.now(), pid: process.pid },
19
+ warn,
20
+ );
21
+ const compactor = createIdleCompactor(
22
+ {
23
+ idleMs: config.idleSeconds * 1000,
24
+ cacheTtlMs: config.cacheTtlSeconds * 1000,
25
+ includeSubagents: config.includeSubagents,
26
+ },
27
+ {
28
+ now: () => Date.now(),
29
+ schedule: (run, delayMs) => {
30
+ const timer = setTimeout(run, delayMs);
31
+ return () => clearTimeout(timer);
32
+ },
33
+ isEligible: async sessionID => {
34
+ const info = await ctx.session.get({ sessionID });
35
+ // Official OpenAI models now retain their prompt cache without this idle compaction.
36
+ if (info.model?.providerID === "openai") return false;
37
+ // Hooks are location-scoped today, so only the owning instance has state. This check keeps a second
38
+ // instance from compacting someone else's session should that ever stop being true.
39
+ if (info.location.directory !== ctx.location.directory) return false;
40
+ return config.includeSubagents || info.parentID === undefined;
41
+ },
42
+ // Queue rather than steer: an idle session runs the compaction now, and a racing user message is never injected mid-run.
43
+ compact: async sessionID => {
44
+ await ctx.session.compact({ sessionID, delivery: "queue" });
45
+ },
46
+ report: warn,
47
+ trace: trace => {
48
+ log?.sink(trace);
49
+ if (trace.event === "compacting") {
50
+ warn(`compacting ${trace.sessionID} after ${Math.round(trace.idleMs / 1000)}s idle in ${ctx.location.directory}`);
51
+ }
52
+ },
53
+ },
54
+ );
55
+
56
+ // These hooks reach only the instance that owns the session's location, which is what makes them proof of ownership.
57
+ // Every observer is wrapped: a bug here must never fail the session it is watching.
58
+ const promptHook = await ctx.session.hook("prompt", failOpen("prompt hook", event => {
59
+ compactor.userPrompted(event.sessionID);
60
+ }, warn));
61
+ const contextHook = await ctx.session.hook("context", failOpen("context hook", event => {
62
+ compactor.modelCalled(event.sessionID);
63
+ }, warn));
64
+ const compactionHook = await ctx.session.hook("compaction", failOpen("compaction hook", event => {
65
+ compactor.compactionRequested(event.sessionID);
66
+ }, warn));
67
+ // Observe only: the keep-alive request is answered normally; this hook never alters it.
68
+ const warmingHook = await ctx.session.hook("generate", failOpen("generate hook", event => {
69
+ if (isWarmingPing(event.messages, config.warmingPrompt)) compactor.warmingPinged(event.sessionID);
70
+ }, warn));
71
+
72
+ const subscription = new AbortController();
73
+ // One bad event is reported and skipped; it must not end the subscription.
74
+ const onEvent = failOpen("event handler", (event: { type: string; data?: unknown }) => {
75
+ const sessionID = (event.data as { sessionID?: string } | undefined)?.sessionID;
76
+ if (sessionID === undefined) return;
77
+ switch (event.type) {
78
+ case "session.execution.started":
79
+ compactor.executionStarted(sessionID);
80
+ break;
81
+ case "session.execution.succeeded":
82
+ case "session.execution.failed":
83
+ case "session.execution.interrupted":
84
+ compactor.executionSettled(sessionID);
85
+ break;
86
+ case "session.deleted":
87
+ compactor.forget(sessionID);
88
+ break;
89
+ }
90
+ }, warn);
91
+ const observing = (async () => {
92
+ for await (const event of ctx.event.subscribe({ signal: subscription.signal })) onEvent(event);
93
+ })().catch(() => {
94
+ if (!subscription.signal.aborted) {
95
+ console.warn("opencode-autocompact: event subscription ended; automatic idle compaction is inactive");
96
+ }
97
+ });
98
+
99
+ log?.sink({ event: "loaded", idleSeconds: config.idleSeconds, cacheTtlSeconds: config.cacheTtlSeconds, includeSubagents: config.includeSubagents });
100
+ return async () => {
101
+ subscription.abort();
102
+ compactor.dispose();
103
+ await promptHook.dispose();
104
+ await contextHook.dispose();
105
+ await compactionHook.dispose();
106
+ await warmingHook.dispose();
107
+ await observing;
108
+ log?.sink({ event: "disposed" });
109
+ await log?.flush();
110
+ };
111
+ },
112
+ });
package/src/log.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { appendFile, mkdir, rename, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { Trace, TraceSink } from "./trace";
5
+
6
+ const MAX_BYTES = 5 * 1024 * 1024;
7
+ const ROTATE_CHECK_EVERY = 500;
8
+
9
+ /** `$XDG_STATE_HOME/opencode-autocompact/decisions.jsonl`, falling back to `~/.local/state`. */
10
+ export function defaultLogPath(env: Readonly<Record<string, string | undefined>> = process.env): string {
11
+ const state = env.XDG_STATE_HOME && env.XDG_STATE_HOME.length > 0 ? env.XDG_STATE_HOME : join(homedir(), ".local", "state");
12
+ return join(state, "opencode-autocompact", "decisions.jsonl");
13
+ }
14
+
15
+ export interface LogContext {
16
+ /** Directory of the plugin instance that made the decision; the host loads one instance per location. */
17
+ readonly instance: string;
18
+ readonly now: () => number;
19
+ readonly pid: number;
20
+ }
21
+
22
+ export interface DecisionLog {
23
+ readonly sink: TraceSink;
24
+ /** Resolves when every queued line has been written (or has failed). */
25
+ flush(): Promise<void>;
26
+ }
27
+
28
+ /** One JSON object per line: easy to grep, and `O_APPEND` keeps lines from different instances intact. */
29
+ export function formatLine(trace: Trace, context: LogContext): string {
30
+ return `${JSON.stringify({ at: new Date(context.now()).toISOString(), instance: context.instance, pid: context.pid, ...trace })}\n`;
31
+ }
32
+
33
+ /**
34
+ * Fail-open: a logging problem is reported once and never reaches the compaction path. The previous file is kept as
35
+ * `<path>.1` when the log grows past 5 MB; rotation renames, it never deletes.
36
+ */
37
+ export function createDecisionLog(path: string, context: LogContext, warn: (message: string) => void): DecisionLog {
38
+ let queue: Promise<void> = Promise.resolve();
39
+ let written = 0;
40
+ let warned = false;
41
+
42
+ const rotateIfLarge = async (): Promise<void> => {
43
+ const size = await stat(path).then(info => info.size, () => 0);
44
+ if (size > MAX_BYTES) await rename(path, `${path}.1`);
45
+ };
46
+
47
+ const write = async (line: string): Promise<void> => {
48
+ try {
49
+ if (written++ % ROTATE_CHECK_EVERY === 0) {
50
+ await mkdir(dirname(path), { recursive: true });
51
+ await rotateIfLarge();
52
+ }
53
+ await appendFile(path, line, { mode: 0o600 });
54
+ } catch {
55
+ if (!warned) {
56
+ warned = true;
57
+ warn(`could not write the decision log at ${path}; automatic compaction is unaffected`);
58
+ }
59
+ }
60
+ };
61
+
62
+ return {
63
+ sink: trace => {
64
+ queue = queue.then(() => write(formatLine(trace, context)));
65
+ },
66
+ flush: () => queue,
67
+ };
68
+ }
package/src/safe.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wraps an observer so that its own bug can never reach the host. An uncaught throw in a hook fails the session's run
3
+ * ("Failed to drain Session"), and this plugin only watches sessions, so it must never be the cause of that.
4
+ * Reports once per distinct error class to keep a repeating fault from flooding the log.
5
+ */
6
+ export function failOpen<Args extends ReadonlyArray<unknown>>(
7
+ name: string,
8
+ observe: (...args: Args) => void,
9
+ report: (message: string) => void,
10
+ ): (...args: Args) => void {
11
+ const reported = new Set<string>();
12
+ return (...args) => {
13
+ try {
14
+ observe(...args);
15
+ } catch (error) {
16
+ const kind = error instanceof Error ? error.name : "unknown";
17
+ if (reported.has(kind)) return;
18
+ reported.add(kind);
19
+ report(`${name} failed (${kind}); the session was not affected`);
20
+ }
21
+ };
22
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * One decision the compactor made. Entries carry session IDs and timings only, never conversation content or
3
+ * native error text, so the log is safe to keep and share.
4
+ */
5
+ export type Trace =
6
+ | { readonly event: "loaded"; readonly idleSeconds: number; readonly cacheTtlSeconds: number; readonly includeSubagents: boolean }
7
+ | { readonly event: "disposed" }
8
+ | { readonly event: "owned"; readonly sessionID: string; readonly via: "prompt" | "context" }
9
+ | { readonly event: "armed"; readonly sessionID: string; readonly via: "settled" | "keepalive"; readonly fireInMs: number }
10
+ | { readonly event: "disarmed"; readonly sessionID: string; readonly why: "prompt" | "model-call" | "execution-started" | "compaction-seen" | "forgotten" }
11
+ | { readonly event: "keepalive-ignored"; readonly sessionID: string; readonly why: "running" | "already-compacted" }
12
+ | { readonly event: "settle-ignored"; readonly sessionID: string; readonly why: "already-compacted" }
13
+ | { readonly event: "skipped"; readonly sessionID: string; readonly why: "cache-expired" | "ineligible" | "activity-during-lookup"; readonly idleMs: number }
14
+ | { readonly event: "compacting"; readonly sessionID: string; readonly idleMs: number }
15
+ | { readonly event: "compaction-seen"; readonly sessionID: string; readonly by: "plugin" | "other" }
16
+ | { readonly event: "compact-result"; readonly sessionID: string; readonly ok: boolean; readonly errorName?: string };
17
+
18
+ export type TraceSink = (trace: Trace) => void;
package/src/warming.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The native warming plugin sends a keep-alive as a generate request whose final message is exactly its prompt.
3
+ * Keep-alives emit no session events, so this request shape is the only signal that warming is still running.
4
+ */
5
+ export function isWarmingPing(messages: ReadonlyArray<unknown>, warmingPrompt: string): boolean {
6
+ const last: unknown = messages.at(-1);
7
+ if (typeof last !== "object" || last === null) return false;
8
+ const { role, content } = last as { role?: unknown; content?: unknown };
9
+ if (role !== "user" || !Array.isArray(content) || content.length !== 1) return false;
10
+ const [part] = content as ReadonlyArray<{ type?: unknown; text?: unknown }>;
11
+ return part?.type === "text" && part.text === warmingPrompt;
12
+ }