@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 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.0.0-beta.0...HEAD
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
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
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
- for (const slot of usable) {
722
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
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 pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
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
- if (usable.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
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 slot = pickSlot(s, snap);
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 === "timeline_reprocess") continue;
6879
+ if (isSideTool(tool3, p.state?.input)) continue;
6783
6880
  const status = p.state?.status;
6784
6881
  const summary = inputSummary(p.state?.input);
6785
- const result = summarizeToolResult(p.state);
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
- Write 1-8 timeline entries describing the work as tasks, features, fixes, or stages.
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 provider, feature, fix, config, docs, or test change; merge only exact duplicates.
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 substantive work or decision, output nothing.
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 excerpt = buildExcerpt(messages, state2.cursor);
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
- const messages = msgRes?.data ?? [];
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
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify });
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
- for (const slot of usable) {
722
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
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 pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
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
- if (usable.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
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) {