gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita6af7883a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mrea6af7883/lib/librarian.hl

49.2 KB

  1. // librarian.hl — `librarian`: the creator's decisions in one place per project (mission 029, ticket antcolony#21).
  2. //
  3. // Why: a worker sees its own ticket, the concept and the TITLES of other tickets — the creator's answers and decisions
  4. // made in comments never reach it, so workers ask about things long decided (gitoria#3). Sources (creator, 2026-09-24:
  5. // "the librarian should be enough. i have to adopt to the workflow"): TICKETS only, plus once the architect's seed of
  6. // decisions made in chat (`--seed`). The librarian reads every text
  7. // the CREATOR wrote in tickets (comments, state-change texts, ticket texts, edits — incl. answers on question tickets),
  8. // asks ONE one-shot Claude step per new text (`--json-schema templates/librarian.schema.json`) whether it is a decision,
  9. // for which project (or `all`), under which topic, what it means and which earlier entries it corrects, and keeps:
  10. // - `<dev folder>/DECISIONS.md` per project (current state only, by topic, contents at the top, newest first per topic)
  11. // + `DECISIONS-archive.md` beside it (superseded entries, kept with quote + link);
  12. // dev folder not on this host → the code folder; neither → `librarian/projects/<name>/DECISIONS.md`;
  13. // mission 030: a project with `"workers": false` (hybriel) ALWAYS → `librarian/projects/<name>/` (never into its repo);
  14. // - `templates/decisions-global.md` (+ `decisions-global-archive.md`) for `all`.
  15. // Every entry = date · the creator's EXACT words (taken from the ticket by the code — the model may only pick a
  16. // verbatim excerpt, checked character for character) · where (ticket link / chat seed) · ONE line "means" marked as the
  17. // librarian's (or, for the chat seed, the architect's) reading. Never a reading without the quote.
  18. // Data: `librarian/decisions.json` (the entries — the .md files are rendered from it, never edited by hand),
  19. // `librarian/state.json` (cursor = newest ticket `updatedMs` read, floor = --since, done = event id → outcome, tries),
  20. // `librarian/calls/<event>-{prompt.md,result.json,stderr.log}` (every model call, for the audit). READS tickets only.
  21. //
  22. // ./colony librarian dry run: lists the new creator texts; no model call, nothing written
  23. // ./colony librarian --post-files classify every new text (one model call each) + write the files
  24. // [--since DATE|ms] [--max N] [--model M] [--retry-refused]
  25. // ./colony librarian --post-files --seed FILE [--seed-source TEXT] import decisions made in CHAT (architect's seed file)
  26. // ./colony librarian --render re-render every file from decisions.json (no tickets, no model)
  27. // ORDER (the creator: a worker must know decisions from OTHER tickets written seconds earlier): `run` awaits one
  28. // `librarianPass` at the start of EVERY iteration before it picks / starts anything, and starts nothing in an iteration
  29. // that left a creator text unfiled (daemon.hl); `brief`, `work`, `cycle` by hand await one pass before the brief is built
  30. // (`librarianFirst`, scheduler.hl). One GET /api/tickets + the updated tickets; no model call unless a creator text is
  31. // new; files are rewritten only when something changed.
  32. import { env } from 'hl:proc'
  33. import { readFile, writeFile, exists, mkDir, remove } from 'hl:fs'
  34. import { now, timestamp } from 'hl:time'
  35. import { sha256 } from 'hl:crypto'
  36. import { fetch } from 'hl:fetch'
  37. import { jsonErrorAt } from './jsoncheck.hl'
  38. import { loadRegistry } from './registry.hl'
  39. import { getJson, baseUrl, isCreatorEvent } from './tickets.hl'
  40. import { runClaude, readClaude, usageLine, setting, isWholeNumber, round4, rateLimitOf } from './claude.hl'
  41. import { NL, readJson, fromCwd, isList, isObject, isString, option, sortByNumber, sortStrings, fenceFor } from './util.hl'
  42. import { isoToMs } from './quota.hl'
  43. static DEFAULT_CREATORS = 'Caramboleyo,creator'
  44. static LOCK_STALE_MS = 900000
  45. static FULL_LINES_DEFAULT = 150
  46. static LAYOUTS_DEFAULT = '/media/STORAGE/projects/antcolony-docs/docs/layouts-conventions.md'
  47. static GENERATED = '<!-- generated by the antcolony librarian (antcolony-scheduler: ./colony librarian) from librarian/decisions.json — do not edit by hand, it is rewritten -->'
  48. // ---- settings --------------------------------------------------------------------------------------------------
  49. static homeDir = () => {
  50. let h = env('COLONY_HOME')
  51. return h == null || h == '' ? null : h
  52. }
  53. // the librarian's own folder (state, entries, calls): COLONY_LIBRARIAN_DIR, else <scheduler>/librarian
  54. static libDir = () => {
  55. let d = env('COLONY_LIBRARIAN_DIR')
  56. if (d != null && d != '') { return fromCwd(d) }
  57. let h = homeDir()
  58. return h == null ? './librarian' : h + '/librarian'
  59. }
  60. // the decisions for ALL projects: COLONY_DECISIONS_GLOBAL, else <scheduler>/templates/decisions-global.md
  61. static globalFile = () => {
  62. let g = env('COLONY_DECISIONS_GLOBAL')
  63. if (g != null && g != '') { return fromCwd(g) }
  64. let h = homeDir()
  65. return h == null ? './templates/decisions-global.md' : h + '/templates/decisions-global.md'
  66. }
  67. // DECISIONS.md → DECISIONS-archive.md, decisions-global.md → decisions-global-archive.md
  68. static archiveOf = (path) => { return path.endsWith('.md') ? path.slice(0, path.length - 3) + '-archive.md' : path + '-archive' }
  69. static layoutsConventions = () => {
  70. let v = env('COLONY_LAYOUTS_CONVENTIONS')
  71. return v == null || v == '' ? LAYOUTS_DEFAULT : v
  72. }
  73. // the creator's display names in tickets (the API shows names, not user ids — see README "Librarian")
  74. static creatorNames = () => {
  75. let v = env('COLONY_CREATOR_NAMES')
  76. if (v == null || v.trim() == '') { v = DEFAULT_CREATORS }
  77. let out = []
  78. for (p of v.split(',')) { if (p.trim() != '') { out.push(p.trim()) } }
  79. return out
  80. }
  81. static thisHost = () => {
  82. let h = env('COLONY_HOST')
  83. if (h == null || h == '') { h = exists('/etc/hostname') ? readFile('/etc/hostname').trim() : '' }
  84. return h
  85. }
  86. // → { dir, model, bin, timeout, maxTurns, max } or { error }. `pre` = option prefix ('' for `librarian`, '--librarian-'
  87. // style names are used by `run`: --librarian-model, --librarian-claude, --librarian-max)
  88. static librarianSettings = (argv, inRun) => {
  89. let model = setting(argv, inRun ? '--librarian-model' : '--model', 'COLONY_LIBRARIAN_MODEL', 'sonnet')
  90. let fallbackBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')
  91. let bin = setting(argv, '--librarian-claude', 'COLONY_LIBRARIAN_CLAUDE', fallbackBin)
  92. let timeoutText = setting(argv, '--librarian-timeout', 'COLONY_LIBRARIAN_TIMEOUT', '300')
  93. let turnsText = setting(argv, '--librarian-max-turns', 'COLONY_LIBRARIAN_MAX_TURNS', '4')
  94. let maxText = setting(argv, inRun ? '--librarian-max' : '--max', 'COLONY_LIBRARIAN_MAX', '0')
  95. if (!isWholeNumber(timeoutText) || timeoutText == '0') { return { error = 'the librarian timeout must be whole seconds ≥ 1, got ' + timeoutText } }
  96. if (!isWholeNumber(turnsText) || turnsText == '0') { return { error = 'the librarian max turns must be a whole number ≥ 1, got ' + turnsText } }
  97. if (!isWholeNumber(maxText)) { return { error = (inRun ? '--librarian-max' : '--max') + ' must be a whole number (0 = no limit), got ' + maxText } }
  98. // antcolony#20: `local` (or `local:<alias>`) = a small model on a local llama-server (OpenAI-compatible endpoint) answers
  99. // first; an unreachable server or an answer the checks refuse goes on to Claude (`--librarian-fallback`, default sonnet, `off` = no fallback)
  100. let localUrl = setting(argv, '--librarian-local-url', 'COLONY_LOCAL_URL', 'http://127.0.0.1:8080/v1')
  101. let fallback = setting(argv, '--librarian-fallback', 'COLONY_LIBRARIAN_FALLBACK', 'sonnet')
  102. let localTimeout = setting(argv, '--librarian-local-timeout', 'COLONY_LOCAL_TIMEOUT', '120')
  103. if (!isWholeNumber(localTimeout) || localTimeout == '0') { return { error = 'the local model timeout must be whole seconds ≥ 1, got ' + localTimeout } }
  104. return { dir = libDir() model = model bin = bin timeout = timeoutText maxTurns = turnsText max = toNumber(maxText) localUrl = localUrl fallback = fallback localTimeout = toNumber(localTimeout) }
  105. }
  106. // ---- small helpers ---------------------------------------------------------------------------------------------
  107. static oneLine = (s) => {
  108. let t = ('' + s).replaceAll('\r', ' ').replaceAll(NL, ' ')
  109. while (t.indexOf(' ') >= 0) { t = t.replaceAll(' ', ' ') }
  110. return t.trim()
  111. }
  112. static clip = (s, n) => {
  113. let t = oneLine(s)
  114. return t.length <= n ? t : t.slice(0, n - 1) + '…'
  115. }
  116. static money = (n) => { return n == null ? '$?' : '$' + round4(n) }
  117. // `--since`: epoch ms, `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM[:SS]` (UTC) → ms or null
  118. static sinceMs = (s) => {
  119. if (s == null || s == '') { return null }
  120. if (isWholeNumber(s)) { return toNumber(s) }
  121. let t = s
  122. if (t.length == 10) { t = t + 'T00:00:00Z' }
  123. else if (t.length == 16) { t = t + ':00Z' }
  124. else if (t.length == 19) { t = t + 'Z' }
  125. return isoToMs(t)
  126. }
  127. // ---- where the files live --------------------------------------------------------------------------------------
  128. // → { path, where } — the project's DECISIONS.md: dev folder on THIS host, else its code folder on this host, else
  129. // the librarian's own folder (a project registered on another host, or one without a registry file)
  130. static projectFile = (name, reg) => {
  131. if (name == 'all') { return { path = globalFile() where = 'all projects' } }
  132. let host = thisHost().toLowerCase()
  133. let m = reg == null ? null : reg.projects[name]
  134. // mission 030: a project with `"workers": false` (hybriel: its own process in its own repo) never gets a file from us
  135. if (m != null && !m.takesWorkers) { return { path = libDir() + '/projects/' + name + '/DECISIONS.md' where = 'librarian folder (the project takes no workers: "workers": false — nothing is written into its repo)' } }
  136. if (m != null) {
  137. if (m.dev != null && m.dev.host != null && m.dev.folder != null && m.dev.host.toLowerCase() == host && exists(m.dev.folder)) { return { path = m.dev.folder + '/DECISIONS.md' where = 'dev folder' } }
  138. if (m.code != null && m.code.host != null && m.code.folder != null && m.code.host.toLowerCase() == host && exists(m.code.folder)) { return { path = m.code.folder + '/DECISIONS.md' where = 'code folder (the dev folder is on ' + m.dev.host + ')' } }
  139. }
  140. return { path = libDir() + '/projects/' + name + '/DECISIONS.md' where = 'librarian folder (' + (m == null ? 'no registry file' : 'no dev or code folder on this host') + ')' }
  141. }
  142. // ---- store + state ---------------------------------------------------------------------------------------------
  143. static loadStore = (dir) => {
  144. let f = dir + '/decisions.json'
  145. if (!exists(f)) { return { entries = [] } }
  146. let r = readJson(f)
  147. if (r.error != null) { return { error = r.error } }
  148. if (!isObject(r.value) || !isList(r.value.entries)) { return { error = f + ': no entries list' } }
  149. return r.value
  150. }
  151. static saveStore = (dir, store) => {
  152. writeFile(dir + '/decisions.json', JSON.stringify(store), 420)
  153. return null
  154. }
  155. static loadState = (dir) => {
  156. let f = dir + '/state.json'
  157. if (!exists(f)) { return { cursorMs = 0 floorMs = 0 done = {} tries = {} passes = 0 } }
  158. let r = readJson(f)
  159. if (r.error != null) { return { error = r.error } }
  160. let s = r.value
  161. if (s.done == null) { s.done = {} }
  162. if (s.tries == null) { s.tries = {} }
  163. if (s.cursorMs == null) { s.cursorMs = 0 }
  164. if (s.floorMs == null) { s.floorMs = 0 }
  165. if (s.passes == null) { s.passes = 0 }
  166. return s
  167. }
  168. static saveState = (dir, st) => {
  169. writeFile(dir + '/state.json', JSON.stringify(st), 420)
  170. return null
  171. }
  172. // ---- the creator's texts ---------------------------------------------------------------------------------------
  173. // the creator's own words in one event → list of strings (empty = nothing to read: a bare state change, a link)
  174. static quotesOf = (e, t) => {
  175. let out = []
  176. if (e.kind == 'created') {
  177. if (t.subject != null && t.subject.trim() != '') { out.push(t.subject.trim()) }
  178. if (t.summary != null && t.summary.trim() != '') { out.push(t.summary.trim()) }
  179. } else if (e.kind == 'comment' || e.kind == 'state') {
  180. if (e.text != null && e.text.trim() != '') { out.push(e.text.trim()) }
  181. } else if (e.kind == 'edit') {
  182. if (e.subjectChanged == true && e.newSubject != null && e.newSubject.trim() != '') { out.push(e.newSubject.trim()) }
  183. if (e.summaryChanged == true && e.newSummary != null && e.newSummary.trim() != '') { out.push(e.newSummary.trim()) }
  184. }
  185. return out
  186. }
  187. static whatOf = (e) => {
  188. if (e.kind == 'created') { return 'ticket text (opened the ticket)' }
  189. if (e.kind == 'state') { return 'state change ' + e.from + ' → ' + e.to }
  190. if (e.kind == 'edit') { return 'edit of the ticket' }
  191. return 'comment'
  192. }
  193. static textOf = (e, t, evs, i, quotes) => {
  194. let before = []
  195. let j = i - 8
  196. if (j < 0) { j = 0 }
  197. while (j < i) {
  198. let b = evs[j]
  199. let bt = b.text == null ? '' : b.text
  200. if (b.kind == 'edit') { bt = 'edited: ' + (b.newSubject == null ? '' : b.newSubject) }
  201. before.push({ author = b.author kind = b.kind to = b.to == null ? '' : b.to when = b.when text = clip(bt, 600) })
  202. j = j + 1
  203. }
  204. let refText = t.project + '#' + t.number
  205. let urlText = baseUrl() + t.href
  206. let subj = t.subject == null ? '' : t.subject
  207. let summ = t.summary == null ? '' : t.summary
  208. let whenText = e.when == null ? '' : e.when
  209. return { key = e.id kind = e.kind what = whatOf(e) author = e.author when = whenText date = whenText.slice(0, 10) createdMs = e.createdMs quotes = quotes project = t.project number = t.number ref = refText url = urlText subject = subj summary = summ state = t.state isQuestion = subj.startsWith('Question') before = before }
  210. }
  211. // every new creator text → { texts (oldest first), cursorMs (newest updatedMs seen), read, total } or { error }
  212. static gatherTexts = (names, floorMs, cursorMs, done) => {
  213. let all = getJson('/api/tickets')
  214. if (all.status != 200) { return { error = 'GET /api/tickets → ' + all.status + (all.text == null ? '' : ' ' + clip(all.text, 120)) } }
  215. let texts = []
  216. let maxUpd = cursorMs
  217. let read = 0
  218. let lowest = cursorMs > floorMs ? cursorMs : floorMs
  219. for (t of all.json.tickets) {
  220. let upd = t.updatedMs == null ? 0 : t.updatedMs
  221. if (upd > maxUpd) { maxUpd = upd }
  222. if (upd >= lowest) {
  223. let d = getJson(t.apiHref)
  224. if (d.status != 200) { return { error = 'GET ' + t.apiHref + ' → ' + d.status } }
  225. read = read + 1
  226. let evs = d.json.events
  227. let tk = d.json.ticket
  228. let i = 0
  229. while (i < evs.length) {
  230. let e = evs[i]
  231. let ms = e.createdMs == null ? 0 : e.createdMs
  232. if (isCreatorEvent(t.project, e, names) && done[e.id] == null && ms >= floorMs) {
  233. let q = quotesOf(e, tk)
  234. if (q.length > 0) { texts.push(textOf(e, tk, evs, i, q)) }
  235. }
  236. i = i + 1
  237. }
  238. }
  239. }
  240. sortByNumber(texts, (x) => { return x.createdMs })
  241. return { texts = texts cursorMs = maxUpd read = read total = all.json.tickets.length }
  242. }
  243. // ---- topics ----------------------------------------------------------------------------------------------------
  244. static normTopic = (s) => {
  245. let t = oneLine(s).toLowerCase()
  246. let p = t.indexOf(' (')
  247. if (p > 0) { t = t.slice(0, p) }
  248. return t.trim()
  249. }
  250. static topicsOf = (entries, project) => {
  251. let out = []
  252. for (e of entries) { if (e.status == 'current' && e.project == project && !out.includes(e.topic)) { out.push(e.topic) } }
  253. sortStrings(out)
  254. return out
  255. }
  256. // ---- the model call --------------------------------------------------------------------------------------------
  257. static promptOf = (x, allowed, entries) => {
  258. let topics = {}
  259. for (p of allowed) {
  260. let ts = topicsOf(entries, p)
  261. if (ts.length > 0) { topics[p] = ts }
  262. }
  263. let cur = []
  264. for (e of entries) {
  265. if (e.status == 'current') { cur.push({ id = e.id project = e.project topic = e.topic quote = clip(e.quotes.join(' · '), 300) means = e.means }) }
  266. }
  267. let ct = { quotes = x.quotes what = x.what author = x.author when = x.when }
  268. let input = { creatorText = ct }
  269. input.ticket = { ref = x.ref project = x.project subject = x.subject summary = clip(x.summary, 2000) state = x.state isQuestion = x.isQuestion url = x.url }
  270. input.historyBefore = x.before
  271. input.allowedProjects = allowed
  272. input.existingTopics = topics
  273. input.currentEntries = cur
  274. let js = JSON.stringify(input)
  275. let f = fenceFor(js)
  276. return readFile('./templates/librarian.md').trim() + NL + NL + '## Input (JSON)' + NL + NL + f + 'json' + NL + js + NL + f + NL
  277. }
  278. // antcolony#20: a small local model answers the same question (same prompt, the schema enforced by the server) →
  279. // { answer } or { reason }; the call's files stay in <dir>/calls/ like Claude's
  280. static localAnswer = (c, x, allowed, entries) => {
  281. let base = c.dir + '/calls/' + x.key + '-local'
  282. let prompt = promptOf(x, allowed, entries)
  283. writeFile(base + '-prompt.md', prompt, 384)
  284. let alias = c.model.indexOf(':') > 0 ? c.model.slice(c.model.indexOf(':') + 1) : 'local'
  285. let schema = JSON.parse(readFile('./templates/librarian.schema.json'))
  286. let body = { model = alias temperature = 0 max_tokens = 700 messages = [{ role = 'user' content = prompt }] response_format = { type = 'json_schema' json_schema = { name = 'librarian' strict = true schema = schema } } }
  287. let res = fetch(c.localUrl + '/chat/completions', { method = 'POST' headers = { 'user-agent' = 'antcolony-scheduler' } json = body timeoutMs = c.localTimeout * 1000 })
  288. if (res == null) { return { reason = 'the local model server ' + c.localUrl + ' did not answer' } }
  289. let text = res.text()
  290. writeFile(base + '-result.json', text == null ? '' : text, 384)
  291. if (res.status != 200) { return { reason = 'the local model server answered ' + res.status } }
  292. if (jsonErrorAt(text) >= 0) { return { reason = 'the local model server answer is not JSON' } }
  293. let v = JSON.parse(text)
  294. if (!isObject(v) || !isList(v.choices) || v.choices.length == 0 || !isObject(v.choices[0].message) || !isString(v.choices[0].message.content)) { return { reason = 'the local model answer has no message' } }
  295. let content = v.choices[0].message.content
  296. if (jsonErrorAt(content) >= 0) { return { reason = 'the local model answer is cut off or not JSON (finish: ' + v.choices[0].finish_reason + ')' } }
  297. let a = JSON.parse(content)
  298. if (!isObject(a)) { return { reason = 'the local model answer is not a JSON object' } }
  299. return { answer = a }
  300. }
  301. // one text → done({ answer } | { reason }), with cost; the files of the call stay in <dir>/calls/
  302. static classify = (c, x, allowed, entries, done) => {
  303. let calls = c.dir + '/calls'
  304. if (c.model == 'local' || c.model.startsWith('local:')) {
  305. mkDir(calls, 448)
  306. let la = localAnswer(c, x, allowed, entries)
  307. let why = la.reason
  308. if (la.answer != null) {
  309. let chk = checkAnswer(la.answer, x, allowed, entries)
  310. if (chk.problems == null) {
  311. console.log(' LOCAL MODEL (' + c.model + ') answered')
  312. done({ answer = la.answer cost = 0 })
  313. return null
  314. }
  315. why = 'the answer failed the checks: ' + chk.problems.join('; ')
  316. }
  317. if (c.fallback == 'off' || c.fallback == '') {
  318. done({ reason = 'local model: ' + why })
  319. return null
  320. }
  321. console.log(' LOCAL MODEL could not answer (' + why + ') → Claude ' + c.fallback)
  322. let c2 = c
  323. c2.model = c.fallback
  324. return classify(c2, x, allowed, entries, done)
  325. }
  326. mkDir(calls, 448)
  327. let base = calls + '/' + x.key
  328. let inFile = base + '-prompt.md'
  329. let outFile = base + '-result.json'
  330. let errFile = base + '-stderr.log'
  331. writeFile(inFile, promptOf(x, allowed, entries), 384)
  332. let args = ['-p' '--output-format' 'json' '--json-schema' readFile('./templates/librarian.schema.json') '--model' c.model '--max-turns' c.maxTurns '--permission-mode' 'default' '--permission-prompts' 'none' '--strict-mcp-config' '--tools' '' '--no-session-persistence']
  333. let p = runClaude({ label = 'librarian' folder = c.dir inFile = inFile outFile = outFile errFile = errFile bin = c.bin args = args timeout = c.timeout env = null }, (r) => {
  334. let rc = readClaude(outFile, r.ev.exit, r.timedOut, c.timeout, 'librarian')
  335. let cost = null
  336. if (rc.res != null) {
  337. cost = rc.res.total_cost_usd
  338. console.log(' ' + usageLine('LIBRARIAN USAGE', rc.res))
  339. }
  340. // mission 034 (antcolony#25): the call ran into the Claude usage limit (live: subtype "success", is_error, 429,
  341. // empty answer) → reported as LIMITED, not as "no answer" (the pass stops; `run` pauses until the reset)
  342. if (rc.value != null) {
  343. let rl = rateLimitOf(rc.value, rc.res)
  344. if (rl.limited) {
  345. done({ limited = true resetMs = rl.resetMs limitText = rl.text cost = cost })
  346. return null
  347. }
  348. }
  349. if (rc.reason != null) {
  350. done({ reason = rc.reason cost = cost })
  351. return null
  352. }
  353. if (rc.res.structured_output == null) {
  354. done({ reason = 'no structured answer (Claude: ' + rc.res.subtype + (rc.res.is_error ? ', error' : '') + ', ' + rc.res.num_turns + ' turns, exit ' + r.ev.exit + ')' cost = cost })
  355. return null
  356. }
  357. done({ answer = rc.res.structured_output cost = cost })
  358. return null
  359. })
  360. return p
  361. }
  362. // the code decides what is stored: → { problems } or { decision: false, why } or { decision: true, project, topic,
  363. // quotes, quoteNote, means, supersedes, dropped, why }
  364. static KEYS = ['decision' 'project' 'topic' 'quote' 'means' 'supersedes' 'why']
  365. static checkAnswer = (a, x, allowed, entries) => {
  366. let problems = []
  367. if (!isObject(a)) { return { problems = ['the answer is not an object'] } }
  368. for (k of a.keys()) { if (!KEYS.includes(k)) { problems.push("unknown key '" + k + "'") } }
  369. for (k of KEYS) { if (a[k] == null) { problems.push("'" + k + "' is missing") } }
  370. if (problems.length > 0) { return { problems = problems } }
  371. if (hlTypeName(a.decision) != 'Boolean') { problems.push("'decision' must be true or false") }
  372. if (!isString(a.why)) { problems.push("'why' must be text") }
  373. if (problems.length > 0) { return { problems = problems } }
  374. let why = oneLine(a.why)
  375. if (a.decision == false) { return { decision = false why = why } }
  376. if (!isString(a.project) || !allowed.includes(a.project)) { problems.push("'project' must be one of " + allowed.join(', ') + ", got '" + a.project + "'") }
  377. let topic = isString(a.topic) ? normTopic(a.topic) : ''
  378. if (topic == '' || topic.length > 40) { problems.push("'topic' must be 1–40 characters, got '" + a.topic + "'") }
  379. let means = isString(a.means) ? oneLine(a.means) : ''
  380. if (means == '') { problems.push("'means' must not be empty for a decision") }
  381. if (!isString(a.quote)) { problems.push("'quote' must be text") }
  382. if (!isList(a.supersedes)) { problems.push("'supersedes' must be a list") }
  383. if (problems.length > 0) { return { problems = problems } }
  384. // the quote: the whole text, or a VERBATIM excerpt of it (checked here — the model never writes the creator's words)
  385. let quotes = x.quotes
  386. let note = ''
  387. let ex = a.quote.trim()
  388. if (ex != '') {
  389. let hit = false
  390. for (q of x.quotes) { if (q.includes(ex)) { hit = true } }
  391. if (hit) {
  392. let whole = false
  393. for (q of x.quotes) { if (q == ex) { whole = true } }
  394. if (!whole) { quotes = [ex] }
  395. } else { note = 'the excerpt the model gave is not verbatim — the whole text is stored' }
  396. }
  397. let sup = []
  398. let dropped = []
  399. for (id of a.supersedes) {
  400. let found = false
  401. for (e of entries) { if (e.id == id && e.status == 'current') { found = true } }
  402. if (found && !sup.includes(id)) { sup.push(id) } else { dropped.push('' + id) }
  403. }
  404. // the reading is ONE line of at most 300 characters (first live pass: 5 of 155 answers were longer — clipped, not refused)
  405. if (means.length > 300) {
  406. means = means.slice(0, 299) + '…'
  407. note = note == '' ? 'the reading was longer than 300 characters — clipped' : note + '; the reading was clipped to 300 characters'
  408. }
  409. return { decision = true project = a.project topic = topic quotes = quotes quoteNote = note means = means supersedes = sup dropped = dropped why = why }
  410. }
  411. // ---- rendering -------------------------------------------------------------------------------------------------
  412. static slug = (s) => {
  413. let out = ''
  414. let low = s.toLowerCase()
  415. let i = 0
  416. while (i < low.length) {
  417. let c = low.charCodeAt(i)
  418. let ch = low.slice(i, i + 1)
  419. if ((c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c == 45 || c == 95) { out = out + ch }
  420. else if (c == 32) { out = out + '-' }
  421. i = i + 1
  422. }
  423. return out
  424. }
  425. static linkOf = (e) => {
  426. if (e.source.kind == 'seed') { return 'source: ' + e.source.text }
  427. let subj = clip(e.source.subject, 70).replaceAll('[', '(').replaceAll(']', ')')
  428. return '[' + e.source.ref.replaceAll('#', ' #') + ' — ' + subj + '](' + e.source.url + ')'
  429. }
  430. static readingLabel = (e) => {
  431. if (e.means == null || e.means == '') { return 'means: — (no reading recorded; the quote is all there is)' }
  432. let who = e.readingBy == 'architect' ? "the architect's reading" : "the librarian's reading"
  433. return 'means (' + who + ', not the creator\'s words): ' + e.means
  434. }
  435. static entryLines = (e, byId) => {
  436. let o = []
  437. let multi = false
  438. for (q of e.quotes) { if (q.indexOf(NL) >= 0) { multi = true } }
  439. let ctx = e.context != null && e.context != '' ? e.context + ' ' : ''
  440. if (!multi) {
  441. let qs = []
  442. for (q of e.quotes) { qs.push('**"' + q + '"**') }
  443. o.push('- ' + e.date + ' · ' + ctx + qs.join(' · ') + ' · ' + linkOf(e))
  444. } else {
  445. o.push('- ' + e.date + ' · ' + ctx + linkOf(e) + ' · the creator wrote:')
  446. let first = true
  447. for (q of e.quotes) {
  448. if (!first) { o.push(' >') }
  449. for (l of q.split(NL)) { o.push(' > ' + l.replaceAll('\r', '')) }
  450. first = false
  451. }
  452. }
  453. o.push(' - ' + readingLabel(e))
  454. if (e.status == 'superseded') {
  455. let by = byId[e.supersededBy]
  456. let byText = by == null ? e.supersededBy : e.supersededBy + ' — **"' + clip(by.quotes.join(' · '), 120) + '"** · ' + linkOf(by)
  457. o.push(' - superseded on ' + e.supersededOn + ' by ' + byText)
  458. }
  459. return o
  460. }
  461. // the text of one DECISIONS file (current or archive) for `name` from `entries` (already filtered)
  462. static renderFile = (name, entries, archive, byId) => {
  463. let o = []
  464. let label = name == 'all' ? 'all projects' : name
  465. o.push('# Decisions — ' + label + (archive ? ' (archive: superseded)' : ''))
  466. o.push('')
  467. o.push(GENERATED)
  468. o.push('')
  469. if (archive) {
  470. o.push('Superseded decisions of the creator for **' + label + '** — kept with the quote and where it was said. The current ones are in `' + (name == 'all' ? 'decisions-global.md' : 'DECISIONS.md') + '` beside this file. Nothing here is valid any more.')
  471. } else {
  472. o.push('The creator\'s decisions for **' + label + '** — current state only (superseded ones: `' + (name == 'all' ? 'decisions-global-archive.md' : 'DECISIONS-archive.md') + '` beside this file).')
  473. o.push('**A decision here overrides anything else; ask only if it is not decided here.**')
  474. o.push('Each entry: date · the creator\'s exact words in quotes · where it was said · "means" = a READING (the librarian\'s or the architect\'s), not the creator\'s words — when in doubt, the quote counts.')
  475. }
  476. o.push('')
  477. if (entries.length == 0) {
  478. o.push(archive ? '_(nothing superseded yet)_' : '_(no decisions recorded yet)_')
  479. o.push('')
  480. return o.join(NL)
  481. }
  482. let topics = []
  483. for (e of entries) { if (!topics.includes(e.topic)) { topics.push(e.topic) } }
  484. sortStrings(topics)
  485. o.push('## Contents')
  486. for (tp of topics) {
  487. let n = 0
  488. for (e of entries) { if (e.topic == tp) { n = n + 1 } }
  489. o.push('- [' + tp + '](#' + slug(tp) + ') — ' + n)
  490. }
  491. for (tp of topics) {
  492. o.push('')
  493. o.push('## ' + tp)
  494. o.push('')
  495. let list = []
  496. for (e of entries) { if (e.topic == tp) { list.push(e) } }
  497. sortByNumber(list, (e) => { return 0 - e.ms })
  498. for (e of list) { for (l of entryLines(e, byId)) { o.push(l) } }
  499. }
  500. o.push('')
  501. return o.join(NL)
  502. }
  503. // write every file whose text changed → list of 'path (n current, k superseded)' lines written
  504. static renderAll = (store, reg) => {
  505. let byId = {}
  506. for (e of store.entries) { byId[e.id] = e }
  507. let names = ['all']
  508. for (n of reg.names) { names.push(n) }
  509. for (e of store.entries) { if (!names.includes(e.project)) { names.push(e.project) } }
  510. let written = []
  511. for (name of names) {
  512. let cur = []
  513. let old = []
  514. for (e of store.entries) {
  515. if (e.project == name) { if (e.status == 'current') { cur.push(e) } else { old.push(e) } }
  516. }
  517. let pf = projectFile(name, reg)
  518. let slash = pf.path.lastIndexOf('/')
  519. if (slash > 0) { mkDir(pf.path.slice(0, slash), 493) }
  520. let text = renderFile(name, cur, false, byId)
  521. if (!exists(pf.path) || readFile(pf.path) != text) {
  522. writeFile(pf.path, text, 420)
  523. written.push(pf.path + ' (' + cur.length + ' current · ' + pf.where + ')')
  524. }
  525. let ap = archiveOf(pf.path)
  526. if (old.length > 0 || exists(ap)) {
  527. let at = renderFile(name, old, true, byId)
  528. if (!exists(ap) || readFile(ap) != at) {
  529. writeFile(ap, at, 420)
  530. written.push(ap + ' (' + old.length + ' superseded)')
  531. }
  532. }
  533. }
  534. return written
  535. }
  536. // ---- the brief's part (brief.hl) -------------------------------------------------------------------------------
  537. static STOP = ['that' 'this' 'with' 'from' 'have' 'should' 'would' 'will' 'into' 'when' 'what' 'which' 'there' 'their' 'then' 'than' 'also' 'only' 'make' 'made' 'like' 'just' 'some' 'each' 'every' 'more' 'most' 'other' 'about' 'after' 'before' 'worker' 'workers' 'creator' 'colony' 'must' 'need' 'needs' 'does' 'done' 'them' 'they' 'your' 'here' 'where' 'were' 'been' 'being' 'because' 'while' 'within' 'without' 'over' 'under' 'again' 'same' 'such' 'very' 'work' 'working' 'question' 'ticket' 'tickets' 'project' 'projects' 'reading' 'librarian' 'means' 'decision' 'decisions' 'should' 'these' 'those' 'could' 'into' 'onto' 'upon' 'still' 'already' 'later' 'first' 'next' 'used' 'uses' 'using']
  538. // the words of a text that can match: lower case, letters/digits, ≥ 4 characters, no stop words → stems (first 5)
  539. static stemsOf = (text) => {
  540. let out = []
  541. let low = ('' + text).toLowerCase()
  542. let w = ''
  543. let i = 0
  544. while (i <= low.length) {
  545. let c = i < low.length ? low.charCodeAt(i) : 32
  546. if ((c >= 97 && c <= 122) || (c >= 48 && c <= 57)) { w = w + low.slice(i, i + 1) }
  547. else {
  548. if (w.length >= 4 && !STOP.includes(w)) {
  549. let st = w.length > 5 ? w.slice(0, 5) : w
  550. if (!out.includes(st)) { out.push(st) }
  551. }
  552. w = ''
  553. }
  554. i = i + 1
  555. }
  556. return out
  557. }
  558. static demote = (line) => { return line.startsWith('#') ? '##' + line : line }
  559. // the lines of one decisions file for the brief: the whole file while it is short, else its contents + the topic
  560. // sections whose name or reading lines share a word with the ticket (subject + summary)
  561. static fileLines = (label, pf, ticketText, full) => {
  562. let o = []
  563. if (!exists(pf.path)) {
  564. o.push('_(no decisions recorded yet for ' + label + ')_')
  565. return o
  566. }
  567. let lines = readFile(pf.path).split(NL)
  568. let body = []
  569. for (l of lines) { if (!l.startsWith('# ') && !l.startsWith('<!-- generated')) { body.push(l) } }
  570. while (body.length > 0 && body[0].trim() == '') { body = body.slice(1) }
  571. if (lines.length <= full) {
  572. for (l of body) { o.push(demote(l)) }
  573. return o
  574. }
  575. // long file: split into the head (before the first topic) and the topic sections
  576. let head = []
  577. let sections = []
  578. let cur = null
  579. for (l of body) {
  580. if (l.startsWith('## ') && l != '## Contents') {
  581. if (cur != null) { sections.push(cur) }
  582. cur = { name = l.slice(3).trim() lines = [l] }
  583. } else if (cur != null) { cur.lines.push(l) } else { head.push(l) }
  584. }
  585. if (cur != null) { sections.push(cur) }
  586. let want = stemsOf(ticketText)
  587. let picked = []
  588. let names = []
  589. for (s of sections) {
  590. let hay = s.name
  591. for (l of s.lines) { if (l.indexOf('means (') >= 0) { hay = hay + ' ' + l.slice(l.indexOf('means (')) } }
  592. let has = stemsOf(hay)
  593. let hit = false
  594. for (st of want) { if (has.includes(st)) { hit = true } }
  595. if (hit) {
  596. picked.push(s)
  597. names.push(s.name)
  598. }
  599. }
  600. for (l of head) { o.push(demote(l)) }
  601. o.push('')
  602. o.push('**This file is long (' + lines.length + ' lines): only the topics that share a word with this ticket are carried here (' + (names.length == 0 ? 'none matched' : names.join(', ')) + '; ' + picked.length + ' of ' + sections.length + ' topics). Search the whole file `' + pf.path + '` before you ask anything.**')
  603. for (s of picked) {
  604. o.push('')
  605. for (l of s.lines) { o.push(demote(l)) }
  606. }
  607. return o
  608. }
  609. // the brief's section "Decisions of the creator" (after the conventions): global + the project's file, with paths
  610. static decisionLines = (project, reg, ticketText) => {
  611. let fullText = env('COLONY_DECISIONS_FULL_LINES')
  612. let full = fullText != null && isWholeNumber(fullText) ? toNumber(fullText) : FULL_LINES_DEFAULT
  613. let host = thisHost()
  614. let g = projectFile('all', reg)
  615. let p = projectFile(project, reg)
  616. let o = []
  617. o.push('## Decisions of the creator — a decision here overrides anything else; ask only if it is not decided here')
  618. o.push('Collected by the librarian from the creator\'s own words (tickets; for all projects also the architect\'s chat seed). Each entry quotes the creator exactly; the "means" line is only a reading — when in doubt, the quote counts. Superseded decisions are in the `-archive.md` file beside each file.')
  619. o.push('- All projects: `' + host + ':' + g.path + '`')
  620. o.push('- ' + project + ': `' + host + ':' + p.path + '`')
  621. o.push('- **Search both files (e.g. grep your topic) before you ask a question.** A question about something decided there is refused.')
  622. o.push('')
  623. o.push('### For all projects')
  624. o.push('')
  625. for (l of fileLines('all projects', g, ticketText, full)) { o.push(l) }
  626. o.push('')
  627. o.push('### For ' + project)
  628. o.push('')
  629. for (l of fileLines(project, p, ticketText, full)) { o.push(l) }
  630. return o
  631. }
  632. // ---- seed (decisions made in chat) -----------------------------------------------------------------------------
  633. // the architect's seed file: `## Topic` headings, `- "quote" · "quote" · reading: …` bullets (continuation lines
  634. // indented). A bullet without a quoted part is not an entry (printed as skipped). → { items, skipped }
  635. static parseSeed = (text) => {
  636. let items = []
  637. let skipped = []
  638. let topic = 'general'
  639. let cur = null
  640. let flush = () => {
  641. if (cur == null) { return null }
  642. let item = cur
  643. cur = null
  644. let r = item.indexOf('reading:')
  645. let head = r >= 0 ? item.slice(0, r) : item
  646. let means = r >= 0 ? oneLine(item.slice(r + 8)) : ''
  647. let quotes = []
  648. let ctx = ''
  649. let i = head.indexOf('"')
  650. if (i > 0) { ctx = head.slice(0, i).trim() }
  651. while (i >= 0) {
  652. let j = head.indexOf('"', i + 1)
  653. if (j < 0) { i = -1 } else {
  654. let q = head.slice(i + 1, j)
  655. if (q.trim() != '') { quotes.push(q) }
  656. i = head.indexOf('"', j + 1)
  657. }
  658. }
  659. if (quotes.length == 0) { skipped.push(topic + ': ' + clip(item, 100)) }
  660. else { items.push({ topic = topic context = ctx quotes = quotes means = means }) }
  661. return null
  662. }
  663. for (l of text.split(NL)) {
  664. if (l.startsWith('## ')) {
  665. flush()
  666. topic = normTopic(l.slice(3))
  667. } else if (l.startsWith('- ')) {
  668. flush()
  669. cur = l.slice(2).trim()
  670. } else if (cur != null && l.startsWith(' ') && l.trim() != '') {
  671. cur = cur + ' ' + l.trim()
  672. } else { flush() }
  673. }
  674. flush()
  675. return { items = items skipped = skipped }
  676. }
  677. static importSeed = (c, file, sourceText) => {
  678. let p = fromCwd(file)
  679. if (!exists(p)) { return { error = 'no such seed file: ' + p } }
  680. let store = loadStore(c.dir)
  681. if (store.error != null) { return { error = store.error } }
  682. let st = loadState(c.dir)
  683. if (st.error != null) { return { error = st.error } }
  684. let ps = parseSeed(readFile(p))
  685. let added = 0
  686. let have = 0
  687. let base = isoToMs('2026-09-24T00:00:00Z')
  688. let dateText = '2026-09-23/24'
  689. let src = sourceText
  690. for (it of ps.items) {
  691. let h = sha256(it.quotes.join(NL)).slice(0, 16)
  692. let key = 'seed:' + h
  693. if (st.done[key] != null) { have = have + 1 } else {
  694. let eid = 'g-' + h.slice(0, 12)
  695. let entry = { id = eid project = 'all' topic = it.topic quotes = it.quotes context = it.context means = it.means readingBy = 'architect' date = dateText ms = base source = { kind = 'seed' text = src file = p } added = now() status = 'current' }
  696. store.entries.push(entry)
  697. let d = st.done
  698. d[key] = 'decision ' + eid
  699. st.done = d
  700. added = added + 1
  701. console.log('LIBRARIAN SEED ' + eid + ' · all · ' + it.topic + ' · "' + clip(it.quotes.join(' · '), 100) + '"')
  702. }
  703. }
  704. for (s of ps.skipped) { console.log('librarian: seed item without a quote — skipped (no reading without the quote): ' + s) }
  705. saveStore(c.dir, store)
  706. saveState(c.dir, st)
  707. return { added = added have = have skipped = ps.skipped.length store = store }
  708. }
  709. // ---- the pass --------------------------------------------------------------------------------------------------
  710. static lockFile = (dir) => { return dir + '/lock' }
  711. static takeLock = (dir) => {
  712. let f = lockFile(dir)
  713. if (exists(f)) {
  714. let r = readJson(f)
  715. let ms = r.error == null && r.value.ms != null ? r.value.ms : 0
  716. if (now() - ms < LOCK_STALE_MS) { return { error = 'another librarian pass holds ' + f + ' since ' + timestamp(ms).slice(0, 19) + 'Z (stale after 15 min)' } }
  717. }
  718. writeFile(f, JSON.stringify({ ms = now() }), 384)
  719. return { ok = true }
  720. }
  721. static dropLock = (dir) => {
  722. remove(lockFile(dir))
  723. return null
  724. }
  725. // o = { argv, post (write), auto (called by `run`: quiet when nothing is new, files only when something changed),
  726. // stop () → true when the run wants to stop }. done(summary) is called once:
  727. // summary = { newTexts, decisions, not, refused, failed, cost, written, error, left } — mission 034: a call that ran into the
  728. // Claude usage limit stops the pass → summary.limited = true, resetMs (null = unknown), limitText (no try counted)
  729. static librarianPass = (o, done) => {
  730. let argv = o.argv
  731. let c = librarianSettings(argv, o.auto)
  732. if (c.error != null) {
  733. console.log('librarian: REFUSED — ' + c.error)
  734. done({ error = c.error })
  735. return false
  736. }
  737. if (homeDir() == null) {
  738. console.log('librarian: REFUSED — COLONY_HOME is not set (run through ./colony, which sets it)')
  739. done({ error = 'no COLONY_HOME' })
  740. return false
  741. }
  742. mkDir(c.dir, 448)
  743. let reg = loadRegistry()
  744. for (e of reg.errors) { console.log('registry: ' + e) }
  745. let allowed = []
  746. for (n of reg.names) { allowed.push(n) }
  747. // --render: files from the store, nothing else
  748. if (argv.includes('--render')) {
  749. let store = loadStore(c.dir)
  750. if (store.error != null) {
  751. console.log('librarian: ' + store.error)
  752. done({ error = store.error })
  753. return false
  754. }
  755. let w = renderAll(store, reg)
  756. for (l of w) { console.log('LIBRARIAN FILE ' + l) }
  757. console.log('LIBRARIAN RENDER — ' + w.length + ' file(s) written, the others unchanged')
  758. done({ written = w })
  759. return true
  760. }
  761. // --seed FILE: decisions made in chat (the architect's seed) — no model call
  762. let seed = option(argv, '--seed')
  763. if (seed != null) {
  764. if (!o.post) {
  765. console.log('librarian: REFUSED — --seed writes: add --post-files')
  766. done({ error = 'seed without --post-files' })
  767. return false
  768. }
  769. let lk = takeLock(c.dir)
  770. if (lk.error != null) {
  771. console.log('librarian: BUSY — ' + lk.error)
  772. done({ error = lk.error })
  773. return false
  774. }
  775. let srcText = option(argv, '--seed-source')
  776. if (srcText == null || srcText == '') { srcText = 'architect session 2026-09-23/24, chat' }
  777. let si = importSeed(c, seed, srcText)
  778. if (si.error != null) {
  779. dropLock(c.dir)
  780. console.log('librarian: ' + si.error)
  781. done({ error = si.error })
  782. return false
  783. }
  784. let w = renderAll(si.store, reg)
  785. for (l of w) { console.log('LIBRARIAN FILE ' + l) }
  786. dropLock(c.dir)
  787. console.log('LIBRARIAN SEED — ' + si.added + ' new entr' + (si.added == 1 ? 'y' : 'ies') + ', ' + si.have + ' already there (HAVE), ' + si.skipped + ' item(s) without a quote skipped · ' + w.length + ' file(s) written')
  788. done({ decisions = si.added written = w })
  789. return true
  790. }
  791. let st = loadState(c.dir)
  792. if (st.error != null) {
  793. console.log('librarian: ' + st.error)
  794. done({ error = st.error })
  795. return false
  796. }
  797. let sinceText = option(argv, '--since')
  798. let floor = st.floorMs
  799. if (sinceText != null) {
  800. let s = sinceMs(sinceText)
  801. if (s == null) {
  802. console.log('librarian: REFUSED — --since must be epoch ms, YYYY-MM-DD or YYYY-MM-DDTHH:MM (UTC), got ' + sinceText)
  803. done({ error = 'bad --since' })
  804. return false
  805. }
  806. floor = s
  807. }
  808. // --redo EVENT[,EVENT…] (ticket event ids): classify these texts again — their outcome is forgotten and an entry the
  809. // librarian made from them is removed (its own reading was wrong; not a correction by the creator, so no archive)
  810. let redo = option(argv, '--redo')
  811. if (redo != null) {
  812. if (!o.post) {
  813. console.log('librarian: REFUSED — --redo writes: add --post-files')
  814. done({ error = 'redo without --post-files' })
  815. return false
  816. }
  817. let rstore = loadStore(c.dir)
  818. if (rstore.error != null) {
  819. console.log('librarian: ' + rstore.error)
  820. done({ error = rstore.error })
  821. return false
  822. }
  823. let keys = []
  824. for (k of redo.split(',')) { if (k.trim() != '') { keys.push(k.trim()) } }
  825. let keepDone = {}
  826. for (k of st.done.keys()) { if (!keys.includes(k)) { keepDone[k] = st.done[k] } }
  827. let keepTries = {}
  828. for (k of st.tries.keys()) { if (!keys.includes(k)) { keepTries[k] = st.tries[k] } }
  829. let kept = []
  830. for (e of rstore.entries) {
  831. let srcKey = e.source == null || e.source.kind != 'ticket' ? '' : e.source.event
  832. if (keys.includes(srcKey)) { console.log('librarian: --redo removes ' + e.id + ' ("' + clip(e.quotes.join(' · '), 80) + '")') } else { kept.push(e) }
  833. }
  834. rstore.entries = kept
  835. saveStore(c.dir, rstore)
  836. st.done = keepDone
  837. st.tries = keepTries
  838. st.cursorMs = 0
  839. console.log('librarian: --redo ' + keys.length + ' text(s): ' + keys.join(', '))
  840. }
  841. if (argv.includes('--retry-refused')) {
  842. let keep = {}
  843. for (k of st.done.keys()) { if (!st.done[k].startsWith('refused')) { keep[k] = st.done[k] } }
  844. st.done = keep
  845. st.tries = {}
  846. st.cursorMs = 0
  847. }
  848. let names = creatorNames()
  849. let g = gatherTexts(names, floor, sinceText != null ? 0 : st.cursorMs, st.done)
  850. if (g.error != null) {
  851. console.log('librarian: ERROR — ' + g.error + ' (nothing written, the next pass tries again)')
  852. done({ error = g.error })
  853. return false
  854. }
  855. let texts = g.texts
  856. if (!o.auto || texts.length > 0) { console.log('librarian: ' + texts.length + ' new creator text(s) (creator = ' + names.join(' / ') + ') · read ' + g.read + ' of ' + g.total + ' ticket(s) updated since ' + (floor > st.cursorMs && sinceText != null ? timestamp(floor).slice(0, 19) + 'Z (--since)' : (st.cursorMs == 0 ? 'the beginning' : timestamp(st.cursorMs).slice(0, 19) + 'Z')) + ' · ' + baseUrl()) }
  857. if (!o.post) {
  858. for (x of texts) { console.log('LIBRARIAN TEXT ' + x.ref + ' · ' + x.what + ' · ' + x.when + ' · "' + clip(x.quotes.join(' · '), 100) + '"') }
  859. console.log('LIBRARIAN DRY RUN — ' + texts.length + ' new creator text(s); no model call, nothing written (--post-files classifies them and writes the files)')
  860. done({ newTexts = texts.length })
  861. return true
  862. }
  863. if (texts.length == 0) {
  864. st.cursorMs = g.cursorMs
  865. st.floorMs = floor
  866. st.passes = st.passes + 1
  867. st.lastPass = timestamp(now())
  868. saveState(c.dir, st)
  869. let w = []
  870. if (!o.auto) {
  871. let store0 = loadStore(c.dir)
  872. if (store0.error == null) { w = renderAll(store0, reg) }
  873. for (l of w) { console.log('LIBRARIAN FILE ' + l) }
  874. console.log('LIBRARIAN: nothing new — no model call · ' + w.length + ' file(s) written, the others unchanged')
  875. }
  876. done({ newTexts = 0 written = w cost = 0 left = 0 })
  877. return true
  878. }
  879. let lk = takeLock(c.dir)
  880. if (lk.error != null) {
  881. console.log('librarian: BUSY — ' + lk.error)
  882. done({ error = lk.error })
  883. return false
  884. }
  885. let store = loadStore(c.dir)
  886. if (store.error != null) {
  887. dropLock(c.dir)
  888. console.log('librarian: ' + store.error)
  889. done({ error = store.error })
  890. return false
  891. }
  892. let sum = { newTexts = texts.length decisions = 0 not = 0 refused = 0 failed = 0 cost = 0 calls = 0 written = [] superseded = 0 left = texts.length }
  893. let changed = false
  894. let idx = 0
  895. let limit = c.max > 0 && c.max < texts.length ? c.max : texts.length
  896. let finish = (why) => {
  897. // the cursor moves only when every text of this pass got an outcome (a capped / stopped pass reads them again)
  898. let left = 0
  899. for (x of texts) { if (st.done[x.key] == null) { left = left + 1 } }
  900. if (left == 0) { st.cursorMs = g.cursorMs }
  901. sum.left = left
  902. st.floorMs = floor
  903. st.passes = st.passes + 1
  904. st.lastPass = timestamp(now())
  905. saveState(c.dir, st)
  906. if (changed || !o.auto) {
  907. sum.written = renderAll(store, reg)
  908. for (l of sum.written) { console.log('LIBRARIAN FILE ' + l) }
  909. }
  910. dropLock(c.dir)
  911. console.log('LIBRARIAN: ' + sum.calls + ' of ' + texts.length + ' new text(s) classified' + (why == null ? '' : ' (' + why + ')') + ' · ' + sum.decisions + ' decision(s), ' + sum.superseded + ' superseded, ' + sum.not + ' not a decision, ' + sum.refused + ' refused, ' + sum.failed + ' without an answer · ' + sum.written.length + ' file(s) written')
  912. console.log('LIBRARIAN COST: ' + money(sum.cost) + ' for ' + sum.calls + ' model call(s) (' + c.model + ')')
  913. done(sum)
  914. return null
  915. }
  916. let step = null
  917. step = () => {
  918. if (idx >= limit) {
  919. finish(limit < texts.length ? 'limit ' + limit + ' per pass — the rest next pass' : null)
  920. return null
  921. }
  922. if (o.stop != null && o.stop()) {
  923. finish('stop requested — the rest next pass')
  924. return null
  925. }
  926. let x = texts[idx]
  927. idx = idx + 1
  928. let allowedHere = []
  929. for (n of allowed) { allowedHere.push(n) }
  930. if (!allowedHere.includes(x.project)) { allowedHere.push(x.project) }
  931. allowedHere.push('all')
  932. console.log('LIBRARIAN TEXT ' + x.ref + ' · ' + x.what + ' · ' + x.author + ' · ' + x.when + ' · "' + clip(x.quotes.join(' · '), 100) + '"')
  933. classify(c, x, allowedHere, store.entries, (r) => {
  934. sum.calls = sum.calls + 1
  935. if (r.cost != null) { sum.cost = sum.cost + r.cost }
  936. // mission 034: the usage limit — no try counted, the text stays unfiled, the pass stops here (no further call)
  937. if (r.limited == true) {
  938. sum.limited = true
  939. sum.resetMs = r.resetMs
  940. sum.limitText = r.limitText
  941. console.log(' → USAGE LIMIT — ' + r.limitText + ' · resets ' + (r.resetMs == null ? 'at an unknown time' : timestamp(r.resetMs).slice(0, 19) + 'Z') + ' → the pass stops, the text stays unfiled (no try counted)')
  942. finish('Claude usage limit reached — the rest after the reset')
  943. return null
  944. }
  945. let tries = st.tries[x.key] == null ? 1 : st.tries[x.key] + 1
  946. let tr = st.tries
  947. tr[x.key] = tries
  948. st.tries = tr
  949. let dn = st.done
  950. if (r.reason != null) {
  951. sum.failed = sum.failed + 1
  952. if (tries >= 3) { dn[x.key] = 'failed 3 times: ' + clip(r.reason, 200) }
  953. console.log(' → NO ANSWER (' + tries + ' of 3 tries) — ' + r.reason)
  954. } else {
  955. let a = checkAnswer(r.answer, x, allowedHere, store.entries)
  956. if (a.problems != null) {
  957. sum.refused = sum.refused + 1
  958. if (tries >= 2) { dn[x.key] = 'refused twice: ' + clip(a.problems.join('; '), 200) }
  959. console.log(' → REFUSED (' + tries + ' of 2 tries) — ' + a.problems.join('; '))
  960. } else if (a.decision == false) {
  961. sum.not = sum.not + 1
  962. dn[x.key] = 'not a decision: ' + clip(a.why, 200)
  963. console.log(' → not a decision — ' + a.why)
  964. } else {
  965. let eid = 'd-' + x.key
  966. let src = { kind = 'ticket' ref = x.ref url = x.url subject = x.subject what = x.what author = x.author event = x.key }
  967. let entry = { id = eid project = a.project topic = a.topic quotes = a.quotes context = '' means = a.means readingBy = 'librarian' date = x.date ms = x.createdMs source = src added = now() status = 'current' why = a.why }
  968. // corrections: the entries it supersedes move to the archive (kept, marked)
  969. let es = []
  970. for (e of store.entries) {
  971. if (a.supersedes.includes(e.id)) {
  972. let ee = e
  973. ee.status = 'superseded'
  974. ee.supersededBy = eid
  975. ee.supersededOn = x.date
  976. es.push(ee)
  977. sum.superseded = sum.superseded + 1
  978. console.log(' → supersedes ' + e.id + ' ("' + clip(e.quotes.join(' · '), 80) + '")')
  979. } else { es.push(e) }
  980. }
  981. es.push(entry)
  982. store.entries = es
  983. changed = true
  984. sum.decisions = sum.decisions + 1
  985. dn[x.key] = 'decision ' + eid
  986. console.log(' → DECISION ' + eid + ' · ' + a.project + ' · ' + a.topic + ' · quote "' + clip(a.quotes.join(' · '), 80) + '" · means: ' + a.means)
  987. if (a.quoteNote != '') { console.log(' → note: ' + a.quoteNote) }
  988. if (a.dropped.length > 0) { console.log(' → note: unknown / not current ids in supersedes dropped: ' + a.dropped.join(', ')) }
  989. saveStore(c.dir, store)
  990. }
  991. }
  992. st.done = dn
  993. saveState(c.dir, st)
  994. step()
  995. return null
  996. })
  997. return null
  998. }
  999. step()
  1000. return true
  1001. }
  1002. // ORDER for the commands that build a brief by hand (`brief`, `work`, `cycle` — scheduler.hl): one pass FIRST, awaited,
  1003. // so a decision the creator wrote in any ticket a moment ago is in DECISIONS.md before the brief is assembled. Skipped
  1004. // for `--dry-run` (no model call there) and with `--librarian off` / COLONY_LIBRARIAN=off. A failed / busy pass is said
  1005. // and the command goes on with the files as they are. `then()` runs once.
  1006. static librarianFirst = (argv, what, then) => {
  1007. let off = setting(argv, '--librarian', 'COLONY_LIBRARIAN', 'on') == 'off'
  1008. if (off || argv.includes('--dry-run')) {
  1009. then()
  1010. return null
  1011. }
  1012. librarianPass({ argv = argv post = true auto = true stop = null }, (s) => {
  1013. if (s != null && s.limited == true) { console.log('librarian: the Claude usage limit is reached (resets ' + (s.resetMs == null ? 'at an unknown time' : timestamp(s.resetMs).slice(0, 19) + 'Z') + ') — ' + s.left + ' creator text(s) not filed; ' + what + ' goes on with the files as they are') }
  1014. else if (s != null && s.error != null) { console.log('librarian: ' + what + ' goes on WITHOUT a finished librarian pass (' + s.error + ') — the decisions files are as they were') }
  1015. else if (s != null && s.left != null && s.left > 0) { console.log('librarian: ' + s.left + ' creator text(s) not filed yet — ' + what + ' goes on with the files as they are') }
  1016. then()
  1017. return null
  1018. })
  1019. return null
  1020. }
  1021. // `librarian …` by hand
  1022. static runLibrarian = (argv) => {
  1023. let post = argv.includes('--post-files')
  1024. librarianPass({ argv = argv post = post auto = false stop = null }, (s) => { return null })
  1025. return true
  1026. }

Branches

Latest commits

  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre