@cortexkit/opencode-magic-context 0.15.0 → 0.15.2
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 +12 -6
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/cli/diagnostics.d.ts.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli.js +71 -82
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +20 -0
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts +45 -0
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3459 -3328
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/bounded-session-map.d.ts +45 -0
- package/dist/shared/bounded-session-map.d.ts.map +1 -0
- package/dist/shared/data-path.d.ts +12 -0
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +15 -5
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/bounded-session-map.test.ts +97 -0
- package/src/shared/bounded-session-map.ts +84 -0
- package/src/shared/data-path.test.ts +69 -0
- package/src/shared/data-path.ts +18 -0
- package/src/shared/models-dev-cache.ts +5 -10
- package/src/shared/rpc-types.ts +15 -5
- package/src/tui/data/context-db.ts +1 -0
- package/src/tui/index.tsx +13 -4
- package/src/tui/slots/sidebar-content.tsx +33 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc-handlers.d.ts","sourceRoot":"","sources":["../../src/plugin/rpc-handlers.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"rpc-handlers.d.ts","sourceRoot":"","sources":["../../src/plugin/rpc-handlers.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAMzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAIlF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AA0elE;;GAEG;AACH,wBAAgB,mBAAmB,CAC/B,SAAS,EAAE,qBAAqB,EAChC,IAAI,EAAE;IACF,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GACF,IAAI,CA4GN"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded LRU map keyed by session id.
|
|
3
|
+
*
|
|
4
|
+
* Rationale: magic-context maintains several module-scope Maps that track
|
|
5
|
+
* per-session state (prepared injection cache, per-message token cache, etc.).
|
|
6
|
+
* These are cleared on the `session.deleted` event, but sessions that are
|
|
7
|
+
* never explicitly deleted — because OpenCode crashed, the user force-quit,
|
|
8
|
+
* the session was archived rather than deleted, or the session simply outlived
|
|
9
|
+
* the plugin process's interest in it — leak entries for the lifetime of the
|
|
10
|
+
* plugin process.
|
|
11
|
+
*
|
|
12
|
+
* In long-running OpenCode instances with thousands of sessions over time,
|
|
13
|
+
* an unbounded `Map<sessionId, LargeObject>` can retain tens of megabytes
|
|
14
|
+
* indefinitely. A session-scoped LRU with a generous cap (e.g. 100) covers
|
|
15
|
+
* any realistic working-set of active sessions a user actually cares about,
|
|
16
|
+
* while evicting cold session ids that will either never return or be
|
|
17
|
+
* rebuilt from durable SQLite state on their next transform pass.
|
|
18
|
+
*
|
|
19
|
+
* Implementation notes:
|
|
20
|
+
* - Built on `Map` which preserves insertion order. On every `set`/`get`
|
|
21
|
+
* touch we delete+reinsert to move the key to the tail (most-recent).
|
|
22
|
+
* - Eviction drops the oldest entry (first in iteration order).
|
|
23
|
+
* - The cached value type is generic — callers decide what per-session state
|
|
24
|
+
* to store. For injection/token state, all three properties of the cached
|
|
25
|
+
* object are safe to throw away: they are either recomputable from the
|
|
26
|
+
* messages array on the next pass, or reloadable from SQLite.
|
|
27
|
+
*/
|
|
28
|
+
export declare class BoundedSessionMap<V> {
|
|
29
|
+
private readonly maxEntries;
|
|
30
|
+
private readonly store;
|
|
31
|
+
constructor(maxEntries: number);
|
|
32
|
+
get(sessionId: string): V | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Peek without touching recency — useful for `has`-style checks that
|
|
35
|
+
* should not rearrange LRU order. Use sparingly; `get` is the normal
|
|
36
|
+
* access path.
|
|
37
|
+
*/
|
|
38
|
+
peek(sessionId: string): V | undefined;
|
|
39
|
+
has(sessionId: string): boolean;
|
|
40
|
+
set(sessionId: string, value: V): void;
|
|
41
|
+
delete(sessionId: string): boolean;
|
|
42
|
+
clear(): void;
|
|
43
|
+
get size(): number;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=bounded-session-map.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bounded-session-map.d.ts","sourceRoot":"","sources":["../../src/shared/bounded-session-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,iBAAiB,CAAC,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwB;gBAElC,UAAU,EAAE,MAAM;IAO9B,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IASrC;;;;OAIG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAItC,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI/B,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI;IAYtC,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIlC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI,MAAM,CAEjB;CACJ"}
|
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
export declare function getDataDir(): string;
|
|
2
2
|
export declare function getOpenCodeStorageDir(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve OpenCode's cache base directory.
|
|
5
|
+
*
|
|
6
|
+
* OpenCode uses the `xdg-basedir` package, which — on every platform, including
|
|
7
|
+
* Windows — falls back to `<homedir>/.cache` when `XDG_CACHE_HOME` is unset.
|
|
8
|
+
* A previous Windows-specific branch that resolved to `%LOCALAPPDATA%` did not
|
|
9
|
+
* match OpenCode's own resolution and caused `doctor --force` to target a
|
|
10
|
+
* non-existent directory, leaving the real cache at `C:\Users\<user>\.cache`
|
|
11
|
+
* untouched.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getCacheDir(): string;
|
|
14
|
+
export declare function getOpenCodeCacheDir(): string;
|
|
3
15
|
//# sourceMappingURL=data-path.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"data-path.d.ts","sourceRoot":"","sources":["../../src/shared/data-path.ts"],"names":[],"mappings":"AAGA,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C"}
|
|
1
|
+
{"version":3,"file":"data-path.d.ts","sourceRoot":"","sources":["../../src/shared/data-path.ts"],"names":[],"mappings":"AAGA,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"models-dev-cache.d.ts","sourceRoot":"","sources":["../../src/shared/models-dev-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;
|
|
1
|
+
{"version":3,"file":"models-dev-cache.d.ts","sourceRoot":"","sources":["../../src/shared/models-dev-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AASH,UAAU,kBAAkB;IACxB,MAAM,EAAE;QACJ,SAAS,EAAE,MAAM,OAAO,CAAC;YAAE,IAAI,CAAC,EAAE;gBAAE,SAAS,CAAC,EAAE,OAAO,CAAA;aAAE,CAAA;SAAE,CAAC,CAAC;KAChE,CAAC;CACL;AAqND;;;;;;;;;;GAUG;AACH,wBAAsB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDzF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAchG;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAChB,MAAM,GAAG,SAAS,CAkBpB;AAED,4CAA4C;AAC5C,wBAAgB,mBAAmB,IAAI,IAAI,CAK1C;AAED,oDAAoD;AACpD,wBAAgB,sBAAsB,IAAI;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACrB,CAOA"}
|
|
@@ -35,13 +35,23 @@ export interface SidebarSnapshot {
|
|
|
35
35
|
*/
|
|
36
36
|
toolCallTokens: number;
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* Measured token cost of tool schemas (description + JSON-schema
|
|
39
|
+
* parameters) OpenCode sends in the request `tools` parameter. Populated
|
|
40
|
+
* by the `tool.definition` plugin hook, keyed by
|
|
41
|
+
* `{providerID, modelID, agentName}`. Zero until the first turn after
|
|
42
|
+
* plugin startup measures the current agent's tool set. Display layer
|
|
43
|
+
* shows this as "Tool Definitions".
|
|
43
44
|
*/
|
|
44
45
|
toolDefinitionTokens: number;
|
|
46
|
+
/**
|
|
47
|
+
* Residual catch-all: provider-side wrapping not captured elsewhere —
|
|
48
|
+
* the JSON envelope around the `tools` array, tool-choice fields,
|
|
49
|
+
* provider-specific cache-control markers, tokenizer imprecision, etc.
|
|
50
|
+
* Computed as `inputTokens − systemPromptTokens − messagesBlock −
|
|
51
|
+
* toolCallTokens − toolDefinitionTokens` and clamped to ≥ 0. Display
|
|
52
|
+
* layer shows this as "Overhead".
|
|
53
|
+
*/
|
|
54
|
+
overheadTokens: number;
|
|
45
55
|
}
|
|
46
56
|
export interface StatusDetail extends SidebarSnapshot {
|
|
47
57
|
tagCounter: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc-types.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,eAAe;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,kBAAkB,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB
|
|
1
|
+
{"version":3,"file":"rpc-types.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,eAAe;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,gBAAgB,EAAE,MAAM,CAAC;IACzB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,kBAAkB,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;;;;OAOG;IACH,cAAc,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,YAAa,SAAQ,eAAe;IACjD,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,oBAAoB,EAAE,YAAY,GAAG,QAAQ,CAAC;IAC9C;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,uBAAuB,EAAE,MAAM,CAAC;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,sBAAsB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context-db.d.ts","sourceRoot":"","sources":["../../../src/tui/data/context-db.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAA0B,eAAe,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEpG,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC;AAS9C,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAGrD;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,IAAI,IAAI,CAG/B;
|
|
1
|
+
{"version":3,"file":"context-db.d.ts","sourceRoot":"","sources":["../../../src/tui/data/context-db.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAA0B,eAAe,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEpG,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC;AAS9C,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAGrD;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,IAAI,IAAI,CAG/B;AA4BD,sDAAsD;AACtD,wBAAsB,mBAAmB,CACrC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAClB,OAAO,CAAC,eAAe,CAAC,CAc1B;AAED,wDAAwD;AACxD,wBAAsB,gBAAgB,CAClC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CA4CvB;AAED,qCAAqC;AACrC,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ5E;AAED,6CAA6C;AAC7C,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQvE;AAED,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,yDAAyD;AACzD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAchE"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { BoundedSessionMap } from "./bounded-session-map";
|
|
3
|
+
|
|
4
|
+
describe("BoundedSessionMap", () => {
|
|
5
|
+
it("rejects non-positive caps", () => {
|
|
6
|
+
expect(() => new BoundedSessionMap(0)).toThrow();
|
|
7
|
+
expect(() => new BoundedSessionMap(-5)).toThrow();
|
|
8
|
+
expect(() => new BoundedSessionMap(Number.NaN)).toThrow();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("stores and retrieves values", () => {
|
|
12
|
+
const map = new BoundedSessionMap<number>(3);
|
|
13
|
+
map.set("a", 1);
|
|
14
|
+
map.set("b", 2);
|
|
15
|
+
expect(map.get("a")).toBe(1);
|
|
16
|
+
expect(map.get("b")).toBe(2);
|
|
17
|
+
expect(map.get("missing")).toBeUndefined();
|
|
18
|
+
expect(map.size).toBe(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("evicts the oldest entry when cap is exceeded", () => {
|
|
22
|
+
const map = new BoundedSessionMap<string>(3);
|
|
23
|
+
map.set("a", "alpha");
|
|
24
|
+
map.set("b", "bravo");
|
|
25
|
+
map.set("c", "charlie");
|
|
26
|
+
map.set("d", "delta"); // evicts "a"
|
|
27
|
+
expect(map.has("a")).toBe(false);
|
|
28
|
+
expect(map.has("b")).toBe(true);
|
|
29
|
+
expect(map.has("c")).toBe(true);
|
|
30
|
+
expect(map.has("d")).toBe(true);
|
|
31
|
+
expect(map.size).toBe(3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats get() as a touch for LRU ordering", () => {
|
|
35
|
+
const map = new BoundedSessionMap<string>(3);
|
|
36
|
+
map.set("a", "alpha");
|
|
37
|
+
map.set("b", "bravo");
|
|
38
|
+
map.set("c", "charlie");
|
|
39
|
+
// Touch "a" — now "b" is the oldest.
|
|
40
|
+
expect(map.get("a")).toBe("alpha");
|
|
41
|
+
map.set("d", "delta");
|
|
42
|
+
expect(map.has("b")).toBe(false);
|
|
43
|
+
expect(map.has("a")).toBe(true);
|
|
44
|
+
expect(map.has("c")).toBe(true);
|
|
45
|
+
expect(map.has("d")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("peek() does NOT touch recency", () => {
|
|
49
|
+
const map = new BoundedSessionMap<number>(3);
|
|
50
|
+
map.set("a", 1);
|
|
51
|
+
map.set("b", 2);
|
|
52
|
+
map.set("c", 3);
|
|
53
|
+
expect(map.peek("a")).toBe(1);
|
|
54
|
+
// Adding a fourth entry should still evict "a" since peek didn't touch it.
|
|
55
|
+
map.set("d", 4);
|
|
56
|
+
expect(map.has("a")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("set() on existing key refreshes recency without growing size", () => {
|
|
60
|
+
const map = new BoundedSessionMap<number>(3);
|
|
61
|
+
map.set("a", 1);
|
|
62
|
+
map.set("b", 2);
|
|
63
|
+
map.set("c", 3);
|
|
64
|
+
map.set("a", 100); // refresh "a" to most-recent with new value
|
|
65
|
+
expect(map.size).toBe(3);
|
|
66
|
+
expect(map.get("a")).toBe(100);
|
|
67
|
+
map.set("d", 4); // evicts "b" (now oldest)
|
|
68
|
+
expect(map.has("b")).toBe(false);
|
|
69
|
+
expect(map.has("a")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("delete() removes entries and returns true when present", () => {
|
|
73
|
+
const map = new BoundedSessionMap<number>(3);
|
|
74
|
+
map.set("a", 1);
|
|
75
|
+
expect(map.delete("a")).toBe(true);
|
|
76
|
+
expect(map.delete("a")).toBe(false);
|
|
77
|
+
expect(map.size).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("clear() drops all entries", () => {
|
|
81
|
+
const map = new BoundedSessionMap<number>(3);
|
|
82
|
+
map.set("a", 1);
|
|
83
|
+
map.set("b", 2);
|
|
84
|
+
map.clear();
|
|
85
|
+
expect(map.size).toBe(0);
|
|
86
|
+
expect(map.get("a")).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("tolerates cap=1 edge case (every set evicts previous)", () => {
|
|
90
|
+
const map = new BoundedSessionMap<number>(1);
|
|
91
|
+
map.set("a", 1);
|
|
92
|
+
map.set("b", 2);
|
|
93
|
+
expect(map.has("a")).toBe(false);
|
|
94
|
+
expect(map.get("b")).toBe(2);
|
|
95
|
+
expect(map.size).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded LRU map keyed by session id.
|
|
3
|
+
*
|
|
4
|
+
* Rationale: magic-context maintains several module-scope Maps that track
|
|
5
|
+
* per-session state (prepared injection cache, per-message token cache, etc.).
|
|
6
|
+
* These are cleared on the `session.deleted` event, but sessions that are
|
|
7
|
+
* never explicitly deleted — because OpenCode crashed, the user force-quit,
|
|
8
|
+
* the session was archived rather than deleted, or the session simply outlived
|
|
9
|
+
* the plugin process's interest in it — leak entries for the lifetime of the
|
|
10
|
+
* plugin process.
|
|
11
|
+
*
|
|
12
|
+
* In long-running OpenCode instances with thousands of sessions over time,
|
|
13
|
+
* an unbounded `Map<sessionId, LargeObject>` can retain tens of megabytes
|
|
14
|
+
* indefinitely. A session-scoped LRU with a generous cap (e.g. 100) covers
|
|
15
|
+
* any realistic working-set of active sessions a user actually cares about,
|
|
16
|
+
* while evicting cold session ids that will either never return or be
|
|
17
|
+
* rebuilt from durable SQLite state on their next transform pass.
|
|
18
|
+
*
|
|
19
|
+
* Implementation notes:
|
|
20
|
+
* - Built on `Map` which preserves insertion order. On every `set`/`get`
|
|
21
|
+
* touch we delete+reinsert to move the key to the tail (most-recent).
|
|
22
|
+
* - Eviction drops the oldest entry (first in iteration order).
|
|
23
|
+
* - The cached value type is generic — callers decide what per-session state
|
|
24
|
+
* to store. For injection/token state, all three properties of the cached
|
|
25
|
+
* object are safe to throw away: they are either recomputable from the
|
|
26
|
+
* messages array on the next pass, or reloadable from SQLite.
|
|
27
|
+
*/
|
|
28
|
+
export class BoundedSessionMap<V> {
|
|
29
|
+
private readonly maxEntries: number;
|
|
30
|
+
private readonly store = new Map<string, V>();
|
|
31
|
+
|
|
32
|
+
constructor(maxEntries: number) {
|
|
33
|
+
if (!Number.isFinite(maxEntries) || maxEntries < 1) {
|
|
34
|
+
throw new Error(`BoundedSessionMap: maxEntries must be >= 1, got ${maxEntries}`);
|
|
35
|
+
}
|
|
36
|
+
this.maxEntries = maxEntries;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(sessionId: string): V | undefined {
|
|
40
|
+
const value = this.store.get(sessionId);
|
|
41
|
+
if (value === undefined) return undefined;
|
|
42
|
+
// Touch: move to most-recent position.
|
|
43
|
+
this.store.delete(sessionId);
|
|
44
|
+
this.store.set(sessionId, value);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Peek without touching recency — useful for `has`-style checks that
|
|
50
|
+
* should not rearrange LRU order. Use sparingly; `get` is the normal
|
|
51
|
+
* access path.
|
|
52
|
+
*/
|
|
53
|
+
peek(sessionId: string): V | undefined {
|
|
54
|
+
return this.store.get(sessionId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
has(sessionId: string): boolean {
|
|
58
|
+
return this.store.has(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set(sessionId: string, value: V): void {
|
|
62
|
+
if (this.store.has(sessionId)) {
|
|
63
|
+
// Refresh recency.
|
|
64
|
+
this.store.delete(sessionId);
|
|
65
|
+
} else if (this.store.size >= this.maxEntries) {
|
|
66
|
+
// Evict oldest entry. Map iteration is insertion-ordered.
|
|
67
|
+
const oldest = this.store.keys().next().value;
|
|
68
|
+
if (oldest !== undefined) this.store.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
this.store.set(sessionId, value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
delete(sessionId: string): boolean {
|
|
74
|
+
return this.store.delete(sessionId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clear(): void {
|
|
78
|
+
this.store.clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get size(): number {
|
|
82
|
+
return this.store.size;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getCacheDir, getDataDir, getOpenCodeCacheDir, getOpenCodeStorageDir } from "./data-path";
|
|
5
|
+
|
|
6
|
+
const savedEnv = {
|
|
7
|
+
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
|
|
8
|
+
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
|
9
|
+
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("data-path", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
process.env.XDG_CACHE_HOME = undefined;
|
|
15
|
+
process.env.XDG_DATA_HOME = undefined;
|
|
16
|
+
process.env.LOCALAPPDATA = undefined;
|
|
17
|
+
// Bun's env handling: explicit delete for unset
|
|
18
|
+
delete process.env.XDG_CACHE_HOME;
|
|
19
|
+
delete process.env.XDG_DATA_HOME;
|
|
20
|
+
delete process.env.LOCALAPPDATA;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (savedEnv.XDG_CACHE_HOME !== undefined)
|
|
25
|
+
process.env.XDG_CACHE_HOME = savedEnv.XDG_CACHE_HOME;
|
|
26
|
+
if (savedEnv.XDG_DATA_HOME !== undefined)
|
|
27
|
+
process.env.XDG_DATA_HOME = savedEnv.XDG_DATA_HOME;
|
|
28
|
+
if (savedEnv.LOCALAPPDATA !== undefined) process.env.LOCALAPPDATA = savedEnv.LOCALAPPDATA;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("getCacheDir falls back to <homedir>/.cache when XDG_CACHE_HOME is unset (all platforms)", () => {
|
|
32
|
+
// Matches OpenCode's xdg-basedir behavior on every platform, including
|
|
33
|
+
// Windows. A previous bug mapped Windows to %LOCALAPPDATA% and caused
|
|
34
|
+
// doctor --force to target a non-existent cache directory.
|
|
35
|
+
expect(getCacheDir()).toBe(path.join(os.homedir(), ".cache"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("getCacheDir honors XDG_CACHE_HOME when set", () => {
|
|
39
|
+
process.env.XDG_CACHE_HOME = "/tmp/custom-cache";
|
|
40
|
+
expect(getCacheDir()).toBe("/tmp/custom-cache");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("getCacheDir ignores LOCALAPPDATA on Windows (must match OpenCode's xdg-basedir)", () => {
|
|
44
|
+
// Even with LOCALAPPDATA set, cache must go to ~/.cache to match
|
|
45
|
+
// OpenCode's own resolution. Otherwise doctor --force clears the
|
|
46
|
+
// wrong directory on Windows.
|
|
47
|
+
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local";
|
|
48
|
+
expect(getCacheDir()).toBe(path.join(os.homedir(), ".cache"));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("getOpenCodeCacheDir appends 'opencode' to the cache base", () => {
|
|
52
|
+
expect(getOpenCodeCacheDir()).toBe(path.join(os.homedir(), ".cache", "opencode"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("getOpenCodeCacheDir with XDG_CACHE_HOME set", () => {
|
|
56
|
+
process.env.XDG_CACHE_HOME = "/tmp/custom-cache";
|
|
57
|
+
expect(getOpenCodeCacheDir()).toBe(path.join("/tmp/custom-cache", "opencode"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("getDataDir falls back to <homedir>/.local/share when XDG_DATA_HOME is unset", () => {
|
|
61
|
+
expect(getDataDir()).toBe(path.join(os.homedir(), ".local", "share"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("getOpenCodeStorageDir composes correctly", () => {
|
|
65
|
+
expect(getOpenCodeStorageDir()).toBe(
|
|
66
|
+
path.join(os.homedir(), ".local", "share", "opencode", "storage"),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/shared/data-path.ts
CHANGED
|
@@ -8,3 +8,21 @@ export function getDataDir(): string {
|
|
|
8
8
|
export function getOpenCodeStorageDir(): string {
|
|
9
9
|
return path.join(getDataDir(), "opencode", "storage");
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve OpenCode's cache base directory.
|
|
14
|
+
*
|
|
15
|
+
* OpenCode uses the `xdg-basedir` package, which — on every platform, including
|
|
16
|
+
* Windows — falls back to `<homedir>/.cache` when `XDG_CACHE_HOME` is unset.
|
|
17
|
+
* A previous Windows-specific branch that resolved to `%LOCALAPPDATA%` did not
|
|
18
|
+
* match OpenCode's own resolution and caused `doctor --force` to target a
|
|
19
|
+
* non-existent directory, leaving the real cache at `C:\Users\<user>\.cache`
|
|
20
|
+
* untouched.
|
|
21
|
+
*/
|
|
22
|
+
export function getCacheDir(): string {
|
|
23
|
+
return process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getOpenCodeCacheDir(): string {
|
|
27
|
+
return path.join(getCacheDir(), "opencode");
|
|
28
|
+
}
|
|
@@ -24,6 +24,7 @@ import { createHash } from "node:crypto";
|
|
|
24
24
|
import { existsSync, readFileSync } from "node:fs";
|
|
25
25
|
import { homedir, platform } from "node:os";
|
|
26
26
|
import { join } from "node:path";
|
|
27
|
+
import { getCacheDir } from "./data-path";
|
|
27
28
|
import { sessionLog } from "./logger";
|
|
28
29
|
|
|
29
30
|
interface OpencodeClientLike {
|
|
@@ -59,16 +60,10 @@ function getModelsJsonPath(): string {
|
|
|
59
60
|
const explicit = process.env.OPENCODE_MODELS_PATH?.trim();
|
|
60
61
|
if (explicit) return explicit;
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
cacheBase = xdgCache;
|
|
67
|
-
} else if (os === "win32") {
|
|
68
|
-
cacheBase = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
|
|
69
|
-
} else {
|
|
70
|
-
cacheBase = join(homedir(), ".cache");
|
|
71
|
-
}
|
|
63
|
+
// OpenCode uses `xdg-basedir`, which falls back to `<homedir>/.cache` on
|
|
64
|
+
// every platform (including Windows) when XDG_CACHE_HOME is unset. See
|
|
65
|
+
// shared/data-path.ts#getCacheDir for the shared helper.
|
|
66
|
+
const cacheBase = getCacheDir();
|
|
72
67
|
|
|
73
68
|
// 2. Custom models source → hashed filename (matches OpenCode).
|
|
74
69
|
// source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -36,13 +36,23 @@ export interface SidebarSnapshot {
|
|
|
36
36
|
*/
|
|
37
37
|
toolCallTokens: number;
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
39
|
+
* Measured token cost of tool schemas (description + JSON-schema
|
|
40
|
+
* parameters) OpenCode sends in the request `tools` parameter. Populated
|
|
41
|
+
* by the `tool.definition` plugin hook, keyed by
|
|
42
|
+
* `{providerID, modelID, agentName}`. Zero until the first turn after
|
|
43
|
+
* plugin startup measures the current agent's tool set. Display layer
|
|
44
|
+
* shows this as "Tool Definitions".
|
|
44
45
|
*/
|
|
45
46
|
toolDefinitionTokens: number;
|
|
47
|
+
/**
|
|
48
|
+
* Residual catch-all: provider-side wrapping not captured elsewhere —
|
|
49
|
+
* the JSON envelope around the `tools` array, tool-choice fields,
|
|
50
|
+
* provider-specific cache-control markers, tokenizer imprecision, etc.
|
|
51
|
+
* Computed as `inputTokens − systemPromptTokens − messagesBlock −
|
|
52
|
+
* toolCallTokens − toolDefinitionTokens` and clamped to ≥ 0. Display
|
|
53
|
+
* layer shows this as "Overhead".
|
|
54
|
+
*/
|
|
55
|
+
overheadTokens: number;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export interface StatusDetail extends SidebarSnapshot {
|
package/src/tui/index.tsx
CHANGED
|
@@ -207,12 +207,19 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
|
|
|
207
207
|
|
|
208
208
|
const elapsed = () => (s().lastResponseTime > 0 ? Date.now() - s().lastResponseTime : 0)
|
|
209
209
|
|
|
210
|
-
// Token breakdown segments — same colors as sidebar
|
|
210
|
+
// Token breakdown segments — same colors as sidebar. Kept in sync with
|
|
211
|
+
// slots/sidebar-content.tsx so the status dialog and sidebar read identically.
|
|
211
212
|
const COLORS = {
|
|
213
|
+
// Cool / structured — injected by the plugin into message[0]
|
|
212
214
|
system: "#c084fc",
|
|
213
215
|
compartments: "#60a5fa",
|
|
214
216
|
facts: "#fbbf24",
|
|
215
217
|
memories: "#34d399",
|
|
218
|
+
// Warm / user-facing — chat and tool traffic
|
|
219
|
+
conversation: "#f87171",
|
|
220
|
+
toolCalls: "#fb923c",
|
|
221
|
+
toolDefs: "#f472b6",
|
|
222
|
+
overhead: "#9ca3af",
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
const breakdownSegments = () => {
|
|
@@ -245,11 +252,13 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
|
|
|
245
252
|
})
|
|
246
253
|
|
|
247
254
|
if (d.conversationTokens > 0)
|
|
248
|
-
segs.push({ label: "Conversation", tokens: d.conversationTokens, color:
|
|
255
|
+
segs.push({ label: "Conversation", tokens: d.conversationTokens, color: COLORS.conversation })
|
|
249
256
|
if (d.toolCallTokens > 0)
|
|
250
|
-
segs.push({ label: "Tool Calls", tokens: d.toolCallTokens, color:
|
|
257
|
+
segs.push({ label: "Tool Calls", tokens: d.toolCallTokens, color: COLORS.toolCalls })
|
|
251
258
|
if (d.toolDefinitionTokens > 0)
|
|
252
|
-
segs.push({ label: "Tool Defs
|
|
259
|
+
segs.push({ label: "Tool Defs", tokens: d.toolDefinitionTokens, color: COLORS.toolDefs })
|
|
260
|
+
if (d.overheadTokens > 0)
|
|
261
|
+
segs.push({ label: "Overhead", tokens: d.overheadTokens, color: COLORS.overhead })
|
|
253
262
|
|
|
254
263
|
return { segs, total }
|
|
255
264
|
}
|
|
@@ -23,11 +23,17 @@ function relativeTime(ms: number): string {
|
|
|
23
23
|
|
|
24
24
|
// Token breakdown segment colors (hardcoded hex values)
|
|
25
25
|
const COLORS = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// Cool / structured — injected by the plugin into message[0]
|
|
27
|
+
system: "#c084fc", // Purple
|
|
28
|
+
compartments: "#60a5fa", // Blue
|
|
29
|
+
facts: "#fbbf24", // Yellow/orange
|
|
30
|
+
memories: "#34d399", // Green
|
|
31
|
+
// Warm / user-facing — regular chat and tool traffic. Grouped visually
|
|
32
|
+
// by hue family so the user reads them as a related block.
|
|
33
|
+
conversation: "#f87171", // Red
|
|
34
|
+
toolCalls: "#fb923c", // Orange
|
|
35
|
+
toolDefs: "#f472b6", // Pink
|
|
36
|
+
overhead: "#9ca3af", // Gray — catch-all residual
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
interface TokenSegment {
|
|
@@ -95,7 +101,7 @@ const TokenBreakdown = (props: {
|
|
|
95
101
|
result.push({
|
|
96
102
|
key: "conv",
|
|
97
103
|
tokens: s.conversationTokens,
|
|
98
|
-
color:
|
|
104
|
+
color: COLORS.conversation,
|
|
99
105
|
label: "Conversation",
|
|
100
106
|
})
|
|
101
107
|
}
|
|
@@ -106,19 +112,35 @@ const TokenBreakdown = (props: {
|
|
|
106
112
|
result.push({
|
|
107
113
|
key: "tool-calls",
|
|
108
114
|
tokens: s.toolCallTokens,
|
|
109
|
-
color:
|
|
115
|
+
color: COLORS.toolCalls,
|
|
110
116
|
label: "Tool Calls",
|
|
111
117
|
})
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
// Tool Definitions =
|
|
115
|
-
//
|
|
120
|
+
// Tool Definitions = measured description + JSON-schema parameters for
|
|
121
|
+
// each tool OpenCode sends in the `tools` request parameter, populated
|
|
122
|
+
// by the `tool.definition` plugin hook keyed by {provider, model, agent}.
|
|
123
|
+
// Zero until the first turn measures the active agent's tool set.
|
|
116
124
|
if (s.toolDefinitionTokens > 0) {
|
|
117
125
|
result.push({
|
|
118
126
|
key: "tool-defs",
|
|
119
127
|
tokens: s.toolDefinitionTokens,
|
|
120
|
-
color: COLORS.
|
|
121
|
-
label: "Tool Defs
|
|
128
|
+
color: COLORS.toolDefs,
|
|
129
|
+
label: "Tool Defs",
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Overhead = residual between input tokens and everything measured above.
|
|
134
|
+
// Captures provider-side JSON wrapping around the tools array,
|
|
135
|
+
// tool_choice/cache-control markers, and tokenizer imprecision. Before
|
|
136
|
+
// the first turn's tool.definition measurement lands, the real tool
|
|
137
|
+
// schema cost also shows up here.
|
|
138
|
+
if (s.overheadTokens > 0) {
|
|
139
|
+
result.push({
|
|
140
|
+
key: "overhead",
|
|
141
|
+
tokens: s.overheadTokens,
|
|
142
|
+
color: COLORS.overhead,
|
|
143
|
+
label: "Overhead",
|
|
122
144
|
})
|
|
123
145
|
}
|
|
124
146
|
|