@lawaty/lacode 0.11.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +63 -29
- package/dist/agents.js +24 -16
- package/dist/commands.js +11 -3
- package/dist/index.js +567 -106
- package/dist/lacode.js +4 -4
- package/dist/pool.js +14 -38
- package/dist/tunnel.js +1 -1
- package/docs/CONFIGURATION.md +2 -2
- package/docs/USER-STORIES.md +15 -16
- package/package.json +3 -3
- package/presets/README.md +22 -24
- package/presets/free-tier.json +24 -40
package/dist/index.js
CHANGED
|
@@ -626,48 +626,24 @@ function snapshot(s) {
|
|
|
626
626
|
}
|
|
627
627
|
var loadOf = (counts, model) => counts.get(model) ?? 0;
|
|
628
628
|
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
|
|
629
|
-
function
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
function readCursor(s) {
|
|
636
|
-
try {
|
|
637
|
-
const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
|
|
638
|
-
if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
|
|
639
|
-
} catch {
|
|
629
|
+
function pickSlot(s, snap) {
|
|
630
|
+
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
631
|
+
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
632
|
+
const pool = usable.length > 0 ? usable : slots;
|
|
633
|
+
for (const slot of usable) {
|
|
634
|
+
if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
|
|
640
635
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
function writeCursor(s, seq) {
|
|
644
|
-
if (!s.dirReady) return;
|
|
645
|
-
const path4 = join2(s.dir, CURSOR_FILE);
|
|
646
|
-
try {
|
|
647
|
-
writeFileSync(`${path4}.tmp`, JSON.stringify({ seq }));
|
|
648
|
-
renameSync(`${path4}.tmp`, path4);
|
|
649
|
-
} catch {
|
|
636
|
+
for (const slot of usable) {
|
|
637
|
+
if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
|
|
650
638
|
}
|
|
639
|
+
return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
|
|
651
640
|
}
|
|
652
|
-
function
|
|
653
|
-
const advance = options?.advance !== false;
|
|
641
|
+
function poolPass(s, snap) {
|
|
654
642
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
655
|
-
const seq = roundRobinSequence(slots);
|
|
656
|
-
if (seq.length > 0) {
|
|
657
|
-
const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
|
|
658
|
-
const start = readCursor(s) % seq.length;
|
|
659
|
-
for (let step = 0; step < seq.length; step++) {
|
|
660
|
-
const at = (start + step) % seq.length;
|
|
661
|
-
const slot = byIndex.get(seq[at]);
|
|
662
|
-
if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
|
|
663
|
-
if (advance) writeCursor(s, at + 1);
|
|
664
|
-
return slot;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
643
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
669
|
-
|
|
670
|
-
|
|
644
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
|
|
645
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
|
|
646
|
+
return "overflow";
|
|
671
647
|
}
|
|
672
648
|
function agentFor(base, slot) {
|
|
673
649
|
return `${base}-${slot}`;
|
|
@@ -802,9 +778,12 @@ Every question of the form "what does this file do", "where is X handled", or
|
|
|
802
778
|
that proves it. Never one whole refactor handed over in a single piece \u2014
|
|
803
779
|
see \`# Task size\` below.
|
|
804
780
|
|
|
805
|
-
Spawn the
|
|
806
|
-
|
|
807
|
-
|
|
781
|
+
Spawn the pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The plugin decides
|
|
782
|
+
which model runs the work; do not try to choose one yourself.
|
|
783
|
+
|
|
784
|
+
\`expert\` is not part of the pool and is expensive: spawn it only when the user
|
|
785
|
+
explicitly asks for it. Never escalate to it on your own, and never as a
|
|
786
|
+
fallback when a pooled spawn fails.
|
|
808
787
|
|
|
809
788
|
# When the pool is unavailable
|
|
810
789
|
|
|
@@ -813,12 +792,12 @@ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeate
|
|
|
813
792
|
failing, or a runtime message saying no slot is available \u2014 that is not a reason
|
|
814
793
|
to do the work yourself, and not a reason to stop and ask.
|
|
815
794
|
|
|
816
|
-
**Retry.** A
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
picked the wrong number.
|
|
795
|
+
**Retry.** A cooling model is transient and costs you nothing but a turn. Spawn
|
|
796
|
+
the same base again. If a spawn fails, do not switch to a different name \u2014 a
|
|
797
|
+
failure means the pool has a routing problem, not that you picked the wrong one.
|
|
820
798
|
|
|
821
|
-
Retry is always available
|
|
799
|
+
Retry is always available
|
|
800
|
+
and never wrong, which is why it is the default. But
|
|
822
801
|
"never wrong" is not "sufficient": looping silently against a pool with every
|
|
823
802
|
model cooling goes nowhere and burns the slot doing it. So separate the two
|
|
824
803
|
failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
|
|
@@ -826,7 +805,8 @@ means retry. **Persistent or structural** \u2014 every slot cooling, the same sp
|
|
|
826
805
|
failing repeatedly with the same error, a malformed model id \u2014 means stop
|
|
827
806
|
retrying and *report* it: say what failed (slot, model, error) and continue
|
|
828
807
|
with whatever else the task needs, or ask the user. Surface the failure; do not
|
|
829
|
-
spin on it and do not let it end the whole task.
|
|
808
|
+
spin on it and do not let it end the whole task. Do not escalate to \`expert\` as
|
|
809
|
+
a workaround; it is opt-in by the user.
|
|
830
810
|
|
|
831
811
|
**Never fall back to reading, grepping, exploring or implementing on your own.**
|
|
832
812
|
Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
|
|
@@ -943,8 +923,11 @@ When you need to understand the codebase:
|
|
|
943
923
|
2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
|
|
944
924
|
3. @explore will return its findings \u2014 you process them and plan accordingly
|
|
945
925
|
|
|
946
|
-
Spawn the
|
|
947
|
-
|
|
926
|
+
Spawn the pooled base (\`explore\`, \`implement\`, \`context\`); the plugin decides
|
|
927
|
+
which model runs the work. Do not try to choose one yourself.
|
|
928
|
+
|
|
929
|
+
\`expert\` is not part of the pool and is expensive: spawn it only when the user
|
|
930
|
+
explicitly asks for it.
|
|
948
931
|
|
|
949
932
|
If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
|
|
950
933
|
failures \u2014 do NOT fall back to reading or planning from your own inspection.
|
|
@@ -1272,7 +1255,7 @@ Trigger: \`.opencode/context/\` exists and work has just been done here.
|
|
|
1272
1255
|
- an architectural decision
|
|
1273
1256
|
- a new dependency between subsystems
|
|
1274
1257
|
- a renamed or relocated subsystem
|
|
1275
|
-
|
|
1258
|
+
- an invariant not yet recorded in the map
|
|
1276
1259
|
|
|
1277
1260
|
Do not update documentation merely because source files changed. Do not
|
|
1278
1261
|
rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
|
|
@@ -1578,7 +1561,7 @@ function buildAgentSet() {
|
|
|
1578
1561
|
for (let slot = 1; slot <= MAX_SLOTS; slot++) {
|
|
1579
1562
|
const variant = {
|
|
1580
1563
|
...def,
|
|
1581
|
-
description: `Hidden pool
|
|
1564
|
+
description: `Hidden pool slot ${slot} for ${base}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
|
|
1582
1565
|
hidden: true,
|
|
1583
1566
|
// `mode: "primary"` is what keeps a variant OUT of the model's system
|
|
1584
1567
|
// prompt. `hidden` cannot do it.
|
|
@@ -1590,9 +1573,10 @@ function buildAgentSet() {
|
|
|
1590
1573
|
// and by default-agent resolution. A variant left as `mode: "subagent"`
|
|
1591
1574
|
// is therefore listed to the model under its own name -- "explore-1:
|
|
1592
1575
|
// Hidden pool variant explore-1" -- and the model copies that name
|
|
1593
|
-
// straight into its next spawn. The
|
|
1594
|
-
// naming
|
|
1595
|
-
//
|
|
1576
|
+
// straight into its next spawn. The task hook coerces a hand-named
|
|
1577
|
+
// variant back to its base, because naming one is how a caller would
|
|
1578
|
+
// pick the provider and skip ordered slot fill and the slot ceiling. The coercion is right; the
|
|
1579
|
+
// leak that provokes it is not.
|
|
1596
1580
|
//
|
|
1597
1581
|
// `mode` is the only field that filter respects, and setting it here is
|
|
1598
1582
|
// safe: the task tool never reads an agent's mode -- it resolves the
|
|
@@ -1666,7 +1650,7 @@ context file only if this work introduced or revealed information that will
|
|
|
1666
1650
|
materially help future agents \u2014 a new boundary or module, a new or moved
|
|
1667
1651
|
canonical entry point, a changed workflow, a new convention, an architectural
|
|
1668
1652
|
decision, a new dependency between subsystems, a renamed or moved subsystem,
|
|
1669
|
-
or
|
|
1653
|
+
or an invariant not yet recorded.
|
|
1670
1654
|
|
|
1671
1655
|
Do not edit documentation just because source files changed. No
|
|
1672
1656
|
timestamp-only edits. If nothing material changed, make no edits and report
|
|
@@ -1753,11 +1737,11 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
|
|
|
1753
1737
|
2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
|
|
1754
1738
|
- one slot per line: \`<provider>/<model-id> [weight]\`
|
|
1755
1739
|
- at most ${MAX_SLOTS} slots, and a model may appear only once
|
|
1756
|
-
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's
|
|
1740
|
+
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget for one pass \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
|
|
1757
1741
|
- \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
|
|
1758
1742
|
If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
|
|
1759
1743
|
|
|
1760
|
-
3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers
|
|
1744
|
+
3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers slots. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
|
|
1761
1745
|
|
|
1762
1746
|
4. Report the before \u2192 after slot table and the resolved path. The running process watches the pool files: it reloads the table, re-binds the variant agents and validates every slot, so no restart is needed. If a slot names a model the provider does not serve, it will be benched and named in the log and toast \u2014 report that instead of claiming the swap succeeded.
|
|
1763
1747
|
|
|
@@ -1780,6 +1764,14 @@ $ARGUMENTS
|
|
|
1780
1764
|
- Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
|
|
1781
1765
|
|
|
1782
1766
|
Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
|
|
1767
|
+
},
|
|
1768
|
+
"image-in-cb": {
|
|
1769
|
+
description: "Attach the clipboard image to this message \u2014 macOS needs pngpaste, Linux needs wl-paste or xclip.",
|
|
1770
|
+
template: "$ARGUMENTS"
|
|
1771
|
+
},
|
|
1772
|
+
"image": {
|
|
1773
|
+
description: "Attach one or more image files to this message",
|
|
1774
|
+
template: "$ARGUMENTS"
|
|
1783
1775
|
}
|
|
1784
1776
|
};
|
|
1785
1777
|
function applyCommands(cfg, commands = COMMANDS) {
|
|
@@ -2240,15 +2232,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2240
2232
|
};
|
|
2241
2233
|
sweepDirIfDue();
|
|
2242
2234
|
const abortSession = async (sessionID) => {
|
|
2243
|
-
if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
|
|
2244
2235
|
try {
|
|
2245
|
-
const
|
|
2246
|
-
|
|
2236
|
+
const result = await client.session.abort({
|
|
2237
|
+
path: { id: sessionID },
|
|
2238
|
+
query: { directory },
|
|
2247
2239
|
signal: AbortSignal.timeout(ABORT_TIMEOUT_MS)
|
|
2248
2240
|
});
|
|
2249
|
-
if (!
|
|
2250
|
-
|
|
2251
|
-
|
|
2241
|
+
if (!result.response?.ok) {
|
|
2242
|
+
return { ok: false, why: `server answered HTTP ${result.response?.status ?? "?"}` };
|
|
2243
|
+
}
|
|
2244
|
+
if (result.data === false) {
|
|
2245
|
+
return { ok: false, why: "server reported the session was not aborted" };
|
|
2246
|
+
}
|
|
2252
2247
|
return { ok: true, why: "" };
|
|
2253
2248
|
} catch (error) {
|
|
2254
2249
|
return { ok: false, why: String(error) };
|
|
@@ -2267,20 +2262,90 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2267
2262
|
for (const [key, claim] of s.own) if (claim.g === sessionID) return { key, claim };
|
|
2268
2263
|
return void 0;
|
|
2269
2264
|
};
|
|
2270
|
-
const
|
|
2265
|
+
const identifyPooledSession = async (sessionID) => {
|
|
2266
|
+
try {
|
|
2267
|
+
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
2268
|
+
if (!info?.parentID) return void 0;
|
|
2269
|
+
const msgs = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then(
|
|
2270
|
+
(r) => r.data
|
|
2271
|
+
).catch(() => void 0);
|
|
2272
|
+
if (!Array.isArray(msgs)) return void 0;
|
|
2273
|
+
const userMsg = msgs.find((m) => m?.info?.role === "user");
|
|
2274
|
+
if (!userMsg?.info) return void 0;
|
|
2275
|
+
const agent = userMsg.info.agent;
|
|
2276
|
+
if (typeof agent !== "string") return void 0;
|
|
2277
|
+
const modelInfo = userMsg.info.model;
|
|
2278
|
+
const userModel = modelInfo?.providerID && modelInfo?.modelID ? `${modelInfo.providerID}/${modelInfo.modelID}` : void 0;
|
|
2279
|
+
if (s.known.size > 0 && !s.known.has(agent)) return void 0;
|
|
2280
|
+
if (s.known.size === 0 && manualVariant(agent) === void 0 && !(BASES.includes(agent) && userModel)) {
|
|
2281
|
+
return void 0;
|
|
2282
|
+
}
|
|
2283
|
+
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
2284
|
+
if (!slots || slots.length === 0) return void 0;
|
|
2285
|
+
let slot;
|
|
2286
|
+
const variantBase = manualVariant(agent);
|
|
2287
|
+
if (variantBase) {
|
|
2288
|
+
const idx = Number(agent.slice(agent.lastIndexOf("-") + 1));
|
|
2289
|
+
if (!Number.isFinite(idx)) return void 0;
|
|
2290
|
+
slot = slots.find((sl) => sl.index === idx);
|
|
2291
|
+
} else if (BASES.includes(agent) && userModel) {
|
|
2292
|
+
slot = slots.find((sl) => sl.model === userModel);
|
|
2293
|
+
}
|
|
2294
|
+
if (!slot) return void 0;
|
|
2295
|
+
return { agent, slot: slot.index, model: slot.model };
|
|
2296
|
+
} catch {
|
|
2297
|
+
return void 0;
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
const failOver = async (sessionID, model, reason, opts2) => {
|
|
2271
2301
|
const entry = claimFor(sessionID);
|
|
2302
|
+
if (entry) {
|
|
2303
|
+
const { key, claim } = entry;
|
|
2304
|
+
const kill = await abortSession(sessionID);
|
|
2305
|
+
if (kill.ok) {
|
|
2306
|
+
release(s, key);
|
|
2307
|
+
await log(
|
|
2308
|
+
"warn",
|
|
2309
|
+
`limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now`,
|
|
2310
|
+
{ base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: true, reason }
|
|
2311
|
+
);
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
await log(
|
|
2315
|
+
"warn",
|
|
2316
|
+
`limit (${reason}) on ${model}; abort failed (${kill.why}); claim on slot ${claim.k} held for retry`,
|
|
2317
|
+
{ base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: false, reason }
|
|
2318
|
+
);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2272
2321
|
if (!entry) {
|
|
2322
|
+
if (opts2?.cool !== false && sessionID) {
|
|
2323
|
+
const pooled = await identifyPooledSession(sessionID);
|
|
2324
|
+
if (pooled) {
|
|
2325
|
+
recordStrike(s, pooled.model, reason, opts2?.stated);
|
|
2326
|
+
const kill = await abortSession(sessionID);
|
|
2327
|
+
await log(
|
|
2328
|
+
"warn",
|
|
2329
|
+
`limit (${reason}) on ${pooled.model}; aborted pooled ${pooled.agent} session ${sessionID} (slot ${pooled.slot}) to fail over now`,
|
|
2330
|
+
{ base: pooled.agent, model: pooled.model, slot: pooled.slot, session: sessionID, aborted: kill.ok, reason }
|
|
2331
|
+
);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
} else if (opts2?.cool === false && sessionID) {
|
|
2335
|
+
const pooled = await identifyPooledSession(sessionID);
|
|
2336
|
+
if (pooled) {
|
|
2337
|
+
const kill = await abortSession(sessionID);
|
|
2338
|
+
await log(
|
|
2339
|
+
"warn",
|
|
2340
|
+
`limit (${reason}) on ${pooled.model}; aborted pooled ${pooled.agent} session ${sessionID} (slot ${pooled.slot}) to fail over now`,
|
|
2341
|
+
{ base: pooled.agent, model: pooled.model, slot: pooled.slot, session: sessionID, aborted: kill.ok, reason }
|
|
2342
|
+
);
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2273
2346
|
await log("warn", `limit (${reason}) on ${model} for session ${sessionID ?? "?"}, which has no pool claim; nothing aborted`);
|
|
2274
2347
|
return;
|
|
2275
2348
|
}
|
|
2276
|
-
const { key, claim } = entry;
|
|
2277
|
-
const kill = await abortSession(sessionID);
|
|
2278
|
-
release(s, key);
|
|
2279
|
-
await log(
|
|
2280
|
-
"warn",
|
|
2281
|
-
kill.ok ? `limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now` : `limit (${reason}) on ${model}; released in-flight ${claim.v} claim after a failed abort (${kill.why})`,
|
|
2282
|
-
{ base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: kill.ok, reason }
|
|
2283
|
-
);
|
|
2284
2349
|
};
|
|
2285
2350
|
const reapStuck = async () => {
|
|
2286
2351
|
if (s.reaping) return;
|
|
@@ -2364,7 +2429,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2364
2429
|
if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
|
|
2365
2430
|
return [
|
|
2366
2431
|
...report.outcomes.map(
|
|
2367
|
-
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the
|
|
2432
|
+
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the pool` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
|
|
2368
2433
|
),
|
|
2369
2434
|
report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
|
|
2370
2435
|
].join("\n");
|
|
@@ -2528,7 +2593,12 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2528
2593
|
}
|
|
2529
2594
|
}
|
|
2530
2595
|
}
|
|
2531
|
-
|
|
2596
|
+
if (provider) {
|
|
2597
|
+
await failOver(sessionID, provider ?? own?.claim.m ?? "?", named, { stated: void 0, cool: false });
|
|
2598
|
+
} else {
|
|
2599
|
+
const stated2 = outage ? void 0 : statedCooldown(message);
|
|
2600
|
+
await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
|
|
2601
|
+
}
|
|
2532
2602
|
}
|
|
2533
2603
|
return;
|
|
2534
2604
|
}
|
|
@@ -2546,6 +2616,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2546
2616
|
if (!limited2 && !outage) return;
|
|
2547
2617
|
const own = claimFor(sessionID);
|
|
2548
2618
|
const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
|
|
2619
|
+
let stated2;
|
|
2549
2620
|
if (own) {
|
|
2550
2621
|
if (outage) {
|
|
2551
2622
|
const transient = coolUnavailable(s, own.claim.m);
|
|
@@ -2553,7 +2624,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2553
2624
|
await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
|
|
2554
2625
|
}
|
|
2555
2626
|
} else {
|
|
2556
|
-
|
|
2627
|
+
stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
|
|
2557
2628
|
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
2558
2629
|
if (strike2) {
|
|
2559
2630
|
await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
|
|
@@ -2564,7 +2635,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2564
2635
|
}
|
|
2565
2636
|
}
|
|
2566
2637
|
}
|
|
2567
|
-
await failOver(sessionID, own?.claim.m ?? "?", named);
|
|
2638
|
+
await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
|
|
2568
2639
|
return;
|
|
2569
2640
|
}
|
|
2570
2641
|
if (event.type === "session.created") {
|
|
@@ -2592,23 +2663,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2592
2663
|
await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
|
|
2593
2664
|
return;
|
|
2594
2665
|
}
|
|
2666
|
+
const ownMsg = claimFor(info.sessionID);
|
|
2595
2667
|
if (isUnavailableText(haystack) || !limited && transientGateway) {
|
|
2596
2668
|
const transient = coolUnavailable(s, model);
|
|
2597
2669
|
if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
2670
|
+
await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
|
|
2598
2671
|
return;
|
|
2599
2672
|
}
|
|
2600
2673
|
const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
|
|
2601
2674
|
const reason = limitReason(haystack);
|
|
2602
2675
|
const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
|
|
2603
2676
|
if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
|
|
2604
|
-
await failOver(info.sessionID, model, reason ?? "rate-limited");
|
|
2677
|
+
await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
|
|
2605
2678
|
} catch (error) {
|
|
2606
2679
|
await log("error", `limit detection failed: ${String(error)}`);
|
|
2607
2680
|
}
|
|
2608
2681
|
},
|
|
2609
2682
|
"tool.execute.before": async (input, output) => {
|
|
2610
2683
|
if (input.tool !== "task") return;
|
|
2611
|
-
|
|
2684
|
+
let requested = output.args?.subagent_type;
|
|
2612
2685
|
if (typeof requested !== "string") {
|
|
2613
2686
|
await warnOnce(
|
|
2614
2687
|
`task spawn has no subagent_type, so the pool cannot route it -- it will run on the caller's own model. Pass subagent_type "explore" or "implement" to use a slot.`
|
|
@@ -2617,12 +2690,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2617
2690
|
}
|
|
2618
2691
|
const manual = manualVariant(requested);
|
|
2619
2692
|
if (manual) {
|
|
2620
|
-
await log("warn", `
|
|
2693
|
+
await log("warn", `rewrote a hand-named pool slot to its base; the pool owns slot choice`, {
|
|
2621
2694
|
base: manual
|
|
2622
2695
|
});
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
);
|
|
2696
|
+
requested = manual;
|
|
2697
|
+
output.args.subagent_type = manual;
|
|
2626
2698
|
}
|
|
2627
2699
|
try {
|
|
2628
2700
|
await reapStuck();
|
|
@@ -2635,8 +2707,6 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2635
2707
|
await warnOnce(`variant ${variant} missing from config; spawn left unchanged`);
|
|
2636
2708
|
return;
|
|
2637
2709
|
}
|
|
2638
|
-
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
2639
|
-
output.args.subagent_type = variant;
|
|
2640
2710
|
await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
|
|
2641
2711
|
slot: slot.index,
|
|
2642
2712
|
weight: slot.weight,
|
|
@@ -2644,6 +2714,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2644
2714
|
ceiling: slotCeiling(slot),
|
|
2645
2715
|
load: (snap.counts.get(slot.model) ?? 0) + 1
|
|
2646
2716
|
});
|
|
2717
|
+
output.args.subagent_type = variant;
|
|
2718
|
+
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
2647
2719
|
return;
|
|
2648
2720
|
}
|
|
2649
2721
|
const pinned = s.models.get(requested);
|
|
@@ -2663,7 +2735,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2663
2735
|
},
|
|
2664
2736
|
tool: {
|
|
2665
2737
|
pool_recheck: tool({
|
|
2666
|
-
description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the
|
|
2738
|
+
description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the pool; if it is still limited, the existing cooldown stands; if the provider does not serve it at all, it is benched so routing stops choosing it. Pass `models` to validate those even when they are not cooling. Call it after editing the pool, after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
|
|
2667
2739
|
args: {
|
|
2668
2740
|
models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to validate, as provider/model-id. Omit to probe only the models that are cooling.")
|
|
2669
2741
|
},
|
|
@@ -2674,12 +2746,13 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2674
2746
|
}
|
|
2675
2747
|
}),
|
|
2676
2748
|
pool_status: tool({
|
|
2677
|
-
description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task. Check it before launching many parallel subagents.",
|
|
2749
|
+
description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task; COOLING/UNAVAILABLE = not routable. Check it before launching many parallel subagents.",
|
|
2678
2750
|
args: {},
|
|
2679
2751
|
async execute() {
|
|
2680
2752
|
refreshCatalog(s, serverUrl);
|
|
2681
2753
|
const slots = ensureSlots(s, (m) => void warnOnce(m));
|
|
2682
2754
|
const snap = snapshot(s);
|
|
2755
|
+
const pass = poolPass(s, snap);
|
|
2683
2756
|
const now = Date.now();
|
|
2684
2757
|
const byModel = /* @__PURE__ */ new Map();
|
|
2685
2758
|
const add = (model, base) => {
|
|
@@ -2690,14 +2763,14 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2690
2763
|
};
|
|
2691
2764
|
for (const claim of snap.own.values()) add(claim.m, claim.v);
|
|
2692
2765
|
for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
|
|
2693
|
-
const next = pickSlot(s, snap
|
|
2766
|
+
const next = pickSlot(s, snap);
|
|
2694
2767
|
const line = (slot) => {
|
|
2695
2768
|
const entry = byModel.get(slot.model);
|
|
2696
2769
|
const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
|
|
2697
2770
|
const strike = snap.cooling.get(slot.model);
|
|
2698
2771
|
const status = s.catalog.get(slot.model);
|
|
2699
2772
|
const notes = [
|
|
2700
|
-
strike ? `COOLING ${Math.ceil((strike.until - now) / 1e3)}s
|
|
2773
|
+
strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
|
|
2701
2774
|
status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
|
|
2702
2775
|
].filter(Boolean);
|
|
2703
2776
|
const marker = slot.index === next.index ? " <- next" : "";
|
|
@@ -2713,8 +2786,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2713
2786
|
return {
|
|
2714
2787
|
title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
|
|
2715
2788
|
output: [
|
|
2716
|
-
`shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
|
|
2717
|
-
|
|
2789
|
+
`shared pool over ${slots.length} model(s) from ${s.origin}; pass ${pass}; bases: ${BASES.join(", ")}
|
|
2790
|
+
two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 to its ceiling, then overflow goes to the least-loaded slot (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
|
|
2718
2791
|
...slots.map(line),
|
|
2719
2792
|
"",
|
|
2720
2793
|
"by process:",
|
|
@@ -2730,7 +2803,7 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2730
2803
|
// different situations and this is the line that tells them apart.
|
|
2731
2804
|
probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : coolingNow.length > 0 ? "never in this process (cooldowns came from disk)" : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool (or run /pool-recheck) to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool (or run /pool-recheck) to ask now.",
|
|
2732
2805
|
watchMs > 0 ? `pool config watch: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slot table and validates every slot.` : "pool config watch: off (watchMs: 0).",
|
|
2733
|
-
coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
|
|
2806
|
+
coolingNow.length === 0 ? "no models cooling down." : `unavailable/cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
|
|
2734
2807
|
].join("\n")
|
|
2735
2808
|
};
|
|
2736
2809
|
}
|
|
@@ -3158,7 +3231,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3158
3231
|
"",
|
|
3159
3232
|
NO_BULK_READS,
|
|
3160
3233
|
"",
|
|
3161
|
-
'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or
|
|
3234
|
+
'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or an invariant not yet recorded. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
|
|
3162
3235
|
].join("\n");
|
|
3163
3236
|
return {
|
|
3164
3237
|
event: async ({ event }) => {
|
|
@@ -3272,15 +3345,330 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
3272
3345
|
};
|
|
3273
3346
|
});
|
|
3274
3347
|
|
|
3348
|
+
// src/plugins/image-paste.ts
|
|
3349
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3350
|
+
import { globSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3351
|
+
import { homedir as homedir4 } from "node:os";
|
|
3352
|
+
import { basename as basename2, join as join6 } from "node:path";
|
|
3353
|
+
|
|
3354
|
+
// src/lib/clipboard.ts
|
|
3355
|
+
import { spawnSync } from "node:child_process";
|
|
3356
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3357
|
+
var RUN_TIMEOUT_MS = 3e3;
|
|
3358
|
+
var defaultRun = async (cmd, args) => {
|
|
3359
|
+
try {
|
|
3360
|
+
const result = spawnSync(cmd, args, {
|
|
3361
|
+
timeout: RUN_TIMEOUT_MS,
|
|
3362
|
+
killSignal: "SIGKILL",
|
|
3363
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3364
|
+
});
|
|
3365
|
+
const stderr = result.stderr ? result.stderr.toString().trim() : "";
|
|
3366
|
+
const detail = result.error ? String(result.error.code ?? result.error.message) : "";
|
|
3367
|
+
return {
|
|
3368
|
+
ok: result.status === 0,
|
|
3369
|
+
stdout: result.stdout ?? Buffer.alloc(0),
|
|
3370
|
+
stderr: [stderr, detail].filter(Boolean).join("\n")
|
|
3371
|
+
};
|
|
3372
|
+
} catch (error) {
|
|
3373
|
+
return { ok: false, stdout: Buffer.alloc(0), stderr: String(error) };
|
|
3374
|
+
}
|
|
3375
|
+
};
|
|
3376
|
+
var defaultHave = (cmd) => {
|
|
3377
|
+
try {
|
|
3378
|
+
return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
|
|
3379
|
+
} catch {
|
|
3380
|
+
return false;
|
|
3381
|
+
}
|
|
3382
|
+
};
|
|
3383
|
+
var hasImageSignature = (bytes) => {
|
|
3384
|
+
const head = bytes.toString("latin1", 0, 12);
|
|
3385
|
+
if (head.startsWith("\x89PNG")) return true;
|
|
3386
|
+
if (head.startsWith("\xFF\xD8\xFF")) return true;
|
|
3387
|
+
if (head.startsWith("GIF8")) return true;
|
|
3388
|
+
if (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") return true;
|
|
3389
|
+
return false;
|
|
3390
|
+
};
|
|
3391
|
+
var validImage = (result) => {
|
|
3392
|
+
if (!result.ok || result.stdout.length === 0) return null;
|
|
3393
|
+
return hasImageSignature(result.stdout) ? result.stdout : null;
|
|
3394
|
+
};
|
|
3395
|
+
var linuxCandidates = (env) => {
|
|
3396
|
+
const xclip = { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-o"] };
|
|
3397
|
+
const wlPaste = { cmd: "wl-paste", args: ["--type", "image/png"] };
|
|
3398
|
+
const wayland = Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
|
|
3399
|
+
return wayland ? [wlPaste, xclip] : [xclip, wlPaste];
|
|
3400
|
+
};
|
|
3401
|
+
var WINDOWS_SCRIPT = [
|
|
3402
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
3403
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
3404
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
3405
|
+
"if ($null -eq $img) { exit 1 }",
|
|
3406
|
+
"$path = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) + '.png'",
|
|
3407
|
+
"$img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
3408
|
+
"Write-Output $path"
|
|
3409
|
+
].join("\n");
|
|
3410
|
+
var readWindows = async (run) => {
|
|
3411
|
+
const result = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT]);
|
|
3412
|
+
if (!result.ok) return null;
|
|
3413
|
+
const path4 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
3414
|
+
if (!path4) return null;
|
|
3415
|
+
try {
|
|
3416
|
+
return validImage({ ok: true, stdout: await readFile(path4) });
|
|
3417
|
+
} finally {
|
|
3418
|
+
await rm(path4, { force: true }).catch(() => {
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
async function readClipboardImage(deps = {}) {
|
|
3423
|
+
try {
|
|
3424
|
+
const platform = deps.platform ?? process.platform;
|
|
3425
|
+
const env = deps.env ?? process.env;
|
|
3426
|
+
const run = deps.run ?? defaultRun;
|
|
3427
|
+
const have = deps.have ?? defaultHave;
|
|
3428
|
+
if (platform === "darwin") {
|
|
3429
|
+
if (!have("pngpaste")) return null;
|
|
3430
|
+
return validImage(await run("pngpaste", ["-"]));
|
|
3431
|
+
}
|
|
3432
|
+
if (platform === "win32") {
|
|
3433
|
+
return await readWindows(run);
|
|
3434
|
+
}
|
|
3435
|
+
if (platform === "linux") {
|
|
3436
|
+
for (const candidate of linuxCandidates(env)) {
|
|
3437
|
+
if (!have(candidate.cmd)) continue;
|
|
3438
|
+
const bytes = validImage(await run(candidate.cmd, candidate.args));
|
|
3439
|
+
if (bytes) return bytes;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
return null;
|
|
3443
|
+
} catch {
|
|
3444
|
+
return null;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
// src/plugins/image-paste.ts
|
|
3449
|
+
var DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
3450
|
+
function resolveMaxBytes(opts) {
|
|
3451
|
+
if (typeof opts.maxBytes === "number" && Number.isFinite(opts.maxBytes)) return opts.maxBytes;
|
|
3452
|
+
const parsed = Number.parseInt(process.env.LACODE_IMAGE_MAX_BYTES ?? "", 10);
|
|
3453
|
+
return Number.isNaN(parsed) ? DEFAULT_MAX_BYTES : parsed;
|
|
3454
|
+
}
|
|
3455
|
+
var DEFAULT_MAX_IMAGES = 10;
|
|
3456
|
+
var MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
3457
|
+
function resolveMaxImages(opts) {
|
|
3458
|
+
if (typeof opts.maxImages === "number" && Number.isFinite(opts.maxImages)) return opts.maxImages;
|
|
3459
|
+
const parsed = Number.parseInt(process.env.LACODE_IMAGE_MAX_IMAGES ?? "", 10);
|
|
3460
|
+
return Number.isNaN(parsed) ? DEFAULT_MAX_IMAGES : parsed;
|
|
3461
|
+
}
|
|
3462
|
+
function detectImageKind(bytes) {
|
|
3463
|
+
if (bytes.length >= 4 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
|
|
3464
|
+
return { mime: "image/png", ext: "png" };
|
|
3465
|
+
}
|
|
3466
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
|
|
3467
|
+
return { mime: "image/jpeg", ext: "jpg" };
|
|
3468
|
+
}
|
|
3469
|
+
if (bytes.length >= 4 && bytes.toString("latin1", 0, 4) === "GIF8") {
|
|
3470
|
+
return { mime: "image/gif", ext: "gif" };
|
|
3471
|
+
}
|
|
3472
|
+
if (bytes.length >= 12 && bytes.toString("latin1", 0, 4) === "RIFF" && bytes.toString("latin1", 8, 12) === "WEBP") {
|
|
3473
|
+
return { mime: "image/webp", ext: "webp" };
|
|
3474
|
+
}
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
function splitArguments(input) {
|
|
3478
|
+
const out = [];
|
|
3479
|
+
let current = "";
|
|
3480
|
+
let quote = null;
|
|
3481
|
+
for (const ch of input) {
|
|
3482
|
+
if (quote !== null) {
|
|
3483
|
+
if (ch === quote) quote = null;
|
|
3484
|
+
else current += ch;
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
if (ch === "'" || ch === '"') {
|
|
3488
|
+
quote = ch;
|
|
3489
|
+
continue;
|
|
3490
|
+
}
|
|
3491
|
+
if (/\s/.test(ch)) {
|
|
3492
|
+
if (current !== "") out.push(current);
|
|
3493
|
+
current = "";
|
|
3494
|
+
continue;
|
|
3495
|
+
}
|
|
3496
|
+
current += ch;
|
|
3497
|
+
}
|
|
3498
|
+
if (current !== "") out.push(current);
|
|
3499
|
+
return out;
|
|
3500
|
+
}
|
|
3501
|
+
function expandTilde(p) {
|
|
3502
|
+
if (p === "~") return homedir4();
|
|
3503
|
+
if (p.startsWith("~/")) return join6(homedir4(), p.slice(2));
|
|
3504
|
+
return p;
|
|
3505
|
+
}
|
|
3506
|
+
function expandGlob(p) {
|
|
3507
|
+
try {
|
|
3508
|
+
const matches = globSync(p);
|
|
3509
|
+
if (matches.length > 0) return [...matches].sort();
|
|
3510
|
+
} catch {
|
|
3511
|
+
}
|
|
3512
|
+
return [p];
|
|
3513
|
+
}
|
|
3514
|
+
function storeImage(bytes, dir, sessionID, label) {
|
|
3515
|
+
const kind = detectImageKind(bytes);
|
|
3516
|
+
if (kind === null) return null;
|
|
3517
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
3518
|
+
mkdirSync4(dir, { recursive: true });
|
|
3519
|
+
const target = join6(dir, `${hash}.${kind.ext}`);
|
|
3520
|
+
const tmp = `${target}.tmp`;
|
|
3521
|
+
writeFileSync4(tmp, bytes, { mode: 384 });
|
|
3522
|
+
renameSync3(tmp, target);
|
|
3523
|
+
const part = {
|
|
3524
|
+
id: randomUUID(),
|
|
3525
|
+
sessionID,
|
|
3526
|
+
messageID: randomUUID(),
|
|
3527
|
+
type: "file",
|
|
3528
|
+
mime: kind.mime,
|
|
3529
|
+
filename: `${label}-${hash.slice(0, 8)}.${kind.ext}`,
|
|
3530
|
+
url: `data:${kind.mime};base64,${bytes.toString("base64")}`
|
|
3531
|
+
};
|
|
3532
|
+
return { part, hash };
|
|
3533
|
+
}
|
|
3534
|
+
function warn(input, message) {
|
|
3535
|
+
input.client.app.log({ body: { service: "image-paste", level: "warn", message } }).catch(() => {
|
|
3536
|
+
});
|
|
3537
|
+
}
|
|
3538
|
+
async function toast(input, message) {
|
|
3539
|
+
try {
|
|
3540
|
+
await input.client.tui.showToast({ body: { title: "lacode", message, variant: "warning" } });
|
|
3541
|
+
} catch {
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
async function imagePasteHooks(input, options, deps) {
|
|
3545
|
+
try {
|
|
3546
|
+
const opts = options ?? {};
|
|
3547
|
+
if (opts.enabled === false) return {};
|
|
3548
|
+
const maxBytes = resolveMaxBytes(opts);
|
|
3549
|
+
const maxImages = resolveMaxImages(opts);
|
|
3550
|
+
const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join6(homedir4(), ".local/share/opencode/lacode/images");
|
|
3551
|
+
const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
|
|
3552
|
+
return {
|
|
3553
|
+
"command.execute.before": async (cmd, output) => {
|
|
3554
|
+
try {
|
|
3555
|
+
if (cmd.command === "image-in-cb") {
|
|
3556
|
+
const bytes = await readClipboard();
|
|
3557
|
+
if (bytes === null) {
|
|
3558
|
+
warn(input, "No image on the clipboard");
|
|
3559
|
+
await toast(input, "No image on the clipboard");
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
if (bytes.length > maxBytes) {
|
|
3563
|
+
const message = `image too large: ${bytes.length} bytes, max ${maxBytes}`;
|
|
3564
|
+
warn(input, message);
|
|
3565
|
+
await toast(input, message);
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
const stored = storeImage(bytes, dir, cmd.sessionID, "clipboard");
|
|
3569
|
+
if (stored === null) {
|
|
3570
|
+
warn(input, "unsupported clipboard image format (expected PNG, JPEG, GIF or WebP)");
|
|
3571
|
+
await toast(input, "unsupported clipboard image format (expected PNG, JPEG, GIF or WebP)");
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
const firstText = output.parts.findIndex((p) => p.type === "text");
|
|
3575
|
+
if (firstText === -1) output.parts.push(stored.part);
|
|
3576
|
+
else output.parts.splice(firstText, 0, stored.part);
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
if (cmd.command === "image") {
|
|
3580
|
+
const rawArgs = typeof cmd.arguments === "string" ? cmd.arguments : "";
|
|
3581
|
+
const resolved = [];
|
|
3582
|
+
for (const arg of splitArguments(rawArgs)) resolved.push(...expandGlob(expandTilde(arg)));
|
|
3583
|
+
const parts = [];
|
|
3584
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3585
|
+
let totalBytes = 0;
|
|
3586
|
+
const skipped = [];
|
|
3587
|
+
for (const filePath of resolved) {
|
|
3588
|
+
const name = basename2(filePath);
|
|
3589
|
+
let size;
|
|
3590
|
+
try {
|
|
3591
|
+
const stat = statSync4(filePath);
|
|
3592
|
+
if (!stat.isFile()) {
|
|
3593
|
+
skipped.push(name);
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
size = stat.size;
|
|
3597
|
+
} catch {
|
|
3598
|
+
skipped.push(name);
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
if (size > maxBytes) {
|
|
3602
|
+
skipped.push(name);
|
|
3603
|
+
continue;
|
|
3604
|
+
}
|
|
3605
|
+
let bytes;
|
|
3606
|
+
try {
|
|
3607
|
+
bytes = readFileSync6(filePath);
|
|
3608
|
+
} catch {
|
|
3609
|
+
skipped.push(name);
|
|
3610
|
+
continue;
|
|
3611
|
+
}
|
|
3612
|
+
if (detectImageKind(bytes) === null) {
|
|
3613
|
+
skipped.push(name);
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
3617
|
+
if (seen.has(hash)) continue;
|
|
3618
|
+
if (parts.length >= maxImages || totalBytes + bytes.length > MAX_TOTAL_BYTES) {
|
|
3619
|
+
skipped.push(name);
|
|
3620
|
+
continue;
|
|
3621
|
+
}
|
|
3622
|
+
const stored = storeImage(bytes, dir, cmd.sessionID, name.replace(/\.[^.]+$/, "") || "image");
|
|
3623
|
+
if (stored === null) {
|
|
3624
|
+
skipped.push(name);
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
seen.add(hash);
|
|
3628
|
+
parts.push(stored.part);
|
|
3629
|
+
totalBytes += bytes.length;
|
|
3630
|
+
}
|
|
3631
|
+
if (skipped.length > 0) {
|
|
3632
|
+
const message = `skipped ${skipped.length} image file${skipped.length === 1 ? "" : "s"}: ${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", \u2026" : ""}`;
|
|
3633
|
+
warn(input, message);
|
|
3634
|
+
await toast(input, message);
|
|
3635
|
+
}
|
|
3636
|
+
if (parts.length > 0) {
|
|
3637
|
+
const firstText = output.parts.findIndex((p) => p.type === "text");
|
|
3638
|
+
if (firstText === -1) output.parts.push(...parts);
|
|
3639
|
+
else output.parts.splice(firstText, 0, ...parts);
|
|
3640
|
+
}
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
try {
|
|
3645
|
+
input.client.app.log({ body: { service: "image-paste", level: "error", message: `image-paste failed: ${String(error)}` } }).catch(() => {
|
|
3646
|
+
});
|
|
3647
|
+
} catch {
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
};
|
|
3652
|
+
} catch {
|
|
3653
|
+
return {};
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// src/lib/prompt-guard.ts
|
|
3658
|
+
var SLOT = new RegExp(`\\b(${BASES.join("|")})(?:-[1-9][0-9]*)+\\b`, "g");
|
|
3659
|
+
function scrubSlotNames(text) {
|
|
3660
|
+
return text.replace(SLOT, "$1");
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3275
3663
|
// src/plugins/notify.ts
|
|
3276
3664
|
import { spawn as spawn2 } from "node:child_process";
|
|
3277
3665
|
import { existsSync as existsSync3 } from "node:fs";
|
|
3278
3666
|
import * as path3 from "node:path";
|
|
3279
3667
|
|
|
3280
3668
|
// src/lib/notify.ts
|
|
3281
|
-
import { closeSync as closeSync2, mkdirSync as
|
|
3282
|
-
import { homedir as
|
|
3283
|
-
import { join as
|
|
3669
|
+
import { closeSync as closeSync2, mkdirSync as mkdirSync5, openSync as openSync2, readFileSync as readFileSync7, renameSync as renameSync4, statSync as statSync5, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5, writeSync as writeSync2 } from "node:fs";
|
|
3670
|
+
import { homedir as homedir5 } from "node:os";
|
|
3671
|
+
import { join as join7 } from "node:path";
|
|
3284
3672
|
var INTERNAL_TITLE_PREFIXES = [
|
|
3285
3673
|
// context-autoupdate spawns the cartographer through the session API, with no
|
|
3286
3674
|
// parentID, so it looks exactly like a root session going idle. Notifying for
|
|
@@ -3459,7 +3847,7 @@ var Notifier = class {
|
|
|
3459
3847
|
return this.lastSent.size;
|
|
3460
3848
|
}
|
|
3461
3849
|
};
|
|
3462
|
-
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ??
|
|
3850
|
+
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join7(homedir5(), ".local", "share", "opencode", "lacode", "notify");
|
|
3463
3851
|
var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
3464
3852
|
var CLAIM_WINDOW_MS = 5e3;
|
|
3465
3853
|
var isPidAlive2 = (pid) => {
|
|
@@ -3470,11 +3858,11 @@ var isPidAlive2 = (pid) => {
|
|
|
3470
3858
|
return error.code === "EPERM";
|
|
3471
3859
|
}
|
|
3472
3860
|
};
|
|
3473
|
-
var claimFile = (sessionID, kind) =>
|
|
3861
|
+
var claimFile = (sessionID, kind) => join7(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
|
|
3474
3862
|
var claimNotification = (sessionID, kind) => {
|
|
3475
3863
|
const path4 = claimFile(sessionID, kind);
|
|
3476
3864
|
try {
|
|
3477
|
-
|
|
3865
|
+
mkdirSync5(notificationStateDir(), { recursive: true });
|
|
3478
3866
|
} catch {
|
|
3479
3867
|
return true;
|
|
3480
3868
|
}
|
|
@@ -3498,7 +3886,7 @@ var claimNotification = (sessionID, kind) => {
|
|
|
3498
3886
|
const judge = () => {
|
|
3499
3887
|
let raw;
|
|
3500
3888
|
try {
|
|
3501
|
-
raw =
|
|
3889
|
+
raw = readFileSync7(path4, "utf8").trim();
|
|
3502
3890
|
} catch (error) {
|
|
3503
3891
|
return error.code === "ENOENT" ? "gone" : "unknown";
|
|
3504
3892
|
}
|
|
@@ -3511,7 +3899,7 @@ var claimNotification = (sessionID, kind) => {
|
|
|
3511
3899
|
if (holder === process.pid) return "ours";
|
|
3512
3900
|
let age = Number.POSITIVE_INFINITY;
|
|
3513
3901
|
try {
|
|
3514
|
-
age = Date.now() -
|
|
3902
|
+
age = Date.now() - statSync5(path4).mtimeMs;
|
|
3515
3903
|
} catch {
|
|
3516
3904
|
return "gone";
|
|
3517
3905
|
}
|
|
@@ -3554,9 +3942,9 @@ var claimNotification = (sessionID, kind) => {
|
|
|
3554
3942
|
let lockHolder;
|
|
3555
3943
|
let lockAge = Number.POSITIVE_INFINITY;
|
|
3556
3944
|
try {
|
|
3557
|
-
const parsed = Number.parseInt(
|
|
3945
|
+
const parsed = Number.parseInt(readFileSync7(lockPath, "utf8").trim(), 10);
|
|
3558
3946
|
lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
3559
|
-
lockAge = Date.now() -
|
|
3947
|
+
lockAge = Date.now() - statSync5(lockPath).mtimeMs;
|
|
3560
3948
|
} catch {
|
|
3561
3949
|
return create();
|
|
3562
3950
|
}
|
|
@@ -3589,13 +3977,48 @@ var claimNotification = (sessionID, kind) => {
|
|
|
3589
3977
|
var releaseNotificationClaim = (sessionID, kind) => {
|
|
3590
3978
|
const path4 = claimFile(sessionID, kind);
|
|
3591
3979
|
try {
|
|
3592
|
-
const parsed = Number.parseInt(
|
|
3980
|
+
const parsed = Number.parseInt(readFileSync7(path4, "utf8").trim(), 10);
|
|
3593
3981
|
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
3594
3982
|
if (parsed !== process.pid) return;
|
|
3595
3983
|
unlinkSync4(path4);
|
|
3596
3984
|
} catch {
|
|
3597
3985
|
}
|
|
3598
3986
|
};
|
|
3987
|
+
var completionFile = (sessionID) => join7(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
|
|
3988
|
+
var claimCompletion = (sessionID, kind) => {
|
|
3989
|
+
const path4 = completionFile(sessionID);
|
|
3990
|
+
try {
|
|
3991
|
+
mkdirSync5(notificationStateDir(), { recursive: true });
|
|
3992
|
+
const fd = openSync2(path4, "wx");
|
|
3993
|
+
try {
|
|
3994
|
+
writeFileSync5(fd, `${process.pid} ${kind}
|
|
3995
|
+
`);
|
|
3996
|
+
} finally {
|
|
3997
|
+
closeSync2(fd);
|
|
3998
|
+
}
|
|
3999
|
+
return true;
|
|
4000
|
+
} catch (error) {
|
|
4001
|
+
if (error.code === "EEXIST") return false;
|
|
4002
|
+
return true;
|
|
4003
|
+
}
|
|
4004
|
+
};
|
|
4005
|
+
var releaseCompletion = (sessionID) => {
|
|
4006
|
+
try {
|
|
4007
|
+
unlinkSync4(completionFile(sessionID));
|
|
4008
|
+
} catch {
|
|
4009
|
+
}
|
|
4010
|
+
};
|
|
4011
|
+
var markCompletion = (sessionID, kind) => {
|
|
4012
|
+
try {
|
|
4013
|
+
mkdirSync5(notificationStateDir(), { recursive: true });
|
|
4014
|
+
const path4 = completionFile(sessionID);
|
|
4015
|
+
const tmp = `${path4}.${process.pid}.tmp`;
|
|
4016
|
+
writeFileSync5(tmp, `${process.pid} ${kind}
|
|
4017
|
+
`);
|
|
4018
|
+
renameSync4(tmp, path4);
|
|
4019
|
+
} catch {
|
|
4020
|
+
}
|
|
4021
|
+
};
|
|
3599
4022
|
|
|
3600
4023
|
// src/plugins/notify.ts
|
|
3601
4024
|
var SERVICE3 = "notify";
|
|
@@ -3919,9 +4342,16 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3919
4342
|
void log("debug", "suppressed: cooldown", { sessionID });
|
|
3920
4343
|
return;
|
|
3921
4344
|
}
|
|
3922
|
-
if (
|
|
4345
|
+
if (outcome === "done") {
|
|
4346
|
+
if (!claimCompletion(sessionID, "done")) {
|
|
4347
|
+
void log("debug", "suppressed: completion already announced", { sessionID });
|
|
4348
|
+
return;
|
|
4349
|
+
}
|
|
4350
|
+
} else if (!claimNotification(sessionID, kind)) {
|
|
3923
4351
|
void log("debug", "suppressed: another process is raising this", { sessionID, kind });
|
|
3924
4352
|
return;
|
|
4353
|
+
} else if (outcome === "error") {
|
|
4354
|
+
markCompletion(sessionID, "error");
|
|
3925
4355
|
}
|
|
3926
4356
|
const message = isRemote ? `${decision.message} (remote)` : decision.message;
|
|
3927
4357
|
const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
|
|
@@ -3932,9 +4362,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3932
4362
|
let sent = { ok: false };
|
|
3933
4363
|
try {
|
|
3934
4364
|
sent = await deliver(decision.title, message, decision.urgency, sound, requestID, sessionID);
|
|
4365
|
+
} catch (error) {
|
|
4366
|
+
if (outcome === "done" || outcome === "error") releaseCompletion(sessionID);
|
|
4367
|
+
throw error;
|
|
3935
4368
|
} finally {
|
|
3936
|
-
scheduleClaimRelease(sessionID, kind);
|
|
4369
|
+
if (outcome !== "done") scheduleClaimRelease(sessionID, kind);
|
|
3937
4370
|
}
|
|
4371
|
+
if ((outcome === "done" || outcome === "error") && !sent.ok) releaseCompletion(sessionID);
|
|
3938
4372
|
if (sent.action && requestID) {
|
|
3939
4373
|
const outstanding = permissions.get(sessionID);
|
|
3940
4374
|
const ids = outstanding ? [...outstanding].filter((id) => id !== UNANSWERABLE_REQUEST) : [];
|
|
@@ -3958,6 +4392,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3958
4392
|
const { sessionID: sessionID2, status } = event.properties;
|
|
3959
4393
|
if (status.type === "busy" && sessionID2) {
|
|
3960
4394
|
rememberActive(sessionID2);
|
|
4395
|
+
releaseCompletion(sessionID2);
|
|
3961
4396
|
}
|
|
3962
4397
|
return;
|
|
3963
4398
|
}
|
|
@@ -4036,6 +4471,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
4036
4471
|
permissions.delete(sessionID2);
|
|
4037
4472
|
forgetLive(sessionID2);
|
|
4038
4473
|
forgetPermissionPopup(sessionID2);
|
|
4474
|
+
releaseCompletion(sessionID2);
|
|
4039
4475
|
}
|
|
4040
4476
|
return;
|
|
4041
4477
|
}
|
|
@@ -4486,10 +4922,10 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
4486
4922
|
};
|
|
4487
4923
|
let started = null;
|
|
4488
4924
|
const initialize = () => started ??= probe();
|
|
4489
|
-
const injectProvider = async (cfg,
|
|
4925
|
+
const injectProvider = async (cfg, warn2) => {
|
|
4490
4926
|
const providers = cfg.provider ??= {};
|
|
4491
4927
|
if (providers.omniroute !== void 0) {
|
|
4492
|
-
await
|
|
4928
|
+
await warn2("omniroute provider is already defined in your config; lacode left it untouched");
|
|
4493
4929
|
return;
|
|
4494
4930
|
}
|
|
4495
4931
|
const exposed = selector.candidates();
|
|
@@ -4552,11 +4988,17 @@ var inertHooks = {
|
|
|
4552
4988
|
dispose: async () => {
|
|
4553
4989
|
}
|
|
4554
4990
|
};
|
|
4991
|
+
var promptDriftWarned = false;
|
|
4555
4992
|
var LaCode = (async (input, options) => {
|
|
4556
4993
|
const opts = options ?? {};
|
|
4557
4994
|
if (opts.enabled === false) return inertHooks;
|
|
4558
4995
|
const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
|
|
4559
4996
|
});
|
|
4997
|
+
const warnPromptDrift = async () => {
|
|
4998
|
+
if (promptDriftWarned) return;
|
|
4999
|
+
promptDriftWarned = true;
|
|
5000
|
+
await log("warn", "a system prompt named a pool slot variant; stripped it before the model saw it. Remove the literal from the prompt source.");
|
|
5001
|
+
};
|
|
4560
5002
|
let version;
|
|
4561
5003
|
try {
|
|
4562
5004
|
version = readInstalledVersion(PACKAGE_ROOT);
|
|
@@ -4578,6 +5020,7 @@ var LaCode = (async (input, options) => {
|
|
|
4578
5020
|
const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
|
|
4579
5021
|
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
4580
5022
|
const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
|
|
5023
|
+
const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
|
|
4581
5024
|
const config = async (cfg) => {
|
|
4582
5025
|
try {
|
|
4583
5026
|
const injected = applyAgents(cfg, agentSet);
|
|
@@ -4612,12 +5055,30 @@ var LaCode = (async (input, options) => {
|
|
|
4612
5055
|
"tool.execute.before": pool["tool.execute.before"],
|
|
4613
5056
|
"tool.execute.after": pool["tool.execute.after"],
|
|
4614
5057
|
tool: pool.tool,
|
|
5058
|
+
"command.execute.before": imagepaste["command.execute.before"],
|
|
4615
5059
|
dispose: async () => {
|
|
4616
5060
|
await pool.dispose?.();
|
|
4617
5061
|
await autoupdate.dispose?.();
|
|
4618
5062
|
await notifier.dispose?.();
|
|
4619
5063
|
await selfupdate.dispose?.();
|
|
4620
5064
|
await omniroute.dispose?.();
|
|
5065
|
+
await imagepaste.dispose?.();
|
|
5066
|
+
},
|
|
5067
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
5068
|
+
if (!Array.isArray(output?.system)) return;
|
|
5069
|
+
let changed = false;
|
|
5070
|
+
for (let i = 0; i < output.system.length; i++) {
|
|
5071
|
+
const text = output.system[i];
|
|
5072
|
+
if (typeof text !== "string") continue;
|
|
5073
|
+
const scrubbed = scrubSlotNames(text);
|
|
5074
|
+
if (scrubbed !== text) {
|
|
5075
|
+
output.system[i] = scrubbed;
|
|
5076
|
+
changed = true;
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
if (changed) {
|
|
5080
|
+
await warnPromptDrift();
|
|
5081
|
+
}
|
|
4621
5082
|
}
|
|
4622
5083
|
};
|
|
4623
5084
|
});
|