@lawaty/lacode 2.0.0-beta.0 → 2.2.0-beta.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 +35 -1
- package/dist/index.js +310 -24
- package/dist/pool.js +53 -8
- package/docs/CONFIGURATION.md +3 -1
- package/package.json +2 -1
- package/presets/README.md +6 -6
- package/scripts/pool-selection.mjs +187 -0
- package/scripts/refresh-pool.mjs +115 -87
- package/scripts/score-models.mjs +188 -59
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,39 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.2.0-beta.0] — context-aware pool routing
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Pool: context-aware routing. `pool.txt` lines carry a `c:<context>` token, and the router prefers the smallest model whose context fits the task prompt, so large-context models are reserved for large tasks.
|
|
17
|
+
- Pool: `npm run score` probes providers with no direct HTTP endpoint (or an unexpanded `${...}` URL) through the local opencode session, and considers catalogue-priced models on providers other than `opencode`/`opencode-go`, whose credits cover them.
|
|
18
|
+
- Pool: a rate-limit reset or a `402 out of credits` answer schedules a transient retry; a transient failure keeps the model's previous score until the retry.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Pool: the context floor is now 100k. Slots are ordered as `opencode-go` (weight 4), small-context providers (weight 2), large-context providers (weight 1), and at most one `opencode` model last.
|
|
23
|
+
- Pool: small-context models are preferred for ordinary work; large-context models are used when the estimated task size needs them or when small slots are full.
|
|
24
|
+
|
|
25
|
+
## [2.1.0-beta.0] — every provider counts, and the timer installs itself
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Pool: `npm run score` scans every configured provider in one `opencode models --verbose` call. `--min-context` (default 200000) and `--min-score` (default 60) exclude weak models, and both floors are recorded in `model-scores.json`.
|
|
30
|
+
- Timer migration: plugin startup installs or refreshes the pool-refresh systemd user timer once per package version, from a stable copy at `~/.local/lib/lacode/refresh-pool.mjs`. Opt out with `LACODE_NO_TIMER_MIGRATION=1`.
|
|
31
|
+
- `scripts/refresh-pool.mjs --install-timer --stable` installs that stable timer by hand.
|
|
32
|
+
- Timeline: a root `task` part expands into the child session's title and outcome, and `NO_PROJECT_CHANGES` is the explicit success token for a summary that found no project changes.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Pool: `scripts/refresh-pool.mjs` rebuilds `~/.config/lacode/pool.txt` from the best scored models across all providers — every opencode-go model at weight 4, then the best `10 - n` others at weight 2. Unscored/paid lines are no longer preserved.
|
|
37
|
+
- Pool: the refresh timer runs daily at 01:00 with no randomized delay; the 15-minute in-process cooldown probe is unchanged.
|
|
38
|
+
- Timer units are written atomically, and the installer enables user linger best-effort.
|
|
39
|
+
- Timeline: side-maintenance sessions and their tool parts are excluded from the summarizer prompt and the rendered timeline; an empty summarizer reply is now a failure instead of an empty success.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Timeline: a `NO_PROJECT_CHANGES` slice still advances the durable cursor, and `/reprocess-timeline` no longer aborts on it.
|
|
44
|
+
|
|
12
45
|
## [2.0.0-beta.0] — The timeline remembers, and the map has one job
|
|
13
46
|
|
|
14
47
|
### Added
|
|
@@ -1132,7 +1165,8 @@ experimental; `latest` remains 0.4.2.
|
|
|
1132
1165
|
|
|
1133
1166
|
- First published release: pooled subagents over a shared set of models.
|
|
1134
1167
|
|
|
1135
|
-
[Unreleased]: https://github.com/lawaty/lacode/compare/v2.
|
|
1168
|
+
[Unreleased]: https://github.com/lawaty/lacode/compare/v2.1.0-beta.0...HEAD
|
|
1169
|
+
[2.1.0-beta.0]: https://github.com/lawaty/lacode/compare/v2.0.0-beta.0...v2.1.0-beta.0
|
|
1136
1170
|
[2.0.0-beta.0]: https://github.com/lawaty/lacode/compare/v1.3.1...v2.0.0-beta.0
|
|
1137
1171
|
[1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
|
|
1138
1172
|
[1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
|
package/dist/index.js
CHANGED
|
@@ -109,7 +109,14 @@ function parseSlots(parsed, origin) {
|
|
|
109
109
|
problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
|
|
110
110
|
}
|
|
111
111
|
const notify = coerceNotify(entry.notify, at, model, problems);
|
|
112
|
-
|
|
112
|
+
const rawContext = entry?.context;
|
|
113
|
+
let context;
|
|
114
|
+
if (rawContext !== void 0) {
|
|
115
|
+
const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
|
|
116
|
+
if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
|
|
117
|
+
else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
|
|
118
|
+
}
|
|
119
|
+
slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context } });
|
|
113
120
|
});
|
|
114
121
|
if (slots.length > SLOTS_WARN_AT) {
|
|
115
122
|
problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
|
|
@@ -129,18 +136,26 @@ function parseTextSlots(text, origin) {
|
|
|
129
136
|
let weight;
|
|
130
137
|
let bucket;
|
|
131
138
|
let notify;
|
|
139
|
+
let context;
|
|
132
140
|
for (const token of rest) {
|
|
133
141
|
if (token.startsWith("notify:")) {
|
|
134
142
|
if (notify === void 0) notify = token.slice("notify:".length);
|
|
135
143
|
continue;
|
|
136
144
|
}
|
|
145
|
+
if (token.startsWith("c:")) {
|
|
146
|
+
if (context === void 0) {
|
|
147
|
+
const parsedContext = Number(token.slice(2));
|
|
148
|
+
if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
137
152
|
if (token.startsWith("b:")) {
|
|
138
153
|
if (bucket === void 0) bucket = token.slice(2);
|
|
139
154
|
} else if (weight === void 0) {
|
|
140
155
|
weight = token;
|
|
141
156
|
}
|
|
142
157
|
}
|
|
143
|
-
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
|
|
158
|
+
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context } });
|
|
144
159
|
}
|
|
145
160
|
return parseSlots({ slots: entries }, origin);
|
|
146
161
|
}
|
|
@@ -713,20 +728,50 @@ function snapshot(s) {
|
|
|
713
728
|
}
|
|
714
729
|
var loadOf = (counts, slot) => counts.get(slot) ?? 0;
|
|
715
730
|
var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
|
|
716
|
-
function pickSlot(s, snap) {
|
|
731
|
+
function pickSlot(s, snap, neededContext) {
|
|
717
732
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
718
733
|
const configured = slots.filter((slot) => providerReady(s, slot.model));
|
|
719
734
|
const usable = configured.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
720
735
|
const pool = usable.length > 0 ? usable : configured.length > 0 ? configured : slots;
|
|
721
|
-
|
|
722
|
-
|
|
736
|
+
if (neededContext === void 0) {
|
|
737
|
+
for (const slot of usable) {
|
|
738
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
739
|
+
}
|
|
740
|
+
return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
|
|
741
|
+
}
|
|
742
|
+
const adequate = usable.filter((slot) => slot.context == null || slot.context >= neededContext);
|
|
743
|
+
const adequateKnown = adequate.filter((slot) => slot.context != null);
|
|
744
|
+
const candidates = adequate.length > 0 ? adequate : usable;
|
|
745
|
+
const fallbackPool = adequate.length > 0 ? adequate : pool;
|
|
746
|
+
if (adequateKnown.length === 0) {
|
|
747
|
+
const known = usable.filter((slot) => slot.context != null);
|
|
748
|
+
if (known.length > 0) {
|
|
749
|
+
const largest = Math.max(...known.map((slot) => slot.context));
|
|
750
|
+
const largestSlots = known.filter((slot) => slot.context === largest);
|
|
751
|
+
for (const slot of largestSlots) {
|
|
752
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
753
|
+
}
|
|
754
|
+
return largestSlots.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, largestSlots[0]);
|
|
755
|
+
}
|
|
756
|
+
for (const slot of candidates) {
|
|
757
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
758
|
+
}
|
|
759
|
+
return fallbackPool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, fallbackPool[0]);
|
|
760
|
+
}
|
|
761
|
+
const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
|
|
762
|
+
if (underWeight.length > 0) {
|
|
763
|
+
const known = underWeight.filter((slot) => slot.context != null);
|
|
764
|
+
if (known.length === 0) return underWeight[0];
|
|
765
|
+
return known.reduce((best, slot) => slot.context < best.context ? slot : best, known[0]);
|
|
723
766
|
}
|
|
724
|
-
return
|
|
767
|
+
return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
|
|
725
768
|
}
|
|
726
|
-
function poolPass(s, snap) {
|
|
769
|
+
function poolPass(s, snap, neededContext) {
|
|
727
770
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
728
771
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
729
|
-
|
|
772
|
+
const candidates = neededContext === void 0 ? usable : usable.filter((slot) => slot.context == null || slot.context >= neededContext);
|
|
773
|
+
const pool = candidates.length > 0 ? candidates : usable;
|
|
774
|
+
if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
|
|
730
775
|
return "overflow";
|
|
731
776
|
}
|
|
732
777
|
function agentFor(base, slot) {
|
|
@@ -2964,6 +3009,11 @@ var reportText = (report) => {
|
|
|
2964
3009
|
`summary: ${answered} answered, ${benched} benched, ${cooling} still cooling`
|
|
2965
3010
|
].join("\n");
|
|
2966
3011
|
};
|
|
3012
|
+
var CONTEXT_HEADROOM_TOKENS = 2e4;
|
|
3013
|
+
function estimateContextTokens(prompt) {
|
|
3014
|
+
if (typeof prompt !== "string" || prompt.length === 0) return void 0;
|
|
3015
|
+
return Math.ceil(prompt.length / 4) + CONTEXT_HEADROOM_TOKENS;
|
|
3016
|
+
}
|
|
2967
3017
|
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
2968
3018
|
const opts = options ?? {};
|
|
2969
3019
|
const notify = opts.notify ?? ((title, message) => void notifyDesktop(title, message));
|
|
@@ -3887,7 +3937,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3887
3937
|
refreshCatalog(s, serverUrl);
|
|
3888
3938
|
const snap = snapshot(s);
|
|
3889
3939
|
if (BASES.includes(requested)) {
|
|
3890
|
-
const
|
|
3940
|
+
const neededContext = estimateContextTokens(output.args?.prompt);
|
|
3941
|
+
const slot = pickSlot(s, snap, neededContext);
|
|
3891
3942
|
const variant = agentFor(requested, slot.index);
|
|
3892
3943
|
if (s.known.size > 0 && !s.known.has(variant)) {
|
|
3893
3944
|
await warnOnce(`variant ${variant} missing from config; spawn left unchanged`);
|
|
@@ -6489,7 +6540,8 @@ async function selfUpdateHooks(input, options) {
|
|
|
6489
6540
|
}
|
|
6490
6541
|
|
|
6491
6542
|
// src/plugins/timeline.ts
|
|
6492
|
-
import { writeFileSync as writeFileSync10, renameSync as renameSync7, rmSync as rmSync3 } from "node:fs";
|
|
6543
|
+
import { existsSync as existsSync5, writeFileSync as writeFileSync10, renameSync as renameSync7, rmSync as rmSync3 } from "node:fs";
|
|
6544
|
+
import { join as join12 } from "node:path";
|
|
6493
6545
|
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
6494
6546
|
|
|
6495
6547
|
// src/lib/timeline.ts
|
|
@@ -6761,6 +6813,49 @@ function summarizeToolResult(state2) {
|
|
|
6761
6813
|
}
|
|
6762
6814
|
return "";
|
|
6763
6815
|
}
|
|
6816
|
+
function isSideTool(tool3, input) {
|
|
6817
|
+
if (tool3 === "timeline_reprocess" || tool3 === "pool_status" || tool3 === "pool_recheck") return true;
|
|
6818
|
+
if (tool3 === "task" && input !== null && input !== void 0 && typeof input === "object") {
|
|
6819
|
+
return input.subagent_type === "context";
|
|
6820
|
+
}
|
|
6821
|
+
return false;
|
|
6822
|
+
}
|
|
6823
|
+
function mentionsSideMaintenance(text) {
|
|
6824
|
+
return /\b(?:timeline_reprocess|pool_status|pool_recheck|reprocess-timeline|cartographer)\b/i.test(text);
|
|
6825
|
+
}
|
|
6826
|
+
function isSideMaintenanceTask(input, rootDirectory, projectOwnsTooling) {
|
|
6827
|
+
if (projectOwnsTooling) return false;
|
|
6828
|
+
let description = "";
|
|
6829
|
+
let prompt = "";
|
|
6830
|
+
if (input !== null && input !== void 0 && typeof input === "object") {
|
|
6831
|
+
const rec = input;
|
|
6832
|
+
if (typeof rec.description === "string") description = rec.description;
|
|
6833
|
+
if (typeof rec.prompt === "string") prompt = rec.prompt;
|
|
6834
|
+
}
|
|
6835
|
+
const text = [description, prompt].filter((s) => typeof s === "string" && s.length > 0).join("\n");
|
|
6836
|
+
if (text === "") return false;
|
|
6837
|
+
if (/\.config\/(?:opencode|lacode)|\.local\/share\/opencode/i.test(text)) return true;
|
|
6838
|
+
if (mentionsSideMaintenance(text)) return true;
|
|
6839
|
+
const root = rootDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
6840
|
+
for (const match of text.matchAll(/\/(?:home|Users|root|opt|srv|mnt|workspace|workspaces)\/[^\s"'`),;]+/gi)) {
|
|
6841
|
+
const cleaned = match[0].replace(/[.,:;!?]+$/, "");
|
|
6842
|
+
if (!(cleaned.includes("/Projects/") || cleaned.includes("/projects/"))) continue;
|
|
6843
|
+
if (cleaned === root || cleaned.startsWith(root + "/")) continue;
|
|
6844
|
+
return true;
|
|
6845
|
+
}
|
|
6846
|
+
return false;
|
|
6847
|
+
}
|
|
6848
|
+
function taskChildSessionId(part) {
|
|
6849
|
+
const sessionId = part.state?.metadata?.sessionId;
|
|
6850
|
+
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId;
|
|
6851
|
+
const output = part.state?.output;
|
|
6852
|
+
if (typeof output !== "string") return void 0;
|
|
6853
|
+
const taskMatch = output.match(/task id="([A-Za-z0-9_.-]+)"/);
|
|
6854
|
+
if (taskMatch) return taskMatch[1];
|
|
6855
|
+
const sesMatch = output.match(/ses_[A-Za-z0-9]+/);
|
|
6856
|
+
if (sesMatch) return sesMatch[0];
|
|
6857
|
+
return void 0;
|
|
6858
|
+
}
|
|
6764
6859
|
function renderMessage(m) {
|
|
6765
6860
|
const role = m.info.role;
|
|
6766
6861
|
const lines = [];
|
|
@@ -6774,18 +6869,29 @@ function renderMessage(m) {
|
|
|
6774
6869
|
}
|
|
6775
6870
|
} else if (role === "assistant") {
|
|
6776
6871
|
for (const p of m.parts) {
|
|
6872
|
+
if (p.side === true) continue;
|
|
6777
6873
|
if (p.type === "text") {
|
|
6778
6874
|
const text = collapse(p.text ?? "");
|
|
6779
6875
|
lines.push(`[assistant] ${trunc(text, 1500)}`);
|
|
6780
6876
|
} else if (p.type === "tool") {
|
|
6877
|
+
if (p.side === true) continue;
|
|
6781
6878
|
const tool3 = p.tool ?? "";
|
|
6782
|
-
if (tool3
|
|
6879
|
+
if (isSideTool(tool3, p.state?.input)) continue;
|
|
6783
6880
|
const status = p.state?.status;
|
|
6784
6881
|
const summary = inputSummary(p.state?.input);
|
|
6785
|
-
|
|
6882
|
+
let result = summarizeToolResult(p.state);
|
|
6883
|
+
let suppressInput = false;
|
|
6884
|
+
if (tool3 === "task" && typeof p.childTitle === "string" && collapse(p.childTitle) !== "") {
|
|
6885
|
+
const title = trunc(collapse(p.childTitle), 160);
|
|
6886
|
+
result = title;
|
|
6887
|
+
if (typeof p.childOutcome === "string" && collapse(p.childOutcome) !== "") {
|
|
6888
|
+
result = `${title}: ${trunc(collapse(p.childOutcome), 600)}`;
|
|
6889
|
+
}
|
|
6890
|
+
if (summary && title.startsWith(summary)) suppressInput = true;
|
|
6891
|
+
}
|
|
6786
6892
|
let line = `[tool] ${tool3}`;
|
|
6787
6893
|
if (status) line += ` (${status})`;
|
|
6788
|
-
const inputPart = summary ? trunc(summary, 220) : "";
|
|
6894
|
+
const inputPart = summary && !suppressInput ? trunc(summary, 220) : "";
|
|
6789
6895
|
let rest = "";
|
|
6790
6896
|
if (inputPart) rest += ` ${inputPart}`;
|
|
6791
6897
|
if (result) rest += ` -> ${result}`;
|
|
@@ -6910,12 +7016,17 @@ var DEFAULT_MAX_ENTRIES = 8;
|
|
|
6910
7016
|
var MAX_ENTRIES = 10;
|
|
6911
7017
|
var PROMPT_TIMEOUT_MS = 9e4;
|
|
6912
7018
|
var SYSTEM = `You are the session timeline summarizer. You receive a slice of a coding session transcript.
|
|
6913
|
-
|
|
7019
|
+
Maintain a timeline of the project's own work: features, fixes, refactors, behavior changes, user-visible capabilities, architectural decisions, data/model changes, and documentation that defines project behavior.
|
|
7020
|
+
Write 1-8 timeline entries describing that project work.
|
|
6914
7021
|
|
|
6915
7022
|
Rules:
|
|
6916
7023
|
- One entry per line, each starting with "- ".
|
|
6917
7024
|
- Each entry is 6-16 words, past tense, plain text.
|
|
6918
|
-
- Cover each distinct task in the slice. Do not drop a
|
|
7025
|
+
- Cover each distinct project task in the slice. Do not drop a feature, fix, refactor, behavior change, user-visible capability, architectural decision, data/model change, or documentation that defines project behavior; merge only exact duplicates.
|
|
7026
|
+
- Record project test changes at the capability level, not as housekeeping; do not report counts.
|
|
7027
|
+
- Ignore self-maintenance and infrastructure: the context map/cartographer, timeline rebuilding/summarization, model-pool status/rechecks, plugin install/update, agent/command configuration, formatting/lint/dependency churn, and build/dist artifacts.
|
|
7028
|
+
- Name modules, components, services, or capabilities when they are the subject. Do not list file paths, line numbers, counts, commit hashes, or build artifacts.
|
|
7029
|
+
- Record decisions or findings that changed the plan, not routine searches or reads.
|
|
6919
7030
|
- Describe the TASK or OUTCOME, not the implementation. "Pool: added bucket-based routing" is right; "added b:<name> to pool.ts and agent-pool.ts" is too detailed.
|
|
6920
7031
|
- Never describe the mechanism: no flags, aliases, overrides, endpoints, probes, config keys, declarations, tags, or file changes. State what changed for the system: which capability now works, which bug is fixed, which behavior changed.
|
|
6921
7032
|
- Never use these words in an entry: tag, alias, override, flag, probe, endpoint, config key, catalog, file, line, count.
|
|
@@ -6923,13 +7034,12 @@ Rules:
|
|
|
6923
7034
|
- Use a short area prefix when it clarifies: "Pool:", "Providers:", "Config:", "Docs:", "Tests:", "Fix:", "Feature:".
|
|
6924
7035
|
- Name the capability, behavior, feature, bug, or decision. Do not name files, paths, functions, commands, environment variables, API keys, raw model IDs, test file names, or build artifacts. Provider and product names are allowed when they identify the task.
|
|
6925
7036
|
- Do not include counts of slots, providers, tests, lines, or files.
|
|
6926
|
-
- Omit internal housekeeping: builds, dist rebuilds, test-file additions, formatting, and dependency churn. If the slice only contains housekeeping, output nothing.
|
|
6927
7037
|
- Describe what was DONE or DECIDED. Never write "User asked...", "Assistant said...", "Discussed...", "Reviewed...", "Explained...", or "Considered...".
|
|
6928
7038
|
- Merge related changes into one stage; do not repeat the same task in different words.
|
|
6929
7039
|
- No markdown: no bold, italics, backticks, or code blocks.
|
|
6930
7040
|
- If a technical detail is the essence of the task, name it at the capability level: "Added Cloudflare Workers AI as a provider", not its account-id variables.
|
|
6931
|
-
- If the slice contains no
|
|
6932
|
-
- Output only the entries. No headings, no preamble, no closing sentence.
|
|
7041
|
+
- If the slice contains no project work, output exactly NO_PROJECT_CHANGES and nothing else. Otherwise output only the bullets.
|
|
7042
|
+
- Output only the entries or NO_PROJECT_CHANGES. No headings, no preamble, no closing sentence.
|
|
6933
7043
|
|
|
6934
7044
|
Example output:
|
|
6935
7045
|
- Pool: added bucket-based routing for model groups.
|
|
@@ -6957,7 +7067,7 @@ This:
|
|
|
6957
7067
|
- Pool: moved shared-window models to the end.
|
|
6958
7068
|
- Providers: replaced the retired Gemini 2.5 Flash with Gemini 3.5 Flash.
|
|
6959
7069
|
|
|
6960
|
-
Your entire response must consist only of lines beginning with "- ".`;
|
|
7070
|
+
Your entire response must consist only of lines beginning with "- ", or exactly NO_PROJECT_CHANGES.`;
|
|
6961
7071
|
function errorText(value) {
|
|
6962
7072
|
if (value instanceof Error) return value.message;
|
|
6963
7073
|
if (typeof value === "string") return value;
|
|
@@ -6972,6 +7082,85 @@ function errorText(value) {
|
|
|
6972
7082
|
}
|
|
6973
7083
|
return String(value);
|
|
6974
7084
|
}
|
|
7085
|
+
var MAX_TASK_CHILDREN_EXPANDED = 40;
|
|
7086
|
+
async function expandTaskSessions(client, messages, directory, owned, onError, cursor) {
|
|
7087
|
+
const projectOwnsTooling = existsSync5(join12(directory, "src/plugins/timeline.ts"));
|
|
7088
|
+
let attempts = 0;
|
|
7089
|
+
const expandedMessages = [];
|
|
7090
|
+
let startIndex = 0;
|
|
7091
|
+
if (cursor) {
|
|
7092
|
+
const cursorIndex = messages.findIndex((m) => m.info.id === cursor);
|
|
7093
|
+
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
|
|
7094
|
+
}
|
|
7095
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
7096
|
+
const message = messages[messageIndex];
|
|
7097
|
+
if (messageIndex < startIndex) {
|
|
7098
|
+
expandedMessages.push(message);
|
|
7099
|
+
continue;
|
|
7100
|
+
}
|
|
7101
|
+
const parts = message.parts ?? [];
|
|
7102
|
+
let changed = false;
|
|
7103
|
+
const nextParts = [];
|
|
7104
|
+
for (const part of parts) {
|
|
7105
|
+
if (message.info.role === "assistant" && part.type === "text" && !projectOwnsTooling && mentionsSideMaintenance(part.text ?? "")) {
|
|
7106
|
+
nextParts.push({ ...part, side: true });
|
|
7107
|
+
changed = true;
|
|
7108
|
+
continue;
|
|
7109
|
+
}
|
|
7110
|
+
if (part.type !== "tool" || part.tool !== "task" || attempts >= MAX_TASK_CHILDREN_EXPANDED) {
|
|
7111
|
+
nextParts.push(part);
|
|
7112
|
+
continue;
|
|
7113
|
+
}
|
|
7114
|
+
if (isSideMaintenanceTask(part.state?.input, directory, projectOwnsTooling)) {
|
|
7115
|
+
nextParts.push({ ...part, side: true });
|
|
7116
|
+
continue;
|
|
7117
|
+
}
|
|
7118
|
+
if (isSideTool("task", part.state?.input)) {
|
|
7119
|
+
nextParts.push({ ...part, side: true });
|
|
7120
|
+
continue;
|
|
7121
|
+
}
|
|
7122
|
+
if (typeof part.childTitle === "string" && part.childTitle.trim() !== "") {
|
|
7123
|
+
nextParts.push(part);
|
|
7124
|
+
continue;
|
|
7125
|
+
}
|
|
7126
|
+
const childId = taskChildSessionId(part);
|
|
7127
|
+
if (!childId || owned.has(childId)) {
|
|
7128
|
+
nextParts.push(part);
|
|
7129
|
+
continue;
|
|
7130
|
+
}
|
|
7131
|
+
attempts++;
|
|
7132
|
+
try {
|
|
7133
|
+
const fetched = await client.session.get({ path: { id: childId }, query: { directory } });
|
|
7134
|
+
const info = fetched?.data;
|
|
7135
|
+
const rawTitle = typeof info?.title === "string" ? info.title.trim() : "";
|
|
7136
|
+
if (!rawTitle || isInternalTitle(info?.title)) {
|
|
7137
|
+
nextParts.push(part);
|
|
7138
|
+
continue;
|
|
7139
|
+
}
|
|
7140
|
+
const childRes = await client.session.messages({ path: { id: childId }, query: { directory } });
|
|
7141
|
+
const childMessages = childRes?.data ?? [];
|
|
7142
|
+
let childOutcome;
|
|
7143
|
+
for (let i = childMessages.length - 1; i >= 0; i--) {
|
|
7144
|
+
const childMessage = childMessages[i];
|
|
7145
|
+
if (childMessage?.info?.role !== "assistant") continue;
|
|
7146
|
+
const textParts = (childMessage.parts ?? []).filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text ?? "");
|
|
7147
|
+
const joined = textParts.join("\n").replace(/\s+/g, " ").trim();
|
|
7148
|
+
if (!joined) continue;
|
|
7149
|
+
childOutcome = joined.length > 800 ? joined.slice(0, 800) : joined;
|
|
7150
|
+
break;
|
|
7151
|
+
}
|
|
7152
|
+
const cloned = childOutcome ? { ...part, childTitle: rawTitle, childOutcome } : { ...part, childTitle: rawTitle };
|
|
7153
|
+
nextParts.push(cloned);
|
|
7154
|
+
changed = true;
|
|
7155
|
+
} catch (error) {
|
|
7156
|
+
if (onError) onError("expand-task-session failed", { childId, error });
|
|
7157
|
+
nextParts.push(part);
|
|
7158
|
+
}
|
|
7159
|
+
}
|
|
7160
|
+
expandedMessages.push(changed ? { ...message, parts: nextParts } : message);
|
|
7161
|
+
}
|
|
7162
|
+
return expandedMessages;
|
|
7163
|
+
}
|
|
6975
7164
|
function timelineHooks(input, options) {
|
|
6976
7165
|
const { client, directory } = input;
|
|
6977
7166
|
const opts = options ?? {};
|
|
@@ -7179,13 +7368,17 @@ function timelineHooks(input, options) {
|
|
|
7179
7368
|
const textParts = parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n").trim();
|
|
7180
7369
|
const reasoningParts = parts.filter((part) => part.type === "reasoning").map((part) => part.text ?? "").join("\n").trim();
|
|
7181
7370
|
const text = textParts || reasoningParts;
|
|
7371
|
+
const trimmed = text.trim();
|
|
7372
|
+
if (trimmed === "NO_PROJECT_CHANGES") {
|
|
7373
|
+
return { ok: true, bullets: [] };
|
|
7374
|
+
}
|
|
7375
|
+
if (trimmed === "") {
|
|
7376
|
+
return { ok: false, bullets: [], error: "empty summarizer output" };
|
|
7377
|
+
}
|
|
7182
7378
|
const bullets = parseBullets(text, maxEntries);
|
|
7183
7379
|
if (bullets.length > 0) {
|
|
7184
7380
|
return { ok: true, bullets };
|
|
7185
7381
|
}
|
|
7186
|
-
if (text.trim() === "") {
|
|
7187
|
-
return { ok: true, bullets: [] };
|
|
7188
|
-
}
|
|
7189
7382
|
const salvaged = salvageBullets(text, maxEntries);
|
|
7190
7383
|
if (salvaged.length > 0) {
|
|
7191
7384
|
log("warn", "timeline: salvaged non-bulleted output", { sessionID: id, text: text.slice(0, 300) });
|
|
@@ -7228,7 +7421,8 @@ function timelineHooks(input, options) {
|
|
|
7228
7421
|
}
|
|
7229
7422
|
const response = await client.session.messages({ path: { id }, query: { directory } });
|
|
7230
7423
|
const messages = response?.data ?? [];
|
|
7231
|
-
const
|
|
7424
|
+
const expanded = await expandTaskSessions(client, messages, directory, owned, debug, state2.cursor?.messageID);
|
|
7425
|
+
const excerpt = buildExcerpt(expanded, state2.cursor);
|
|
7232
7426
|
if (!excerpt.text) {
|
|
7233
7427
|
state2.pending = false;
|
|
7234
7428
|
state2.failures = 0;
|
|
@@ -7286,7 +7480,7 @@ function timelineHooks(input, options) {
|
|
|
7286
7480
|
const info = infoRes?.data;
|
|
7287
7481
|
if (!info) return "Session not found.";
|
|
7288
7482
|
const msgRes = await client.session.messages({ path: { id: rootId }, query: { directory } });
|
|
7289
|
-
|
|
7483
|
+
let messages = msgRes?.data ?? [];
|
|
7290
7484
|
writeFileSync10(temp, "");
|
|
7291
7485
|
for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
|
|
7292
7486
|
if (abort?.aborted) {
|
|
@@ -7296,6 +7490,7 @@ function timelineHooks(input, options) {
|
|
|
7296
7490
|
}
|
|
7297
7491
|
return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
|
|
7298
7492
|
}
|
|
7493
|
+
messages = await expandTaskSessions(client, messages, directory, owned, debug, cursor?.messageID);
|
|
7299
7494
|
const excerpt = buildExcerpt(messages, cursor);
|
|
7300
7495
|
if (!excerpt.text) break;
|
|
7301
7496
|
const prior = readEntriesAt(temp, 4);
|
|
@@ -7449,6 +7644,96 @@ function timelineHooks(input, options) {
|
|
|
7449
7644
|
};
|
|
7450
7645
|
}
|
|
7451
7646
|
|
|
7647
|
+
// src/lib/timer-migration.ts
|
|
7648
|
+
import { execFile } from "node:child_process";
|
|
7649
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync9, readFileSync as readFileSync10, renameSync as renameSync8, writeFileSync as writeFileSync11 } from "node:fs";
|
|
7650
|
+
import { homedir as homedir8 } from "node:os";
|
|
7651
|
+
import { dirname as dirname6, join as join13 } from "node:path";
|
|
7652
|
+
var TIMER_MIGRATION_ENV = "LACODE_NO_TIMER_MIGRATION";
|
|
7653
|
+
var TIMER_MIGRATION_STATE_VERSION = 1;
|
|
7654
|
+
var defaultRun2 = (file, args, options) => new Promise((resolve5, reject) => {
|
|
7655
|
+
execFile(file, args, { stdio: options?.stdio ?? "ignore" }, (error) => {
|
|
7656
|
+
if (error) reject(error);
|
|
7657
|
+
else resolve5();
|
|
7658
|
+
});
|
|
7659
|
+
});
|
|
7660
|
+
function timerMigrationStatePath(home = homedir8()) {
|
|
7661
|
+
return join13(home, ".local", "share", "opencode", "lacode", "migrations.json");
|
|
7662
|
+
}
|
|
7663
|
+
function timerMigrationUnitPath(home = homedir8()) {
|
|
7664
|
+
return join13(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
|
|
7665
|
+
}
|
|
7666
|
+
function readState2(path4) {
|
|
7667
|
+
try {
|
|
7668
|
+
return JSON.parse(readFileSync10(path4, "utf8"));
|
|
7669
|
+
} catch {
|
|
7670
|
+
return {};
|
|
7671
|
+
}
|
|
7672
|
+
}
|
|
7673
|
+
function writeState2(path4, state2) {
|
|
7674
|
+
mkdirSync9(dirname6(path4), { recursive: true });
|
|
7675
|
+
const tmp = `${path4}.${process.pid}.tmp`;
|
|
7676
|
+
writeFileSync11(tmp, JSON.stringify(state2) + "\n");
|
|
7677
|
+
renameSync8(tmp, path4);
|
|
7678
|
+
}
|
|
7679
|
+
async function runTimerMigration(options = {}) {
|
|
7680
|
+
const env = options.env ?? process.env;
|
|
7681
|
+
if (env[TIMER_MIGRATION_ENV] === "1" || env.NODE_TEST_CONTEXT !== void 0) return { action: "disabled" };
|
|
7682
|
+
const platform = options.platform ?? process.platform;
|
|
7683
|
+
if (platform !== "linux") return { action: "skipped", reason: "not-linux" };
|
|
7684
|
+
const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
|
|
7685
|
+
const version = options.version ?? readInstalledVersion(packageRoot);
|
|
7686
|
+
if (!version) return { action: "skipped", reason: "no-version" };
|
|
7687
|
+
const home = options.home ?? homedir8();
|
|
7688
|
+
const statePath = options.statePath ?? timerMigrationStatePath(home);
|
|
7689
|
+
const state2 = readState2(statePath);
|
|
7690
|
+
if (state2.timersVersion === version && existsSync6(timerMigrationUnitPath(home))) {
|
|
7691
|
+
return { action: "up-to-date", version };
|
|
7692
|
+
}
|
|
7693
|
+
const run = options.run ?? defaultRun2;
|
|
7694
|
+
try {
|
|
7695
|
+
await run("systemctl", ["--user", "show-environment"], { stdio: "ignore" });
|
|
7696
|
+
} catch {
|
|
7697
|
+
return { action: "skipped", reason: "no-systemd" };
|
|
7698
|
+
}
|
|
7699
|
+
const script = join13(packageRoot, "scripts", "refresh-pool.mjs");
|
|
7700
|
+
if (!existsSync6(script)) return { action: "failed", version, error: `timer script not found: ${script}` };
|
|
7701
|
+
try {
|
|
7702
|
+
await run(process.execPath, [script, "--install-timer", "--stable"], { stdio: "ignore" });
|
|
7703
|
+
} catch (error) {
|
|
7704
|
+
return { action: "failed", version, error: String(error?.message ?? error) };
|
|
7705
|
+
}
|
|
7706
|
+
try {
|
|
7707
|
+
writeState2(statePath, {
|
|
7708
|
+
version: TIMER_MIGRATION_STATE_VERSION,
|
|
7709
|
+
timersVersion: version,
|
|
7710
|
+
installedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
7711
|
+
});
|
|
7712
|
+
} catch (error) {
|
|
7713
|
+
options.log?.("warn", `timer migration state not written: ${String(error?.message ?? error)}`);
|
|
7714
|
+
}
|
|
7715
|
+
return { action: "installed", version };
|
|
7716
|
+
}
|
|
7717
|
+
|
|
7718
|
+
// src/plugins/timer-migration.ts
|
|
7719
|
+
function timerMigrationHooks(input, options) {
|
|
7720
|
+
if (options?.enabled === false) return {};
|
|
7721
|
+
const log = (level, message, extra) => {
|
|
7722
|
+
void input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
|
|
7723
|
+
});
|
|
7724
|
+
};
|
|
7725
|
+
void runTimerMigration({
|
|
7726
|
+
packageRoot: PACKAGE_ROOT,
|
|
7727
|
+
version: readInstalledVersion(PACKAGE_ROOT),
|
|
7728
|
+
log
|
|
7729
|
+
}).then((result) => {
|
|
7730
|
+
if (result.action === "installed") log("info", `timer migration installed for ${result.version}`);
|
|
7731
|
+
else if (result.action === "failed") log("warn", `timer migration failed: ${result.error}`);
|
|
7732
|
+
else log("debug", `timer migration: ${result.action}`);
|
|
7733
|
+
}).catch((error) => log("error", `timer migration threw: ${String(error)}`));
|
|
7734
|
+
return {};
|
|
7735
|
+
}
|
|
7736
|
+
|
|
7452
7737
|
// src/index.ts
|
|
7453
7738
|
var inertHooks = {
|
|
7454
7739
|
config: async () => {
|
|
@@ -7489,6 +7774,7 @@ var LaCode = (async (input, options) => {
|
|
|
7489
7774
|
const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
|
|
7490
7775
|
const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
|
|
7491
7776
|
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
7777
|
+
void timerMigrationHooks(input, options);
|
|
7492
7778
|
const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
|
|
7493
7779
|
const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
|
|
7494
7780
|
const compat = await settled("provider-compat", () => providerCompatHooks(input)) ?? inertHooks;
|
package/dist/pool.js
CHANGED
|
@@ -109,7 +109,14 @@ function parseSlots(parsed, origin) {
|
|
|
109
109
|
problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
|
|
110
110
|
}
|
|
111
111
|
const notify = coerceNotify(entry.notify, at, model, problems);
|
|
112
|
-
|
|
112
|
+
const rawContext = entry?.context;
|
|
113
|
+
let context;
|
|
114
|
+
if (rawContext !== void 0) {
|
|
115
|
+
const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
|
|
116
|
+
if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
|
|
117
|
+
else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
|
|
118
|
+
}
|
|
119
|
+
slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context } });
|
|
113
120
|
});
|
|
114
121
|
if (slots.length > SLOTS_WARN_AT) {
|
|
115
122
|
problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
|
|
@@ -129,18 +136,26 @@ function parseTextSlots(text, origin) {
|
|
|
129
136
|
let weight;
|
|
130
137
|
let bucket;
|
|
131
138
|
let notify;
|
|
139
|
+
let context;
|
|
132
140
|
for (const token of rest) {
|
|
133
141
|
if (token.startsWith("notify:")) {
|
|
134
142
|
if (notify === void 0) notify = token.slice("notify:".length);
|
|
135
143
|
continue;
|
|
136
144
|
}
|
|
145
|
+
if (token.startsWith("c:")) {
|
|
146
|
+
if (context === void 0) {
|
|
147
|
+
const parsedContext = Number(token.slice(2));
|
|
148
|
+
if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
137
152
|
if (token.startsWith("b:")) {
|
|
138
153
|
if (bucket === void 0) bucket = token.slice(2);
|
|
139
154
|
} else if (weight === void 0) {
|
|
140
155
|
weight = token;
|
|
141
156
|
}
|
|
142
157
|
}
|
|
143
|
-
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify } });
|
|
158
|
+
entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context } });
|
|
144
159
|
}
|
|
145
160
|
return parseSlots({ slots: entries }, origin);
|
|
146
161
|
}
|
|
@@ -713,20 +728,50 @@ function snapshot(s) {
|
|
|
713
728
|
}
|
|
714
729
|
var loadOf = (counts, slot) => counts.get(slot) ?? 0;
|
|
715
730
|
var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
|
|
716
|
-
function pickSlot(s, snap) {
|
|
731
|
+
function pickSlot(s, snap, neededContext) {
|
|
717
732
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
718
733
|
const configured = slots.filter((slot) => providerReady(s, slot.model));
|
|
719
734
|
const usable = configured.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
720
735
|
const pool = usable.length > 0 ? usable : configured.length > 0 ? configured : slots;
|
|
721
|
-
|
|
722
|
-
|
|
736
|
+
if (neededContext === void 0) {
|
|
737
|
+
for (const slot of usable) {
|
|
738
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
739
|
+
}
|
|
740
|
+
return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
|
|
741
|
+
}
|
|
742
|
+
const adequate = usable.filter((slot) => slot.context == null || slot.context >= neededContext);
|
|
743
|
+
const adequateKnown = adequate.filter((slot) => slot.context != null);
|
|
744
|
+
const candidates = adequate.length > 0 ? adequate : usable;
|
|
745
|
+
const fallbackPool = adequate.length > 0 ? adequate : pool;
|
|
746
|
+
if (adequateKnown.length === 0) {
|
|
747
|
+
const known = usable.filter((slot) => slot.context != null);
|
|
748
|
+
if (known.length > 0) {
|
|
749
|
+
const largest = Math.max(...known.map((slot) => slot.context));
|
|
750
|
+
const largestSlots = known.filter((slot) => slot.context === largest);
|
|
751
|
+
for (const slot of largestSlots) {
|
|
752
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
753
|
+
}
|
|
754
|
+
return largestSlots.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, largestSlots[0]);
|
|
755
|
+
}
|
|
756
|
+
for (const slot of candidates) {
|
|
757
|
+
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
758
|
+
}
|
|
759
|
+
return fallbackPool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, fallbackPool[0]);
|
|
760
|
+
}
|
|
761
|
+
const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
|
|
762
|
+
if (underWeight.length > 0) {
|
|
763
|
+
const known = underWeight.filter((slot) => slot.context != null);
|
|
764
|
+
if (known.length === 0) return underWeight[0];
|
|
765
|
+
return known.reduce((best, slot) => slot.context < best.context ? slot : best, known[0]);
|
|
723
766
|
}
|
|
724
|
-
return
|
|
767
|
+
return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
|
|
725
768
|
}
|
|
726
|
-
function poolPass(s, snap) {
|
|
769
|
+
function poolPass(s, snap, neededContext) {
|
|
727
770
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
728
771
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
729
|
-
|
|
772
|
+
const candidates = neededContext === void 0 ? usable : usable.filter((slot) => slot.context == null || slot.context >= neededContext);
|
|
773
|
+
const pool = candidates.length > 0 ? candidates : usable;
|
|
774
|
+
if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
|
|
730
775
|
return "overflow";
|
|
731
776
|
}
|
|
732
777
|
function agentFor(base, slot) {
|