@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.
- package/README.md +6 -27
- package/package.json +7 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +97 -18
- package/src/bridge/index.test.ts +147 -1
- package/src/bridge/index.ts +52 -2
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -354
- package/src/index.ts +9 -272
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
|
@@ -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
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -1,115 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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("
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 {}
|