@lawaty/lacode 1.3.0-beta.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,16 @@ commit as the changelog entry it describes.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.3.1] — reprocess-timeline rebuilds the caller's timeline, and the pool refreshes daily
13
+
14
+ ### Added
15
+
16
+ - Pool: `scripts/refresh-pool.mjs` rebuilds `~/.config/lacode/pool.txt` from the score output — opencode-go free models first, preserved unscored providers in the middle, the best opencode model last — with an atomic, fail-closed write. `--install-timer` installs a daily systemd user timer; `--from-scores` reuses an existing `model-scores.json`.
17
+
18
+ ### Fixed
19
+
20
+ - Timeline: `/reprocess-timeline` now runs inline (`subtask: false`) instead of in a child session, so `timeline_reprocess` rebuilds the caller's timeline rather than the child's.
21
+
12
22
  ## [1.3.0-beta.9] — a timeline reply without bullets still lands
13
23
 
14
24
  ### Fixed
@@ -1105,7 +1115,8 @@ experimental; `latest` remains 0.4.2.
1105
1115
 
1106
1116
  - First published release: pooled subagents over a shared set of models.
1107
1117
 
1108
- [Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.0-beta.9...HEAD
1118
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.1...HEAD
1119
+ [1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
1109
1120
  [1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
1110
1121
  [1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
1111
1122
  [1.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.0
package/dist/commands.js CHANGED
@@ -225,9 +225,13 @@ $ARGUMENTS
225
225
 
226
226
  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.`
227
227
  },
228
+ // `agent: "implement"` is a subagent, so without `subtask: false` this command
229
+ // would run in a child session and `timeline_reprocess` would rebuild the
230
+ // child's timeline instead of the caller's.
228
231
  "reprocess-timeline": {
229
232
  description: "Rebuild this session's timeline from its full transcript",
230
233
  agent: "implement",
234
+ subtask: false,
231
235
  template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
232
236
  },
233
237
  "image-in-cb": {
package/dist/index.js CHANGED
@@ -2085,9 +2085,13 @@ $ARGUMENTS
2085
2085
 
2086
2086
  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.`
2087
2087
  },
2088
+ // `agent: "implement"` is a subagent, so without `subtask: false` this command
2089
+ // would run in a child session and `timeline_reprocess` would rebuild the
2090
+ // child's timeline instead of the caller's.
2088
2091
  "reprocess-timeline": {
2089
2092
  description: "Rebuild this session's timeline from its full transcript",
2090
2093
  agent: "implement",
2094
+ subtask: false,
2091
2095
  template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
2092
2096
  },
2093
2097
  "image-in-cb": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.3.0-beta.9",
3
+ "version": "1.3.1",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,7 +50,8 @@
50
50
  "CHANGELOG.md",
51
51
  "README.md",
52
52
  "docs",
53
- "scripts/score-models.mjs"
53
+ "scripts/score-models.mjs",
54
+ "scripts/refresh-pool.mjs"
54
55
  ],
55
56
  "scripts": {
56
57
  "build": "node scripts/build.mjs",
@@ -58,7 +59,8 @@
58
59
  "postpublish": "node scripts/sync-hosts.mjs && ./bin/oc-sync",
59
60
  "test": "node --test tests/",
60
61
  "benchmark": "node tests/benchmark/run.mjs",
61
- "score": "node scripts/score-models.mjs"
62
+ "score": "node scripts/score-models.mjs",
63
+ "refresh-pool": "node scripts/refresh-pool.mjs"
62
64
  },
63
65
  "devDependencies": {
64
66
  "esbuild": "^0.28.2"
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // Daily-refreshable pool builder.
3
+ //
4
+ // Reads the scorer's output (~/.config/lacode/model-scores.json) and proposes a
5
+ // rewritten ~/.config/lacode/pool.txt. It never partially writes and never
6
+ // writes on a fail-closed condition.
7
+ //
8
+ // Layout (exact, per spec):
9
+ // 1. every free opencode-go model with ok:true and a numeric score, one slot
10
+ // each, sorted by score descending, weight 4
11
+ // 2. every slot from the current pool whose provider is NOT present in the
12
+ // score file, preserved exactly (raw line text, weight/bucket/notify, order)
13
+ // 3. exactly one free opencode model — the best ok:true numeric score —
14
+ // weight 2, with b:opencode
15
+ //
16
+ // Total = #opencode-go free + #preserved unscored + 1.
17
+ //
18
+ // This file ships as source and must run with plain `node` (ESM, no TS).
19
+
20
+ import { mkdirSync, renameSync, writeFileSync, readFileSync, existsSync } from "node:fs"
21
+ import { execFileSync } from "node:child_process"
22
+ import { homedir } from "node:os"
23
+ import { dirname, join, resolve } from "node:path"
24
+ import { fileURLToPath } from "node:url"
25
+
26
+ const HOME = homedir()
27
+ export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
28
+ export const DEFAULT_POOL_PATH = join(HOME, ".config", "lacode", "pool.txt")
29
+ export const SYSTEMD_USER_DIR = (home = HOME) => join(home, ".config", "systemd", "user")
30
+ export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
31
+ export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
32
+
33
+ const isSlotLine = (line) => {
34
+ const stripped = String(line).split("#")[0].trim()
35
+ return stripped.length > 0 && stripped.includes("/")
36
+ }
37
+
38
+ const providerOf = (modelOrLine) => {
39
+ const stripped = String(modelOrLine).split("#")[0].trim()
40
+ const token = stripped.split(/\s+/)[0] || ""
41
+ const slash = token.indexOf("/")
42
+ return slash < 0 ? token : token.slice(0, slash)
43
+ }
44
+
45
+ const isFree = (modelId) => {
46
+ const id = String(modelId || "")
47
+ return id.endsWith(":free") || id.endsWith("-free")
48
+ }
49
+
50
+ const numericScore = (row) => typeof row?.score === "number" && Number.isFinite(row.score)
51
+
52
+ const okFreeRows = (providerEntry) => {
53
+ const rows = Array.isArray(providerEntry?.rows) ? providerEntry.rows : []
54
+ return rows.filter((row) => row?.ok === true && numericScore(row) && isFree(row.model))
55
+ }
56
+
57
+ function splitHeaderAndSlots(text) {
58
+ const lines = String(text ?? "").split(/\r?\n/)
59
+ // Drop a single trailing empty element produced by a final newline so we can
60
+ // reason about the file's logical lines; we re-add a final newline on output.
61
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
62
+ let first = -1
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (isSlotLine(lines[i])) { first = i; break }
65
+ }
66
+ if (first === -1) return { header: lines, slots: [] }
67
+ return { header: lines.slice(0, first), slots: lines.slice(first) }
68
+ }
69
+
70
+ function refreshComment(now, scoresGeneratedAt) {
71
+ const when = now instanceof Date ? now.toISOString() : String(now)
72
+ const gen = scoresGeneratedAt ? ` (scores generated ${scoresGeneratedAt})` : ""
73
+ return `# Refreshed ${when} from model-scores.json${gen}: opencode-go free models first, best opencode last.`
74
+ }
75
+
76
+ function withRefreshComment(headerLines, comment) {
77
+ const kept = headerLines.filter((line) => !/^#\s*Refreshed\b/.test(String(line)))
78
+ return [...kept, comment]
79
+ }
80
+
81
+ function formatGeneratedLine(model, weight, bucket) {
82
+ const parts = [String(model), String(weight)]
83
+ if (bucket) parts.push(`b:${bucket}`)
84
+ return parts.join(" ")
85
+ }
86
+
87
+ /**
88
+ * Pure builder. Throws on every fail-closed condition; never touches the filesystem.
89
+ *
90
+ * @param {{ scores: any, currentPoolText: string, now?: Date|string }} input
91
+ * @returns {{ text: string, slots: string[], warnings: string[], chosen: { opencodeGo: string[], opencode: string, preserved: string[] } }}
92
+ */
93
+ export function buildPool(input) {
94
+ const warnings = []
95
+ const scores = input?.scores
96
+ if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers)) {
97
+ throw new Error("score file is missing or invalid (expected { providers: [...] })")
98
+ }
99
+
100
+ const providerEntries = scores.providers
101
+ const scoredProviders = new Set(
102
+ providerEntries.map((p) => String(p?.provider ?? "").trim()).filter(Boolean),
103
+ )
104
+
105
+ const goEntry = providerEntries.find((p) => p?.provider === "opencode-go")
106
+ const goRows = okFreeRows(goEntry).sort((a, b) => b.score - a.score)
107
+ if (goRows.length === 0) {
108
+ throw new Error("no free opencode-go model with ok:true and a numeric score is available; refusing to write pool.txt")
109
+ }
110
+
111
+ const opEntry = providerEntries.find((p) => p?.provider === "opencode")
112
+ const opRows = okFreeRows(opEntry).sort((a, b) => b.score - a.score)
113
+ if (opRows.length === 0) {
114
+ throw new Error("no free opencode model with ok:true and a numeric score is available; refusing to write pool.txt")
115
+ }
116
+ const bestOp = opRows[0].model
117
+
118
+ const { header, slots: currentSlots } = splitHeaderAndSlots(input?.currentPoolText ?? "")
119
+ const preserved = currentSlots.filter((line) => {
120
+ if (!isSlotLine(line)) return false
121
+ return !scoredProviders.has(providerOf(line))
122
+ })
123
+
124
+ const block1 = goRows.map((row) => formatGeneratedLine(row.model, 4))
125
+ const block3 = [formatGeneratedLine(bestOp, 2, "opencode")]
126
+ const outSlots = [...block1, ...preserved, ...block3]
127
+
128
+ const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
129
+ const outHeader = withRefreshComment(header, comment)
130
+
131
+ const text = [...outHeader, "", ...outSlots, ""].join("\n")
132
+ return {
133
+ text,
134
+ slots: outSlots,
135
+ warnings,
136
+ chosen: {
137
+ opencodeGo: goRows.map((r) => r.model),
138
+ opencode: bestOp,
139
+ preserved: preserved.map((l) => String(l).split("#")[0].trim().split(/\s+/)[0]),
140
+ },
141
+ }
142
+ }
143
+
144
+ function readJsonFile(path) {
145
+ if (!existsSync(path)) throw new Error(`file not found: ${path}`)
146
+ const raw = readFileSync(path, "utf8")
147
+ try {
148
+ return JSON.parse(raw)
149
+ } catch (error) {
150
+ throw new Error(`invalid JSON in ${path}: ${String(error?.message ?? error)}`)
151
+ }
152
+ }
153
+
154
+ function readTextFile(path) {
155
+ if (!existsSync(path)) return ""
156
+ return readFileSync(path, "utf8")
157
+ }
158
+
159
+ function atomicWriteSimple(path, text) {
160
+ const dir = dirname(path)
161
+ const tmp = join(dir, `.pool.txt.tmp-${process.pid}-${Date.now()}`)
162
+ writeFileSync(tmp, text, "utf8")
163
+ renameSync(tmp, path)
164
+ }
165
+
166
+ function runScorer(max) {
167
+ const here = dirname(fileURLToPath(import.meta.url))
168
+ const scorer = resolve(here, "score-models.mjs")
169
+ if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
170
+ const args = [scorer, "--max", String(max)]
171
+ execFileSync(process.execPath, args, { stdio: "inherit" })
172
+ }
173
+
174
+ export function renderTimerUnits(input = {}) {
175
+ const nodePath = input.nodePath || process.execPath
176
+ const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
177
+ const home = input.home || HOME
178
+ const servicePath = join(SYSTEMD_USER_DIR(home), REFRESH_SERVICE_NAME)
179
+ const timerPath = join(SYSTEMD_USER_DIR(home), REFRESH_TIMER_NAME)
180
+ const service = `[Unit]
181
+ Description=Refresh the LaCode model pool from free-model scores
182
+ After=network-online.target
183
+ Wants=network-online.target
184
+
185
+ [Service]
186
+ Type=oneshot
187
+ ExecStart=${nodePath} ${scriptPath}
188
+ Environment=PATH=${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin
189
+ Nice=10
190
+ `
191
+ const timer = `[Unit]
192
+ Description=Daily LaCode model pool refresh
193
+
194
+ [Timer]
195
+ OnCalendar=daily
196
+ RandomizedDelaySec=2h
197
+ Persistent=true
198
+ Unit=${REFRESH_SERVICE_NAME}
199
+
200
+ [Install]
201
+ WantedBy=timers.target
202
+ `
203
+ return { service, timer, servicePath, timerPath }
204
+ }
205
+
206
+ function installTimerUnits() {
207
+ const units = renderTimerUnits({
208
+ nodePath: process.execPath,
209
+ scriptPath: fileURLToPath(import.meta.url),
210
+ home: HOME,
211
+ })
212
+ mkdirSync(dirname(units.servicePath), { recursive: true })
213
+ writeFileSync(units.servicePath, units.service, "utf8")
214
+ writeFileSync(units.timerPath, units.timer, "utf8")
215
+ try {
216
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" })
217
+ execFileSync("systemctl", ["--user", "enable", "--now", REFRESH_TIMER_NAME], { stdio: "inherit" })
218
+ } catch (error) {
219
+ console.error(`failed to install systemd user timer: ${String(error?.message ?? error)}`)
220
+ console.error(`unit files written:`)
221
+ console.error(` ${units.servicePath}`)
222
+ console.error(` ${units.timerPath}`)
223
+ console.error(`manual commands:`)
224
+ console.error(` systemctl --user daemon-reload`)
225
+ console.error(` systemctl --user enable --now ${REFRESH_TIMER_NAME}`)
226
+ process.exitCode = 1
227
+ return false
228
+ }
229
+ console.log(`installed ${units.servicePath}`)
230
+ console.log(`installed ${units.timerPath}`)
231
+ console.log(`enabled ${REFRESH_TIMER_NAME}`)
232
+ return true
233
+ }
234
+
235
+ function usage() {
236
+ console.log(`Usage: node scripts/refresh-pool.mjs [options]
237
+
238
+ --from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
239
+ --install-timer install and enable a user systemd oneshot service + daily timer
240
+ --print print the proposed pool.txt and make no changes (alias: --dry-run)
241
+ --dry-run same as --print
242
+ --max N models per provider passed to the scorer (default: 20)
243
+ --scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
244
+ --pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
245
+ -h, --help this message`)
246
+ }
247
+
248
+ async function main() {
249
+ const argv = process.argv.slice(2)
250
+ if (argv.includes("--help") || argv.includes("-h")) { usage(); return }
251
+ if (argv.includes("--install-timer")) {
252
+ installTimerUnits()
253
+ return
254
+ }
255
+ const fromScores = argv.includes("--from-scores")
256
+ const printOnly = argv.includes("--print") || argv.includes("--dry-run")
257
+ const value = (flag, fallback) => {
258
+ const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
259
+ if (!hit) return fallback
260
+ return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
261
+ }
262
+ const max = Math.max(1, Number(value("--max", "20")) || 20)
263
+ const scoresPath = value("--scores", DEFAULT_SCORES_PATH)
264
+ const poolPath = value("--pool", DEFAULT_POOL_PATH)
265
+
266
+ if (!fromScores) {
267
+ try {
268
+ runScorer(max)
269
+ } catch (error) {
270
+ console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
271
+ process.exitCode = 1
272
+ return
273
+ }
274
+ }
275
+
276
+ let scores
277
+ try {
278
+ scores = readJsonFile(scoresPath)
279
+ } catch (error) {
280
+ console.error(String(error?.message ?? error))
281
+ process.exitCode = 1
282
+ return
283
+ }
284
+
285
+ const currentPoolText = readTextFile(poolPath)
286
+ let built
287
+ try {
288
+ built = buildPool({ scores, currentPoolText, now: new Date() })
289
+ } catch (error) {
290
+ console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
291
+ process.exitCode = 1
292
+ return
293
+ }
294
+
295
+ const oldSlotCount = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
296
+ const newSlotCount = built.slots.filter(isSlotLine).length
297
+ const summary = [
298
+ `opencode-go [${built.chosen.opencodeGo.join(", ")}]`,
299
+ `preserved-unscored [${built.chosen.preserved.join(", ")}]`,
300
+ `opencode-last [${built.chosen.opencode}]`,
301
+ ].join(" | ")
302
+
303
+ if (printOnly) {
304
+ process.stdout.write(built.text)
305
+ console.error(`--print: proposed ${oldSlotCount} -> ${newSlotCount} slots; no changes written. ${summary}`)
306
+ return
307
+ }
308
+
309
+ if (existsSync(poolPath)) {
310
+ try {
311
+ const existing = readFileSync(poolPath, "utf8")
312
+ if (existing === built.text) {
313
+ console.log(`unchanged: ${poolPath} (${newSlotCount} slots). ${summary}`)
314
+ return
315
+ }
316
+ } catch {}
317
+ }
318
+
319
+ try {
320
+ atomicWriteSimple(poolPath, built.text)
321
+ } catch (error) {
322
+ console.error(`failed to write ${poolPath}: ${String(error?.message ?? error)}`)
323
+ process.exitCode = 1
324
+ return
325
+ }
326
+ console.log(`wrote ${poolPath}: ${oldSlotCount} -> ${newSlotCount} slots. ${summary}`)
327
+ }
328
+
329
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
330
+ if (isMain) {
331
+ main().catch((error) => {
332
+ console.error(String(error?.stack ?? error?.message ?? error))
333
+ process.exitCode = 1
334
+ })
335
+ }