@selesai/code 0.10.1 → 0.11.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/CHANGELOG.md +23 -0
- package/dist/config.d.ts +5 -3
- package/dist/config.js +6 -4
- package/dist/core/skills.d.ts +2 -0
- package/dist/core/skills.js +1 -0
- package/dist/defaults/settings.json +0 -3
- package/dist/extensions/auto-session-name.test.ts +157 -0
- package/dist/extensions/auto-session-name.ts +96 -0
- package/dist/extensions/cost-reconcile.test.ts +76 -0
- package/dist/extensions/cost-reconcile.ts +278 -0
- package/dist/extensions/enable-readonly-tools.test.ts +11 -0
- package/dist/extensions/enable-readonly-tools.ts +7 -3
- package/dist/extensions/package.json +3 -0
- package/dist/extensions/pi-tool-display/CHANGELOG.md +329 -0
- package/dist/extensions/pi-tool-display/LICENSE +21 -0
- package/dist/extensions/pi-tool-display/README.md +417 -0
- package/dist/extensions/pi-tool-display/config/config.example.json +40 -0
- package/dist/extensions/pi-tool-display/index.ts +3 -0
- package/dist/extensions/pi-tool-display/package-lock.json +2555 -0
- package/dist/extensions/pi-tool-display/package.json +86 -0
- package/dist/extensions/pi-tool-display/src/agent-dir.ts +35 -0
- package/dist/extensions/pi-tool-display/src/ansi-utils.ts +116 -0
- package/dist/extensions/pi-tool-display/src/bash-display.ts +213 -0
- package/dist/extensions/pi-tool-display/src/capabilities.ts +88 -0
- package/dist/extensions/pi-tool-display/src/config-modal.ts +541 -0
- package/dist/extensions/pi-tool-display/src/config-store.ts +304 -0
- package/dist/extensions/pi-tool-display/src/debug-logger.ts +150 -0
- package/dist/extensions/pi-tool-display/src/diff-presentation.ts +72 -0
- package/dist/extensions/pi-tool-display/src/diff-renderer.ts +2517 -0
- package/dist/extensions/pi-tool-display/src/disposable.ts +34 -0
- package/dist/extensions/pi-tool-display/src/extension-lifecycle.ts +14 -0
- package/dist/extensions/pi-tool-display/src/index.ts +109 -0
- package/dist/extensions/pi-tool-display/src/line-width-safety.ts +93 -0
- package/dist/extensions/pi-tool-display/src/modal-icons.ts +57 -0
- package/dist/extensions/pi-tool-display/src/pending-diff-preview.ts +370 -0
- package/dist/extensions/pi-tool-display/src/presets.ts +117 -0
- package/dist/extensions/pi-tool-display/src/render-utils.ts +186 -0
- package/dist/extensions/pi-tool-display/src/settings-inspector-modal.ts +475 -0
- package/dist/extensions/pi-tool-display/src/thinking-label.ts +343 -0
- package/dist/extensions/pi-tool-display/src/tool-call-box.ts +181 -0
- package/dist/extensions/pi-tool-display/src/tool-metadata.ts +139 -0
- package/dist/extensions/pi-tool-display/src/tool-overrides.ts +2168 -0
- package/dist/extensions/pi-tool-display/src/types.ts +109 -0
- package/dist/extensions/pi-tool-display/src/user-message-box-markdown.ts +64 -0
- package/dist/extensions/pi-tool-display/src/user-message-box-native.ts +66 -0
- package/dist/extensions/pi-tool-display/src/user-message-box-patch.ts +72 -0
- package/dist/extensions/pi-tool-display/src/user-message-box-renderer.ts +419 -0
- package/dist/extensions/pi-tool-display/src/user-message-box-utils.ts +106 -0
- package/dist/extensions/pi-tool-display/src/write-display-utils.ts +31 -0
- package/dist/extensions/pi-tool-display/src/zellij-modal.ts +1027 -0
- package/dist/extensions/pi-tool-display/tests/active-backlog-red.test.ts +65 -0
- package/dist/extensions/pi-tool-display/tests/ansi-utils.test.ts +345 -0
- package/dist/extensions/pi-tool-display/tests/bash-display.test.ts +442 -0
- package/dist/extensions/pi-tool-display/tests/capabilities-edge.test.ts +360 -0
- package/dist/extensions/pi-tool-display/tests/capabilities.test.ts +54 -0
- package/dist/extensions/pi-tool-display/tests/config-modal.test.ts +360 -0
- package/dist/extensions/pi-tool-display/tests/config-store.test.ts +87 -0
- package/dist/extensions/pi-tool-display/tests/custom-tool-overrides.test.ts +415 -0
- package/dist/extensions/pi-tool-display/tests/debug-logger-edge.test.ts +630 -0
- package/dist/extensions/pi-tool-display/tests/debug-logger.test.ts +62 -0
- package/dist/extensions/pi-tool-display/tests/diff-renderer-ansi.test.ts +55 -0
- package/dist/extensions/pi-tool-display/tests/diff-renderer-edge.test.ts +886 -0
- package/dist/extensions/pi-tool-display/tests/diff-renderer-numbering.test.ts +135 -0
- package/dist/extensions/pi-tool-display/tests/diff-renderer-width.test.ts +454 -0
- package/dist/extensions/pi-tool-display/tests/diff-renderer-wrap.test.ts +169 -0
- package/dist/extensions/pi-tool-display/tests/index-integration.test.ts +354 -0
- package/dist/extensions/pi-tool-display/tests/pending-diff-preview.test.ts +79 -0
- package/dist/extensions/pi-tool-display/tests/presets-edge.test.ts +229 -0
- package/dist/extensions/pi-tool-display/tests/presets.test.ts +26 -0
- package/dist/extensions/pi-tool-display/tests/reload-behavior.test.ts +1241 -0
- package/dist/extensions/pi-tool-display/tests/render-utils.test.ts +710 -0
- package/dist/extensions/pi-tool-display/tests/settings-inspector.test.ts +450 -0
- package/dist/extensions/pi-tool-display/tests/thinking-label-edge.test.ts +904 -0
- package/dist/extensions/pi-tool-display/tests/thinking-label.test.ts +72 -0
- package/dist/extensions/pi-tool-display/tests/tool-call-box.test.ts +105 -0
- package/dist/extensions/pi-tool-display/tests/tool-overrides-config.test.ts +613 -0
- package/dist/extensions/pi-tool-display/tests/tool-overrides-mcp.test.ts +499 -0
- package/dist/extensions/pi-tool-display/tests/tool-overrides-registration.test.ts +315 -0
- package/dist/extensions/pi-tool-display/tests/tool-overrides-write-meta.test.ts +68 -0
- package/dist/extensions/pi-tool-display/tests/tool-ui-utils.test.ts +413 -0
- package/dist/extensions/pi-tool-display/tests/user-message-box-performance-red.test.ts +176 -0
- package/dist/extensions/pi-tool-display/tests/user-message-box.test.ts +908 -0
- package/dist/extensions/pi-tool-display/tests/zellij-modal.test.ts +848 -0
- package/dist/extensions/pi-tool-display/tool-display-api-consumer.d.ts +30 -0
- package/dist/extensions/pi-tool-display/tool-display-api-consumer.js +40 -0
- package/dist/extensions/pi-zentui/extensions/zentui/format.ts +46 -1
- package/dist/extensions/pi-zentui/extensions/zentui/index.ts +10 -0
- package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +1 -1
- package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +3 -3
- package/dist/extensions/pi-zentui/test/format.test.ts +50 -2
- package/dist/modes/interactive/components/startup-box.d.ts +38 -0
- package/dist/modes/interactive/components/startup-box.js +90 -0
- package/dist/modes/interactive/interactive-mode.d.ts +3 -0
- package/dist/modes/interactive/interactive-mode.js +85 -122
- package/dist/skills/agent-browser/SKILL.md +1 -0
- package/dist/skills/batch-grill-me/SKILL.md +1 -0
- package/dist/skills/brandkit/SKILL.md +1 -0
- package/dist/skills/design-references/SKILL.md +1 -0
- package/dist/skills/design-taste-frontend/SKILL.md +1 -0
- package/dist/skills/design-taste-frontend-v1/SKILL.md +1 -0
- package/dist/skills/full-output-enforcement/SKILL.md +1 -0
- package/dist/skills/gpt-taste/SKILL.md +1 -0
- package/dist/skills/grill-me/SKILL.md +1 -0
- package/dist/skills/handoff/SKILL.md +1 -0
- package/dist/skills/handoff-text/SKILL.md +1 -0
- package/dist/skills/high-end-visual-design/SKILL.md +1 -0
- package/dist/skills/image-to-code/SKILL.md +1 -0
- package/dist/skills/imagegen-frontend-mobile/SKILL.md +1 -0
- package/dist/skills/imagegen-frontend-web/SKILL.md +1 -0
- package/dist/skills/implanger/SKILL.md +1 -0
- package/dist/skills/improve-codebase/SKILL.md +1 -0
- package/dist/skills/industrial-brutalist-ui/SKILL.md +1 -0
- package/dist/skills/minimalist-ui/SKILL.md +1 -0
- package/dist/skills/pi-subagents/SKILL.md +1 -0
- package/dist/skills/planger/SKILL.md +1 -0
- package/dist/skills/ponytail/SKILL.md +1 -0
- package/dist/skills/ponytail-audit/SKILL.md +1 -0
- package/dist/skills/ponytail-debt/SKILL.md +1 -0
- package/dist/skills/ponytail-gain/SKILL.md +1 -0
- package/dist/skills/ponytail-help/SKILL.md +1 -0
- package/dist/skills/ponytail-review/SKILL.md +1 -0
- package/dist/skills/redesign-existing-projects/SKILL.md +1 -0
- package/dist/skills/selesai-handoff/SKILL.md +1 -0
- package/dist/skills/stitch-design-taste/SKILL.md +1 -0
- package/dist/skills/unlazy/SKILL.md +1 -0
- package/dist/skills/web-design-guidelines/SKILL.md +1 -0
- package/dist/skills/workflow/SKILL.md +1 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@selesai/code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.11.0] - 2026-08-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Auto session naming.** The new `auto-session-name` extension names the session on every user message using the currently selected model. It sends only user-message history (no tool results, assistant replies, or thinking) with a 10-token output cap and a 5-word name limit, and runs in the background so it never delays your message. Thinking is explicitly disabled via `reasoning_effort: "none"` for gateways that ignore the standard `thinking: {type:"disabled"}` field.
|
|
9
|
+
- **Cost reconciliation.** The new `cost-reconcile` extension records the real billed cost reported by gateways (LiteLLM `x-litellm-response-cost` header, OpenRouter `usage.cost`/`total_cost`) as session entries; the zentui footer prefers these authoritative amounts over rate-card estimates.
|
|
10
|
+
- **Tool display.** The `pi-tool-display` extension is now bundled: compact OpenCode-style tool rendering, diff visualization, output truncation, a native user message box, and the `/tool-display` command with presets.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Read-only tool bootstrap.** `grep`, `find`, and `ls` are registered only when not already owned by another extension (e.g. `pi-tool-display`), so boxed renderers are no longer discarded on startup.
|
|
14
|
+
- **Zentui user messages.** The user-message markdown theme now uses the `accent` color instead of `userMessageText`.
|
|
15
|
+
- **Zentui cost totals.** The footer refreshes once ~12s after settle so late gateway cost reconciliation is picked up.
|
|
16
|
+
- **Bundled extensions.** `pi-tool-display`'s user-owned `config.json` is excluded from the shipped package; the default `packages` list no longer installs it from git.
|
|
17
|
+
|
|
18
|
+
## [0.10.2] - 2026-08-28
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Startup brand banner.** The interactive TUI now shows an ASCII logo banner on startup, followed by boxed listings of loaded skills (grouped by category) and active tools. The banner dismisses automatically on the first user message.
|
|
22
|
+
- **Skill categories.** Skills can declare a `category` frontmatter field; the startup skills box labels each skill with its category (e.g. `[design] brandkit`).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **`quietStartup` no longer hides the brand banner.** It now only suppresses the keybinding help and the loaded-resources listing; the logo banner always appears and is dismissed on the first user message.
|
|
26
|
+
- **Theme seeding.** Bundled themes now load directly from the installed package instead of being copied into `~/.selesai/agent/themes`. Copies left by older releases are removed at startup only when they are byte-identical to the bundled files, so user-edited themes survive. This also fixes the duplicate-theme name collisions on startup.
|
|
27
|
+
|
|
5
28
|
## [0.10.1] - 2026-08-28
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/dist/config.d.ts
CHANGED
|
@@ -163,9 +163,11 @@ export declare function seedDefaultExtensions(agentDir: string, bundledExtension
|
|
|
163
163
|
*/
|
|
164
164
|
export declare function seedDefaultSkills(agentDir: string, bundledSkillsDir?: string): string[];
|
|
165
165
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
166
|
+
* Bundled themes load directly from the installed package via
|
|
167
|
+
* additionalThemePaths. Do not copy them into the user's agent dir, otherwise
|
|
168
|
+
* startup discovers both copies and every theme reports a name collision.
|
|
169
|
+
* Remove files copied by older releases only when they are byte-identical to
|
|
170
|
+
* bundled files, so user-edited themes survive.
|
|
169
171
|
*/
|
|
170
172
|
export declare function seedDefaultThemes(agentDir: string, bundledThemesDir?: string): string[];
|
|
171
173
|
/**
|
package/dist/config.js
CHANGED
|
@@ -761,12 +761,14 @@ export function seedDefaultSkills(agentDir, bundledSkillsDir = getBundledSkillsD
|
|
|
761
761
|
return seedBundledDir(join(agentDir, "skills"), bundledSkillsDir);
|
|
762
762
|
}
|
|
763
763
|
/**
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
764
|
+
* Bundled themes load directly from the installed package via
|
|
765
|
+
* additionalThemePaths. Do not copy them into the user's agent dir, otherwise
|
|
766
|
+
* startup discovers both copies and every theme reports a name collision.
|
|
767
|
+
* Remove files copied by older releases only when they are byte-identical to
|
|
768
|
+
* bundled files, so user-edited themes survive.
|
|
767
769
|
*/
|
|
768
770
|
export function seedDefaultThemes(agentDir, bundledThemesDir = getBundledThemesDir()) {
|
|
769
|
-
return
|
|
771
|
+
return removeIdenticalBundledFiles(join(agentDir, "themes"), bundledThemesDir);
|
|
770
772
|
}
|
|
771
773
|
/**
|
|
772
774
|
* Run bootstrap for the agent dir: ensure directories, seed a missing
|
package/dist/core/skills.d.ts
CHANGED
|
@@ -3,12 +3,14 @@ import { type SourceInfo } from "./source-info.ts";
|
|
|
3
3
|
export interface SkillFrontmatter {
|
|
4
4
|
name?: string;
|
|
5
5
|
description?: string;
|
|
6
|
+
category?: string;
|
|
6
7
|
"disable-model-invocation"?: boolean;
|
|
7
8
|
[key: string]: unknown;
|
|
8
9
|
}
|
|
9
10
|
export interface Skill {
|
|
10
11
|
name: string;
|
|
11
12
|
description: string;
|
|
13
|
+
category?: string;
|
|
12
14
|
filePath: string;
|
|
13
15
|
baseDir: string;
|
|
14
16
|
sourceInfo: SourceInfo;
|
package/dist/core/skills.js
CHANGED
|
@@ -232,6 +232,7 @@ function loadSkillFromFile(filePath, source) {
|
|
|
232
232
|
skill: {
|
|
233
233
|
name,
|
|
234
234
|
description: frontmatter.description,
|
|
235
|
+
category: frontmatter.category?.trim() || undefined,
|
|
235
236
|
filePath,
|
|
236
237
|
baseDir: skillDir,
|
|
237
238
|
sourceInfo: createSkillSourceInfo(filePath, skillDir, source),
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@selesai/code";
|
|
3
|
+
import autoSessionNameExtension, {
|
|
4
|
+
MAX_MESSAGE_CHARS,
|
|
5
|
+
MAX_NAME_WORDS,
|
|
6
|
+
MAX_PREVIOUS_MESSAGES,
|
|
7
|
+
NAMING_MAX_TOKENS,
|
|
8
|
+
NAMING_SYSTEM_PROMPT,
|
|
9
|
+
previousUserMessages,
|
|
10
|
+
} from "./auto-session-name.ts";
|
|
11
|
+
|
|
12
|
+
vi.mock("@earendil-works/pi-ai/compat", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("@earendil-works/pi-ai/compat")>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
complete: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
21
|
+
const completeMock = vi.mocked(complete);
|
|
22
|
+
|
|
23
|
+
type Handler = (event: unknown, ctx: ExtensionContext) => void | Promise<void>;
|
|
24
|
+
|
|
25
|
+
function userEntry(text: string, id: string): SessionEntry {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
type: "message",
|
|
29
|
+
message: { role: "user", content: [{ type: "text", text }], timestamp: 100 },
|
|
30
|
+
parentId: "root",
|
|
31
|
+
timestamp: "2025-01-01T00:00:00.000Z",
|
|
32
|
+
} as unknown as SessionEntry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createHarness(branch: SessionEntry[] = []) {
|
|
36
|
+
const handlers = new Map<string, Handler>();
|
|
37
|
+
const setSessionName = vi.fn();
|
|
38
|
+
const pi = {
|
|
39
|
+
on: vi.fn((event: string, handler: Handler) => handlers.set(event, handler)),
|
|
40
|
+
setSessionName,
|
|
41
|
+
} as unknown as ExtensionAPI;
|
|
42
|
+
const model = { provider: "openai", id: "gpt-5", name: "GPT-5" };
|
|
43
|
+
const ctx = {
|
|
44
|
+
model,
|
|
45
|
+
modelRegistry: {
|
|
46
|
+
getApiKeyAndHeaders: vi.fn().mockResolvedValue({ ok: true, apiKey: "key", headers: {} }),
|
|
47
|
+
},
|
|
48
|
+
sessionManager: { getBranch: () => branch },
|
|
49
|
+
} as unknown as ExtensionContext;
|
|
50
|
+
|
|
51
|
+
autoSessionNameExtension(pi);
|
|
52
|
+
return { handlers, setSessionName, ctx };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function textResponse(text: string) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text", text }],
|
|
58
|
+
stopReason: "stop",
|
|
59
|
+
usage: { input: 0, output: 0 },
|
|
60
|
+
} as never;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("auto-session-name", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("registers an input handler", () => {
|
|
69
|
+
const { handlers } = createHarness();
|
|
70
|
+
expect(handlers.has("input")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("names the session from the user prompt with a small system prompt and 10-token cap", async () => {
|
|
74
|
+
const { handlers, setSessionName, ctx } = createHarness();
|
|
75
|
+
completeMock.mockResolvedValue(textResponse("Refactor auth"));
|
|
76
|
+
|
|
77
|
+
await handlers.get("input")!({ type: "input", text: "refactor the auth module", source: "interactive" }, ctx);
|
|
78
|
+
await vi.waitFor(() => expect(setSessionName).toHaveBeenCalledWith("Refactor auth"));
|
|
79
|
+
|
|
80
|
+
expect(completeMock).toHaveBeenCalledTimes(1);
|
|
81
|
+
const [model, context, options] = completeMock.mock.calls[0];
|
|
82
|
+
expect(model).toBe(ctx.model);
|
|
83
|
+
expect(context.systemPrompt).toBe(NAMING_SYSTEM_PROMPT);
|
|
84
|
+
expect(context.messages).toHaveLength(1);
|
|
85
|
+
expect(context.messages[0].role).toBe("user");
|
|
86
|
+
expect(context.messages[0].content).toBe("refactor the auth module");
|
|
87
|
+
expect(options?.maxTokens).toBe(NAMING_MAX_TOKENS);
|
|
88
|
+
expect(options?.samplingParams).toEqual({ reasoning_effort: "none" });
|
|
89
|
+
expect(setSessionName).toHaveBeenCalledWith("Refactor auth");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("caps the name at 5 words even if the model returns more", async () => {
|
|
93
|
+
const { handlers, setSessionName, ctx } = createHarness();
|
|
94
|
+
completeMock.mockResolvedValue(textResponse("one two three four five six seven"));
|
|
95
|
+
|
|
96
|
+
await handlers.get("input")!({ type: "input", text: "hello", source: "interactive" }, ctx);
|
|
97
|
+
await vi.waitFor(() => expect(setSessionName).toHaveBeenCalledWith("one two three four five"));
|
|
98
|
+
|
|
99
|
+
expect(setSessionName.mock.calls[0][0].split(/\s+/)).toHaveLength(MAX_NAME_WORDS);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("includes previous user messages (newest first) for context", async () => {
|
|
103
|
+
const branch = [userEntry("first message", "u1"), userEntry("second message", "u2")];
|
|
104
|
+
const { handlers, ctx } = createHarness(branch);
|
|
105
|
+
completeMock.mockResolvedValue(textResponse("Auth refactor"));
|
|
106
|
+
|
|
107
|
+
await handlers.get("input")!({ type: "input", text: "now fix the tests", source: "interactive" }, ctx);
|
|
108
|
+
|
|
109
|
+
const [, context] = completeMock.mock.calls[0];
|
|
110
|
+
expect(context.messages[0].content).toBe("second message\nfirst message\nnow fix the tests");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("truncates long previous messages and caps the count", () => {
|
|
114
|
+
const long = "x".repeat(MAX_MESSAGE_CHARS + 50);
|
|
115
|
+
const branch = Array.from({ length: MAX_PREVIOUS_MESSAGES + 5 }, (_, i) =>
|
|
116
|
+
userEntry(i === MAX_PREVIOUS_MESSAGES + 4 ? long : `msg ${i}`, `u${i}`),
|
|
117
|
+
);
|
|
118
|
+
const texts = previousUserMessages({ getBranch: () => branch });
|
|
119
|
+
|
|
120
|
+
expect(texts).toHaveLength(MAX_PREVIOUS_MESSAGES);
|
|
121
|
+
expect(texts[0]).toBe(`${"x".repeat(MAX_MESSAGE_CHARS)}…`);
|
|
122
|
+
expect(texts[1]).toBe("msg 23");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("skips extension-sourced input, commands, and empty text", async () => {
|
|
126
|
+
const { handlers, setSessionName, ctx } = createHarness();
|
|
127
|
+
|
|
128
|
+
await handlers.get("input")!({ type: "input", text: "hi", source: "extension" }, ctx);
|
|
129
|
+
await handlers.get("input")!({ type: "input", text: "/name foo", source: "interactive" }, ctx);
|
|
130
|
+
await handlers.get("input")!({ type: "input", text: " ", source: "interactive" }, ctx);
|
|
131
|
+
|
|
132
|
+
expect(completeMock).not.toHaveBeenCalled();
|
|
133
|
+
expect(setSessionName).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does not set a name when the model returns no text", async () => {
|
|
137
|
+
const { handlers, setSessionName, ctx } = createHarness();
|
|
138
|
+
completeMock.mockResolvedValue({ content: [], stopReason: "stop" } as never);
|
|
139
|
+
|
|
140
|
+
await handlers.get("input")!({ type: "input", text: "hello", source: "interactive" }, ctx);
|
|
141
|
+
|
|
142
|
+
expect(setSessionName).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("swallows auth and completion errors without breaking the message flow", async () => {
|
|
146
|
+
const { handlers, setSessionName, ctx } = createHarness();
|
|
147
|
+
ctx.modelRegistry.getApiKeyAndHeaders = vi.fn().mockResolvedValue({ ok: false, error: "no key" });
|
|
148
|
+
await handlers.get("input")!({ type: "input", text: "hello", source: "interactive" }, ctx);
|
|
149
|
+
expect(setSessionName).not.toHaveBeenCalled();
|
|
150
|
+
|
|
151
|
+
ctx.modelRegistry.getApiKeyAndHeaders = vi.fn().mockResolvedValue({ ok: true, apiKey: "key" });
|
|
152
|
+
completeMock.mockRejectedValue(new Error("boom"));
|
|
153
|
+
await handlers.get("input")!({ type: "input", text: "hello", source: "interactive" }, ctx);
|
|
154
|
+
await vi.waitFor(() => expect(completeMock).toHaveBeenCalled());
|
|
155
|
+
expect(setSessionName).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-session-name
|
|
3
|
+
*
|
|
4
|
+
* Names the session on every user message using the currently selected model.
|
|
5
|
+
* Sends only user messages (current prompt plus previous user messages,
|
|
6
|
+
* truncated) for context, capped at 10 output tokens (1-5 word name).
|
|
7
|
+
* Thinking is never enabled. Equivalent to running /name automatically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
11
|
+
import type { ExtensionAPI, SessionEntry } from "@selesai/code";
|
|
12
|
+
|
|
13
|
+
export const NAMING_SYSTEM_PROMPT =
|
|
14
|
+
"Reply with only a short session name (1-5 words) for this conversation based on the user's messages. No punctuation, no quotes, no explanation.";
|
|
15
|
+
|
|
16
|
+
export const NAMING_MAX_TOKENS = 10;
|
|
17
|
+
/** Hard cap on the name itself: at most 5 words (≈5 tokens). */
|
|
18
|
+
export const MAX_NAME_WORDS = 5;
|
|
19
|
+
/** How many previous user messages to include, newest first. */
|
|
20
|
+
export const MAX_PREVIOUS_MESSAGES = 20;
|
|
21
|
+
/** Per-message character cap when truncating. */
|
|
22
|
+
export const MAX_MESSAGE_CHARS = 200;
|
|
23
|
+
|
|
24
|
+
function truncate(text: string): string {
|
|
25
|
+
return text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Collect previous user message texts from the session branch, newest first, truncated. */
|
|
29
|
+
export function previousUserMessages(sessionManager: { getBranch(): SessionEntry[] }): string[] {
|
|
30
|
+
const branch = sessionManager.getBranch();
|
|
31
|
+
const texts: string[] = [];
|
|
32
|
+
for (let i = branch.length - 1; i >= 0 && texts.length < MAX_PREVIOUS_MESSAGES; i--) {
|
|
33
|
+
const entry = branch[i];
|
|
34
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
35
|
+
const content = entry.message.content;
|
|
36
|
+
const text = typeof content === "string" ? content : content
|
|
37
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
38
|
+
.map((c) => c.text)
|
|
39
|
+
.join("\n");
|
|
40
|
+
if (text.trim()) texts.push(truncate(text.trim()));
|
|
41
|
+
}
|
|
42
|
+
return texts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function autoSessionNameExtension(pi: ExtensionAPI): void {
|
|
46
|
+
// Guards run synchronously; the naming call itself is fire-and-forget so it
|
|
47
|
+
// never blocks the user's message from being sent.
|
|
48
|
+
let namingSeq = 0;
|
|
49
|
+
pi.on("input", (event, ctx) => {
|
|
50
|
+
// Only real user messages; skip extension-sourced messages and commands.
|
|
51
|
+
if (event.source === "extension") return;
|
|
52
|
+
const text = event.text.trim();
|
|
53
|
+
if (!text || text.startsWith("/")) return;
|
|
54
|
+
if (!ctx.model) return;
|
|
55
|
+
|
|
56
|
+
const seq = ++namingSeq;
|
|
57
|
+
void (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
60
|
+
if (!auth.ok || !auth.apiKey) return;
|
|
61
|
+
|
|
62
|
+
const previous = previousUserMessages(ctx.sessionManager);
|
|
63
|
+
const conversation = [...previous, text].join("\n");
|
|
64
|
+
|
|
65
|
+
const response = await complete(
|
|
66
|
+
ctx.model,
|
|
67
|
+
{
|
|
68
|
+
systemPrompt: NAMING_SYSTEM_PROMPT,
|
|
69
|
+
messages: [{ role: "user", content: conversation, timestamp: Date.now() }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
apiKey: auth.apiKey,
|
|
73
|
+
headers: auth.headers,
|
|
74
|
+
maxTokens: NAMING_MAX_TOKENS,
|
|
75
|
+
cacheRetention: "none",
|
|
76
|
+
// The tokenin gateway ignores `thinking: {type:"disabled"}` and burns the
|
|
77
|
+
// whole budget on reasoning; `reasoning_effort: "none"` is what disables it.
|
|
78
|
+
samplingParams: { reasoning_effort: "none" },
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
const name = response.content
|
|
82
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
83
|
+
.map((c) => c.text)
|
|
84
|
+
.join("")
|
|
85
|
+
.trim()
|
|
86
|
+
.split(/\s+/)
|
|
87
|
+
.slice(0, MAX_NAME_WORDS)
|
|
88
|
+
.join(" ");
|
|
89
|
+
// Only the latest request may set the name; slower older responses lose.
|
|
90
|
+
if (name && seq === namingSeq) pi.setSessionName(name);
|
|
91
|
+
} catch {
|
|
92
|
+
// Best-effort naming; never break the user's message flow.
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractCosts, parseCostHeader } from "./cost-reconcile.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseCostHeader", () => {
|
|
5
|
+
it("parses LiteLLM scientific-notation cost", () => {
|
|
6
|
+
expect(parseCostHeader("4.45284e-06")).toBe(4.45284e-6);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("parses plain decimal cost", () => {
|
|
10
|
+
expect(parseCostHeader("0.00123")).toBe(0.00123);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("rejects null, garbage, and negatives", () => {
|
|
14
|
+
expect(parseCostHeader(null)).toBeUndefined();
|
|
15
|
+
expect(parseCostHeader("abc")).toBeUndefined();
|
|
16
|
+
expect(parseCostHeader("-1")).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("extractCosts", () => {
|
|
21
|
+
it("reads OpenRouter chat-completions usage cost from a final SSE chunk", () => {
|
|
22
|
+
const body =
|
|
23
|
+
'Data: {"id":"gen-abc","provider":"OpenAI"}\n\n' +
|
|
24
|
+
'data: {"id":"gen-abc","usage":{"prompt_tokens":10,"cost":0.0123},"choices":[]}\n\n' +
|
|
25
|
+
"data: [DONE]\n\n";
|
|
26
|
+
const { ids, cost } = extractCosts(body);
|
|
27
|
+
expect(ids).toContain("gen-abc");
|
|
28
|
+
expect(cost).toBe(0.0123);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reads OpenRouter native total_cost from a JSON body", () => {
|
|
32
|
+
const { ids, cost } = extractCosts(
|
|
33
|
+
'{"id":"gen-xyz","total_cost":0.05152,"usage":{"total_tokens":330}}',
|
|
34
|
+
);
|
|
35
|
+
expect(ids).toEqual(["gen-xyz"]);
|
|
36
|
+
expect(cost).toBe(0.05152);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reads object-shaped cost {total} payloads", () => {
|
|
40
|
+
const { cost } = extractCosts('{"id":"gen-obj","cost":{"input":0.01,"total":0.0456}}');
|
|
41
|
+
expect(cost).toBe(0.0456);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps the last cost when multiple chunks carry usage", () => {
|
|
45
|
+
const body =
|
|
46
|
+
'data: {"id":"gen-1","usage":{"cost":0.01}}\n\ndata: {"id":"gen-1","usage":{"cost":0.02}}\n\n';
|
|
47
|
+
const { cost } = extractCosts(body);
|
|
48
|
+
expect(cost).toBe(0.02);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns no cost when the payload has none", () => {
|
|
52
|
+
const { ids, cost } = extractCosts('data: {"id":"resp-1","delta":"hi"}\n\n');
|
|
53
|
+
expect(ids).toContain("resp-1");
|
|
54
|
+
expect(cost).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("ignores negative or non-finite costs", () => {
|
|
58
|
+
const { cost } = extractCosts('{"id":"gen-neg","cost":-1}');
|
|
59
|
+
expect(cost).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("extracts ids from anthropic-style message_start without cost", () => {
|
|
63
|
+
const body =
|
|
64
|
+
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_01ABC","usage":{}}}\n\n' +
|
|
65
|
+
'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":15}}\n\n';
|
|
66
|
+
const { ids, cost } = extractCosts(body);
|
|
67
|
+
expect(ids).toContain("msg_01ABC");
|
|
68
|
+
expect(cost).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("caps captured ids", () => {
|
|
72
|
+
const body = Array.from({ length: 100 }, (_, i) => `{"id":"id-${i}"}`).join("\n");
|
|
73
|
+
const { ids } = extractCosts(body);
|
|
74
|
+
expect(ids.length).toBe(50);
|
|
75
|
+
});
|
|
76
|
+
});
|