mod8-cli 0.2.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 +87 -0
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/bin/mod8.js +2 -0
- package/dist/cli.js +302 -0
- package/dist/commands/addProvider.js +105 -0
- package/dist/commands/all.js +158 -0
- package/dist/commands/chat.js +855 -0
- package/dist/commands/config.js +29 -0
- package/dist/commands/devAuthStatus.js +34 -0
- package/dist/commands/devHostAsk.js +51 -0
- package/dist/commands/devHostSystem.js +15 -0
- package/dist/commands/devResolve.js +54 -0
- package/dist/commands/devSimulate.js +235 -0
- package/dist/commands/devWorkAsk.js +55 -0
- package/dist/commands/intentRouting.js +280 -0
- package/dist/commands/keys.js +55 -0
- package/dist/commands/list.js +27 -0
- package/dist/commands/login.js +147 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/prompt.js +63 -0
- package/dist/commands/providers.js +30 -0
- package/dist/commands/verify.js +5 -0
- package/dist/input/compose.js +37 -0
- package/dist/input/files.js +49 -0
- package/dist/input/stdin.js +14 -0
- package/dist/providers/anthropic.js +115 -0
- package/dist/providers/displayName.js +25 -0
- package/dist/providers/errorHints.js +175 -0
- package/dist/providers/generic.js +331 -0
- package/dist/providers/genericChat.js +265 -0
- package/dist/providers/google.js +63 -0
- package/dist/providers/hostSystem.js +173 -0
- package/dist/providers/index.js +38 -0
- package/dist/providers/mock.js +87 -0
- package/dist/providers/modelResolution.js +42 -0
- package/dist/providers/openai.js +75 -0
- package/dist/providers/pricing.js +47 -0
- package/dist/providers/proxy.js +148 -0
- package/dist/providers/registry.js +196 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/workSystem.js +33 -0
- package/dist/storage/auth.js +65 -0
- package/dist/storage/config.js +35 -0
- package/dist/storage/keys.js +59 -0
- package/dist/storage/providers.js +337 -0
- package/dist/storage/sessions.js +150 -0
- package/dist/types.js +9 -0
- package/dist/util/debug.js +79 -0
- package/dist/util/errors.js +157 -0
- package/dist/util/prompt.js +111 -0
- package/dist/util/secrets.js +110 -0
- package/dist/util/text.js +53 -0
- package/dist/util/time.js +25 -0
- package/dist/verify/runner.js +437 -0
- package/package.json +69 -0
- package/specs/all-mode.yaml +44 -0
- package/specs/behavior/auto-fallback.yaml +49 -0
- package/specs/behavior/bare-name-routing.yaml +223 -0
- package/specs/behavior/bare-paste-confirm.yaml +125 -0
- package/specs/behavior/env-var-respected.yaml +108 -0
- package/specs/behavior/error-fidelity.yaml +92 -0
- package/specs/behavior/error-hints.yaml +160 -0
- package/specs/behavior/fresh-vs-resume.yaml +94 -0
- package/specs/behavior/fuzzy-match.yaml +208 -0
- package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
- package/specs/behavior/intent-no-mismatch.yaml +115 -0
- package/specs/behavior/login-logout.yaml +97 -0
- package/specs/behavior/no-model-allowlist.yaml +80 -0
- package/specs/behavior/paste-key.yaml +342 -0
- package/specs/behavior/provider-switching.yaml +186 -0
- package/specs/behavior/providers-json-respected.yaml +106 -0
- package/specs/behavior/self-knowledge.yaml +119 -0
- package/specs/behavior/stress-session.yaml +226 -0
- package/specs/behavior/switch-back-when-failing.yaml +90 -0
- package/specs/behavior/work-character.yaml +109 -0
- package/specs/chat-meta.yaml +349 -0
- package/specs/chat-startup.yaml +148 -0
- package/specs/chat.yaml +91 -0
- package/specs/config.yaml +42 -0
- package/specs/install.yaml +112 -0
- package/specs/keys.yaml +81 -0
- package/specs/one-shot.yaml +65 -0
- package/specs/pipe-and-files.yaml +40 -0
- package/specs/providers.yaml +172 -0
- package/specs/sessions.yaml +115 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider configuration store.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old keys.json with a richer providers.json keyed by provider
|
|
5
|
+
* id, each entry carrying:
|
|
6
|
+
* - apiKey: the BYOK secret
|
|
7
|
+
* - apiType: 'anthropic' | 'openai-compat' | 'gemini'
|
|
8
|
+
* - name: display label
|
|
9
|
+
* - baseUrl?: for openai-compat providers (Anthropic/Gemini use SDK defaults)
|
|
10
|
+
* - defaultModel: model id used when a turn doesn't specify one
|
|
11
|
+
* - color: display color (hex)
|
|
12
|
+
* - custom: true if user-registered (not from KNOWN_PROVIDERS)
|
|
13
|
+
*
|
|
14
|
+
* Backwards compat: on first read, if providers.json is missing but the legacy
|
|
15
|
+
* keys.json exists (with anthropic/openai/google entries), import them using
|
|
16
|
+
* built-in templates from the registry. keys.json is left untouched on disk
|
|
17
|
+
* so existing tooling keeps working.
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from 'fs';
|
|
20
|
+
import { homedir } from 'os';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import { KNOWN_PROVIDERS, PROVIDER_SYNONYMS, HIGH_CONFIDENCE_BRAND_ALIASES, templateById, pickPaletteColor, } from '../providers/registry.js';
|
|
23
|
+
const CONFIG_DIR = process.env.MOD8_CONFIG_DIR ?? join(homedir(), '.config', 'mod8');
|
|
24
|
+
const PROVIDERS_FILE = join(CONFIG_DIR, 'providers.json');
|
|
25
|
+
const LEGACY_KEYS_FILE = join(CONFIG_DIR, 'keys.json');
|
|
26
|
+
async function readProvidersFile() {
|
|
27
|
+
try {
|
|
28
|
+
const data = await fs.readFile(PROVIDERS_FILE, 'utf8');
|
|
29
|
+
const parsed = JSON.parse(data);
|
|
30
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code !== 'ENOENT')
|
|
37
|
+
throw err;
|
|
38
|
+
// No providers.json yet — try migrating from legacy keys.json.
|
|
39
|
+
return migrateFromLegacy();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function migrateFromLegacy() {
|
|
43
|
+
let legacy;
|
|
44
|
+
try {
|
|
45
|
+
const data = await fs.readFile(LEGACY_KEYS_FILE, 'utf8');
|
|
46
|
+
const parsed = JSON.parse(data);
|
|
47
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
48
|
+
return {};
|
|
49
|
+
legacy = parsed;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [id, key] of Object.entries(legacy)) {
|
|
56
|
+
if (typeof key !== 'string' || !key)
|
|
57
|
+
continue;
|
|
58
|
+
const tpl = templateById(id);
|
|
59
|
+
if (!tpl)
|
|
60
|
+
continue; // unknown legacy id — skip silently
|
|
61
|
+
out[id] = entryFromTemplate(tpl, key);
|
|
62
|
+
}
|
|
63
|
+
// Persist so subsequent reads hit providers.json directly.
|
|
64
|
+
if (Object.keys(out).length > 0)
|
|
65
|
+
await writeProvidersFile(out);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
async function writeProvidersFile(providers) {
|
|
69
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
70
|
+
const data = JSON.stringify(providers, null, 2) + '\n';
|
|
71
|
+
await fs.writeFile(PROVIDERS_FILE, data, { mode: 0o600 });
|
|
72
|
+
await fs.chmod(PROVIDERS_FILE, 0o600);
|
|
73
|
+
}
|
|
74
|
+
export function entryFromTemplate(tpl, apiKey) {
|
|
75
|
+
const entry = {
|
|
76
|
+
apiKey,
|
|
77
|
+
apiType: tpl.apiType,
|
|
78
|
+
name: tpl.name,
|
|
79
|
+
defaultModel: tpl.defaultModel,
|
|
80
|
+
color: tpl.color,
|
|
81
|
+
};
|
|
82
|
+
if (tpl.baseUrl)
|
|
83
|
+
entry.baseUrl = tpl.baseUrl;
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
export async function listProviders() {
|
|
87
|
+
return readProvidersFile();
|
|
88
|
+
}
|
|
89
|
+
export async function getProviderEntry(id) {
|
|
90
|
+
const all = await readProvidersFile();
|
|
91
|
+
return all[id];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the API key for a provider id, honoring env-var overrides for the
|
|
95
|
+
* three legacy providers (back-compat). Used by the legacy single-provider
|
|
96
|
+
* call paths so existing setups keep working.
|
|
97
|
+
*/
|
|
98
|
+
export async function getApiKey(id) {
|
|
99
|
+
if (id === 'anthropic' && process.env.ANTHROPIC_API_KEY)
|
|
100
|
+
return process.env.ANTHROPIC_API_KEY;
|
|
101
|
+
if (id === 'openai' && process.env.OPENAI_API_KEY)
|
|
102
|
+
return process.env.OPENAI_API_KEY;
|
|
103
|
+
if (id === 'google') {
|
|
104
|
+
const k = process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY;
|
|
105
|
+
if (k)
|
|
106
|
+
return k;
|
|
107
|
+
}
|
|
108
|
+
const entry = await getProviderEntry(id);
|
|
109
|
+
return entry?.apiKey;
|
|
110
|
+
}
|
|
111
|
+
export async function setProvider(id, entry) {
|
|
112
|
+
const all = await readProvidersFile();
|
|
113
|
+
all[id] = entry;
|
|
114
|
+
await writeProvidersFile(all);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Save a key for a known-template provider, preserving any user
|
|
118
|
+
* customizations on an existing entry (display name, defaultModel, baseUrl,
|
|
119
|
+
* custom flag) and only falling back to the template defaults for fields
|
|
120
|
+
* the user hasn't overridden.
|
|
121
|
+
*
|
|
122
|
+
* Used by the inline paste-key flow. The previous behavior — overwriting
|
|
123
|
+
* the entry wholesale with template defaults — was a regression: a user
|
|
124
|
+
* who had set defaultModel="gemini-2.5-flash" would have it silently
|
|
125
|
+
* reverted to the registry default ("gemini-2.0-flash") on the next
|
|
126
|
+
* inline paste, sending them into a "model no longer available" loop.
|
|
127
|
+
*/
|
|
128
|
+
export async function saveKeyPreservingEntry(apiKey, template) {
|
|
129
|
+
const existing = await getProviderEntry(template.id);
|
|
130
|
+
const entry = {
|
|
131
|
+
apiKey,
|
|
132
|
+
apiType: existing?.apiType ?? template.apiType,
|
|
133
|
+
name: existing?.name ?? template.name,
|
|
134
|
+
defaultModel: existing?.defaultModel ?? template.defaultModel,
|
|
135
|
+
color: existing?.color ?? template.color,
|
|
136
|
+
};
|
|
137
|
+
const baseUrl = existing?.baseUrl ?? template.baseUrl;
|
|
138
|
+
if (baseUrl)
|
|
139
|
+
entry.baseUrl = baseUrl;
|
|
140
|
+
if (existing?.custom)
|
|
141
|
+
entry.custom = true;
|
|
142
|
+
await setProvider(template.id, entry);
|
|
143
|
+
}
|
|
144
|
+
export async function setProviderKey(id, apiKey) {
|
|
145
|
+
const all = await readProvidersFile();
|
|
146
|
+
if (all[id]) {
|
|
147
|
+
all[id].apiKey = apiKey;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const tpl = templateById(id);
|
|
151
|
+
if (!tpl)
|
|
152
|
+
throw new Error(`unknown provider "${id}" — use mod8 add-provider for custom ones`);
|
|
153
|
+
all[id] = entryFromTemplate(tpl, apiKey);
|
|
154
|
+
}
|
|
155
|
+
await writeProvidersFile(all);
|
|
156
|
+
}
|
|
157
|
+
export async function removeProvider(id) {
|
|
158
|
+
const all = await readProvidersFile();
|
|
159
|
+
if (!(id in all))
|
|
160
|
+
return false;
|
|
161
|
+
delete all[id];
|
|
162
|
+
await writeProvidersFile(all);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
export async function configuredProviderIds() {
|
|
166
|
+
const all = await readProvidersFile();
|
|
167
|
+
return Object.keys(all);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Resolve a "configured" provider with environment-variable key overrides
|
|
171
|
+
* applied — i.e., the entry that should actually be used for a call.
|
|
172
|
+
*/
|
|
173
|
+
export async function resolveConfigured(id) {
|
|
174
|
+
const entry = await getProviderEntry(id);
|
|
175
|
+
const envKey = await envKeyFor(id);
|
|
176
|
+
if (entry && envKey)
|
|
177
|
+
return { ...entry, apiKey: envKey };
|
|
178
|
+
if (entry)
|
|
179
|
+
return entry;
|
|
180
|
+
// No stored entry, but env key + known template → synthesize an entry.
|
|
181
|
+
if (envKey) {
|
|
182
|
+
const tpl = templateById(id);
|
|
183
|
+
if (tpl)
|
|
184
|
+
return entryFromTemplate(tpl, envKey);
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
async function envKeyFor(id) {
|
|
189
|
+
if (id === 'anthropic')
|
|
190
|
+
return process.env.ANTHROPIC_API_KEY;
|
|
191
|
+
if (id === 'openai')
|
|
192
|
+
return process.env.OPENAI_API_KEY;
|
|
193
|
+
if (id === 'google')
|
|
194
|
+
return process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY;
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
/** Used by add-provider to pick a fresh palette color for a custom provider. */
|
|
198
|
+
export async function nextPaletteColor() {
|
|
199
|
+
const all = await readProvidersFile();
|
|
200
|
+
const used = Object.values(all).map((e) => e.color);
|
|
201
|
+
return pickPaletteColor(used);
|
|
202
|
+
}
|
|
203
|
+
/** Treat any id appearing in KNOWN_PROVIDERS or providers.json as valid. */
|
|
204
|
+
export async function isKnownOrConfigured(id) {
|
|
205
|
+
if (KNOWN_PROVIDERS.some((p) => p.id === id))
|
|
206
|
+
return true;
|
|
207
|
+
const all = await readProvidersFile();
|
|
208
|
+
return id in all;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Strict variant of the provider-hint resolver — matches only on id (built-in
|
|
212
|
+
* or configured), configured display name, and the curated HIGH-CONFIDENCE
|
|
213
|
+
* brand aliases (claude, gpt, grok, gemini, chatgpt — unambiguous brand
|
|
214
|
+
* names). Used for bare-name and first-word matching.
|
|
215
|
+
*
|
|
216
|
+
* NOT included: ambiguous synonyms like "haiku", "sonnet", "opus", "bard",
|
|
217
|
+
* "llama" — those are common English/literature words and would false-
|
|
218
|
+
* positive too often. Users can still route to those via the verb-based
|
|
219
|
+
* path (`use haiku`, `talk to llama`) which goes through full resolution.
|
|
220
|
+
*/
|
|
221
|
+
export async function strictResolveProviderHint(hint) {
|
|
222
|
+
const lower = hint.toLowerCase().trim();
|
|
223
|
+
if (!lower)
|
|
224
|
+
return null;
|
|
225
|
+
const stored = await readProvidersFile();
|
|
226
|
+
// 1. Configured id
|
|
227
|
+
if (stored[lower])
|
|
228
|
+
return lower;
|
|
229
|
+
// 2. Built-in id
|
|
230
|
+
if (KNOWN_PROVIDERS.some((p) => p.id === lower))
|
|
231
|
+
return lower;
|
|
232
|
+
// 3. Configured display name (case-insensitive)
|
|
233
|
+
for (const [id, entry] of Object.entries(stored)) {
|
|
234
|
+
if (entry.name.toLowerCase() === lower)
|
|
235
|
+
return id;
|
|
236
|
+
}
|
|
237
|
+
// 4. High-confidence brand alias (claude/gpt/grok/gemini/chatgpt)
|
|
238
|
+
if (HIGH_CONFIDENCE_BRAND_ALIASES[lower]) {
|
|
239
|
+
return HIGH_CONFIDENCE_BRAND_ALIASES[lower];
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Map a user-supplied hint (id, display name, or known nickname) to a real
|
|
245
|
+
* provider id. Tries, in order:
|
|
246
|
+
* 1. Exact id match against the configured store.
|
|
247
|
+
* 2. Exact id match against the built-in registry.
|
|
248
|
+
* 3. Display-name match against the configured store (case-insensitive).
|
|
249
|
+
* 4. Built-in template name match (case-insensitive).
|
|
250
|
+
* 5. PROVIDER_SYNONYMS lookup ("gpt" → "openai", "claude" → "anthropic", …).
|
|
251
|
+
*
|
|
252
|
+
* Returns the canonical id or null if nothing matched. Does NOT verify the
|
|
253
|
+
* provider is configured (the caller should resolveConfigured() afterwards).
|
|
254
|
+
*/
|
|
255
|
+
export async function resolveProviderHint(hint) {
|
|
256
|
+
const lower = hint.toLowerCase().trim();
|
|
257
|
+
if (!lower)
|
|
258
|
+
return null;
|
|
259
|
+
const stored = await readProvidersFile();
|
|
260
|
+
// 1. Exact id (configured)
|
|
261
|
+
if (stored[lower])
|
|
262
|
+
return lower;
|
|
263
|
+
// 2. Exact id (built-in template)
|
|
264
|
+
if (KNOWN_PROVIDERS.some((p) => p.id === lower))
|
|
265
|
+
return lower;
|
|
266
|
+
// 3. Display name (configured)
|
|
267
|
+
for (const [id, entry] of Object.entries(stored)) {
|
|
268
|
+
if (entry.name.toLowerCase() === lower)
|
|
269
|
+
return id;
|
|
270
|
+
}
|
|
271
|
+
// 4. Display name (built-in)
|
|
272
|
+
const tplByName = KNOWN_PROVIDERS.find((p) => p.name.toLowerCase() === lower);
|
|
273
|
+
if (tplByName)
|
|
274
|
+
return tplByName.id;
|
|
275
|
+
// 5. Synonym
|
|
276
|
+
if (PROVIDER_SYNONYMS[lower])
|
|
277
|
+
return PROVIDER_SYNONYMS[lower];
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
function levenshtein(a, b) {
|
|
281
|
+
if (a === b)
|
|
282
|
+
return 0;
|
|
283
|
+
if (!a)
|
|
284
|
+
return b.length;
|
|
285
|
+
if (!b)
|
|
286
|
+
return a.length;
|
|
287
|
+
const an = a.length;
|
|
288
|
+
const bn = b.length;
|
|
289
|
+
let prev = new Array(bn + 1);
|
|
290
|
+
let curr = new Array(bn + 1);
|
|
291
|
+
for (let j = 0; j <= bn; j++)
|
|
292
|
+
prev[j] = j;
|
|
293
|
+
for (let i = 1; i <= an; i++) {
|
|
294
|
+
curr[0] = i;
|
|
295
|
+
for (let j = 1; j <= bn; j++) {
|
|
296
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
297
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
298
|
+
}
|
|
299
|
+
[prev, curr] = [curr, prev];
|
|
300
|
+
}
|
|
301
|
+
return prev[bn];
|
|
302
|
+
}
|
|
303
|
+
export async function fuzzyResolveProviderHint(hint) {
|
|
304
|
+
const lower = hint.toLowerCase().trim();
|
|
305
|
+
if (!lower)
|
|
306
|
+
return [];
|
|
307
|
+
const stored = await readProvidersFile();
|
|
308
|
+
// Candidate haystack: { resolvedId, surfaceString }
|
|
309
|
+
const candidates = [];
|
|
310
|
+
for (const id of Object.keys(stored)) {
|
|
311
|
+
candidates.push({ id, surface: id });
|
|
312
|
+
}
|
|
313
|
+
for (const [id, entry] of Object.entries(stored)) {
|
|
314
|
+
candidates.push({ id, surface: entry.name.toLowerCase() });
|
|
315
|
+
}
|
|
316
|
+
for (const tpl of KNOWN_PROVIDERS) {
|
|
317
|
+
candidates.push({ id: tpl.id, surface: tpl.id });
|
|
318
|
+
candidates.push({ id: tpl.id, surface: tpl.name.toLowerCase() });
|
|
319
|
+
}
|
|
320
|
+
for (const [alias, id] of Object.entries(HIGH_CONFIDENCE_BRAND_ALIASES)) {
|
|
321
|
+
candidates.push({ id, surface: alias });
|
|
322
|
+
}
|
|
323
|
+
const bestPerId = new Map();
|
|
324
|
+
for (const { id, surface } of candidates) {
|
|
325
|
+
const distance = levenshtein(lower, surface);
|
|
326
|
+
if (distance === 0)
|
|
327
|
+
continue; // exact match — handled by the strict resolvers
|
|
328
|
+
if (distance > 2)
|
|
329
|
+
continue; // out of fuzzy range
|
|
330
|
+
const existing = bestPerId.get(id);
|
|
331
|
+
if (!existing || distance < existing.distance) {
|
|
332
|
+
bestPerId.set(id, { id, candidate: surface, distance });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return Array.from(bestPerId.values()).sort((a, b) => a.distance === b.distance ? a.id.localeCompare(b.id) : a.distance - b.distance);
|
|
336
|
+
}
|
|
337
|
+
export const PROVIDERS_FILE_PATH = PROVIDERS_FILE;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
+
import { getKey } from './keys.js';
|
|
6
|
+
const CONFIG_DIR = process.env.MOD8_CONFIG_DIR ?? join(homedir(), '.config', 'mod8');
|
|
7
|
+
const SESSIONS_DIR = join(CONFIG_DIR, 'sessions');
|
|
8
|
+
export const SESSION_ID_RE = /^\d{4}-\d{2}-\d{2}-[a-z0-9]{4}$/;
|
|
9
|
+
export function generateSessionId(now = new Date()) {
|
|
10
|
+
const yyyy = now.getFullYear();
|
|
11
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
12
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
13
|
+
const date = `${yyyy}-${mm}-${dd}`;
|
|
14
|
+
const suffix = Math.random().toString(36).slice(2, 6).padEnd(4, '0');
|
|
15
|
+
return `${date}-${suffix}`;
|
|
16
|
+
}
|
|
17
|
+
async function ensureDir() {
|
|
18
|
+
await fs.mkdir(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
19
|
+
}
|
|
20
|
+
function pathFor(id) {
|
|
21
|
+
return join(SESSIONS_DIR, `${id}.json`);
|
|
22
|
+
}
|
|
23
|
+
export async function createSession() {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const session = {
|
|
26
|
+
version: 1,
|
|
27
|
+
id: generateSessionId(),
|
|
28
|
+
title: null,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
lastActivity: now,
|
|
31
|
+
messages: [],
|
|
32
|
+
};
|
|
33
|
+
await saveSession(session);
|
|
34
|
+
return session;
|
|
35
|
+
}
|
|
36
|
+
export async function saveSession(session) {
|
|
37
|
+
await ensureDir();
|
|
38
|
+
const target = pathFor(session.id);
|
|
39
|
+
const tmp = `${target}.tmp`;
|
|
40
|
+
const data = JSON.stringify(session, null, 2) + '\n';
|
|
41
|
+
await fs.writeFile(tmp, data, { mode: 0o600 });
|
|
42
|
+
await fs.rename(tmp, target);
|
|
43
|
+
await fs.chmod(target, 0o600);
|
|
44
|
+
}
|
|
45
|
+
export async function loadSession(id) {
|
|
46
|
+
if (!SESSION_ID_RE.test(id))
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
const data = await fs.readFile(pathFor(id), 'utf8');
|
|
50
|
+
const parsed = JSON.parse(data);
|
|
51
|
+
if (!parsed || typeof parsed !== 'object')
|
|
52
|
+
return null;
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err.code === 'ENOENT')
|
|
57
|
+
return null;
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function listSessions(limit = 20) {
|
|
62
|
+
await ensureDir();
|
|
63
|
+
let files;
|
|
64
|
+
try {
|
|
65
|
+
files = await fs.readdir(SESSIONS_DIR);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const summaries = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (!file.endsWith('.json'))
|
|
73
|
+
continue;
|
|
74
|
+
if (file.endsWith('.tmp'))
|
|
75
|
+
continue;
|
|
76
|
+
const id = file.slice(0, -5);
|
|
77
|
+
if (!SESSION_ID_RE.test(id))
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
const session = await loadSession(id);
|
|
81
|
+
if (!session)
|
|
82
|
+
continue;
|
|
83
|
+
const turnCount = session.messages.filter((m) => m.role === 'assistant').length;
|
|
84
|
+
summaries.push({
|
|
85
|
+
id: session.id,
|
|
86
|
+
title: session.title,
|
|
87
|
+
lastActivity: session.lastActivity,
|
|
88
|
+
turnCount,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// skip corrupted
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
summaries.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
96
|
+
return summaries.slice(0, limit);
|
|
97
|
+
}
|
|
98
|
+
export async function getMostRecentSession() {
|
|
99
|
+
const summaries = await listSessions(1);
|
|
100
|
+
if (summaries.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
return loadSession(summaries[0].id);
|
|
103
|
+
}
|
|
104
|
+
export async function clearSessionHistory(session) {
|
|
105
|
+
session.messages = [];
|
|
106
|
+
session.title = null;
|
|
107
|
+
session.lastActivity = Date.now();
|
|
108
|
+
await saveSession(session);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Generate a 4-5 word title summarizing the conversation, using Haiku for cost.
|
|
112
|
+
* Returns '' on any failure (no key, network error, etc.) — caller falls back.
|
|
113
|
+
*/
|
|
114
|
+
export async function generateTitle(messages) {
|
|
115
|
+
const apiKey = process.env.ANTHROPIC_API_KEY ?? (await getKey('anthropic'));
|
|
116
|
+
if (!apiKey)
|
|
117
|
+
return '';
|
|
118
|
+
try {
|
|
119
|
+
const client = new Anthropic({ apiKey });
|
|
120
|
+
const truncated = messages.slice(0, 6);
|
|
121
|
+
const res = await client.messages.create({
|
|
122
|
+
model: process.env.MOD8_TITLE_MODEL ?? 'claude-haiku-4-5',
|
|
123
|
+
max_tokens: 32,
|
|
124
|
+
system: 'Generate a 4-5 word title summarizing this conversation. Output ONLY the title — no quotes, no period, no preamble. Be specific to what was discussed.',
|
|
125
|
+
messages: truncated.map((m) => ({ role: m.role, content: m.content })),
|
|
126
|
+
});
|
|
127
|
+
const text = res.content
|
|
128
|
+
.filter((b) => b.type === 'text')
|
|
129
|
+
.map((b) => b.text)
|
|
130
|
+
.join('')
|
|
131
|
+
.trim()
|
|
132
|
+
.replace(/^["']|["']$/g, '')
|
|
133
|
+
.replace(/[.!?]+$/, '');
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Fallback title from first user message when no AI-generated title exists.
|
|
142
|
+
*/
|
|
143
|
+
export function fallbackTitle(session) {
|
|
144
|
+
const firstUser = session.messages.find((m) => m.role === 'user');
|
|
145
|
+
if (!firstUser)
|
|
146
|
+
return '(empty session)';
|
|
147
|
+
const words = firstUser.content.trim().split(/\s+/).slice(0, 5).join(' ');
|
|
148
|
+
return words.length > 60 ? words.slice(0, 57) + '…' : words;
|
|
149
|
+
}
|
|
150
|
+
export const SESSIONS_DIR_PATH = SESSIONS_DIR;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MOD8_DEBUG=1 instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* Prints to stderr (so it never pollutes piped stdout / one-shot output).
|
|
5
|
+
* Always redacts API keys to the masked form before logging.
|
|
6
|
+
*
|
|
7
|
+
* Use cases:
|
|
8
|
+
* - "Is the env var being respected?" → modelResolution log
|
|
9
|
+
* - "What URL did the SDK try to hit?" → providerCall log
|
|
10
|
+
* - "What did the provider actually return?" → providerResponse log
|
|
11
|
+
* - "Why does mod8 say 'model not available'?" → providerError log
|
|
12
|
+
*/
|
|
13
|
+
import { maskApiKey } from './secrets.js';
|
|
14
|
+
export function debugEnabled() {
|
|
15
|
+
return process.env.MOD8_DEBUG === '1';
|
|
16
|
+
}
|
|
17
|
+
function ts() {
|
|
18
|
+
const d = new Date();
|
|
19
|
+
return d.toISOString().slice(11, 23);
|
|
20
|
+
}
|
|
21
|
+
function emit(line) {
|
|
22
|
+
process.stderr.write(`[mod8:debug ${ts()}] ${line}\n`);
|
|
23
|
+
}
|
|
24
|
+
export function debugLog(message, fields) {
|
|
25
|
+
if (!debugEnabled())
|
|
26
|
+
return;
|
|
27
|
+
if (!fields) {
|
|
28
|
+
emit(message);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const parts = [message];
|
|
32
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
33
|
+
parts.push(`${k}=${formatValue(v)}`);
|
|
34
|
+
}
|
|
35
|
+
emit(parts.join(' '));
|
|
36
|
+
}
|
|
37
|
+
function formatValue(v) {
|
|
38
|
+
if (v === undefined)
|
|
39
|
+
return '(undefined)';
|
|
40
|
+
if (v === null)
|
|
41
|
+
return '(null)';
|
|
42
|
+
if (typeof v === 'string')
|
|
43
|
+
return JSON.stringify(v);
|
|
44
|
+
return String(v);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Approximate the URL each provider's SDK will hit. We don't intercept the
|
|
48
|
+
* network, so this is a best-effort hint based on apiType + baseUrl + model.
|
|
49
|
+
* Useful when debugging a "why isn't my custom model getting through?"
|
|
50
|
+
* report — the URL printed here matches what the SDK actually requests.
|
|
51
|
+
*/
|
|
52
|
+
export function approximateProviderUrl(apiType, model, baseUrl) {
|
|
53
|
+
switch (apiType) {
|
|
54
|
+
case 'anthropic':
|
|
55
|
+
return `https://api.anthropic.com/v1/messages (model=${model})`;
|
|
56
|
+
case 'openai-compat': {
|
|
57
|
+
const base = baseUrl ?? 'https://api.openai.com/v1';
|
|
58
|
+
return `${base.replace(/\/$/, '')}/chat/completions (model=${model})`;
|
|
59
|
+
}
|
|
60
|
+
case 'gemini':
|
|
61
|
+
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function debugProviderCall(opts) {
|
|
65
|
+
if (!debugEnabled())
|
|
66
|
+
return;
|
|
67
|
+
emit(`provider-call providerId=${opts.providerId} apiType=${opts.apiType} model=${JSON.stringify(opts.model)} modelSource=${opts.modelSource} key=${maskApiKey(opts.apiKey)} url=${JSON.stringify(approximateProviderUrl(opts.apiType, opts.model, opts.baseUrl))} prompt=${JSON.stringify(opts.promptPreview.slice(0, 200))}`);
|
|
68
|
+
}
|
|
69
|
+
export function debugProviderError(providerId, err) {
|
|
70
|
+
if (!debugEnabled())
|
|
71
|
+
return;
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
emit(`provider-error providerId=${providerId} message=${JSON.stringify(message.slice(0, 800))}`);
|
|
74
|
+
}
|
|
75
|
+
export function debugProviderResponse(providerId, model, tokens) {
|
|
76
|
+
if (!debugEnabled())
|
|
77
|
+
return;
|
|
78
|
+
emit(`provider-ok providerId=${providerId} model=${JSON.stringify(model)} input_tok=${tokens.input} output_tok=${tokens.output} latency_ms=${tokens.latencyMs}`);
|
|
79
|
+
}
|