@pi-archimedes/core 2.8.0 → 2.9.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.
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { getBus, Events } from "../bus.js";
4
+ import { registerSelfUsage } from "./self-usage.js";
5
+
6
+ /**
7
+ * Bus discipline (load-bearing): the bus has NO reset seam, `emit` with zero
8
+ * subscribers queues the payload globally, and `on()` replays queued events
9
+ * WITHOUT removing them. So EVERY test subscribes a per-test listener FIRST,
10
+ * then invokes the handler, asserts only on that test's captured array, and
11
+ * unsubscribes via the `on` return value.
12
+ */
13
+ function captureCostUpdates(): { events: unknown[]; done: () => void } {
14
+ const events: unknown[] = [];
15
+ const unsub = getBus().on(Events.COST_UPDATE, (p) => events.push(p));
16
+ return { events, done: unsub };
17
+ }
18
+
19
+ /**
20
+ * Register and capture the turn_end handler, using the repo's mock-ExtensionAPI
21
+ * idiom (verbatim from bridge/index.test.ts — a plain object fails `tsc` with
22
+ * TS2740; the `as unknown as ExtensionAPI` cast and the `mock.calls` re-typing
23
+ * are BOTH required).
24
+ */
25
+ function registerAndGetTurnEndHandler(): (e: unknown, ctx: unknown) => void {
26
+ const onSpy = vi.fn();
27
+ registerSelfUsage({ on: onSpy } as unknown as ExtensionAPI);
28
+ const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
29
+ const handler = calls.find(([event]) => event === "turn_end")![1];
30
+ expect(handler).toBeTruthy();
31
+ return handler;
32
+ }
33
+
34
+ function assistantMessage(overrides: { usage?: unknown }): { role: "assistant"; usage?: unknown } {
35
+ return { role: "assistant", ...overrides };
36
+ }
37
+
38
+ // ── 1. per-turn emit ─────────────────────────────────────────────────────
39
+
40
+ describe("per-turn emit", () => {
41
+ it("emits a COST_UPDATE with the turn's usage (delta, exact shape — no reasoning/totalTokens)", () => {
42
+ const handler = registerAndGetTurnEndHandler();
43
+ const { events, done } = captureCostUpdates();
44
+ try {
45
+ handler({
46
+ type: "turn_end",
47
+ turnIndex: 0,
48
+ message: assistantMessage({
49
+ usage: {
50
+ input: 100,
51
+ output: 50,
52
+ cacheRead: 10,
53
+ cacheWrite: 5,
54
+ totalTokens: 165,
55
+ cost: { input: 0.001, output: 0.002, cacheRead: 0.0001, cacheWrite: 0.0002, total: 0.0033 },
56
+ },
57
+ }),
58
+ }, { mode: "tui" });
59
+ expect(events).toHaveLength(1);
60
+ expect(events[0]).toEqual({
61
+ source: "main",
62
+ inputTokens: 100,
63
+ outputTokens: 50,
64
+ cacheReadTokens: 10,
65
+ cacheWriteTokens: 5,
66
+ cost: 0.0033,
67
+ });
68
+ } finally {
69
+ done();
70
+ }
71
+ });
72
+ });
73
+
74
+ // ── 2. no usage → no emit ────────────────────────────────────────────────
75
+
76
+ describe("no usage", () => {
77
+ it("an assistant message without usage emits nothing", () => {
78
+ const handler = registerAndGetTurnEndHandler();
79
+ const { events, done } = captureCostUpdates();
80
+ try {
81
+ handler({
82
+ type: "turn_end",
83
+ turnIndex: 0,
84
+ message: assistantMessage({}),
85
+ }, { mode: "tui" });
86
+ expect(events).toHaveLength(0);
87
+ } finally {
88
+ done();
89
+ }
90
+ });
91
+ });
92
+
93
+ // ── 3. two turns → two events (delta semantics) ──────────────────────────
94
+
95
+ describe("delta semantics", () => {
96
+ it("two turns emit two events with per-turn values (NOT accumulated)", () => {
97
+ const handler = registerAndGetTurnEndHandler();
98
+ const { events, done } = captureCostUpdates();
99
+ try {
100
+ handler({
101
+ type: "turn_end",
102
+ turnIndex: 0,
103
+ message: assistantMessage({
104
+ usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 100, cost: { input: 0.001, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.001 } },
105
+ }),
106
+ }, { mode: "tui" });
107
+ handler({
108
+ type: "turn_end",
109
+ turnIndex: 1,
110
+ message: assistantMessage({
111
+ usage: { input: 200, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 200, cost: { input: 0.002, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.002 } },
112
+ }),
113
+ }, { mode: "tui" });
114
+ expect(events).toHaveLength(2);
115
+ expect((events[0] as { inputTokens: number }).inputTokens).toBe(100);
116
+ expect((events[1] as { inputTokens: number }).inputTokens).toBe(200); // NOT 300
117
+ } finally {
118
+ done();
119
+ }
120
+ });
121
+ });
122
+
123
+ // ── 4. unaffected by bridge mode ────────────────────────────────────────
124
+
125
+ describe("bridge independence", () => {
126
+ it("emits without the bridge channel being configured (bus is in-process; only the bridge forwarder is gated)", () => {
127
+ // NO configure() of the bridge channel anywhere in this file — the channel
128
+ // stays inactive by default. The emitter must still fire the bus event
129
+ // (design decision 3: registered unconditionally, not gated on bridge mode).
130
+ const handler = registerAndGetTurnEndHandler();
131
+ const { events, done } = captureCostUpdates();
132
+ try {
133
+ handler({
134
+ type: "turn_end",
135
+ turnIndex: 0,
136
+ message: assistantMessage({
137
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
138
+ }),
139
+ }, { mode: "tui" });
140
+ expect(events).toHaveLength(1);
141
+ expect((events[0] as { source: string }).source).toBe("main");
142
+ } finally {
143
+ done();
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,37 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getBus, Events } from "../bus.js";
3
+
4
+ /**
5
+ * Minimal structural mirror of the pi-ai `Usage` type (core does not depend on
6
+ * @earendil-works/pi-ai — keep the dependency surface unchanged).
7
+ */
8
+ interface UsageLike {
9
+ input?: number;
10
+ output?: number;
11
+ cacheRead?: number;
12
+ cacheWrite?: number;
13
+ cost?: { total?: number };
14
+ }
15
+
16
+ /**
17
+ * Emit the session's OWN usage as a per-turn COST_UPDATE (source "main").
18
+ * Per design decision 1-3: per-turn (turn_end), delta (the turn's usage is
19
+ * not cumulative), NOT gated on bridge mode (the TUI footer's
20
+ * CostAccumulator sums it — the footer's cost line becomes the true session
21
+ * cost; in bridge mode the existing events.ts forwarder carries it to the
22
+ * desktop). `reasoning` is excluded (a subset of `output` — double-count).
23
+ */
24
+ export function registerSelfUsage(pi: ExtensionAPI): void {
25
+ pi.on("turn_end", (event) => {
26
+ const usage = (event.message as { usage?: UsageLike } | undefined)?.usage;
27
+ if (!usage) return;
28
+ getBus().emit(Events.COST_UPDATE, {
29
+ source: "main",
30
+ inputTokens: usage.input ?? 0,
31
+ outputTokens: usage.output ?? 0,
32
+ cacheReadTokens: usage.cacheRead ?? 0,
33
+ cacheWriteTokens: usage.cacheWrite ?? 0,
34
+ cost: usage.cost?.total ?? 0,
35
+ });
36
+ });
37
+ }
@@ -1,115 +1,20 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- vi.mock("./settings-io.js", () => ({
4
- loadConfig: vi.fn(),
5
- saveConfig: vi.fn(),
6
- }));
7
-
8
- const { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES } =
9
- await import("./config.js");
10
- const { loadConfig, saveConfig } = await import("./settings-io.js");
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG } from "./config.js";
11
3
 
12
4
  describe("loadCoreConfig", () => {
13
- beforeEach(() => {
14
- vi.clearAllMocks();
15
- });
16
-
17
- it("uses correct namespace", () => {
18
- vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
19
- loadCoreConfig();
20
- expect(loadConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
21
- });
22
-
23
- it("returns default config when no settings exist", () => {
24
- vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
25
- const result = loadCoreConfig();
26
- expect(result).toEqual({
27
- mutedTheme: false,
28
- codeUnindent: true,
29
- labelText: "Thinking...",
30
- labelColor: "255,215,0",
31
- animationStyle: "vertical-up",
32
- editorSpinBorder: true,
33
- editorSpinSpeed: "normal",
34
- editorSpinLabel: "Working",
35
- editorSpinStyle: "pendulum",
36
- });
37
- });
38
-
39
- it("passes through merged config from settings-io", () => {
40
- const merged = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
41
- vi.mocked(loadConfig).mockReturnValue(merged);
42
- const result = loadCoreConfig();
43
- expect(result).toEqual(merged);
5
+ it("returns empty config", () => {
6
+ expect(loadCoreConfig()).toEqual({});
44
7
  });
45
8
  });
46
9
 
47
10
  describe("saveCoreConfig", () => {
48
- beforeEach(() => {
49
- vi.clearAllMocks();
50
- });
51
-
52
- it("saves with correct namespace", () => {
53
- saveCoreConfig(DEFAULT_CORE_CONFIG);
54
- expect(saveConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
55
- });
56
-
57
- it("passes config through unchanged", () => {
58
- const config = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
59
- saveCoreConfig(config);
60
- expect(saveConfig).toHaveBeenCalledWith("archimedes.core", config);
11
+ it("does nothing", () => {
12
+ expect(() => saveCoreConfig({})).not.toThrow();
61
13
  });
62
14
  });
63
15
 
64
16
  describe("DEFAULT_CORE_CONFIG", () => {
65
- it("has the expected shape", () => {
66
- expect(DEFAULT_CORE_CONFIG).toEqual({
67
- mutedTheme: false,
68
- codeUnindent: true,
69
- labelText: "Thinking...",
70
- labelColor: "255,215,0",
71
- animationStyle: "vertical-up",
72
- editorSpinBorder: true,
73
- editorSpinSpeed: "normal",
74
- editorSpinLabel: "Working",
75
- editorSpinStyle: "pendulum",
76
- });
77
- });
78
-
79
- it("exposes a speed→multiplier map (slow/normal/fast = 1.5/1/0.6 × native tempo)", async () => {
80
- const { SPIN_SPEED_MULT } = await import("./config.js");
81
- expect(SPIN_SPEED_MULT).toEqual({ slow: 1.5, normal: 1, fast: 0.6 });
82
- });
83
-
84
- it("exposes the SPIN_INTERVALS key set (ten gallery-derived styles)", async () => {
85
- const { SPIN_INTERVALS } = await import("./editor/spin.js");
86
- expect(Object.keys(SPIN_INTERVALS)).toEqual([
87
- "typing",
88
- "pulse",
89
- "rain",
90
- "cascade",
91
- "columns",
92
- "wave-rows",
93
- "diagonal-swipe",
94
- "sparkle",
95
- "pendulum",
96
- "marquee",
97
- ]);
98
- });
99
- });
100
-
101
- describe("ANIMATION_STYLES", () => {
102
- it("contains all expected styles", () => {
103
- expect(ANIMATION_STYLES).toEqual([
104
- "diagonal",
105
- "top-right",
106
- "bottom-left",
107
- "bottom-right",
108
- "center-out",
109
- "wave",
110
- "horizontal",
111
- "vertical",
112
- "vertical-up",
113
- ]);
17
+ it("is empty", () => {
18
+ expect(DEFAULT_CORE_CONFIG).toEqual({});
114
19
  });
115
20
  });
package/src/config.ts CHANGED
@@ -1,68 +1,6 @@
1
1
  import { loadConfig, saveConfig } from "./settings-io.js";
2
2
 
3
- export const ANIMATION_STYLES = [
4
- "diagonal",
5
- "top-right",
6
- "bottom-left",
7
- "bottom-right",
8
- "center-out",
9
- "wave",
10
- "horizontal",
11
- "vertical",
12
- "vertical-up",
13
- ] as const;
14
- export type AnimationStyle = (typeof ANIMATION_STYLES)[number];
15
-
16
- export interface CoreConfig {
17
- mutedTheme: boolean;
18
- codeUnindent: boolean;
19
- labelText: string;
20
- labelColor: string;
21
- animationStyle: AnimationStyle;
22
- editorSpinBorder: boolean;
23
- editorSpinSpeed: "slow" | "normal" | "fast";
24
- editorSpinLabel: string;
25
- editorSpinStyle: SpinnerStyle;
26
- }
27
-
28
- /** Border-spinner style (the ten gallery-derived variants, all registered in `SPIN_VARIANTS` (`editor/spin.ts`); unknown values normalize to `typing` via `normalizeSpinnerStyle`). */
29
- export type SpinnerStyle =
30
- | "typing"
31
- | "pulse"
32
- | "rain"
33
- | "cascade"
34
- | "columns"
35
- | "wave-rows"
36
- | "diagonal-swipe"
37
- | "sparkle"
38
- | "pendulum"
39
- | "marquee";
40
-
41
- export const DEFAULT_CORE_CONFIG: CoreConfig = {
42
- mutedTheme: false,
43
- codeUnindent: true,
44
- labelText: "Thinking...",
45
- labelColor: "255,215,0",
46
- animationStyle: "vertical-up",
47
- editorSpinBorder: true,
48
- editorSpinSpeed: "normal",
49
- editorSpinLabel: "Working",
50
- editorSpinStyle: "pendulum",
51
- };
52
-
53
- /** Border-spinner speed setting → the multipliers the `editorSpinSpeed` setting applies to the style's native per-tick interval (SPIN_INTERVALS, `editor/spin.ts` — typing: 120 / 80 / 48 ms). */
54
- export const SPIN_SPEED_MULT: Record<CoreConfig["editorSpinSpeed"], number> = {
55
- slow: 1.5,
56
- normal: 1,
57
- fast: 0.6,
58
- };
59
-
60
- const NAMESPACE = "archimedes.core";
61
-
62
- export function loadCoreConfig(): CoreConfig {
63
- return loadConfig(NAMESPACE, DEFAULT_CORE_CONFIG);
64
- }
65
-
66
- export function saveCoreConfig(config: CoreConfig): void {
67
- saveConfig(NAMESPACE, config);
68
- }
3
+ export interface CoreConfig {}
4
+ export const DEFAULT_CORE_CONFIG: CoreConfig = {};
5
+ export function loadCoreConfig(): CoreConfig { return {}; }
6
+ export function saveCoreConfig(_config: CoreConfig): void {}