@lawaty/lacode 1.3.0-beta.6 → 1.3.0-beta.8
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 +39 -1
- package/README.md +4 -0
- package/dist/agents.js +54 -8
- package/dist/index.js +269 -23
- package/dist/pool.js +35 -3
- package/docs/CONFIGURATION.md +16 -1
- package/docs/USER-STORIES.md +18 -0
- package/package.json +2 -2
- package/scripts/score-models.mjs +200 -10
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ All notable changes to LaCode are recorded here. The format follows
|
|
|
7
7
|
Versions come from git release commits; the npm version is bumped in the same
|
|
8
8
|
commit as the changelog entry it describes.
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [1.3.0-beta.8] — a blocked child is never silent
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Provider compatibility: object-shaped reasoning no longer crashes a spawn.** OpenAI-compatible gateways that stream `choices[].delta.reasoning` as an object (`{ encrypted_content, content, summary }`) are normalized to the string opencode's chunk schema expects, so the spawn survives the first reasoning chunk instead of dying with `invalid_union`.
|
|
17
|
+
- **Pool: the rate-limit-reset notice is on by default only for `opencode` and `opencode-go` slots.** Every other provider stays quiet unless its slot sets `notify:on` on the `pool.txt` line (or `"notify": true` in `pool.json`); `notify:off` silences an `opencode`/`opencode-go` slot, and a model listed on several lines is announced if any of its slots is on (US-34).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Build agent prompt: delegation is now the default.** The root agent's prompt carries the delegate-first gate and hard rules requiring a `task` spawn before repository inspection or edits, with bash reserved for applying and verifying a subagent's result.
|
|
22
|
+
- **`score-models`: probe `opencode`/`opencode-go` through a local `opencode serve --pure` session** instead of direct HTTP, and restrict candidates to concrete text chat/completion models (media generators, safety classifiers and OpenRouter meta-routers are skipped).
|
|
23
|
+
- **Cancelled tasks now surface their reason via a toast** when the task card cannot be rewritten (no `part` sub-client or an unauthenticated PATCH), so the user still sees why the slot was aborted.
|
|
24
|
+
- **Publishing now syncs the config tree too.** `postpublish` runs `scripts/sync-hosts.mjs` and then the repo's `bin/oc-sync`, so a release deploys both the registry package and the working config without a second manual step.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **A subagent blocked on a permission or question now notifies.** Every subagent outcome used to be suppressed, so a permission prompt in another project could be completely silent; `done` and `error` remain suppressed (US-12, US-25).
|
|
29
|
+
|
|
30
|
+
### Documented
|
|
31
|
+
|
|
32
|
+
- **README / CONFIGURATION:** per-slot `notify` semantics and provider-based defaults (US-34).
|
|
33
|
+
- **USER-STORIES:** US-34 added; US-12 and US-25 now record that a blocked subagent still notifies.
|
|
34
|
+
|
|
35
|
+
## [1.3.0-beta.7] — only a rate-limit reset speaks
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **The slot notice is now a rate-limit reset notice, not a generic "slot
|
|
40
|
+
ready".** It fires only when a slot that was cooling for a quota/rate-limit
|
|
41
|
+
reason becomes usable again — either because its window lapsed or a probe
|
|
42
|
+
found it answering. Outages, auth failures, missing models and other
|
|
43
|
+
non-quota cooldowns no longer raise it, which is what made it frequent. The
|
|
44
|
+
title is `LaCode: rate limit reset` and the message says the rate limit has
|
|
45
|
+
reset.
|
|
46
|
+
|
|
10
47
|
## [1.3.0-beta.6] — a recovered slot says it is ready
|
|
11
48
|
|
|
12
49
|
### Added
|
|
@@ -1060,7 +1097,8 @@ experimental; `latest` remains 0.4.2.
|
|
|
1060
1097
|
|
|
1061
1098
|
- First published release: pooled subagents over a shared set of models.
|
|
1062
1099
|
|
|
1063
|
-
[Unreleased]: https://github.com/lawaty/lacode/compare/v1.
|
|
1100
|
+
[Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.0-beta.8...HEAD
|
|
1101
|
+
[1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
|
|
1064
1102
|
[1.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.0
|
|
1065
1103
|
[1.2.0]: https://github.com/lawaty/lacode/compare/v1.1.0...v1.2.0
|
|
1066
1104
|
[1.1.0]: https://github.com/lawaty/lacode/compare/v1.0.0...v1.1.0
|
package/README.md
CHANGED
|
@@ -76,6 +76,10 @@ opencode agent list | grep -E '^(explore|implement|context)'
|
|
|
76
76
|
You should see `explore`, `implement` and `context`, each with hidden `-1`..`-4`
|
|
77
77
|
pool variants alongside. That is the whole install, and you are done.
|
|
78
78
|
|
|
79
|
+
Each pool slot controls the rate-limit-reset desktop notice: it is on by
|
|
80
|
+
default for `opencode` and `opencode-go` slots, and other providers need
|
|
81
|
+
`notify:on` in `pool.txt` or `"notify": true` in `pool.json` to opt in.
|
|
82
|
+
|
|
79
83
|
**If that prints nothing, the plugin did not load** — and opencode will not tell
|
|
80
84
|
you: it starts normally, logs nothing, and the agents are simply absent. A
|
|
81
85
|
plugin that fails to load is silent by design of the host, so this check is the
|
package/dist/agents.js
CHANGED
|
@@ -441,6 +441,48 @@ or search the web. Those tools are denied by configuration, not by convention.
|
|
|
441
441
|
Every question of the form "what does this file do", "where is X handled", or
|
|
442
442
|
"what does this error come from" belongs to an explorer, not to you.
|
|
443
443
|
|
|
444
|
+
# Delegate first \u2014 this is the default
|
|
445
|
+
|
|
446
|
+
Delegation is the default action, not an optimisation you reach for once a task
|
|
447
|
+
looks big. Before you call ANY tool on a turn that touches the repository,
|
|
448
|
+
apply this gate:
|
|
449
|
+
|
|
450
|
+
1. **Finding something out?** (read a file, grep, glob, list, inspect git
|
|
451
|
+
history, understand a call site, trace an error) \u2192 spawn \`@explore\`.
|
|
452
|
+
2. **Changing code?** \u2192 spawn \`@implement\` with one decided unit.
|
|
453
|
+
3. **Applying a patch or output a subagent returned, or verifying it?** (run
|
|
454
|
+
tests, typecheck, lint, build, git) \u2192 you may use \`edit\`/\`write\`/\`bash\`.
|
|
455
|
+
|
|
456
|
+
Only case 3 is yours. Cases 1 and 2 are delegations, and on a normal task the
|
|
457
|
+
very first action of the turn is a \`task\` spawn. A turn that inspects or
|
|
458
|
+
changes repository code without spawning first is a mistake, not a shortcut.
|
|
459
|
+
|
|
460
|
+
If you notice you have already inspected or edited directly: stop, say so, and
|
|
461
|
+
hand the remaining work to the right base. Do not keep going because the direct
|
|
462
|
+
call already happened, and do not "finish quickly" what should have been
|
|
463
|
+
delegated.
|
|
464
|
+
|
|
465
|
+
The direct call is tempting because it saves one round trip. It is also the
|
|
466
|
+
failure mode: it fills your context with source the user pays for again on every
|
|
467
|
+
later turn. A delegated read returns a conclusion; a direct read returns a
|
|
468
|
+
transcript.
|
|
469
|
+
|
|
470
|
+
# Hard rules
|
|
471
|
+
|
|
472
|
+
- The first repository action on a task is a \`task\` spawn \u2014 not \`read\`, \`grep\`,
|
|
473
|
+
\`glob\`, \`list\`, \`bash\`, \`edit\` or \`write\`.
|
|
474
|
+
- Never use bash to look. \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, \`grep\`, \`rg\`,
|
|
475
|
+
\`find\`, \`ls\`, \`tree\`, \`wc\` and friends are exploration, and exploration
|
|
476
|
+
belongs to \`@explore\`. Running them yourself is the exact behaviour this role
|
|
477
|
+
exists to prevent.
|
|
478
|
+
- Never make a code change yourself when \`@implement\` can make it. "It is only
|
|
479
|
+
one line" is not an exception; one line is the ideal size for a small ask.
|
|
480
|
+
- Every direct inspection or edit must be either a returned result you are
|
|
481
|
+
applying or verifying, or it must be named and justified in your next
|
|
482
|
+
message. An unexplained one is a violation of this rule.
|
|
483
|
+
- Subagents are not bound by this rule. \`@explore\`, \`@implement\` and \`@context\`
|
|
484
|
+
do the reading, grepping and mechanical edits.
|
|
485
|
+
|
|
444
486
|
# Delegation
|
|
445
487
|
|
|
446
488
|
- **Investigation \u2192 @explore.** Give it specific, focused instructions
|
|
@@ -470,17 +512,16 @@ to do the work yourself, and not a reason to stop and ask.
|
|
|
470
512
|
the same base again. If a spawn fails, do not switch to a different name \u2014 a
|
|
471
513
|
failure means the pool has a routing problem, not that you picked the wrong one.
|
|
472
514
|
|
|
473
|
-
Retry is always available
|
|
474
|
-
and never wrong, which is why it is the default. But
|
|
515
|
+
Retry is always available and never wrong, which is why it is the default. But
|
|
475
516
|
"never wrong" is not "sufficient": looping silently against a pool with every
|
|
476
517
|
model cooling goes nowhere and burns the slot doing it. So separate the two
|
|
477
518
|
failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
|
|
478
519
|
means retry. **Persistent or structural** \u2014 every slot cooling, the same spawn
|
|
479
520
|
failing repeatedly with the same error, a malformed model id \u2014 means stop
|
|
480
|
-
retrying and *report* it: say what failed (slot, model, error) and continue
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
521
|
+
retrying and *report* it: say what failed (slot, model, error) and continue with
|
|
522
|
+
whatever else the task needs, or ask the user. Surface the failure; do not spin
|
|
523
|
+
on it and do not let it end the whole task. Do not escalate to \`expert\` as a
|
|
524
|
+
workaround; it is opt-in by the user.
|
|
484
525
|
|
|
485
526
|
**Never fall back to reading, grepping, exploring or implementing on your own.**
|
|
486
527
|
Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
|
|
@@ -514,7 +555,8 @@ project. A cheap agent handed it will improvise a design, skip the verification
|
|
|
514
555
|
you never spelled out, and return something you cannot trust.
|
|
515
556
|
|
|
516
557
|
When the work does not divide into small asks it is not implementation work
|
|
517
|
-
yet \u2014
|
|
558
|
+
yet \u2014 use \`@explore\` to break it into a sequence of small asks, or ask the user
|
|
559
|
+
to run \`@plan\`. Do not do the large work yourself.
|
|
518
560
|
|
|
519
561
|
Split into one ask per task, spawn independent asks together in a single
|
|
520
562
|
message so they run in parallel, and sequence only what truly depends on what.
|
|
@@ -531,6 +573,9 @@ Do NOT use bash to inspect the codebase. \`cat\`, \`head\`, \`tail\`, \`sed\`, \
|
|
|
531
573
|
\`grep\`, \`find\`, \`ls\`, \`tree\` and friends are exploration, and exploration goes
|
|
532
574
|
to @explore. Bash is for executing and verifying, not for looking.
|
|
533
575
|
|
|
576
|
+
If a bash command does not run a test, typecheck, linter, build, or git
|
|
577
|
+
operation, assume it is exploration and delegate it.
|
|
578
|
+
|
|
534
579
|
# Repository context
|
|
535
580
|
|
|
536
581
|
Before delegating a non-trivial task, tell @explore to check
|
|
@@ -553,7 +598,8 @@ report instead of editing it yourself.
|
|
|
553
598
|
|
|
554
599
|
When you report back, say which explorer answered which question and name the
|
|
555
600
|
context file(s) that actually shaped the work (or state plainly that the map was
|
|
556
|
-
missing). Never paste context file contents into the report
|
|
601
|
+
missing). Never paste context file contents into the report.
|
|
602
|
+
`,
|
|
557
603
|
permission: {
|
|
558
604
|
"read": "deny",
|
|
559
605
|
"glob": "deny",
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,17 @@ var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
|
39
39
|
var MAX_WEIGHT = 100;
|
|
40
40
|
var slotCeiling = (slot) => slot.weight;
|
|
41
41
|
var slotBucket = (slot) => slot.bucket ?? slot.model;
|
|
42
|
+
var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
|
|
43
|
+
var notifyDefault = (model) => {
|
|
44
|
+
const slash = model.indexOf("/");
|
|
45
|
+
const provider = slash < 0 ? model : model.slice(0, slash);
|
|
46
|
+
return NOTIFY_DEFAULT_ON_PROVIDERS.has(provider);
|
|
47
|
+
};
|
|
48
|
+
var slotNotify = (slot) => typeof slot.notify === "boolean" ? slot.notify : notifyDefault(slot.model);
|
|
49
|
+
var announcesReset = (slots, model) => {
|
|
50
|
+
const matching = slots.filter((slot) => slot.model === model);
|
|
51
|
+
return matching.length > 0 ? matching.some((slot) => slotNotify(slot)) : notifyDefault(model);
|
|
52
|
+
};
|
|
42
53
|
var SLOTS_WARN_AT = 64;
|
|
43
54
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
44
55
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
@@ -67,6 +78,19 @@ var FALLBACK_SLOTS = [
|
|
|
67
78
|
{ index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
|
|
68
79
|
{ index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
|
|
69
80
|
];
|
|
81
|
+
var coerceNotify = (raw, at, model, problems) => {
|
|
82
|
+
if (raw === void 0) return notifyDefault(model);
|
|
83
|
+
if (typeof raw === "boolean") return raw;
|
|
84
|
+
if (typeof raw === "string") {
|
|
85
|
+
const s = raw.trim().toLowerCase();
|
|
86
|
+
if (s === "true" || s === "1" || s === "on" || s === "yes") return true;
|
|
87
|
+
if (s === "false" || s === "0" || s === "off" || s === "no") return false;
|
|
88
|
+
problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
|
|
89
|
+
return notifyDefault(model);
|
|
90
|
+
}
|
|
91
|
+
problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
|
|
92
|
+
return notifyDefault(model);
|
|
93
|
+
};
|
|
70
94
|
function parseSlots(parsed, origin) {
|
|
71
95
|
const problems = [];
|
|
72
96
|
const entries = parsed?.slots;
|
|
@@ -84,7 +108,8 @@ function parseSlots(parsed, origin) {
|
|
|
84
108
|
if (rawBucket !== void 0 && !bucket) {
|
|
85
109
|
problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
|
|
86
110
|
}
|
|
87
|
-
|
|
111
|
+
const notify = coerceNotify(entry.notify, at, model, problems);
|
|
112
|
+
slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
|
|
88
113
|
});
|
|
89
114
|
if (slots.length > SLOTS_WARN_AT) {
|
|
90
115
|
problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
|
|
@@ -103,14 +128,19 @@ function parseTextSlots(text, origin) {
|
|
|
103
128
|
const [model, ...rest] = line.split(/\s+/);
|
|
104
129
|
let weight;
|
|
105
130
|
let bucket;
|
|
131
|
+
let notify;
|
|
106
132
|
for (const token of rest) {
|
|
133
|
+
if (token.startsWith("notify:")) {
|
|
134
|
+
if (notify === void 0) notify = token.slice("notify:".length);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
107
137
|
if (token.startsWith("b:")) {
|
|
108
138
|
if (bucket === void 0) bucket = token.slice(2);
|
|
109
139
|
} else if (weight === void 0) {
|
|
110
140
|
weight = token;
|
|
111
141
|
}
|
|
112
142
|
}
|
|
113
|
-
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
|
|
143
|
+
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
|
|
114
144
|
}
|
|
115
145
|
return parseSlots({ slots: entries }, origin);
|
|
116
146
|
}
|
|
@@ -319,7 +349,7 @@ function ensureSlots(s, onWarn) {
|
|
|
319
349
|
for (const problem of problems) onWarn(problem);
|
|
320
350
|
return slots;
|
|
321
351
|
}
|
|
322
|
-
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
|
|
352
|
+
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}`).join("|")}`;
|
|
323
353
|
function reloadSlots(s, onWarn) {
|
|
324
354
|
const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
|
|
325
355
|
const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
|
|
@@ -1238,6 +1268,48 @@ or search the web. Those tools are denied by configuration, not by convention.
|
|
|
1238
1268
|
Every question of the form "what does this file do", "where is X handled", or
|
|
1239
1269
|
"what does this error come from" belongs to an explorer, not to you.
|
|
1240
1270
|
|
|
1271
|
+
# Delegate first \u2014 this is the default
|
|
1272
|
+
|
|
1273
|
+
Delegation is the default action, not an optimisation you reach for once a task
|
|
1274
|
+
looks big. Before you call ANY tool on a turn that touches the repository,
|
|
1275
|
+
apply this gate:
|
|
1276
|
+
|
|
1277
|
+
1. **Finding something out?** (read a file, grep, glob, list, inspect git
|
|
1278
|
+
history, understand a call site, trace an error) \u2192 spawn \`@explore\`.
|
|
1279
|
+
2. **Changing code?** \u2192 spawn \`@implement\` with one decided unit.
|
|
1280
|
+
3. **Applying a patch or output a subagent returned, or verifying it?** (run
|
|
1281
|
+
tests, typecheck, lint, build, git) \u2192 you may use \`edit\`/\`write\`/\`bash\`.
|
|
1282
|
+
|
|
1283
|
+
Only case 3 is yours. Cases 1 and 2 are delegations, and on a normal task the
|
|
1284
|
+
very first action of the turn is a \`task\` spawn. A turn that inspects or
|
|
1285
|
+
changes repository code without spawning first is a mistake, not a shortcut.
|
|
1286
|
+
|
|
1287
|
+
If you notice you have already inspected or edited directly: stop, say so, and
|
|
1288
|
+
hand the remaining work to the right base. Do not keep going because the direct
|
|
1289
|
+
call already happened, and do not "finish quickly" what should have been
|
|
1290
|
+
delegated.
|
|
1291
|
+
|
|
1292
|
+
The direct call is tempting because it saves one round trip. It is also the
|
|
1293
|
+
failure mode: it fills your context with source the user pays for again on every
|
|
1294
|
+
later turn. A delegated read returns a conclusion; a direct read returns a
|
|
1295
|
+
transcript.
|
|
1296
|
+
|
|
1297
|
+
# Hard rules
|
|
1298
|
+
|
|
1299
|
+
- The first repository action on a task is a \`task\` spawn \u2014 not \`read\`, \`grep\`,
|
|
1300
|
+
\`glob\`, \`list\`, \`bash\`, \`edit\` or \`write\`.
|
|
1301
|
+
- Never use bash to look. \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, \`grep\`, \`rg\`,
|
|
1302
|
+
\`find\`, \`ls\`, \`tree\`, \`wc\` and friends are exploration, and exploration
|
|
1303
|
+
belongs to \`@explore\`. Running them yourself is the exact behaviour this role
|
|
1304
|
+
exists to prevent.
|
|
1305
|
+
- Never make a code change yourself when \`@implement\` can make it. "It is only
|
|
1306
|
+
one line" is not an exception; one line is the ideal size for a small ask.
|
|
1307
|
+
- Every direct inspection or edit must be either a returned result you are
|
|
1308
|
+
applying or verifying, or it must be named and justified in your next
|
|
1309
|
+
message. An unexplained one is a violation of this rule.
|
|
1310
|
+
- Subagents are not bound by this rule. \`@explore\`, \`@implement\` and \`@context\`
|
|
1311
|
+
do the reading, grepping and mechanical edits.
|
|
1312
|
+
|
|
1241
1313
|
# Delegation
|
|
1242
1314
|
|
|
1243
1315
|
- **Investigation \u2192 @explore.** Give it specific, focused instructions
|
|
@@ -1267,17 +1339,16 @@ to do the work yourself, and not a reason to stop and ask.
|
|
|
1267
1339
|
the same base again. If a spawn fails, do not switch to a different name \u2014 a
|
|
1268
1340
|
failure means the pool has a routing problem, not that you picked the wrong one.
|
|
1269
1341
|
|
|
1270
|
-
Retry is always available
|
|
1271
|
-
and never wrong, which is why it is the default. But
|
|
1342
|
+
Retry is always available and never wrong, which is why it is the default. But
|
|
1272
1343
|
"never wrong" is not "sufficient": looping silently against a pool with every
|
|
1273
1344
|
model cooling goes nowhere and burns the slot doing it. So separate the two
|
|
1274
1345
|
failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
|
|
1275
1346
|
means retry. **Persistent or structural** \u2014 every slot cooling, the same spawn
|
|
1276
1347
|
failing repeatedly with the same error, a malformed model id \u2014 means stop
|
|
1277
|
-
retrying and *report* it: say what failed (slot, model, error) and continue
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1348
|
+
retrying and *report* it: say what failed (slot, model, error) and continue with
|
|
1349
|
+
whatever else the task needs, or ask the user. Surface the failure; do not spin
|
|
1350
|
+
on it and do not let it end the whole task. Do not escalate to \`expert\` as a
|
|
1351
|
+
workaround; it is opt-in by the user.
|
|
1281
1352
|
|
|
1282
1353
|
**Never fall back to reading, grepping, exploring or implementing on your own.**
|
|
1283
1354
|
Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
|
|
@@ -1311,7 +1382,8 @@ project. A cheap agent handed it will improvise a design, skip the verification
|
|
|
1311
1382
|
you never spelled out, and return something you cannot trust.
|
|
1312
1383
|
|
|
1313
1384
|
When the work does not divide into small asks it is not implementation work
|
|
1314
|
-
yet \u2014
|
|
1385
|
+
yet \u2014 use \`@explore\` to break it into a sequence of small asks, or ask the user
|
|
1386
|
+
to run \`@plan\`. Do not do the large work yourself.
|
|
1315
1387
|
|
|
1316
1388
|
Split into one ask per task, spawn independent asks together in a single
|
|
1317
1389
|
message so they run in parallel, and sequence only what truly depends on what.
|
|
@@ -1328,6 +1400,9 @@ Do NOT use bash to inspect the codebase. \`cat\`, \`head\`, \`tail\`, \`sed\`, \
|
|
|
1328
1400
|
\`grep\`, \`find\`, \`ls\`, \`tree\` and friends are exploration, and exploration goes
|
|
1329
1401
|
to @explore. Bash is for executing and verifying, not for looking.
|
|
1330
1402
|
|
|
1403
|
+
If a bash command does not run a test, typecheck, linter, build, or git
|
|
1404
|
+
operation, assume it is exploration and delegate it.
|
|
1405
|
+
|
|
1331
1406
|
# Repository context
|
|
1332
1407
|
|
|
1333
1408
|
Before delegating a non-trivial task, tell @explore to check
|
|
@@ -1350,7 +1425,8 @@ report instead of editing it yourself.
|
|
|
1350
1425
|
|
|
1351
1426
|
When you report back, say which explorer answered which question and name the
|
|
1352
1427
|
context file(s) that actually shaped the work (or state plainly that the map was
|
|
1353
|
-
missing). Never paste context file contents into the report
|
|
1428
|
+
missing). Never paste context file contents into the report.
|
|
1429
|
+
`,
|
|
1354
1430
|
permission: {
|
|
1355
1431
|
"read": "deny",
|
|
1356
1432
|
"glob": "deny",
|
|
@@ -2568,7 +2644,9 @@ var safeTitle = (raw, fallback = "session") => {
|
|
|
2568
2644
|
var shouldNotify = (input) => {
|
|
2569
2645
|
const { info, outcome, detail } = input;
|
|
2570
2646
|
if (!info?.id) return { notify: false, reason: "no session info" };
|
|
2571
|
-
if (info.parentID
|
|
2647
|
+
if (info.parentID && outcome !== "permission" && outcome !== "question") {
|
|
2648
|
+
return { notify: false, reason: "subagent" };
|
|
2649
|
+
}
|
|
2572
2650
|
const sessionTitle = safeTitle(info.title);
|
|
2573
2651
|
if (INTERNAL_TITLE_PREFIXES.some((prefix) => sessionTitle.startsWith(prefix))) {
|
|
2574
2652
|
return { notify: false, reason: "internal session" };
|
|
@@ -3033,6 +3111,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3033
3111
|
const pendingCancelledParts = /* @__PURE__ */ new Map();
|
|
3034
3112
|
const patchedCancelledParts = /* @__PURE__ */ new Set();
|
|
3035
3113
|
const cancelledPrefix = "Task Cancelled by LaCode:";
|
|
3114
|
+
const showAbortToast = async (report) => {
|
|
3115
|
+
const toast2 = client.tui?.showToast;
|
|
3116
|
+
if (typeof toast2 !== "function") return;
|
|
3117
|
+
try {
|
|
3118
|
+
const args = {
|
|
3119
|
+
body: { message: `${cancelledPrefix} ${report.message}`, variant: "warning", duration: 8e3 }
|
|
3120
|
+
};
|
|
3121
|
+
if (directory) args.query = { directory };
|
|
3122
|
+
await toast2(args);
|
|
3123
|
+
} catch {
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3036
3126
|
const patchCancelledPart = async (part, key, report) => {
|
|
3037
3127
|
if (patchedCancelledParts.has(key)) return;
|
|
3038
3128
|
patchedCancelledParts.add(key);
|
|
@@ -3082,6 +3172,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3082
3172
|
session: part.sessionID,
|
|
3083
3173
|
callID: part.callID
|
|
3084
3174
|
});
|
|
3175
|
+
await showAbortToast(report);
|
|
3085
3176
|
}
|
|
3086
3177
|
};
|
|
3087
3178
|
const noteAbort = async (key, report) => {
|
|
@@ -3300,16 +3391,17 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3300
3391
|
if (authBenched > 0) parts.push(`benched ${authBenched} auth-failed`);
|
|
3301
3392
|
return parts.join(", ");
|
|
3302
3393
|
};
|
|
3303
|
-
const
|
|
3394
|
+
const resetMessage = (models) => models.length === 1 ? `${models[0]} is available again \u2014 the rate limit has reset.` : `${models.length} slots are available again \u2014 their rate limits have reset: ${models.join(", ")}.`;
|
|
3304
3395
|
const announceReady = (models) => {
|
|
3305
|
-
const
|
|
3396
|
+
const wanted = models.filter((model) => announcesReset(s.slots, model));
|
|
3397
|
+
const fresh = wanted.filter((model) => claimDesktopNotice(model));
|
|
3306
3398
|
if (fresh.length === 0) return;
|
|
3307
3399
|
try {
|
|
3308
|
-
notify("LaCode:
|
|
3400
|
+
notify("LaCode: rate limit reset", resetMessage(fresh));
|
|
3309
3401
|
} catch {
|
|
3310
3402
|
}
|
|
3311
3403
|
};
|
|
3312
|
-
const announce = async (report) => {
|
|
3404
|
+
const announce = async (report, prior) => {
|
|
3313
3405
|
if (!report.changed) return;
|
|
3314
3406
|
const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
|
|
3315
3407
|
await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
|
|
@@ -3319,9 +3411,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3319
3411
|
});
|
|
3320
3412
|
} catch {
|
|
3321
3413
|
}
|
|
3322
|
-
|
|
3414
|
+
const resets = report.cleared.filter((model) => isQuotaReason(prior.get(model)?.reason ?? ""));
|
|
3415
|
+
if (resets.length > 0) announceReady(resets);
|
|
3323
3416
|
};
|
|
3324
3417
|
const runProbe = async (models) => {
|
|
3418
|
+
let prior = /* @__PURE__ */ new Map();
|
|
3419
|
+
try {
|
|
3420
|
+
prior = readLimits(s);
|
|
3421
|
+
} catch {
|
|
3422
|
+
}
|
|
3325
3423
|
let report = EMPTY_REPORT;
|
|
3326
3424
|
try {
|
|
3327
3425
|
report = await probeCooling(client, { state: s, directory, models });
|
|
@@ -3329,7 +3427,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3329
3427
|
await log("error", `pool probe failed: ${String(error)}`);
|
|
3330
3428
|
return EMPTY_REPORT;
|
|
3331
3429
|
}
|
|
3332
|
-
await announce(report);
|
|
3430
|
+
await announce(report, prior);
|
|
3333
3431
|
return report;
|
|
3334
3432
|
};
|
|
3335
3433
|
const probeMs = opts.probeMs ?? PROBE_INTERVAL_MS;
|
|
@@ -3358,13 +3456,13 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3358
3456
|
const back = [];
|
|
3359
3457
|
for (const [model, reason] of coolingSeen) {
|
|
3360
3458
|
if (still.has(model)) continue;
|
|
3361
|
-
if (reason
|
|
3459
|
+
if (!isQuotaReason(reason)) continue;
|
|
3362
3460
|
if (!providerReady(s, model)) continue;
|
|
3363
3461
|
back.push(model);
|
|
3364
3462
|
}
|
|
3365
3463
|
coolingSeen = still;
|
|
3366
3464
|
if (back.length > 0) {
|
|
3367
|
-
void log("info", `pool: ${
|
|
3465
|
+
void log("info", `pool: ${resetMessage(back)}`, { cleared: back });
|
|
3368
3466
|
announceReady(back);
|
|
3369
3467
|
}
|
|
3370
3468
|
}, expiryMs) : void 0;
|
|
@@ -3436,6 +3534,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3436
3534
|
);
|
|
3437
3535
|
};
|
|
3438
3536
|
const runValidation = async (models) => {
|
|
3537
|
+
let prior = /* @__PURE__ */ new Map();
|
|
3538
|
+
try {
|
|
3539
|
+
prior = readLimits(s);
|
|
3540
|
+
} catch {
|
|
3541
|
+
}
|
|
3439
3542
|
let report = EMPTY_REPORT;
|
|
3440
3543
|
try {
|
|
3441
3544
|
report = await probeModels(client, { state: s, directory, models });
|
|
@@ -3443,7 +3546,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3443
3546
|
await log("error", `pool validation failed: ${String(error)}`);
|
|
3444
3547
|
return EMPTY_REPORT;
|
|
3445
3548
|
}
|
|
3446
|
-
await announce(report);
|
|
3549
|
+
await announce(report, prior);
|
|
3447
3550
|
return report;
|
|
3448
3551
|
};
|
|
3449
3552
|
const watchMs = opts.watchMs ?? SLOT_WATCH_MS;
|
|
@@ -6416,6 +6519,147 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
6416
6519
|
};
|
|
6417
6520
|
};
|
|
6418
6521
|
|
|
6522
|
+
// src/lib/provider-compat.ts
|
|
6523
|
+
var WRAPPED = /* @__PURE__ */ Symbol.for("lacode.providerCompat");
|
|
6524
|
+
var REASONING_FIELDS = ["reasoning", "reasoning_content"];
|
|
6525
|
+
var asText = (value) => typeof value === "string" ? value : "";
|
|
6526
|
+
var itemText = (item) => {
|
|
6527
|
+
if (typeof item === "string") return item;
|
|
6528
|
+
if (item && typeof item === "object") return asText(item.text);
|
|
6529
|
+
return "";
|
|
6530
|
+
};
|
|
6531
|
+
function reasoningText(value) {
|
|
6532
|
+
if (typeof value === "string") return value;
|
|
6533
|
+
if (!value || typeof value !== "object") return "";
|
|
6534
|
+
const source = value;
|
|
6535
|
+
const direct = asText(source.text);
|
|
6536
|
+
if (direct) return direct;
|
|
6537
|
+
const summary = source.summary;
|
|
6538
|
+
if (typeof summary === "string") return summary;
|
|
6539
|
+
if (Array.isArray(summary)) return summary.map(itemText).join("");
|
|
6540
|
+
if (summary && typeof summary === "object") return asText(summary.text);
|
|
6541
|
+
const content = source.content;
|
|
6542
|
+
if (typeof content === "string") return content;
|
|
6543
|
+
if (Array.isArray(content)) return content.map(itemText).join("");
|
|
6544
|
+
return "";
|
|
6545
|
+
}
|
|
6546
|
+
function normalizeChunk(value) {
|
|
6547
|
+
if (!value || typeof value !== "object") return false;
|
|
6548
|
+
const choices = value.choices;
|
|
6549
|
+
if (!Array.isArray(choices)) return false;
|
|
6550
|
+
let changed = false;
|
|
6551
|
+
for (const choice of choices) {
|
|
6552
|
+
if (!choice || typeof choice !== "object") continue;
|
|
6553
|
+
for (const key of ["delta", "message"]) {
|
|
6554
|
+
const holder = choice[key];
|
|
6555
|
+
if (!holder || typeof holder !== "object") continue;
|
|
6556
|
+
for (const field of REASONING_FIELDS) {
|
|
6557
|
+
const current = holder[field];
|
|
6558
|
+
if (current === null || typeof current !== "object") continue;
|
|
6559
|
+
holder[field] = reasoningText(current);
|
|
6560
|
+
changed = true;
|
|
6561
|
+
}
|
|
6562
|
+
}
|
|
6563
|
+
}
|
|
6564
|
+
return changed;
|
|
6565
|
+
}
|
|
6566
|
+
function normalizeSseLine(line) {
|
|
6567
|
+
const trimmed = line.trim();
|
|
6568
|
+
if (!trimmed.startsWith("data:")) return line;
|
|
6569
|
+
const payload = trimmed.slice("data:".length).trim();
|
|
6570
|
+
if (!payload || payload === "[DONE]") return line;
|
|
6571
|
+
let parsed;
|
|
6572
|
+
try {
|
|
6573
|
+
parsed = JSON.parse(payload);
|
|
6574
|
+
} catch {
|
|
6575
|
+
return line;
|
|
6576
|
+
}
|
|
6577
|
+
if (!normalizeChunk(parsed)) return line;
|
|
6578
|
+
const newline = line.endsWith("\n") ? "\n" : "";
|
|
6579
|
+
return `data: ${JSON.stringify(parsed)}${newline}`;
|
|
6580
|
+
}
|
|
6581
|
+
function createChunkNormalizer() {
|
|
6582
|
+
const decoder = new TextDecoder();
|
|
6583
|
+
const encoder = new TextEncoder();
|
|
6584
|
+
let buffered = "";
|
|
6585
|
+
return new TransformStream({
|
|
6586
|
+
transform(chunk, controller) {
|
|
6587
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
6588
|
+
let newline = buffered.indexOf("\n");
|
|
6589
|
+
while (newline !== -1) {
|
|
6590
|
+
const line = buffered.slice(0, newline + 1);
|
|
6591
|
+
buffered = buffered.slice(newline + 1);
|
|
6592
|
+
controller.enqueue(encoder.encode(normalizeSseLine(line)));
|
|
6593
|
+
newline = buffered.indexOf("\n");
|
|
6594
|
+
}
|
|
6595
|
+
},
|
|
6596
|
+
flush(controller) {
|
|
6597
|
+
buffered += decoder.decode();
|
|
6598
|
+
if (buffered) controller.enqueue(encoder.encode(normalizeSseLine(buffered)));
|
|
6599
|
+
}
|
|
6600
|
+
});
|
|
6601
|
+
}
|
|
6602
|
+
function wrapFetch(original) {
|
|
6603
|
+
const wrapped = async (input, init2) => {
|
|
6604
|
+
const response = await original(input, init2);
|
|
6605
|
+
try {
|
|
6606
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
6607
|
+
if (!contentType.includes("text/event-stream") || !response.body) return response;
|
|
6608
|
+
const headers = new Headers(response.headers);
|
|
6609
|
+
headers.delete("content-length");
|
|
6610
|
+
headers.delete("content-encoding");
|
|
6611
|
+
headers.delete("transfer-encoding");
|
|
6612
|
+
return new Response(response.body.pipeThrough(createChunkNormalizer()), {
|
|
6613
|
+
status: response.status,
|
|
6614
|
+
statusText: response.statusText,
|
|
6615
|
+
headers
|
|
6616
|
+
});
|
|
6617
|
+
} catch {
|
|
6618
|
+
return response;
|
|
6619
|
+
}
|
|
6620
|
+
};
|
|
6621
|
+
wrapped[WRAPPED] = true;
|
|
6622
|
+
return wrapped;
|
|
6623
|
+
}
|
|
6624
|
+
function installProviderCompat() {
|
|
6625
|
+
const target = globalThis.fetch;
|
|
6626
|
+
if (typeof target !== "function") return () => {
|
|
6627
|
+
};
|
|
6628
|
+
if (target[WRAPPED]) return () => {
|
|
6629
|
+
};
|
|
6630
|
+
const wrapped = wrapFetch(target);
|
|
6631
|
+
globalThis.fetch = wrapped;
|
|
6632
|
+
return () => {
|
|
6633
|
+
if (globalThis.fetch === wrapped) {
|
|
6634
|
+
;
|
|
6635
|
+
globalThis.fetch = target;
|
|
6636
|
+
}
|
|
6637
|
+
};
|
|
6638
|
+
}
|
|
6639
|
+
|
|
6640
|
+
// src/plugins/provider-compat.ts
|
|
6641
|
+
var SERVICE5 = "provider-compat";
|
|
6642
|
+
var providerCompatHooks = async (input) => {
|
|
6643
|
+
const log = (level, message) => input.client.app.log({ body: { service: SERVICE5, level, message } }).catch(() => {
|
|
6644
|
+
});
|
|
6645
|
+
let restore = () => {
|
|
6646
|
+
};
|
|
6647
|
+
try {
|
|
6648
|
+
restore = installProviderCompat();
|
|
6649
|
+
await log("debug", "normalizing object-shaped reasoning in provider streams");
|
|
6650
|
+
} catch (error) {
|
|
6651
|
+
await log("warn", `provider compat unavailable: ${String(error)}`);
|
|
6652
|
+
}
|
|
6653
|
+
return {
|
|
6654
|
+
dispose: async () => {
|
|
6655
|
+
try {
|
|
6656
|
+
restore();
|
|
6657
|
+
} catch {
|
|
6658
|
+
}
|
|
6659
|
+
}
|
|
6660
|
+
};
|
|
6661
|
+
};
|
|
6662
|
+
|
|
6419
6663
|
// src/plugins/selfupdate.ts
|
|
6420
6664
|
async function selfUpdateHooks(input, options) {
|
|
6421
6665
|
const opts = options ?? {};
|
|
@@ -6808,9 +7052,9 @@ function timelineHooks(input, options) {
|
|
|
6808
7052
|
const opts = options ?? {};
|
|
6809
7053
|
const config = typeof opts.timeline === "object" && opts.timeline !== null ? opts.timeline : void 0;
|
|
6810
7054
|
const disabled = opts.timeline === false || config?.enabled === false || process.env.LACODE_TIMELINE === "0";
|
|
6811
|
-
const
|
|
7055
|
+
const SERVICE6 = "timeline";
|
|
6812
7056
|
const poolState = state(options);
|
|
6813
|
-
const log = (level, message, extra) => client.app.log({ body: { service:
|
|
7057
|
+
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE6, level, message, extra } }).catch(() => {
|
|
6814
7058
|
});
|
|
6815
7059
|
const debug = (...args) => {
|
|
6816
7060
|
if (process.env.LACODE_TIMELINE_DEBUG === "1") console.error("[timeline]", ...args);
|
|
@@ -7235,6 +7479,7 @@ var LaCode = (async (input, options) => {
|
|
|
7235
7479
|
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
7236
7480
|
const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
|
|
7237
7481
|
const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
|
|
7482
|
+
const compat = await settled("provider-compat", () => providerCompatHooks(input)) ?? inertHooks;
|
|
7238
7483
|
const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
|
|
7239
7484
|
const timeline = await settled("timeline", () => timelineHooks(input, options)) ?? inertHooks;
|
|
7240
7485
|
const config = async (cfg) => {
|
|
@@ -7281,6 +7526,7 @@ var LaCode = (async (input, options) => {
|
|
|
7281
7526
|
await selfupdate.dispose?.();
|
|
7282
7527
|
await feedback.dispose?.();
|
|
7283
7528
|
await omniroute.dispose?.();
|
|
7529
|
+
await compat.dispose?.();
|
|
7284
7530
|
await imagepaste.dispose?.();
|
|
7285
7531
|
await timeline.dispose?.();
|
|
7286
7532
|
},
|
package/dist/pool.js
CHANGED
|
@@ -39,6 +39,17 @@ var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
|
39
39
|
var MAX_WEIGHT = 100;
|
|
40
40
|
var slotCeiling = (slot) => slot.weight;
|
|
41
41
|
var slotBucket = (slot) => slot.bucket ?? slot.model;
|
|
42
|
+
var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
|
|
43
|
+
var notifyDefault = (model) => {
|
|
44
|
+
const slash = model.indexOf("/");
|
|
45
|
+
const provider = slash < 0 ? model : model.slice(0, slash);
|
|
46
|
+
return NOTIFY_DEFAULT_ON_PROVIDERS.has(provider);
|
|
47
|
+
};
|
|
48
|
+
var slotNotify = (slot) => typeof slot.notify === "boolean" ? slot.notify : notifyDefault(slot.model);
|
|
49
|
+
var announcesReset = (slots, model) => {
|
|
50
|
+
const matching = slots.filter((slot) => slot.model === model);
|
|
51
|
+
return matching.length > 0 ? matching.some((slot) => slotNotify(slot)) : notifyDefault(model);
|
|
52
|
+
};
|
|
42
53
|
var SLOTS_WARN_AT = 64;
|
|
43
54
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
44
55
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
@@ -67,6 +78,19 @@ var FALLBACK_SLOTS = [
|
|
|
67
78
|
{ index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
|
|
68
79
|
{ index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
|
|
69
80
|
];
|
|
81
|
+
var coerceNotify = (raw, at, model, problems) => {
|
|
82
|
+
if (raw === void 0) return notifyDefault(model);
|
|
83
|
+
if (typeof raw === "boolean") return raw;
|
|
84
|
+
if (typeof raw === "string") {
|
|
85
|
+
const s = raw.trim().toLowerCase();
|
|
86
|
+
if (s === "true" || s === "1" || s === "on" || s === "yes") return true;
|
|
87
|
+
if (s === "false" || s === "0" || s === "off" || s === "no") return false;
|
|
88
|
+
problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
|
|
89
|
+
return notifyDefault(model);
|
|
90
|
+
}
|
|
91
|
+
problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
|
|
92
|
+
return notifyDefault(model);
|
|
93
|
+
};
|
|
70
94
|
function parseSlots(parsed, origin) {
|
|
71
95
|
const problems = [];
|
|
72
96
|
const entries = parsed?.slots;
|
|
@@ -84,7 +108,8 @@ function parseSlots(parsed, origin) {
|
|
|
84
108
|
if (rawBucket !== void 0 && !bucket) {
|
|
85
109
|
problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
|
|
86
110
|
}
|
|
87
|
-
|
|
111
|
+
const notify = coerceNotify(entry.notify, at, model, problems);
|
|
112
|
+
slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
|
|
88
113
|
});
|
|
89
114
|
if (slots.length > SLOTS_WARN_AT) {
|
|
90
115
|
problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
|
|
@@ -103,14 +128,19 @@ function parseTextSlots(text, origin) {
|
|
|
103
128
|
const [model, ...rest] = line.split(/\s+/);
|
|
104
129
|
let weight;
|
|
105
130
|
let bucket;
|
|
131
|
+
let notify;
|
|
106
132
|
for (const token of rest) {
|
|
133
|
+
if (token.startsWith("notify:")) {
|
|
134
|
+
if (notify === void 0) notify = token.slice("notify:".length);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
107
137
|
if (token.startsWith("b:")) {
|
|
108
138
|
if (bucket === void 0) bucket = token.slice(2);
|
|
109
139
|
} else if (weight === void 0) {
|
|
110
140
|
weight = token;
|
|
111
141
|
}
|
|
112
142
|
}
|
|
113
|
-
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
|
|
143
|
+
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
|
|
114
144
|
}
|
|
115
145
|
return parseSlots({ slots: entries }, origin);
|
|
116
146
|
}
|
|
@@ -319,7 +349,7 @@ function ensureSlots(s, onWarn) {
|
|
|
319
349
|
for (const problem of problems) onWarn(problem);
|
|
320
350
|
return slots;
|
|
321
351
|
}
|
|
322
|
-
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
|
|
352
|
+
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}`).join("|")}`;
|
|
323
353
|
function reloadSlots(s, onWarn) {
|
|
324
354
|
const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
|
|
325
355
|
const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
|
|
@@ -885,6 +915,7 @@ export {
|
|
|
885
915
|
USER_MODELS_TXT,
|
|
886
916
|
acquire,
|
|
887
917
|
agentFor,
|
|
918
|
+
announcesReset,
|
|
888
919
|
backfillChild,
|
|
889
920
|
bindChild,
|
|
890
921
|
claimKey,
|
|
@@ -909,6 +940,7 @@ export {
|
|
|
909
940
|
manualVariant,
|
|
910
941
|
markProbe,
|
|
911
942
|
noteActivity,
|
|
943
|
+
notifyDefault,
|
|
912
944
|
parseSlotText,
|
|
913
945
|
parseSlots,
|
|
914
946
|
parseTextSlots,
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -7,7 +7,7 @@ The plugin options, where the model pool gets its slots, and exactly what LaCode
|
|
|
7
7
|
The slot list is the first of these that yields usable slots:
|
|
8
8
|
|
|
9
9
|
1. plugin options — `models` in the [tuple](../README.md#configure)
|
|
10
|
-
2. `~/.config/lacode/pool.txt` — flat `model [weight]` per line, preferred
|
|
10
|
+
2. `~/.config/lacode/pool.txt` — flat `model [weight] [b:<bucket>] [notify:on|off]` per line, preferred
|
|
11
11
|
3. `~/.config/lacode/pool.json` (its parent directory is created for you)
|
|
12
12
|
4. the bundled `presets/free-tier.json`
|
|
13
13
|
5. a built-in fallback, so routing always works
|
|
@@ -33,6 +33,21 @@ A slot may also declare a shared quota bucket with `b:<name>` on the line (or a
|
|
|
33
33
|
the sibling would spend the same exhausted window; without one, the model is its
|
|
34
34
|
own bucket. `pool_status` shows each shared bucket's effective capacity.
|
|
35
35
|
|
|
36
|
+
Each slot gets a `notify` flag that controls the desktop "rate-limit reset"
|
|
37
|
+
notice raised when a slot that was cooling for a quota/rate-limit reason becomes
|
|
38
|
+
usable again. The default is **provider-based**: on for `opencode` and
|
|
39
|
+
`opencode-go` slots, off for every other provider. On a `pool.txt` line, set
|
|
40
|
+
`notify:on` to opt a quiet provider's slot into the notice, or `notify:off` to
|
|
41
|
+
silence an `opencode`/`opencode-go` slot; `notify:on`/`notify:off` are accepted
|
|
42
|
+
explicitly. In `pool.json`, set `"notify": true` or `"notify": false`
|
|
43
|
+
(booleans; `on/off/1/0/yes/no` strings are also accepted). If the same model
|
|
44
|
+
appears on several lines, it is announced if **any** of its slots has `notify`
|
|
45
|
+
on — to fully silence a model, set `notify:off` on all of its lines. An
|
|
46
|
+
invalid `notify` value falls back to the provider default and produces a
|
|
47
|
+
warning. The flag gates only the desktop reset notice, not pool routing or
|
|
48
|
+
status, and pool files are hot-reloaded so changes take effect without a
|
|
49
|
+
restart.
|
|
50
|
+
|
|
36
51
|
|
|
37
52
|
## Which models run which agent
|
|
38
53
|
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -222,6 +222,7 @@ A limit does not always arrive as an error. Sometimes the provider accepts the r
|
|
|
222
222
|
**Story ID(s) for tests:** US-12
|
|
223
223
|
**Acceptance criteria:**
|
|
224
224
|
- [ ] Given a question is asked, When the event fires, Then a `question` notification is delivered that includes the question's text.
|
|
225
|
+
- [ ] Given a subagent session asks a question, When the question fires, Then the notification is still delivered, because the parent cannot proceed until the user answers and a blocked child is not background noise.
|
|
225
226
|
- [ ] Given a session has a parked question, When the idle event that follows fires, Then no duplicate "finished" notification is sent.
|
|
226
227
|
- [ ] Given a parked question is replied to, rejected, or the session errors, When that event fires, Then the pending entry is cleared and the next real completion notifies again.
|
|
227
228
|
- [ ] Given a question and a completion occur inside the same cooldown window, When both are evaluated, Then they are not deduplicated against each other.
|
|
@@ -232,6 +233,7 @@ A limit does not always arrive as an error. Sometimes the provider accepts the r
|
|
|
232
233
|
**Story ID(s) for tests:** US-25
|
|
233
234
|
**Acceptance criteria:**
|
|
234
235
|
- [ ] Given a permission is requested, When the `permission.asked` event fires, Then exactly one notification is delivered naming the permission type and the path or pattern being requested.
|
|
236
|
+
- [ ] Given a subagent session asks for permission, When the prompt fires, Then the notification is still delivered, because the parent cannot proceed until the user answers and a suppressed subagent prompt is the silent stall this story removes.
|
|
235
237
|
- [ ] Given opencode asks to reach a directory outside the worktree, When the prompt fires, Then the notification names that directory, resolved from `metadata.parentDir`/`metadata.filepath` and with the trailing glob trimmed, because a glob is not something the user can judge.
|
|
236
238
|
- [ ] Given the permission is granted, denied, or dismissed, When `permission.replied` fires, Then no notification is sent — the prompt was the thing worth interrupting for, not the answer — and only the parked-session block is cleared.
|
|
237
239
|
- [ ] Given a session is parked on a permission prompt, When the idle event fires, Then no "finished" notification is sent, because nothing finished.
|
|
@@ -272,6 +274,21 @@ The popup belongs to its session: working in one chat closes that chat's popup a
|
|
|
272
274
|
- [ ] Given a permission popup is closed this way, When the permission was already answered in the terminal, Then nothing is answered twice: the close takes the popup down and answers nothing.
|
|
273
275
|
- [ ] Given the notification was delivered by a session on a remote host and reached the desktop over the tunnel, When the user works in that chat, Then the popup on the desktop closes too, because the popup lives on the desktop and only the desktop knows its notification id.
|
|
274
276
|
|
|
277
|
+
### US-34: A recovered slot announces itself once, and each slot can stay quiet
|
|
278
|
+
**As a** user running a pool whose slots are rate-limited at different times, **I want** a slot that becomes usable again to raise one notice — and to be able to silence that notice for individual slots, **so that** I hear about capacity coming back without being told about slots I do not care about.
|
|
279
|
+
|
|
280
|
+
A cooldown that was a rate limit is the one kind of recovery worth announcing: when its window lapses or a probe finds the model answering, the capacity is genuinely back. An outage, an auth failure or a missing model is not fixed by waiting, and announcing those would be noise. Several opencode processes share one pool, so the notice is claimed per model and spoken once. The notice follows the provider: `opencode` and `opencode-go` slots announce by default, every other provider is quiet, and any slot can override either way with `notify:on`/`notify:off` (or `"notify": true`/`"notify": false`). The notice names a model, so a model declared on several lines is announced if any of its lines allows it.
|
|
281
|
+
|
|
282
|
+
**Story ID(s) for tests:** US-34
|
|
283
|
+
**Acceptance criteria:**
|
|
284
|
+
- [ ] Given a slot was cooling for a quota/rate-limit reason, When its window lapses or a probe finds it answering, Then one desktop notice with the title `LaCode: rate limit reset` names the recovered model.
|
|
285
|
+
- [ ] Given a slot was cooling for an auth failure, a missing model or a short outage, When that cooldown ends, Then no reset notice is raised, because waiting does not fix those and the alert would be noise.
|
|
286
|
+
- [ ] Given the same pool is shared by several opencode processes, When a slot recovers, Then exactly one process speaks, and the others stay silent.
|
|
287
|
+
- [ ] Given a slot's line sets `notify:off` (or its JSON entry sets `"notify": false`), When that slot recovers, Then no reset notice is raised for it and no cross-process claim is consumed, while other enabled slots still announce.
|
|
288
|
+
- [ ] Given a model appears on several lines, When it recovers, Then it is announced if any of its lines has `notify` on, and is silent only when every line opts out.
|
|
289
|
+
- [ ] Given a slot sets no notify, When it recovers, Then the notice is raised for an opencode or opencode-go slot and not raised for any other provider, because those providers stay quiet unless asked.
|
|
290
|
+
- [ ] Given `notify` is changed in a watched pool file, When the file is saved, Then the new setting applies without a restart.
|
|
291
|
+
|
|
275
292
|
---
|
|
276
293
|
|
|
277
294
|
## Epic E — Remote hosts over SSH
|
|
@@ -523,6 +540,7 @@ table was filled in.
|
|
|
523
540
|
| US-25 | D | `tests/stories/d-notifications.test.mjs` |
|
|
524
541
|
| US-29 | D | `tests/stories/d-notifications.test.mjs` |
|
|
525
542
|
| US-30 | D | `tests/stories/d-notifications.test.mjs`, `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
543
|
+
| US-34 | D | `tests/pool-models-test.mjs`, `tests/pool-recheck-test.mjs` |
|
|
526
544
|
| US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
|
|
527
545
|
| US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
528
546
|
| US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "1.3.0-beta.
|
|
3
|
+
"version": "1.3.0-beta.8",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"scripts": {
|
|
56
56
|
"build": "node scripts/build.mjs",
|
|
57
57
|
"prepack": "npm run build",
|
|
58
|
-
"postpublish": "node scripts/sync-hosts.mjs",
|
|
58
|
+
"postpublish": "node scripts/sync-hosts.mjs && ./bin/oc-sync",
|
|
59
59
|
"test": "node --test tests/",
|
|
60
60
|
"benchmark": "node tests/benchmark/run.mjs",
|
|
61
61
|
"score": "node scripts/score-models.mjs"
|
package/scripts/score-models.mjs
CHANGED
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
// * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
|
|
25
25
|
// pool stays yours to decide.
|
|
26
26
|
// * It never probes a paid model. Only cost 0/0 models from the provider's
|
|
27
|
-
// catalog are candidates.
|
|
27
|
+
// catalog are candidates (non-chat/media/safety/meta-router models are skipped).
|
|
28
28
|
//
|
|
29
29
|
// OUTPUT
|
|
30
30
|
// a per-provider ranked table, an overall ranking, a suggested pool.txt block,
|
|
31
31
|
// and (unless --no-write) the raw rows in ~/.config/lacode/model-scores.json so
|
|
32
32
|
// a later run can compare.
|
|
33
33
|
|
|
34
|
-
import { execFileSync } from "node:child_process"
|
|
34
|
+
import { execFileSync, spawn } from "node:child_process"
|
|
35
35
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
36
36
|
import { homedir } from "node:os"
|
|
37
37
|
import { dirname, join } from "node:path"
|
|
@@ -39,6 +39,7 @@ import { dirname, join } from "node:path"
|
|
|
39
39
|
const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
|
|
40
40
|
const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
|
|
41
41
|
const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
|
|
42
|
+
const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
42
43
|
|
|
43
44
|
// ── scoring weights ─────────────────────────────────────────────────────────
|
|
44
45
|
// Mechanical work prizes speed and availability over raw capability. Context is
|
|
@@ -59,8 +60,8 @@ const value = (flag, fallback) => {
|
|
|
59
60
|
return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
if (has("--help") || has("-h")) {
|
|
63
|
-
|
|
63
|
+
if (has("--help") || has("-h")) {
|
|
64
|
+
console.log(`Usage: npm run score -- [options]
|
|
64
65
|
|
|
65
66
|
--providers a,b providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
|
|
66
67
|
--max N probe at most N free models per provider (default: 12)
|
|
@@ -69,11 +70,14 @@ if (has("--help") || has("-h")) {
|
|
|
69
70
|
--no-write do not write ~/.config/lacode/model-scores.json
|
|
70
71
|
-h, --help this message
|
|
71
72
|
|
|
72
|
-
Only cost 0/0 models are candidates
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
Only cost 0/0 concrete text chat/completion models are candidates
|
|
74
|
+
(non-chat/media generators, safety classifiers, and OpenRouter meta-routers are skipped).
|
|
75
|
+
Credentials come from ~/.local/share/opencode/auth.json; a provider with no
|
|
76
|
+
entry falls back to the "opencode" key (which is how the console-openrouter
|
|
77
|
+
connection authenticates).
|
|
78
|
+
opencode/opencode-go models are probed through a temporary local opencode serve --pure session, while the other providers are probed directly.`)
|
|
79
|
+
process.exit(0)
|
|
80
|
+
}
|
|
77
81
|
|
|
78
82
|
const providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
|
|
79
83
|
const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
|
|
@@ -119,6 +123,19 @@ function parseVerbose(text) {
|
|
|
119
123
|
return rows
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
/** True for concrete text-in/text-out chat models, not media generators, classifiers, or OpenRouter meta-routers. */
|
|
127
|
+
function isChatModel(model) {
|
|
128
|
+
const input = model?.capabilities?.input
|
|
129
|
+
const output = model?.capabilities?.output
|
|
130
|
+
if (input?.text === false) return false
|
|
131
|
+
if (output?.text === false) return false
|
|
132
|
+
if (output?.audio || output?.image || output?.video || output?.pdf) return false
|
|
133
|
+
const apiId = String(model?.api?.id ?? "").toLowerCase()
|
|
134
|
+
if (apiId.startsWith("openrouter/")) return false
|
|
135
|
+
if (/content[-_]safety|moderation|classifier/.test(apiId)) return false
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
function freeModels(provider) {
|
|
123
140
|
let text
|
|
124
141
|
try {
|
|
@@ -135,6 +152,7 @@ function freeModels(provider) {
|
|
|
135
152
|
.filter(({ model }) => model?.api?.url && model?.api?.id)
|
|
136
153
|
.filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
|
|
137
154
|
.filter(({ model }) => !model.status || model.status === "active")
|
|
155
|
+
.filter(({ model }) => isChatModel(model))
|
|
138
156
|
.map(({ id, model }) => ({
|
|
139
157
|
model: id,
|
|
140
158
|
url: model.api.url,
|
|
@@ -166,11 +184,104 @@ const sessionHeader = (provider) =>
|
|
|
166
184
|
// ── probing ─────────────────────────────────────────────────────────────────
|
|
167
185
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
168
186
|
|
|
187
|
+
function failureText(error) {
|
|
188
|
+
if (!error) return "error"
|
|
189
|
+
if (typeof error === "string") return error
|
|
190
|
+
if (error?.data?.message) return error.data.message
|
|
191
|
+
if (error?.message) return error.message
|
|
192
|
+
if (error?.name) return error.name
|
|
193
|
+
try {
|
|
194
|
+
return JSON.stringify(error).slice(0, 240)
|
|
195
|
+
} catch {
|
|
196
|
+
return "error"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let sessionServerPromise = null
|
|
201
|
+
let sessionServerProc = null
|
|
202
|
+
|
|
203
|
+
function startSessionServer() {
|
|
204
|
+
if (sessionServerPromise) return sessionServerPromise
|
|
205
|
+
sessionServerPromise = new Promise((resolve, reject) => {
|
|
206
|
+
let resolved = false
|
|
207
|
+
let output = ""
|
|
208
|
+
let proc
|
|
209
|
+
try {
|
|
210
|
+
proc = spawn("opencode", ["serve", "--hostname=127.0.0.1", "--port=0", "--pure"], {
|
|
211
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
212
|
+
})
|
|
213
|
+
} catch (error) {
|
|
214
|
+
sessionServerPromise = null
|
|
215
|
+
sessionServerProc = null
|
|
216
|
+
reject(error)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
sessionServerProc = proc
|
|
220
|
+
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
if (resolved) return
|
|
223
|
+
resolved = true
|
|
224
|
+
try {
|
|
225
|
+
proc.kill()
|
|
226
|
+
} catch {}
|
|
227
|
+
sessionServerPromise = null
|
|
228
|
+
sessionServerProc = null
|
|
229
|
+
reject(new Error("opencode serve timeout"))
|
|
230
|
+
}, 15000)
|
|
231
|
+
|
|
232
|
+
const onData = (chunk) => {
|
|
233
|
+
if (resolved) return
|
|
234
|
+
output += chunk.toString()
|
|
235
|
+
const match = output.match(/opencode server listening on (https?:\/\/\S+)/)
|
|
236
|
+
if (match) {
|
|
237
|
+
resolved = true
|
|
238
|
+
clearTimeout(timer)
|
|
239
|
+
const url = match[1].replace(/\/+$/, "")
|
|
240
|
+
resolve({ url, proc })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
proc.stdout.on("data", onData)
|
|
244
|
+
proc.stderr.on("data", onData)
|
|
245
|
+
|
|
246
|
+
proc.on("error", (error) => {
|
|
247
|
+
if (resolved) return
|
|
248
|
+
resolved = true
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
sessionServerPromise = null
|
|
251
|
+
sessionServerProc = null
|
|
252
|
+
reject(error)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
proc.on("exit", () => {
|
|
256
|
+
if (sessionServerProc === proc) {
|
|
257
|
+
sessionServerProc = null
|
|
258
|
+
sessionServerPromise = null
|
|
259
|
+
}
|
|
260
|
+
if (resolved) return
|
|
261
|
+
resolved = true
|
|
262
|
+
clearTimeout(timer)
|
|
263
|
+
reject(new Error("opencode serve exited before ready"))
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
return sessionServerPromise
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function stopSessionServer() {
|
|
270
|
+
if (sessionServerProc) {
|
|
271
|
+
try {
|
|
272
|
+
sessionServerProc.kill()
|
|
273
|
+
} catch {}
|
|
274
|
+
sessionServerProc = null
|
|
275
|
+
}
|
|
276
|
+
sessionServerPromise = null
|
|
277
|
+
}
|
|
278
|
+
process.on("exit", stopSessionServer)
|
|
279
|
+
|
|
169
280
|
/**
|
|
170
281
|
* One real request. Returns {ok, ms, reason}. A failure is named, never rounded
|
|
171
282
|
* into a success, and `ms` is only reported for a request that actually ran.
|
|
172
283
|
*/
|
|
173
|
-
async function
|
|
284
|
+
async function probeDirect(candidate, provider) {
|
|
174
285
|
const key = keyFor(provider)
|
|
175
286
|
if (!key) return { ok: false, ms: null, reason: "no credential in auth.json" }
|
|
176
287
|
|
|
@@ -226,6 +337,84 @@ async function probe(candidate, provider) {
|
|
|
226
337
|
}
|
|
227
338
|
}
|
|
228
339
|
|
|
340
|
+
async function probeViaSession(candidate, provider) {
|
|
341
|
+
const started = performance.now()
|
|
342
|
+
let promptStarted = null
|
|
343
|
+
const directory = encodeURIComponent(process.cwd())
|
|
344
|
+
let id = null
|
|
345
|
+
let server = null
|
|
346
|
+
try {
|
|
347
|
+
server = await startSessionServer()
|
|
348
|
+
const createRes = await fetch(`${server.url}/session?directory=${directory}`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { "Content-Type": "application/json" },
|
|
351
|
+
body: JSON.stringify({ title: `score probe ${candidate.model}` }),
|
|
352
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
353
|
+
})
|
|
354
|
+
const createText = await createRes.text()
|
|
355
|
+
let createdJson = null
|
|
356
|
+
try {
|
|
357
|
+
createdJson = JSON.parse(createText)
|
|
358
|
+
} catch {}
|
|
359
|
+
if (!createRes.ok) {
|
|
360
|
+
const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
|
|
361
|
+
const ms = Math.round(performance.now() - started)
|
|
362
|
+
return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}` }
|
|
363
|
+
}
|
|
364
|
+
id = createdJson?.id ?? createdJson?.data?.id ?? null
|
|
365
|
+
if (!id) {
|
|
366
|
+
const ms = Math.round(performance.now() - started)
|
|
367
|
+
return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}` }
|
|
368
|
+
}
|
|
369
|
+
promptStarted = performance.now()
|
|
370
|
+
const msgRes = await fetch(`${server.url}/session/${id}/message?directory=${directory}`, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: { "Content-Type": "application/json" },
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
model: { providerID: provider, modelID: candidate.apiId },
|
|
375
|
+
system: "You are a connection health check. Reply with the single word: ok",
|
|
376
|
+
parts: [{ type: "text", text: "ok" }],
|
|
377
|
+
}),
|
|
378
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
379
|
+
})
|
|
380
|
+
const msgText = await msgRes.text()
|
|
381
|
+
let parsed = null
|
|
382
|
+
try {
|
|
383
|
+
parsed = JSON.parse(msgText)
|
|
384
|
+
} catch {}
|
|
385
|
+
const ms = Math.round(performance.now() - (promptStarted ?? started))
|
|
386
|
+
if (!msgRes.ok) {
|
|
387
|
+
const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
|
|
388
|
+
return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}` }
|
|
389
|
+
}
|
|
390
|
+
const failure = parsed?.error ?? parsed?.info?.error
|
|
391
|
+
if (failure) {
|
|
392
|
+
return { ok: false, ms, reason: failureText(failure).slice(0, 240) }
|
|
393
|
+
}
|
|
394
|
+
if (!parsed?.info) {
|
|
395
|
+
return { ok: false, ms, reason: "no response" }
|
|
396
|
+
}
|
|
397
|
+
return { ok: true, ms, reason: "answered" }
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const ms = Math.round(performance.now() - (promptStarted ?? started))
|
|
400
|
+
if (error?.name === "TimeoutError") {
|
|
401
|
+
return { ok: false, ms, reason: `timeout after ${timeoutMs}ms` }
|
|
402
|
+
}
|
|
403
|
+
return { ok: false, ms, reason: failureText(error).slice(0, 240) }
|
|
404
|
+
} finally {
|
|
405
|
+
if (id && server) {
|
|
406
|
+
try {
|
|
407
|
+
await fetch(`${server.url}/session/${id}?directory=${directory}`, { method: "DELETE" })
|
|
408
|
+
} catch {}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function probe(candidate, provider) {
|
|
414
|
+
if (SESSION_PROVIDERS.has(provider)) return probeViaSession(candidate, provider)
|
|
415
|
+
return probeDirect(candidate, provider)
|
|
416
|
+
}
|
|
417
|
+
|
|
229
418
|
// ── scoring ─────────────────────────────────────────────────────────────────
|
|
230
419
|
const clamp01 = (n) => Math.max(0, Math.min(1, n))
|
|
231
420
|
const logScore = (n, max) => (n && n > 0 ? clamp01(Math.log2(1 + n) / Math.log2(1 + max)) : 0)
|
|
@@ -274,6 +463,7 @@ async function main() {
|
|
|
274
463
|
}
|
|
275
464
|
results.push({ provider: entry.provider, error: null, rows })
|
|
276
465
|
}
|
|
466
|
+
stopSessionServer()
|
|
277
467
|
|
|
278
468
|
// Per-provider ranked tables.
|
|
279
469
|
for (const entry of results) {
|