mohdel 1.0.0 → 1.0.1
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 +5 -5
- package/js/factory/bridge.js +2 -18
- package/js/session/_cooldown.js +0 -3
- package/js/session/_logger.js +1 -8
- package/js/session/_tracing.js +0 -3
- package/js/session/adapters/_chat_completions.js +2 -8
- package/js/session/adapters/_tools.js +1 -4
- package/js/session/adapters/anthropic.js +1 -2
- package/js/session/adapters/fake.js +0 -2
- package/js/session/adapters/gemini.js +1 -2
- package/js/session/adapters/openai.js +1 -2
- package/package.json +2 -2
- package/src/cli/complete.js +2 -12
- package/src/cli/default.js +1 -6
- package/src/cli/entry.js +0 -3
- package/src/cli/instructions.js +0 -5
- package/src/cli/model.js +0 -2
- package/src/cli/onboard.js +1 -16
- package/src/lib/assistants.js +0 -6
- package/src/lib/catalog-review.js +0 -2
- package/src/lib/common.js +13 -2
- package/src/lib/local-conventions.js +1 -2
- package/src/lib/providers.js +3 -3
- package/src/lib/select.js +1 -19
package/README.md
CHANGED
|
@@ -107,11 +107,11 @@ groq/llama-4-scout-17b-16e-instruct
|
|
|
107
107
|
|
|
108
108
|
## Attack surface
|
|
109
109
|
|
|
110
|
-
Anthropic's 2026 threat report describes actors compromising
|
|
111
|
-
services
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
Anthropic's 2026 threat report describes actors compromising LLM wrapper
|
|
111
|
+
services through prompt injection, exfiltrating the production API keys held
|
|
112
|
+
in their cloud containers. That attack needs two things: keys sitting where a
|
|
113
|
+
process can read them, and a component that injected content can steer into
|
|
114
|
+
reading them. Mohdel is built so neither is present.
|
|
115
115
|
|
|
116
116
|
- **Nothing executes.** No `eval`, no `new Function`, no `child_process`
|
|
117
117
|
anywhere in the session, factory or library, and no automatic tool loop. A
|
package/js/factory/bridge.js
CHANGED
|
@@ -76,14 +76,6 @@ import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
|
|
|
76
76
|
export async function runAnswer ({ provider, model, modelKey, configuration, prompt, options = {} }, deps = {}) {
|
|
77
77
|
const envelope = markTrustedMedia(toEnvelope({ modelKey, configuration, prompt, options }))
|
|
78
78
|
|
|
79
|
-
// If the caller passed a `realtimeHandler`, feed every `delta`
|
|
80
|
-
// event into a buffer that invokes the handler on batches matching
|
|
81
|
-
// `bufferOpts` cadence. Without this, streaming callbacks silently
|
|
82
|
-
// never fire — `mo ask --stream` and any integration that relies
|
|
83
|
-
// on streaming callbacks stops working.
|
|
84
|
-
//
|
|
85
|
-
// Skip the buffer allocation entirely when no handler was
|
|
86
|
-
// supplied — the common case.
|
|
87
79
|
const deltaBuffer = options.realtimeHandler
|
|
88
80
|
? createRealtimeDeltaBuffer(options.realtimeHandler, options.bufferOpts)
|
|
89
81
|
: null
|
|
@@ -99,9 +91,6 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
|
|
|
99
91
|
}
|
|
100
92
|
}
|
|
101
93
|
} finally {
|
|
102
|
-
// Flush any pending buffered content regardless of terminal
|
|
103
|
-
// path (success, error, or exception) so the handler sees the
|
|
104
|
-
// tail of the stream.
|
|
105
94
|
deltaBuffer?.flush()
|
|
106
95
|
}
|
|
107
96
|
|
|
@@ -233,9 +222,7 @@ function toEnvelope ({ modelKey, configuration, prompt, options }) {
|
|
|
233
222
|
if (options.identifier) envelope.identifier = options.identifier
|
|
234
223
|
if (options.idleHeartbeatMs !== undefined) envelope.idleHeartbeatMs = options.idleHeartbeatMs
|
|
235
224
|
|
|
236
|
-
// OpenRouter routing prefs ride in their own bag
|
|
237
|
-
// envelope clean. The openrouter adapter reads this via
|
|
238
|
-
// `config.mutateArgs`.
|
|
225
|
+
// OpenRouter routing prefs ride in their own bag; the adapter reads them via `config.mutateArgs`.
|
|
239
226
|
if (options.providerOrder || options.providerAllow || options.providerDeny) {
|
|
240
227
|
envelope.providerOptions = {
|
|
241
228
|
openrouter: {
|
|
@@ -361,10 +348,7 @@ function toEnvelopePrompt (prompt) {
|
|
|
361
348
|
return out
|
|
362
349
|
}
|
|
363
350
|
|
|
364
|
-
//
|
|
365
|
-
// fall through would land a raw non-iterable in the envelope and
|
|
366
|
-
// produce a confusing `prompt.map is not a function` deep inside
|
|
367
|
-
// the adapter.
|
|
351
|
+
// Rejected here; falling through lands a non-iterable in the envelope and fails as `prompt.map is not a function` inside the adapter.
|
|
368
352
|
throw new MohdelError('invalid prompt shape', {
|
|
369
353
|
type: 'SESSION_INVALID_PROMPT',
|
|
370
354
|
retryable: false,
|
package/js/session/_cooldown.js
CHANGED
|
@@ -104,9 +104,6 @@ export function createCooldownTracker (
|
|
|
104
104
|
return { check, recordFailure, reset, coolingDownError, threshold, durationMs }
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Single session-local tracker. Re-exported as named members so
|
|
108
|
-
// callers can `import * as cooldown from './_cooldown.js'` and use
|
|
109
|
-
// `cooldown.reset(key)` / `cooldown.coolingDownError(key)`.
|
|
110
107
|
const defaultTracker = createCooldownTracker()
|
|
111
108
|
export const check = defaultTracker.check
|
|
112
109
|
export const recordFailure = defaultTracker.recordFailure
|
package/js/session/_logger.js
CHANGED
|
@@ -26,10 +26,7 @@ const LEVELS = {
|
|
|
26
26
|
silent: 100
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
// otherwise flood stderr with the lines we deliberately induce.
|
|
31
|
-
// Under vitest, default to silent unless MOHDEL_LOG_LEVEL is set
|
|
32
|
-
// explicitly.
|
|
29
|
+
// Under vitest, default to silent unless MOHDEL_LOG_LEVEL is set: induced anomalies would otherwise flood stderr.
|
|
33
30
|
const DEFAULT_LEVEL = process.env.VITEST ? 'silent' : 'warn'
|
|
34
31
|
const DEFAULT_VERBOSITY = 1
|
|
35
32
|
|
|
@@ -131,8 +128,4 @@ function mergeFields (target, source, includeStack) {
|
|
|
131
128
|
}
|
|
132
129
|
}
|
|
133
130
|
|
|
134
|
-
// Module-level default — used when a caller doesn't pass their own.
|
|
135
|
-
// Keeping the stream open lets adapters import the singleton
|
|
136
|
-
// directly; `withContext` spawns scoped children per call without
|
|
137
|
-
// allocating a new writer.
|
|
138
131
|
export const logger = createLogger()
|
package/js/session/_tracing.js
CHANGED
|
@@ -48,9 +48,6 @@ export async function ensureOtelInitialized () {
|
|
|
48
48
|
sdk.start()
|
|
49
49
|
otelInitialized = true
|
|
50
50
|
} catch (e) {
|
|
51
|
-
// Leave the flag false so a retry is possible. The current caller
|
|
52
|
-
// (bin.js::main) runs this once, so "retry" in practice means a
|
|
53
|
-
// process restart — but the semantics should match the flag name.
|
|
54
51
|
process.stderr.write(
|
|
55
52
|
`${JSON.stringify({ level: 'warn', time: Date.now(), msg: '[mohdel:tracing] OTel SDK init failed', err: { message: e.message } })}\n`
|
|
56
53
|
)
|
|
@@ -148,8 +148,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
148
148
|
args.stream = true
|
|
149
149
|
args.stream_options = { include_usage: true }
|
|
150
150
|
|
|
151
|
-
//
|
|
152
|
-
// churn on long streams.
|
|
151
|
+
// Array + join, not `+=`: per-delta cons-strings are the cost on a long stream.
|
|
153
152
|
const contentParts = []
|
|
154
153
|
const reasoningParts = []
|
|
155
154
|
let first = null
|
|
@@ -199,12 +198,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
199
198
|
}
|
|
200
199
|
if (choice?.delta?.tool_calls) {
|
|
201
200
|
for (const tc of choice.delta.tool_calls) {
|
|
202
|
-
//
|
|
203
|
-
// 1. explicit `tc.index` always wins (normal case);
|
|
204
|
-
// 2. missing `tc.index` but a `tc.id` we've seen before
|
|
205
|
-
// → use the cross-ref we recorded on the opener;
|
|
206
|
-
// 3. new `tc.id` with no index → allocate next slot;
|
|
207
|
-
// 4. neither id nor index → can't correlate; drop + warn.
|
|
201
|
+
// Precedence: explicit `tc.index`, then a known `tc.id`, then a new slot; neither means the chunk cannot be correlated.
|
|
208
202
|
let idx = tc.index
|
|
209
203
|
if (idx == null && tc.id != null && idToIndex[tc.id] != null) {
|
|
210
204
|
idx = idToIndex[tc.id]
|
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const argsObj = (args) => args || {}
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
// malformed JSON before retrying with corrections). Fall back to
|
|
13
|
-
// returning the raw string — downstream adapter code handles the
|
|
14
|
-
// type mismatch. Logging here would create warn-level noise.
|
|
11
|
+
// Malformed tool-call JSON is routine, not an anomaly: return the raw string and let the adapter handle the mismatch.
|
|
15
12
|
const parseArgs = (_name, args) => {
|
|
16
13
|
if (typeof args !== 'string') return argsObj(args)
|
|
17
14
|
try {
|
|
@@ -110,8 +110,7 @@ export async function * anthropic (envelope, deps = {}) {
|
|
|
110
110
|
|
|
111
111
|
const request = buildRequest(envelope, conversation, system, conversationCacheTtl)
|
|
112
112
|
|
|
113
|
-
//
|
|
114
|
-
// churn. Materialized at each exit point.
|
|
113
|
+
// Array + join, not `+=`: per-delta cons-strings are the cost on a long stream.
|
|
115
114
|
const outputParts = []
|
|
116
115
|
const currentOutput = () => outputParts.join('')
|
|
117
116
|
let inputTokens = 0
|
|
@@ -62,8 +62,6 @@ export async function * fake (envelope, deps = {}) {
|
|
|
62
62
|
return
|
|
63
63
|
|
|
64
64
|
case 'hang':
|
|
65
|
-
// Wait for abort. If the signal is already aborted, yield
|
|
66
|
-
// cancelled immediately. Otherwise block until it fires.
|
|
67
65
|
await waitForAbort(signal)
|
|
68
66
|
yield cancelledDone(start, first, envelope, '', 0, 0)
|
|
69
67
|
return
|
|
@@ -101,8 +101,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
101
101
|
request.config = { ...(request.config ?? {}), abortSignal: signal }
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
//
|
|
105
|
-
// churn. Materialized at each exit point.
|
|
104
|
+
// Array + join, not `+=`: per-delta cons-strings are the cost on a long stream.
|
|
106
105
|
const outputParts = []
|
|
107
106
|
const currentOutput = () => outputParts.join('')
|
|
108
107
|
let inputTokens = 0
|
|
@@ -71,8 +71,7 @@ export async function * openai (envelope, deps = {}) {
|
|
|
71
71
|
|
|
72
72
|
const request = buildRequest(envelope, input, instructions)
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
// churn. Materialized at each exit point.
|
|
74
|
+
// Array + join, not `+=`: per-delta cons-strings are the cost on a long stream.
|
|
76
75
|
const outputParts = []
|
|
77
76
|
const currentOutput = () => outputParts.join('')
|
|
78
77
|
let inputTokens = 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mohdel",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christophe Le Bars",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
|
|
137
137
|
"@opentelemetry/sdk-node": "^0.222.0",
|
|
138
138
|
"chalk": "^6.0.0",
|
|
139
|
-
"mohdel-thin-gate-linux-x64-gnu": "1.0.
|
|
139
|
+
"mohdel-thin-gate-linux-x64-gnu": "1.0.1"
|
|
140
140
|
},
|
|
141
141
|
"dependencies": {
|
|
142
142
|
"@anthropic-ai/sdk": "^0.125.0",
|
package/src/cli/complete.js
CHANGED
|
@@ -3,9 +3,7 @@ import { fieldDefs } from '../lib/schema.js'
|
|
|
3
3
|
import { getCuratedModels, catalogEntries } from '../lib/common.js'
|
|
4
4
|
import { ALIASES } from './aliases.js'
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
// every adapter, provider SDK and the OTel stack — 350ms that would be paid on
|
|
8
|
-
// every press of the tab key. Reading the catalog costs about 30ms.
|
|
6
|
+
// Never import the factory here: it loads every provider SDK, on every tab press.
|
|
9
7
|
|
|
10
8
|
const NOUNS = ['model', 'provider', 'creator', 'tag', 'ratelimit', 'ask', 'transcribe', 'default', 'doctor']
|
|
11
9
|
|
|
@@ -18,9 +16,6 @@ const VERBS = {
|
|
|
18
16
|
ratelimit: ['show', 'set', 'rm', 'provider']
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
// What the argument at a given offset after `<noun> <verb>` names. `model` is
|
|
22
|
-
// an id already in the catalog, so `model add` — which takes a new one — is
|
|
23
|
-
// deliberately absent.
|
|
24
19
|
const ARGUMENT = {
|
|
25
20
|
ask: ['model'],
|
|
26
21
|
transcribe: ['model'],
|
|
@@ -79,16 +74,12 @@ export const candidates = async (cword, words) => {
|
|
|
79
74
|
return offer([...NOUNS, ...Object.keys(ALIASES)])
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
// Resolve the alias exactly as the router does, so completion and dispatch
|
|
83
|
-
// never disagree about what a word means.
|
|
84
77
|
const alias = ALIASES[before[0]]
|
|
85
78
|
const chain = alias ? [alias.noun, ...alias.inject, ...before.slice(1)] : before
|
|
86
79
|
const [noun, verb, ...rest] = chain
|
|
87
80
|
|
|
88
81
|
if (chain.length === 1 && VERBS[noun]) return offer(VERBS[noun])
|
|
89
82
|
|
|
90
|
-
// `model show <id>` keys on two words; `ask <id>` on one. Whichever matched
|
|
91
|
-
// decides where the argument list starts.
|
|
92
83
|
const paired = ARGUMENT[`${noun} ${verb}`]
|
|
93
84
|
const shape = paired ?? ARGUMENT[noun]
|
|
94
85
|
if (!shape) return []
|
|
@@ -109,8 +100,7 @@ complete -o default -F _mo_completion mo
|
|
|
109
100
|
`
|
|
110
101
|
|
|
111
102
|
export async function runComplete (args) {
|
|
112
|
-
//
|
|
113
|
-
// catalog offers nothing rather than an error.
|
|
103
|
+
// An unreadable catalog offers nothing rather than breaking the tab key.
|
|
114
104
|
try {
|
|
115
105
|
const cword = Number.parseInt(args[0], 10)
|
|
116
106
|
if (!Number.isFinite(cword)) return
|
package/src/cli/default.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { intro, outro, select, isCancel, cancel } from '@clack/prompts'
|
|
2
2
|
import { getConfig, getCuratedModels, saveConfig, catalogEntries } from '../lib/common.js'
|
|
3
3
|
|
|
4
|
-
// CLI-only. The default model is a convenience for someone typing `mo ask`;
|
|
5
|
-
// the library and the gate never read it. A program picks a model by id or by
|
|
6
|
-
// tag, where the choice is explicit and reviewable — a machine inheriting a
|
|
7
|
-
// human's terminal preference is how a call ends up on a model nobody chose.
|
|
8
4
|
export async function runDefault (args = []) {
|
|
9
5
|
if (args.includes('-h') || args.includes('--help')) {
|
|
10
6
|
console.log(`mohdel default — the model "mo ask" uses when you name none
|
|
@@ -49,8 +45,7 @@ read it: a program names its model, or selects one by tag.`)
|
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
try {
|
|
52
|
-
//
|
|
53
|
-
// object wholesale would drop it.
|
|
48
|
+
// The file also holds the chosen coding agent; replacing it wholesale drops that.
|
|
54
49
|
await saveConfig({ ...(await getConfig()), defaultModel: selectedModelId })
|
|
55
50
|
outro(`Default set to ${selectedModelId} — "mo ask" uses it when you give no model.`)
|
|
56
51
|
} catch (err) {
|
package/src/cli/entry.js
CHANGED
|
@@ -155,9 +155,6 @@ Write an entry with your coding agent: mo model instructions <provider>`)
|
|
|
155
155
|
console.log(`${meta('Undo:')} mo model backup diff prev ${meta('│')} mo model backup restore prev`)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
// The candidate was written by the agent, not by mohdel, so it is never
|
|
159
|
-
// removed unasked. It has served its purpose though, and the catalog's own
|
|
160
|
-
// backups are what an undo uses — not this file.
|
|
161
158
|
if (path === '-') return
|
|
162
159
|
if (args.includes('--rm')) {
|
|
163
160
|
await rm(path, { force: true })
|
package/src/cli/instructions.js
CHANGED
|
@@ -14,8 +14,6 @@ const CATALOG_DOC = 'https://github.com/clbrge/mohdel/blob/main/docs/CATALOG.md'
|
|
|
14
14
|
export const BRIEF_FILE = 'mohdel-brief.md'
|
|
15
15
|
export const BRIEF_HEADING = '# mohdel catalog entry'
|
|
16
16
|
|
|
17
|
-
// A brief mohdel wrote before is stale, not precious — replace it. Anything
|
|
18
|
-
// else owning the name is the user's, so write alongside rather than over it.
|
|
19
17
|
export const writeBrief = async (provider) => {
|
|
20
18
|
const brief = await buildBrief(provider)
|
|
21
19
|
let name = BRIEF_FILE
|
|
@@ -163,9 +161,6 @@ One-shot instead of a session, where the agent supports it:
|
|
|
163
161
|
|
|
164
162
|
const assistant = (await getConfig()).assistant || null
|
|
165
163
|
|
|
166
|
-
// 150 lines of agent-facing markdown is not what someone at a terminal
|
|
167
|
-
// wants. Redirected or piped — how the recipe and agents use it — stdout
|
|
168
|
-
// still carries the brief.
|
|
169
164
|
if (process.stdout.isTTY && !args.includes('--print')) {
|
|
170
165
|
const { preferredAgent, briefPrompt } = await import('../lib/assistants.js')
|
|
171
166
|
const { ok, meta, id: cmd } = await import('./colors.js')
|
package/src/cli/model.js
CHANGED
|
@@ -6,8 +6,6 @@ import { providerOf } from '#core/model-id.js'
|
|
|
6
6
|
import { parseJsonFlag, printAvailableFields, jsonOutput, jsonOutputOne } from './json-output.js'
|
|
7
7
|
import { id, label, tag, price, meta, err, warn, ok } from './colors.js'
|
|
8
8
|
|
|
9
|
-
// An empty catalog is the fresh-install state, not an error — but printing
|
|
10
|
-
// nothing at all reads as a broken command.
|
|
11
9
|
export const EMPTY_CATALOG = 'Catalog is empty. "mo curate <provider>" adds a provider\'s models, ' +
|
|
12
10
|
'then "mo model instructions <provider>" fills in the prices.'
|
|
13
11
|
|
package/src/cli/onboard.js
CHANGED
|
@@ -30,10 +30,7 @@ async function appendToEnvFile (key, value) {
|
|
|
30
30
|
let content = ''
|
|
31
31
|
if (existsSync(ENV_PATH)) {
|
|
32
32
|
content = await readFile(ENV_PATH, 'utf8')
|
|
33
|
-
//
|
|
34
|
-
// because `$&`, `` $` ``, `$'` and `$1` are expanded inside a
|
|
35
|
-
// replacement *string* — a pasted key containing any of them would
|
|
36
|
-
// be silently rewritten before it hit disk.
|
|
33
|
+
// Function form: `$&`, `` $` ``, `$'` and `$1` are expanded inside a replacement string, which would rewrite a pasted key.
|
|
37
34
|
const re = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm')
|
|
38
35
|
if (re.test(content)) {
|
|
39
36
|
content = content.replace(re, () => `${key}=${value}`)
|
|
@@ -52,8 +49,6 @@ export async function runOnboard () {
|
|
|
52
49
|
loadDefaultEnv()
|
|
53
50
|
const { configured, unconfigured } = getConfiguredProviders()
|
|
54
51
|
|
|
55
|
-
// Already set up: a status line and the step that follows from it, not a
|
|
56
|
-
// menu. `mo --help` is the menu.
|
|
57
52
|
if (configured.length > 0) {
|
|
58
53
|
const curated = await getCuratedModels()
|
|
59
54
|
const active = catalogEntries(curated).filter(([, s]) => !s.deprecated)
|
|
@@ -72,8 +67,6 @@ export async function runOnboard () {
|
|
|
72
67
|
let handOver = null
|
|
73
68
|
if (active.length === 0) {
|
|
74
69
|
next.push([`mo curate ${first}`, 'add the models this key can reach'])
|
|
75
|
-
// An interrupted run leaves the brief behind. Telling someone to write
|
|
76
|
-
// one that is already sitting in front of them is the wrong step.
|
|
77
70
|
const { BRIEF_FILE, BRIEF_HEADING } = await import('./instructions.js')
|
|
78
71
|
const briefPath = resolve(process.cwd(), BRIEF_FILE)
|
|
79
72
|
const briefReady = existsSync(briefPath) &&
|
|
@@ -166,13 +159,8 @@ export async function runOnboard () {
|
|
|
166
159
|
// Reload env so the new key is visible, then fill the catalog.
|
|
167
160
|
loadDefaultEnv()
|
|
168
161
|
|
|
169
|
-
// OpenRouter's model list carries prices, so picking by hand is complete
|
|
170
|
-
// there and needs no agent at all.
|
|
171
162
|
const selfPricing = !!providers[selected]?.pricesFromApi
|
|
172
163
|
|
|
173
|
-
// Where the prices are published, the models that cost nothing are the whole
|
|
174
|
-
// offer for someone with no coding agent. The count belongs on the menu: put
|
|
175
|
-
// it behind a search over hundreds of ids and nobody finds it.
|
|
176
164
|
let api = null
|
|
177
165
|
let free = []
|
|
178
166
|
if (selfPricing) {
|
|
@@ -244,9 +232,6 @@ export async function runOnboard () {
|
|
|
244
232
|
await writeCatalogBrief(selected)
|
|
245
233
|
}
|
|
246
234
|
|
|
247
|
-
// The provider API returns model ids, never prices — entries land unpriced and
|
|
248
|
-
// `cost` stays 0 on every result until the numbers are filled in. This is the
|
|
249
|
-
// moment the user is still in the flow.
|
|
250
235
|
async function writeCatalogBrief (selected) {
|
|
251
236
|
const { AGENTS, detectAssistants, preferredAgent, briefPrompt } = await import('../lib/assistants.js')
|
|
252
237
|
const installed = detectAssistants()
|
package/src/lib/assistants.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { statSync } from 'node:fs'
|
|
2
2
|
import { join, delimiter } from 'node:path'
|
|
3
3
|
|
|
4
|
-
// Launch forms are the vendor-documented ones for starting a session on an
|
|
5
|
-
// initial prompt — not the one-shot/print flags, since picking a model is a
|
|
6
|
-
// conversation.
|
|
7
4
|
const AGENTS = [
|
|
8
5
|
{ bin: 'claude', label: 'Claude Code', start: p => `claude ${p}` },
|
|
9
6
|
{ bin: 'codex', label: 'Codex CLI', start: p => `codex ${p}` },
|
|
@@ -46,9 +43,6 @@ export const launchLines = (installed = detectAssistants(), chosen = null, inden
|
|
|
46
43
|
// one argument is the form every agent CLI accepts.
|
|
47
44
|
const custom = (bin) => ({ bin, label: bin, start: p => `${bin} ${p}` })
|
|
48
45
|
|
|
49
|
-
// Which agent the recipe should name. A stated preference beats detection —
|
|
50
|
-
// PATH cannot see which of two installed agents someone actually uses, nor an
|
|
51
|
-
// agent that lives in an editor.
|
|
52
46
|
export const preferredAgent = (chosen, installed = detectAssistants()) => {
|
|
53
47
|
if (chosen) return AGENTS.find(a => a.bin === chosen) || custom(chosen)
|
|
54
48
|
return TOP.find(a => installed.includes(a.bin)) || TOP[0]
|
|
@@ -60,8 +60,6 @@ export const reviewEntry = (key, spec, catalog, { strict = false, local = null }
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
for (const issue of validate(spec, key, { strict })) {
|
|
63
|
-
// Custom fields are namespaced by convention (docs/CATALOG.md) and
|
|
64
|
-
// round-trip untouched, so they are not drift.
|
|
65
63
|
if (issue.message === 'unknown field' && issue.field.includes(':')) continue
|
|
66
64
|
if (issue.message === 'unknown field' && local?.fields[issue.field]) continue
|
|
67
65
|
if (issue.severity === 'error') errors.push(`${key}: ${issue.field} — ${issue.message}`)
|
package/src/lib/common.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join, sep } from 'path'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { existsSync } from 'fs'
|
|
4
|
-
import { readFile, writeFile, mkdir, copyFile, stat } from 'fs/promises'
|
|
4
|
+
import { readFile, writeFile, mkdir, copyFile, stat, rename, unlink } from 'fs/promises'
|
|
5
5
|
import envPaths from 'env-paths'
|
|
6
6
|
import { validate, stripComputed } from './schema.js'
|
|
7
7
|
import { silent } from './logger.js'
|
|
@@ -81,6 +81,17 @@ const getWeek = (ms) => {
|
|
|
81
81
|
return `${d.getFullYear()}-W${String(Math.ceil((d.getDate() + new Date(d.getFullYear(), d.getMonth(), 1).getDay()) / 7)).padStart(2, '0')}-${d.getMonth()}`
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
const writeAtomic = async (filePath, contents) => {
|
|
85
|
+
const tmp = `${filePath}.${process.pid}.tmp`
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(tmp, contents)
|
|
88
|
+
await rename(tmp, filePath)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
await unlink(tmp).catch(() => {})
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
const rotateBackup = async (filePath) => {
|
|
85
96
|
if (!existsSync(filePath)) return
|
|
86
97
|
|
|
@@ -214,7 +225,7 @@ const createFileOperation = (filePath, defaultValue = {}, operationType) => {
|
|
|
214
225
|
}
|
|
215
226
|
|
|
216
227
|
const sortedData = sortObjectKeys(dataToSave)
|
|
217
|
-
await
|
|
228
|
+
await writeAtomic(filePath, JSON.stringify(sortedData, null, 2))
|
|
218
229
|
return true
|
|
219
230
|
} catch (err) {
|
|
220
231
|
moduleLogger.error(`[mohdel:common] failed to save ${operationType}: ${err.message}`)
|
|
@@ -79,8 +79,7 @@ export const parseLocalConventions = (text) => {
|
|
|
79
79
|
return { fields, tags, adding, notes: doc.notes || '' }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Absent is the default state
|
|
83
|
-
// parse is broken policy — it must not read as "no conventions".
|
|
82
|
+
// Absent is the default state; a file that exists but does not parse is not.
|
|
84
83
|
export const loadLocalConventions = async (path = LOCAL_PATH) => {
|
|
85
84
|
if (!existsSync(path)) return null
|
|
86
85
|
try {
|
package/src/lib/providers.js
CHANGED
|
@@ -146,9 +146,9 @@ const providers = {
|
|
|
146
146
|
baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
|
147
147
|
createConfiguration: apiKey => ({ apiKey }),
|
|
148
148
|
references: {
|
|
149
|
-
pricing: 'https://
|
|
150
|
-
models: 'https://www.
|
|
151
|
-
rateLimits: 'https://
|
|
149
|
+
pricing: 'https://docs.qwencloud.com/developer-guides/getting-started/pricing',
|
|
150
|
+
models: 'https://www.qwencloud.com/models',
|
|
151
|
+
rateLimits: 'https://docs.qwencloud.com/developer-guides/administration/rate-limits'
|
|
152
152
|
},
|
|
153
153
|
contextSemantics: 'shared',
|
|
154
154
|
outputCapStrategy: 'accept'
|
package/src/lib/select.js
CHANGED
|
@@ -94,21 +94,12 @@ const replaceModel = async (modelToReplace, newCuratedKey, newModelLabel, newMod
|
|
|
94
94
|
}
|
|
95
95
|
delete curated[modelToReplace.key]
|
|
96
96
|
|
|
97
|
-
// Preserve custom properties from the old model entry,
|
|
98
|
-
// but explicitly exclude 'model' and 'models' fields from the old entry.
|
|
99
|
-
// This prevents old model identifiers from polluting the new entry if the
|
|
100
|
-
// new model's details (newModelDetails) don't specify them, ensuring consistent `upstreamIds`.
|
|
101
97
|
const {
|
|
102
98
|
model: _discardedOldModelIdentifier, // eslint-disable-line no-unused-vars
|
|
103
99
|
models: _discardedOldModelIdentifiers, // eslint-disable-line no-unused-vars
|
|
104
100
|
...restOfOldModelDataProperties
|
|
105
101
|
} = oldModelDataFromCurated || {}
|
|
106
102
|
|
|
107
|
-
// Add new model to curated.
|
|
108
|
-
// Merge properties: Start with applicable old properties (restOfOldModelDataProperties),
|
|
109
|
-
// then layer new model details (newModelDetails), which includes new provider & sdk.
|
|
110
|
-
// Finally, ensure the new label (newModelLabel) is set.
|
|
111
|
-
// Properties in newModelDetails and newModelLabel will override any from restOfOldModelDataProperties.
|
|
112
103
|
curated[newCuratedKey] = {
|
|
113
104
|
...restOfOldModelDataProperties,
|
|
114
105
|
...newModelDetails,
|
|
@@ -165,9 +156,6 @@ const isModelTrackedInCollection = (collection, providerName, modelId) => {
|
|
|
165
156
|
return false
|
|
166
157
|
}
|
|
167
158
|
|
|
168
|
-
// Which creators a provider serves is a fact of the catalog, not a property of
|
|
169
|
-
// the provider: a router's line-up changes week to week. Offer the ones this
|
|
170
|
-
// provider already serves first, then every creator mohdel has a label for.
|
|
171
159
|
const creatorOptions = async (providerName, guess) => {
|
|
172
160
|
const catalog = await getCuratedModels()
|
|
173
161
|
const served = new Set()
|
|
@@ -262,10 +250,6 @@ const filterUncurated = (models, providerName, curated, excluded) => {
|
|
|
262
250
|
})
|
|
263
251
|
}
|
|
264
252
|
|
|
265
|
-
// A model list only carries prices when the provider publishes them
|
|
266
|
-
// (`pricesFromApi`). Where it does, the ones that cost nothing are the whole
|
|
267
|
-
// no-agent story, and a free-text search over hundreds of ids is no way to
|
|
268
|
-
// find them.
|
|
269
253
|
export const freeModels = (models) =>
|
|
270
254
|
models.filter(m => m.inputPrice === 0 && m.outputPrice === 0)
|
|
271
255
|
|
|
@@ -278,9 +262,7 @@ const buildEntry = async (providerName, model, providerInstance) => {
|
|
|
278
262
|
label: model.label || model.id,
|
|
279
263
|
...(info || {})
|
|
280
264
|
})
|
|
281
|
-
//
|
|
282
|
-
// `<vendor>/<model>`, so the vendor segment is the answer when the creator
|
|
283
|
-
// table does not recognize the name.
|
|
265
|
+
// An upstream list names the provider, never the creator; an OpenRouter id carries the vendor in its first segment.
|
|
284
266
|
if (!entry.creator) entry.creator = creatorFromModelId(model.id) || model.id.split('/')[0]
|
|
285
267
|
return entry
|
|
286
268
|
}
|