flashlite 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js ADDED
@@ -0,0 +1,206 @@
1
+ // Config loading. API credentials are read only from ~/.config/flashlite/config.json.
2
+ // The real key is never logged or printed — not even in masked form (see CLAUDE.md).
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { parseEffort } from "./effort.js";
7
+ import { DEFAULT_TOOL_RESULT_BUDGET_BYTES } from "./contextbudget.js";
8
+ const DEFAULTS = {
9
+ base_url: '',
10
+ api_key: null,
11
+ default_model: null,
12
+ reasoning_effort: 'auto',
13
+ max_output_tokens: 8192,
14
+ max_context_tokens: 32_000,
15
+ tool_result_budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES,
16
+ auto_approve: false,
17
+ compact_context_remaining_percent: 60,
18
+ permission_mode: 'ask',
19
+ token_saver: 'off',
20
+ boost_mode: false,
21
+ // Schemas are resent every round; drop the two tools whose behaviour is
22
+ // already covered by write_file (parent creation) and run_command (stat).
23
+ disabled_tools: ['make_dir', 'file_info'],
24
+ };
25
+ /** The endpoint this build shipped before it became provider-agnostic (migration only). */
26
+ const LEGACY_SUBROUTER_BASE_URL = 'https://router.eva.pink/v1';
27
+ export const CONFIG_VERSION = 3;
28
+ /** Values older versions wrote into config.json as their defaults; version 2 replaces them. */
29
+ const LEGACY_DEFAULTS = { maxContext: null, compactPercent: 15, toolBudget: 65_536 };
30
+ /**
31
+ * Validate and normalize an endpoint root: http(s) only, no credentials, query or fragment.
32
+ * Trailing slashes are stripped. Returns null when the value is unusable. The path is kept
33
+ * as given, so providers that need /v1 (or /api/v1) work and those that do not are untouched.
34
+ */
35
+ export function normalizeBaseUrl(value) {
36
+ if (typeof value !== 'string')
37
+ return null;
38
+ const text = value.trim();
39
+ if (!text || text.includes('?') || text.includes('#'))
40
+ return null;
41
+ try {
42
+ const url = new URL(text);
43
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
44
+ return null;
45
+ return text.replace(/\/+$/, '');
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function configDir() {
52
+ return path.join(os.homedir(), '.config', 'flashlite');
53
+ }
54
+ export function configPath() {
55
+ return path.join(configDir(), 'config.json');
56
+ }
57
+ function loadConfigFile() {
58
+ let raw;
59
+ try {
60
+ raw = fs.readFileSync(configPath(), 'utf8');
61
+ }
62
+ catch {
63
+ return null; // no file yet — normal on first run
64
+ }
65
+ try {
66
+ const parsed = JSON.parse(raw);
67
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
68
+ return null;
69
+ return { ...DEFAULTS, ...parsed };
70
+ }
71
+ catch {
72
+ return null; // corrupt file — caller warns
73
+ }
74
+ }
75
+ /** Merge a patch into the config file, creating it (chmod 600) if needed. */
76
+ export function saveConfigFile(patch) {
77
+ const dir = configDir();
78
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
79
+ const file = configPath();
80
+ const loaded = loadConfigFile();
81
+ if (loaded === null && fs.existsSync(file)) {
82
+ // Unreadable or corrupt config: keep a copy rather than silently discarding the saved key.
83
+ try {
84
+ fs.copyFileSync(file, file + '.bak');
85
+ fs.chmodSync(file + '.bak', 0o600);
86
+ }
87
+ catch { /* best effort */ }
88
+ }
89
+ const next = { ...(loaded ?? DEFAULTS), ...patch };
90
+ // Write privately from the first byte (no world-readable window) and replace atomically,
91
+ // so a crash mid-write can never leave a truncated config.
92
+ const tmp = `${file}.${process.pid}.tmp`;
93
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
94
+ fs.renameSync(tmp, file);
95
+ fs.chmodSync(file, 0o600);
96
+ }
97
+ /**
98
+ * One-time upgrade of an existing config.json. Older versions saved their defaults into the
99
+ * file, so changing a default in code never reached existing users. Only values still equal
100
+ * to the old defaults are replaced (a deliberate custom value is left alone), then the file
101
+ * is stamped with CONFIG_VERSION so later manual choices are never overridden again.
102
+ * Returns human-readable notes for what changed.
103
+ */
104
+ export function migrateConfigFile() {
105
+ const file = loadConfigFile();
106
+ if (file === null || (typeof file.config_version === 'number' && file.config_version >= CONFIG_VERSION))
107
+ return [];
108
+ const patch = { config_version: CONFIG_VERSION };
109
+ const notes = [];
110
+ if (file.max_context_tokens === LEGACY_DEFAULTS.maxContext) {
111
+ patch.max_context_tokens = DEFAULTS.max_context_tokens;
112
+ notes.push(`max context ${DEFAULTS.max_context_tokens} tokens`);
113
+ }
114
+ if (file.compact_context_remaining_percent === LEGACY_DEFAULTS.compactPercent) {
115
+ patch.compact_context_remaining_percent = DEFAULTS.compact_context_remaining_percent;
116
+ notes.push(`auto-compaction at ${DEFAULTS.compact_context_remaining_percent}% remaining`);
117
+ }
118
+ if (file.tool_result_budget_bytes === LEGACY_DEFAULTS.toolBudget) {
119
+ patch.tool_result_budget_bytes = DEFAULTS.tool_result_budget_bytes;
120
+ notes.push(`tool-result replay ${DEFAULTS.tool_result_budget_bytes} bytes`);
121
+ }
122
+ // Drop the old built-in endpoint so the next launch asks which provider to use.
123
+ if (file.base_url === LEGACY_SUBROUTER_BASE_URL) {
124
+ patch.base_url = '';
125
+ notes.push('endpoint cleared (set it again on the next launch)');
126
+ }
127
+ try {
128
+ saveConfigFile(patch);
129
+ }
130
+ catch {
131
+ return [];
132
+ } // advisory: never block startup
133
+ return notes.length ? [`config updated to lower-token defaults (${notes.join(', ')}) · change any of them in /settings`] : [];
134
+ }
135
+ /** Endpoint precedence: overrideBaseUrl (--base-url) > FLASHLITE_BASE_URL > config file. Empty means unset. */
136
+ export function resolveConfig(overrideBaseUrl) {
137
+ const warnings = [];
138
+ const file = loadConfigFile();
139
+ if (file === null && fs.existsSync(configPath())) {
140
+ warnings.push('config file is unreadable or invalid — using defaults');
141
+ }
142
+ if (file && 'max_tool_rounds' in file) {
143
+ warnings.push('max_tool_rounds is no longer supported — the tool-round limit was removed; the key is ignored');
144
+ }
145
+ const reasoningEffort = parseEffort(file?.reasoning_effort) ?? DEFAULTS.reasoning_effort;
146
+ if (file && parseEffort(file.reasoning_effort) === undefined)
147
+ warnings.push('invalid reasoning_effort in config — using auto');
148
+ const compactThreshold = file?.compact_context_remaining_percent;
149
+ const compactContextRemainingPercent = typeof compactThreshold === 'number' && Number.isInteger(compactThreshold) && compactThreshold >= 0 && compactThreshold <= 90
150
+ ? compactThreshold : DEFAULTS.compact_context_remaining_percent;
151
+ if (file && compactThreshold !== undefined && compactContextRemainingPercent !== compactThreshold)
152
+ warnings.push(`invalid compact_context_remaining_percent in config — using ${DEFAULTS.compact_context_remaining_percent}`);
153
+ const field = (name, valid, fallback) => {
154
+ const value = file?.[name];
155
+ if (value === undefined)
156
+ return fallback;
157
+ if (valid(value))
158
+ return value;
159
+ warnings.push(`invalid ${name} in config — using default`);
160
+ return fallback;
161
+ };
162
+ const optionalText = (value) => value === null || typeof value === 'string';
163
+ const httpUrl = (value) => normalizeBaseUrl(value) !== null;
164
+ const boundedInteger = (min, max) => (value) => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
165
+ const fileKey = field('api_key', optionalText, null)?.trim() || null;
166
+ const defaultModel = field('default_model', optionalText, null)?.trim() || null;
167
+ const fileBaseUrl = normalizeBaseUrl(field('base_url', (value) => value === '' || httpUrl(value), DEFAULTS.base_url)) ?? '';
168
+ const envRaw = process.env.FLASHLITE_BASE_URL?.trim();
169
+ const envBaseUrl = envRaw ? normalizeBaseUrl(envRaw) : null;
170
+ if (envRaw && !envBaseUrl)
171
+ warnings.push('invalid FLASHLITE_BASE_URL — ignoring it');
172
+ const flagBaseUrl = overrideBaseUrl ? normalizeBaseUrl(overrideBaseUrl) : null;
173
+ const baseUrl = flagBaseUrl ?? envBaseUrl ?? fileBaseUrl;
174
+ const baseUrlSource = flagBaseUrl ? 'flag' : envBaseUrl ? 'env' : fileBaseUrl ? 'config' : 'unset';
175
+ const maxOutputTokens = field('max_output_tokens', boundedInteger(256, 65536), DEFAULTS.max_output_tokens);
176
+ const maxContextTokens = field('max_context_tokens', (value) => value === null || (typeof value === 'number' && Number.isInteger(value) && value >= 1024 && value <= 1_000_000), DEFAULTS.max_context_tokens);
177
+ const toolResultBudgetBytes = field('tool_result_budget_bytes', boundedInteger(0, 4_000_000), DEFAULTS.tool_result_budget_bytes);
178
+ const autoApprove = field('auto_approve', (value) => typeof value === 'boolean', false);
179
+ // Fail closed: an unrecognized permission_mode falls back to ask; never widen.
180
+ const permissionMode = field('permission_mode', (value) => typeof value === 'string' && ['ask', 'files', 'auto', 'unguarded'].includes(value), DEFAULTS.permission_mode);
181
+ const tokenSaver = field('token_saver', (value) => value === 'off' || value === 'low' || value === 'high' || value === 'custom', 'off');
182
+ const boostMode = field('boost_mode', (value) => typeof value === 'boolean', false);
183
+ const promptCaching = field('prompt_caching', (value) => typeof value === 'boolean', true);
184
+ const disabledTools = field('disabled_tools', (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string'), DEFAULTS.disabled_tools ?? []);
185
+ return {
186
+ config: {
187
+ baseUrl,
188
+ apiKey: fileKey ?? '',
189
+ defaultModel,
190
+ reasoningEffort,
191
+ maxOutputTokens,
192
+ maxContextTokens,
193
+ toolResultBudgetBytes,
194
+ autoApprove,
195
+ compactContextRemainingPercent,
196
+ permissionMode,
197
+ tokenSaver,
198
+ boostMode,
199
+ configPath: file ? configPath() : null,
200
+ baseUrlSource,
201
+ promptCaching,
202
+ disabledTools,
203
+ },
204
+ warnings,
205
+ };
206
+ }
@@ -0,0 +1,201 @@
1
+ // contextbudget.ts — bound how much earlier tool output is replayed to the model.
2
+ //
3
+ // The agent loop re-sends the whole conversation on every tool round, so without a
4
+ // ceiling a turn that reads a few files re-uploads every earlier result again and
5
+ // again (roughly rounds × results). This module builds the *replay view* of history
6
+ // used for provider requests: the newest tool results are kept whole up to a per-result
7
+ // cap (anything larger is replayed cropped head+tail), and older ones are replaced with a
8
+ // short stub once their combined content exceeds a byte budget.
9
+ //
10
+ // It never mutates the canonical history, and only ever touches `tool` message
11
+ // content: role, toolCallId, ordering and tool_call/tool_result pairing are preserved,
12
+ // so the transcript and every downstream consumer still see the full results. The
13
+ // model can re-run a tool if it still needs an elided result.
14
+ /** Default ceiling for the sum of replayed tool-result bytes (~2K tokens at 4 chars/token). */
15
+ export const DEFAULT_TOOL_RESULT_BUDGET_BYTES = 8_192;
16
+ /**
17
+ * Hard ceiling for any single replayed tool result (~1K tokens at 4 chars/token). A command
18
+ * that prints tens of KB is cropped head+tail to this even when it is the newest result and
19
+ * therefore exempt from the sum budget: without it, one terminal command can spike the
20
+ * context by ~10k tokens and make the meter rise and fall as it is displaced and elided.
21
+ */
22
+ export const DEFAULT_TOOL_RESULT_MAX_BYTES = 4_096;
23
+ const ELISION_PREFIX = '[earlier tool result elided to save context:';
24
+ // Marker inserted where a per-result crop dropped the middle of an oversized result. Kept
25
+ // in the middle (not the prefix) so the retained head still lines up with any prior cache.
26
+ const CROP_PREFIX = '[tool result cropped to save context:';
27
+ /**
28
+ * Sticky, batched elision. The plain mode above moves its cut-off a little every round,
29
+ * which rewrites an early message each time and defeats provider prefix caching (the
30
+ * cached prefix must be byte-identical). Here a result, once elided, stays elided, and
31
+ * nothing is elided until the live results exceed the budget; then the oldest are dropped
32
+ * in one batch down to half the budget. The start of every request is therefore identical
33
+ * for many rounds in a row, and the cache is only invalidated once per batch.
34
+ */
35
+ function boundSticky(history, budgetBytes, state) {
36
+ const limit = perResultLimit(budgetBytes);
37
+ const live = [];
38
+ let kept = 0;
39
+ for (const message of history) {
40
+ if (message.role !== 'tool' || state.has(message))
41
+ continue;
42
+ live.push(message);
43
+ // Count each result at most `limit` bytes: an oversized newest result is replayed
44
+ // cropped, so it cannot push the live total far past the budget on its own.
45
+ kept += Math.min(message.content.length, limit);
46
+ }
47
+ if (kept > budgetBytes) {
48
+ const target = Math.floor(budgetBytes / 2);
49
+ // The newest result is always kept (cropped to `limit` at most); older ones are elided.
50
+ for (let i = 0; i < live.length - 1 && kept > target; i++) {
51
+ state.add(live[i]);
52
+ kept -= Math.min(live[i].content.length, limit);
53
+ }
54
+ }
55
+ let view;
56
+ for (let i = 0; i < history.length; i++) {
57
+ const message = history[i];
58
+ if (message.role !== 'tool')
59
+ continue;
60
+ if (state.has(message)) {
61
+ view ??= history.slice();
62
+ view[i] = isElidedToolResult(message.content) ? message : { ...message, content: elisionStub(message.content.length) };
63
+ }
64
+ else if (message.content.length > limit) {
65
+ view ??= history.slice();
66
+ view[i] = { ...message, content: cropToolResult(message.content, limit) };
67
+ }
68
+ }
69
+ return view ?? history;
70
+ }
71
+ /** True for contents already replaced by a stub, so elision is idempotent. */
72
+ export function isElidedToolResult(content) {
73
+ return content.startsWith(ELISION_PREFIX);
74
+ }
75
+ function elisionStub(bytes) {
76
+ return `${ELISION_PREFIX} ${bytes} bytes; re-run the tool if you need it again]`;
77
+ }
78
+ /** True for contents already cropped, so cropping is idempotent and prefix-cache stable. */
79
+ export function isCroppedToolResult(content) {
80
+ return content.includes(CROP_PREFIX);
81
+ }
82
+ /** Byte ceiling for a single replayed result: the fixed ceiling, never more than the budget. */
83
+ function perResultLimit(budgetBytes) {
84
+ return Math.max(1, Math.min(DEFAULT_TOOL_RESULT_MAX_BYTES, budgetBytes));
85
+ }
86
+ /**
87
+ * Cap one result by keeping its head and tail and marking the dropped middle. Pure and
88
+ * deterministic: the same content always crops to the same bytes, so provider prefix
89
+ * caching is not disturbed.
90
+ */
91
+ function cropToolResult(content, limit) {
92
+ if (content.length <= limit || isCroppedToolResult(content))
93
+ return content;
94
+ const headBytes = Math.ceil(limit / 2);
95
+ const tailBytes = limit - headBytes;
96
+ const omitted = content.length - limit;
97
+ return `${content.slice(0, headBytes)}\n${CROP_PREFIX} ${omitted} bytes; narrow the tool call if you need the middle]\n${content.slice(content.length - tailBytes)}`;
98
+ }
99
+ const ASSISTANT_ELISION_PREFIX = '[earlier assistant message elided to save context:';
100
+ /** True for assistant contents already replaced by a stub, so elision is idempotent. */
101
+ export function isElidedAssistantMessage(content) {
102
+ return content.startsWith(ASSISTANT_ELISION_PREFIX);
103
+ }
104
+ function assistantStub(bytes) {
105
+ return `${ASSISTANT_ELISION_PREFIX} ${bytes} bytes]`;
106
+ }
107
+ /**
108
+ * Assistant text is safe to stub only when it carries nothing the wire requires: no
109
+ * tool_calls (tool_call/tool_result pairing), no signed thinking blocks, no replayed
110
+ * reasoning, no images, and no content already elided.
111
+ */
112
+ function isElidableAssistant(message) {
113
+ return message.role === 'assistant'
114
+ && message.content.length > 0
115
+ && !isElidedAssistantMessage(message.content)
116
+ && !message.toolCalls?.length
117
+ && !message.thinkingBlocks?.length
118
+ && !message.reasoningContent
119
+ && !message.images?.length;
120
+ }
121
+ /**
122
+ * Sticky companion to boundToolResults: stubs older *assistant prose* once its combined
123
+ * size exceeds the budget, keeping the newest assistant text whole and marking elided
124
+ * messages in the shared state so the cached prefix stays byte-identical between rounds.
125
+ * Only plain-text assistant messages are eligible; tool-call/thinking/reasoning messages
126
+ * are left untouched so pairing and signed blocks survive.
127
+ */
128
+ function boundAssistantProseSticky(history, budgetBytes, state) {
129
+ const live = [];
130
+ let kept = 0;
131
+ for (const message of history) {
132
+ if (!isElidableAssistant(message) || state.has(message))
133
+ continue;
134
+ live.push(message);
135
+ kept += message.content.length;
136
+ }
137
+ if (kept > budgetBytes) {
138
+ const target = Math.floor(budgetBytes / 2);
139
+ // The newest assistant prose is always kept whole.
140
+ for (let i = 0; i < live.length - 1 && kept > target; i++) {
141
+ state.add(live[i]);
142
+ kept -= live[i].content.length;
143
+ }
144
+ }
145
+ let view;
146
+ for (let i = 0; i < history.length; i++) {
147
+ const message = history[i];
148
+ if (message.role !== 'assistant' || !state.has(message))
149
+ continue;
150
+ view ??= history.slice();
151
+ view[i] = isElidedAssistantMessage(message.content) ? message : { ...message, content: assistantStub(message.content.length) };
152
+ }
153
+ return view ?? history;
154
+ }
155
+ /**
156
+ * Replay view for provider requests: recent tool results (and, when enabled, assistant
157
+ * prose) are kept whole and older ones elided. Falls back to the tool-only view when
158
+ * assistant-prose elision is off or no sticky state was supplied.
159
+ */
160
+ export function boundReplay(history, budgetBytes = DEFAULT_TOOL_RESULT_BUDGET_BYTES, state, options) {
161
+ const toolView = boundToolResults(history, budgetBytes, state);
162
+ if (!options?.assistantProse || !state)
163
+ return toolView;
164
+ return boundAssistantProseSticky(toolView, budgetBytes, state);
165
+ }
166
+ /**
167
+ * Return a replay copy of `history` with old tool results elided to fit `budgetBytes`.
168
+ * The newest tool result is always kept whole up to a per-result cap (DEFAULT_TOOL_RESULT_MAX_BYTES);
169
+ * anything larger is replayed cropped head+tail so one big command cannot blow up a
170
+ * request. Older results are kept, newest-first, until the budget is reached, then
171
+ * replaced with a stub. budgetBytes <= 0 disables elision and returns the original array
172
+ * unchanged. Non-tool messages and message identity are never altered.
173
+ */
174
+ export function boundToolResults(history, budgetBytes = DEFAULT_TOOL_RESULT_BUDGET_BYTES, state) {
175
+ if (!Number.isFinite(budgetBytes) || budgetBytes <= 0)
176
+ return history;
177
+ if (state)
178
+ return boundSticky(history, budgetBytes, state);
179
+ const limit = perResultLimit(budgetBytes);
180
+ let kept = 0;
181
+ let keptAny = false;
182
+ let view;
183
+ for (let index = history.length - 1; index >= 0; index--) {
184
+ const message = history[index];
185
+ if (message.role !== 'tool')
186
+ continue;
187
+ const size = Math.min(message.content.length, limit);
188
+ if (!keptAny || kept + size <= budgetBytes) {
189
+ kept += size;
190
+ keptAny = true;
191
+ if (message.content.length > limit) {
192
+ view ??= history.slice();
193
+ view[index] = { ...message, content: cropToolResult(message.content, limit) };
194
+ }
195
+ continue;
196
+ }
197
+ view ??= history.slice();
198
+ view[index] = { ...message, content: elisionStub(message.content.length) };
199
+ }
200
+ return view ?? history;
201
+ }
@@ -0,0 +1,105 @@
1
+ // Custom slash commands: markdown prompt templates discovered from the user's command
2
+ // directory (~/.config/flashlite/commands) and the project's .flashlite/commands directory.
3
+ // A file's body is a prompt template; `$1`..`$9` substitute positional arguments and
4
+ // `$ARGS` substitutes all arguments joined by spaces. No shell interpolation is performed
5
+ // (prompt text is data, not a script). Frontmatter is optional:
6
+ // ---
7
+ // description: Short one-liner for the palette
8
+ // argument-hint: "[query]"
9
+ // ---
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { configDir } from "./config.js";
13
+ const MAX_BODY_BYTES = 64 * 1024;
14
+ const NAME = /^[a-z0-9][a-z0-9_-]*$/i;
15
+ function splitFrontmatter(raw) {
16
+ const normalized = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
17
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
18
+ if (!match)
19
+ return { meta: {}, body: normalized.trim() };
20
+ const meta = {};
21
+ for (const line of match[1].split('\n')) {
22
+ const sep = line.indexOf(':');
23
+ if (sep === -1)
24
+ continue;
25
+ const key = line.slice(0, sep).trim();
26
+ const value = line.slice(sep + 1).trim();
27
+ if (key === 'description')
28
+ meta.description = value;
29
+ else if (key === 'argument-hint' || key === 'argumentHint')
30
+ meta.argumentHint = value;
31
+ }
32
+ return { meta, body: normalized.slice(match[0].length).trim() };
33
+ }
34
+ /** Directory lists are read with a symlink guard; a malformed entry is skipped, not fatal. */
35
+ function readCommandFiles(dir, out) {
36
+ let entries;
37
+ try {
38
+ entries = fs.readdirSync(dir, { withFileTypes: true });
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ for (const entry of entries) {
44
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
45
+ continue;
46
+ const name = entry.name.slice(0, -3);
47
+ if (!NAME.test(name))
48
+ continue;
49
+ const source = path.join(dir, entry.name);
50
+ let stat;
51
+ try {
52
+ stat = fs.lstatSync(source);
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BODY_BYTES)
58
+ continue;
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(source, 'utf8');
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ const { meta, body } = splitFrontmatter(raw);
67
+ if (!body)
68
+ continue;
69
+ out.push({
70
+ name,
71
+ description: meta.description ?? '',
72
+ argumentHint: meta.argumentHint,
73
+ body,
74
+ source,
75
+ });
76
+ }
77
+ }
78
+ /** Discover global + project custom commands. Project commands shadow global ones by name. */
79
+ export function discoverCustomCommands(cwd) {
80
+ const global = path.join(configDir(), 'commands');
81
+ const project = path.join(cwd, '.flashlite', 'commands');
82
+ const found = [];
83
+ readCommandFiles(global, found);
84
+ readCommandFiles(project, found);
85
+ const byName = new Map();
86
+ for (const command of found)
87
+ byName.set(command.name, command); // later (project) wins
88
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
89
+ }
90
+ export function findCustomCommand(commands, name) {
91
+ return commands.find((command) => command.name === name);
92
+ }
93
+ /** Substitute `$1`..`$9` and `$ARGS` into a command body. Unknown positions become ''. */
94
+ export function renderCustomCommand(body, args) {
95
+ return body
96
+ .replace(/\$ARGS\b/g, args.join(' '))
97
+ .replace(/\$([1-9])\b/g, (_, digit) => args[Number(digit) - 1] ?? '');
98
+ }
99
+ /** Palette entries for the `/` menu; ids carry a trailing space hint when args are expected. */
100
+ export function customCommandPalette(commands) {
101
+ return commands.map((command) => ({
102
+ id: `/${command.name}${command.argumentHint ? ' ' : ''}`,
103
+ description: command.description || 'custom command',
104
+ }));
105
+ }
package/dist/effort.js ADDED
@@ -0,0 +1,79 @@
1
+ export const EFFORT_LEVELS = ['auto', 'low', 'medium', 'high', 'xhigh', 'max'];
2
+ export const EFFORT_DESCRIPTIONS = {
3
+ auto: 'Use the provider default',
4
+ low: 'Fast, lightweight reasoning',
5
+ medium: 'Balance reasoning and latency',
6
+ high: 'More thorough reasoning',
7
+ xhigh: 'Extra-high reasoning · model support varies',
8
+ max: 'Maximum reasoning · model support varies',
9
+ };
10
+ const THINKING_BUDGETS = { low: 2048, medium: 4096, high: 8192, xhigh: 16384, max: 32000 };
11
+ export function anthropicThinkingBudget(effort) {
12
+ return effort && effort !== 'auto' ? THINKING_BUDGETS[effort] : undefined;
13
+ }
14
+ /** Match the adapter's actual wire output ceiling when reserving context space. */
15
+ export function effectiveOutputTokens(dialect, maxTokens, effort) {
16
+ const budget = dialect === 'anthropic' ? anthropicThinkingBudget(effort) : undefined;
17
+ return budget ? Math.max(maxTokens, budget + 4096) : maxTokens;
18
+ }
19
+ export function parseEffort(value) {
20
+ if (typeof value !== 'string')
21
+ return undefined;
22
+ const level = value.trim().toLowerCase();
23
+ return EFFORT_LEVELS.find((effort) => effort === level);
24
+ }
25
+ /** True when the model id is any DeepSeek model (base or pro). */
26
+ export function isDeepSeek(model) {
27
+ return model.toLowerCase().includes('deepseek');
28
+ }
29
+ /** True when the DeepSeek model id is a `pro` variant (e.g. `deepseek-pro-…`). */
30
+ function isDeepSeekPro(model) {
31
+ // Match a `pro` token in the same path segment as the deepseek id so an unrelated
32
+ // `proxy`/`prompt` path segment cannot misclassify a base model as pro.
33
+ return model.toLowerCase().split('/').some((segment) => segment.includes('deepseek') && /(^|[-_.])pro($|[-_.])/.test(segment));
34
+ }
35
+ /**
36
+ * The reasoning_effort value to put on the wire for a DeepSeek model, mapping CLI
37
+ * levels onto the provider's real thinking levels. Returns undefined (omit the field)
38
+ * for `auto`. Non-DeepSeek models should send `req.effort` verbatim via the adapter.
39
+ */
40
+ export function deepSeekWireEffort(model, effort) {
41
+ if (!effort || effort === 'auto')
42
+ return undefined;
43
+ if (isDeepSeekPro(model)) {
44
+ // DeepSeek pro only accepts high/max.
45
+ switch (effort) {
46
+ case 'low':
47
+ case 'medium':
48
+ case 'high':
49
+ return 'high';
50
+ case 'xhigh':
51
+ case 'max':
52
+ return 'max';
53
+ default:
54
+ return undefined;
55
+ }
56
+ }
57
+ // Base DeepSeek accepts low/high/max only. Its documented effort mapping rounds
58
+ // `medium`/`xhigh` up to `high` (medium→high, xhigh→high); CLI `low`/`max` pass through.
59
+ switch (effort) {
60
+ case 'low':
61
+ return 'low';
62
+ case 'medium':
63
+ case 'high':
64
+ case 'xhigh':
65
+ return 'high';
66
+ case 'max':
67
+ return 'max';
68
+ default:
69
+ return undefined;
70
+ }
71
+ }
72
+ /** The effort levels the UI may offer for the current model. */
73
+ export function effortLevelsFor(model) {
74
+ if (isDeepSeekPro(model))
75
+ return ['auto', 'high', 'max'];
76
+ if (isDeepSeek(model))
77
+ return ['auto', 'low', 'high', 'max'];
78
+ return EFFORT_LEVELS;
79
+ }
package/dist/hooks.js ADDED
@@ -0,0 +1,86 @@
1
+ // Lifecycle hooks (Claude Code style): executable scripts discovered in
2
+ // ~/.config/flashlite/hooks and <project>/.flashlite/hooks. A hook reads one JSON object on
3
+ // stdin and may write a JSON object on stdout. PreToolUse may veto a tool with
4
+ // {"decision":"block","reason":"…"}; any other output is advisory. Hooks are untrusted and
5
+ // time-boxed; a hung or failing hook never blocks the tool (fail open, with a notice).
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+ import { configDir } from "./config.js";
10
+ const HOOK_TIMEOUT_MS = 10_000;
11
+ function readHooks(dir, out) {
12
+ let entries;
13
+ try {
14
+ entries = fs.readdirSync(dir, { withFileTypes: true });
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const entry of entries) {
20
+ if (!entry.isFile() || entry.isSymbolicLink())
21
+ continue;
22
+ const event = entry.name;
23
+ if (!['PreToolUse', 'PostToolUse', 'Notification', 'Stop'].includes(event))
24
+ continue;
25
+ out.push({ event, path: path.join(dir, entry.name) });
26
+ }
27
+ }
28
+ /** Discover hooks; project hooks append to global hooks (both run, project last). */
29
+ export function discoverHooks(cwd) {
30
+ const hooks = [];
31
+ readHooks(path.join(configDir(), 'hooks'), hooks);
32
+ readHooks(path.join(cwd, '.flashlite', 'hooks'), hooks);
33
+ return hooks;
34
+ }
35
+ /** Run a single hook script with `payload` (JSON) on stdin; resolves after it exits. */
36
+ function runOne(scriptPath, payload) {
37
+ return new Promise((resolve) => {
38
+ const child = spawn('/bin/zsh', ['-f', '-c', `exec "${scriptPath}"`], {
39
+ stdio: ['pipe', 'pipe', 'ignore'],
40
+ env: { ...process.env },
41
+ });
42
+ const timer = setTimeout(() => { child.kill('SIGKILL'); }, HOOK_TIMEOUT_MS);
43
+ let stdout = '';
44
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
45
+ child.on('error', () => { clearTimeout(timer); resolve({ blocked: false }); });
46
+ child.on('close', () => {
47
+ clearTimeout(timer);
48
+ const trimmed = stdout.trim();
49
+ let parsed;
50
+ if (trimmed) {
51
+ try {
52
+ parsed = JSON.parse(trimmed);
53
+ }
54
+ catch { /* non-JSON output is advisory */ }
55
+ }
56
+ if (parsed && typeof parsed === 'object' && parsed.decision === 'block') {
57
+ const reason = typeof parsed.reason === 'string' ? parsed.reason : 'blocked by hook';
58
+ resolve({ blocked: true, reason, output: trimmed });
59
+ }
60
+ else {
61
+ resolve({ blocked: false, output: trimmed || undefined });
62
+ }
63
+ });
64
+ child.stdin.on('error', () => { });
65
+ child.stdin.end(JSON.stringify(payload));
66
+ });
67
+ }
68
+ /** Run every hook for `event`; PreToolUse merges block decisions (first blocker wins). */
69
+ export async function runHooks(hooks, event, payload) {
70
+ const matching = hooks.filter((hook) => hook.event === event);
71
+ const results = [];
72
+ for (const hook of matching)
73
+ results.push(await runOne(hook.path, payload));
74
+ return results;
75
+ }
76
+ /** macOS notification via osascript; best-effort and never throws. */
77
+ export function notifyMac(title, body) {
78
+ if (process.platform !== 'darwin')
79
+ return;
80
+ const script = `display notification ${JSON.stringify(body.slice(0, 200))} with title ${JSON.stringify(title)}`;
81
+ try {
82
+ const child = spawn('/usr/bin/osascript', ['-e', script], { stdio: 'ignore' });
83
+ child.unref();
84
+ }
85
+ catch { /* notifications are advisory */ }
86
+ }