@lawaty/lacode 1.3.0-beta.9 → 2.0.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 +30 -1
- package/README.md +3 -6
- package/dist/agents.js +3 -41
- package/dist/commands.js +6 -5
- package/dist/index.js +200 -235
- package/docs/CONTEXT.md +17 -33
- package/docs/USER-STORIES.md +27 -2
- package/package.json +5 -3
- package/scripts/refresh-pool.mjs +335 -0
package/docs/CONTEXT.md
CHANGED
|
@@ -30,8 +30,7 @@ the exploration it replaces is worse than no map:
|
|
|
30
30
|
`src/orders/`". The cartographer is forbidden by name from writing directory
|
|
31
31
|
listings, copying source, or restating what the README already says.
|
|
32
32
|
- **One file per question.** Five navigation files, each with a defined scope, so
|
|
33
|
-
an agent reads one of them rather than all of them.
|
|
34
|
-
`active.md`, holds the work in flight rather than a map of the repository.
|
|
33
|
+
an agent reads one of them rather than all of them.
|
|
35
34
|
- **Canonical entry points.** The highest-value thing in the map is a short
|
|
36
35
|
"Start here" list per subsystem: the canonical files an agent should open, each
|
|
37
36
|
with a one-phrase reason.
|
|
@@ -48,8 +47,7 @@ harness that produces a number for your own workload.
|
|
|
48
47
|
|
|
49
48
|
## The files in the map
|
|
50
49
|
|
|
51
|
-
`.opencode/context/` holds the five navigation files below
|
|
52
|
-
a work list rather than a map:
|
|
50
|
+
`.opencode/context/` holds the five navigation files below:
|
|
53
51
|
|
|
54
52
|
| File | What belongs in it |
|
|
55
53
|
| --- | --- |
|
|
@@ -58,7 +56,6 @@ a work list rather than a map:
|
|
|
58
56
|
| `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
|
|
59
57
|
| `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
|
|
60
58
|
| `decisions.md` | Architectural constraints that bind future work and that the repository's own decision log does not already record. Each entry states the constraint and points at the page or path that owns it. Capped at 120 non-blank lines; decision records and deliberation are not copied here. |
|
|
61
|
-
| `active.md` | The active backlog: work in flight, grouped by the ISO week each task was opened in. A task that is not marked `[x]` is open and is never removed; a task that is finished and dated is kept for a week after it was finished, then dropped. The plugin enforces that window — see [How it stays current](#how-it-stays-current). Not a navigation file: there is no question it is the answer to. |
|
|
62
59
|
|
|
63
60
|
The project's root `AGENTS.md` is the standing-instructions home: the client's
|
|
64
61
|
own statement of intent, what they want worked on and how they want it
|
|
@@ -98,20 +95,19 @@ Every navigation entry has one shape, in every file:
|
|
|
98
95
|
the drift check.
|
|
99
96
|
|
|
100
97
|
Every file has a hard cap, counted in **non-blank lines**: architecture 150,
|
|
101
|
-
contexts 200, conventions 150, workflows 150, decisions 120
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
cartographer writes the shape, code decides what has aged out.
|
|
98
|
+
contexts 200, conventions 150, workflows 150, decisions 120. Lines are the unit
|
|
99
|
+
because they are what a reader pays for, they bound an entry that wraps to three
|
|
100
|
+
lines, and the validator can count them exactly; an entry count does not bound
|
|
101
|
+
cost, and a token count needs a tokenizer and is model-specific. The cap is
|
|
102
|
+
enforced by a post-generation validator, not only by the prompt.
|
|
103
|
+
|
|
104
|
+
A `[provisional]` entry in any navigation file is dropped 30 days after its
|
|
105
|
+
`verified` date unless it is re-verified. `[binding]` and `[disputed]` never
|
|
106
|
+
expire by age. An entry whose cited source no longer exists is **never**
|
|
107
|
+
auto-dropped: it may be the only copy, so it is reported as unarchived and kept.
|
|
108
|
+
Only provisional entries whose source still resolves are eligible for the sweep.
|
|
109
|
+
The arithmetic lives in `src/lib/map-schema.ts`; the cartographer writes the
|
|
110
|
+
shape, code decides what has aged out.
|
|
115
111
|
|
|
116
112
|
The map is **local and untracked**. It describes *your* checkout on *this*
|
|
117
113
|
machine, so committing it would churn on every session that edits a file, and
|
|
@@ -193,25 +189,13 @@ your source.
|
|
|
193
189
|
If you have defined a command of the same name, yours wins and is left exactly
|
|
194
190
|
as you wrote it — the same merge-without-clobber rule the agents follow.
|
|
195
191
|
|
|
196
|
-
`active.md` is the file the plugin maintains as a backlog rather than as a
|
|
197
|
-
navigation map. The cartographer records what happened — work in flight, and a
|
|
198
|
-
task marked finished with the date it finished — and the plugin decides what is
|
|
199
|
-
still worth keeping: the file is regrouped by week and a task that was finished
|
|
200
|
-
more than a week ago is dropped. The arithmetic lives in
|
|
201
|
-
`src/lib/active-backlog.ts`, not in the cartographer's prompt, because a rule a
|
|
202
|
-
model applies is a rule the model applies when it feels like it, and the failure
|
|
203
|
-
is silent — the backlog just grows. The plugin normalizes the file on every edit
|
|
204
|
-
of it and once when the session starts, so a hand edit is grouped and pruned
|
|
205
|
-
whether the cartographer ran or not, and the file is rewritten only when its
|
|
206
|
-
canonical form differs from what is on disk, so the write cannot loop.
|
|
207
|
-
|
|
208
192
|
The same code owns the navigation files' lifecycle, but it may only **drop** an
|
|
209
193
|
entry that is `[provisional]`, past its 30-day window, and whose cited source
|
|
210
194
|
still exists. It never drops a binding constraint or disputed entry, and it
|
|
211
195
|
never drops an entry with no source — that is the one case where the map may be
|
|
212
196
|
the only copy. The plugin is not a second writer of the map in the permission
|
|
213
197
|
sense: `context` is still the only **agent** that writes it. The lifecycle is
|
|
214
|
-
housekeeping by code,
|
|
198
|
+
housekeeping by code, applied after a run and then audited.
|
|
215
199
|
|
|
216
200
|
## One writer
|
|
217
201
|
|
|
@@ -301,7 +285,7 @@ to check is that permission rule, not the map's content.
|
|
|
301
285
|
- The prompt the model actually runs, in one place: `src/lib/cartographer-prompt.ts`; the tracked copy is `.opencode/prompts/context.txt`, and a test pins equality.
|
|
302
286
|
- The idle/edit trigger and the post-run audit wiring: `src/plugins/context-autoupdate.ts`.
|
|
303
287
|
- The schema, budget, lifecycle and deterministic drift check: `src/lib/map-schema.ts`.
|
|
304
|
-
|
|
288
|
+
|
|
305
289
|
- The one-writer rule check: `src/lib/writer-rule.ts`.
|
|
306
290
|
- Fixtures and their report: `tests/fixtures/context-map/`, `scripts/context-map-fixture.mjs`.
|
|
307
291
|
- The behavioural contracts, with acceptance criteria a test asserts:
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -471,6 +471,29 @@ Enforcement is split deliberately, and the split matters: **what the permission
|
|
|
471
471
|
- [ ] Given the user's config already defines one of the three commands, When the config hook runs, Then that command is left byte-identical while the other two are still added.
|
|
472
472
|
- [ ] Given the commands are injected, When each one's target agent is resolved against the same config object, Then all three resolve to an agent that actually exists there — a command must never point at a missing agent.
|
|
473
473
|
|
|
474
|
+
### US-35: A timeline survives a restart and resumes unfinished work
|
|
475
|
+
**As a** user whose opencode process restarts in the middle of a session, **I want** the timeline to remember what it has already summarized and to finish the slice it had not written yet, **so that** a crash or restart never silently loses part of the session's work record.
|
|
476
|
+
|
|
477
|
+
The cursor and retry state used to live only in memory; a process that stopped between messages and the next idle flush forgot that a slice was pending, and a successful summary that produced no bullet text advanced the cursor only in memory, so a restart could summarize the same slice again. The plugin now writes a small per-session state file next to the JSONL timeline (cursor, pending flag, failure count, next retry time); startup resumes pending work, and a deleted session removes both files.
|
|
478
|
+
|
|
479
|
+
**Story ID(s) for tests:** US-35
|
|
480
|
+
**Acceptance criteria:**
|
|
481
|
+
- [ ] Given a session has a pending timeline slice recorded on disk, When the process restarts, Then the timeline resumes and completes that slice without waiting for a new message event.
|
|
482
|
+
- [ ] Given a summarization succeeds but produces no bullet text, When the process restarts, Then the advanced cursor is still known and the same transcript slice is not summarized again.
|
|
483
|
+
- [ ] Given a session is deleted, When its timeline is removed, Then its durable state file is removed with it.
|
|
484
|
+
|
|
485
|
+
### US-36: A failed timeline summary retries with backoff
|
|
486
|
+
**As a** user whose summarizer call fails transiently, **I want** the timeline to retry on its own with an increasing delay, **so that** a temporary model or network failure does not leave the timeline permanently behind the session.
|
|
487
|
+
|
|
488
|
+
A failed summarization used to clear the pending flag and never reschedule, so only new session activity could try again. A failure now marks the slice pending, records the next attempt time, and schedules a retry; repeated failures back off exponentially up to a cap, and an idle event during the wait re-arms the timer instead of bypassing it. A successful retry clears the failure count.
|
|
489
|
+
|
|
490
|
+
**Story ID(s) for tests:** US-36
|
|
491
|
+
**Acceptance criteria:**
|
|
492
|
+
- [ ] Given a summarization fails transiently, When the retry interval elapses, Then the timeline retries without waiting for new session activity.
|
|
493
|
+
- [ ] Given repeated failures, When retries are scheduled, Then the wait grows with each failure up to a configured cap.
|
|
494
|
+
- [ ] Given a retry is waiting, When the session goes idle before the backoff window elapses, Then the idle event does not bypass the wait.
|
|
495
|
+
- [ ] Given a retry succeeds, When entries are appended, Then the failure count resets to zero and the timeline is no longer pending.
|
|
496
|
+
|
|
474
497
|
---
|
|
475
498
|
|
|
476
499
|
## Epic H — Retention (sweeps)
|
|
@@ -554,6 +577,8 @@ table was filled in.
|
|
|
554
577
|
| US-20 | G | `tests/stories/g-context-management.test.mjs` |
|
|
555
578
|
| US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
|
|
556
579
|
| US-22 | G | `tests/stories/g-context-management.test.mjs` |
|
|
580
|
+
| US-35 | G | `tests/stories/j-timeline-reliability.test.mjs` |
|
|
581
|
+
| US-36 | G | `tests/stories/j-timeline-reliability.test.mjs` |
|
|
557
582
|
| US-32 | H | `tests/retire-old-sessions-test.mjs` |
|
|
558
583
|
| US-33 | H | `tests/prune-old-events-test.mjs` |
|
|
559
584
|
|
|
@@ -561,8 +586,8 @@ The nine suites under `tests/` are unit-style; the `tests/stories/` suites are
|
|
|
561
586
|
the behavioural layer, written one epic per file with every test name carrying
|
|
562
587
|
its story id. Run everything with `npm test`.
|
|
563
588
|
|
|
564
|
-
**No story is uncovered.** The guard reports
|
|
565
|
-
|
|
589
|
+
**No story is uncovered.** The guard reports 36 stories, 348 test cases, and
|
|
590
|
+
36/36 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
|
|
566
591
|
all of epic F have tests, and the coverage guard fails the build if any of them
|
|
567
592
|
loses one. The scan covers the `tests/stories/` suites and the root-level
|
|
568
593
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-beta.0",
|
|
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
|
+
}
|