@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11

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.
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import y from"node:fs";import it from"node:net";import W from"node:path";import C from"node:fs";import P from"node:path";import{spawn as Z,execFile as ut}from"node:child_process";import{createRequire as q}from"node:module";import{StringDecoder as Q}from"node:string_decoder";var wt=2*1024*1024;var gt=192*1024;var yt=64*1024;var tt=String.raw`
3
+ import os, sys, pty, select, struct, fcntl, termios, signal
4
+ cols, rows = int(sys.argv[1]), int(sys.argv[2])
5
+ argv = sys.argv[3:]
6
+ pid, fd = pty.fork()
7
+ if pid == 0:
8
+ try:
9
+ os.execvp(argv[0], argv)
10
+ finally:
11
+ os._exit(127)
12
+ def size(c, r):
13
+ try: fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
14
+ except OSError: pass
15
+ size(cols, rows)
16
+ def wall(f, b):
17
+ while b:
18
+ try: n = os.write(f, b)
19
+ except InterruptedError: continue
20
+ b = b[n:]
21
+ ins = [0, fd, 3]
22
+ ctl = b''
23
+ while True:
24
+ try: r = select.select(ins, [], [])[0]
25
+ except InterruptedError: continue
26
+ if fd in r:
27
+ try: d = os.read(fd, 65536)
28
+ except OSError: d = b''
29
+ if not d: break
30
+ wall(1, d)
31
+ if 0 in r:
32
+ d = os.read(0, 65536)
33
+ if not d:
34
+ ins.remove(0)
35
+ try: os.kill(pid, signal.SIGHUP)
36
+ except OSError: pass
37
+ else:
38
+ try: wall(fd, d)
39
+ except OSError: pass
40
+ if 3 in r:
41
+ d = os.read(3, 4096)
42
+ if not d: ins.remove(3)
43
+ ctl += d
44
+ while b'\n' in ctl:
45
+ line, ctl = ctl.split(b'\n', 1)
46
+ p = line.split()
47
+ try:
48
+ if p[0] == b'R': size(int(p[1]), int(p[2]))
49
+ elif p[0] == b'K': os.kill(pid, int(p[1]))
50
+ except (IndexError, ValueError, OSError): pass
51
+ st = os.waitpid(pid, 0)[1]
52
+ code = os.waitstatus_to_exitcode(st)
53
+ sys.exit(code if code >= 0 else 128 - code)
54
+ `;function et({platform:t=process.platform,exists:n=c=>{try{return C.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return C.statSync(c).isDirectory()}catch{return!1}}}={}){return(t==="darwin"?["/opt/homebrew/bin/python3","/usr/local/bin/python3",...o("/Library/Developer/CommandLineTools")||o("/Applications/Xcode.app")?["/usr/bin/python3"]:[]]:["/usr/bin/python3","/usr/local/bin/python3","/bin/python3"]).find(n)||null}function st(t){return{kind:"node-pty",spawn({file:n,args:o,cwd:c,env:a,cols:d,rows:e}){let r=t.spawn(n,o,{name:"xterm-256color",cols:d,rows:e,cwd:c,env:a});return{pid:r.pid,onData:s=>r.onData(s),onExit:s=>r.onExit(({exitCode:p,signal:u})=>s({code:p,signal:u||null})),write:s=>r.write(s),resize:(s,p)=>{try{r.resize(s,p)}catch{}},kill:(s="SIGHUP")=>{try{r.kill(s)}catch{}}}}}}function U(t,{spawnImpl:n=Z}={}){return{kind:"python",spawn({file:o,args:c,cwd:a,env:d,cols:e,rows:r}){let s=n(t,["-c",tt,String(e),String(r),o,...c],{cwd:a,env:d,stdio:["pipe","pipe","pipe","pipe"]}),p=new Q("utf8"),u=[],H=[],j=!1;s.stdout.on("data",i=>{let h=p.write(i);if(h)for(let b of u)b(h)}),s.stderr.on("data",i=>{let h=String(i);for(let b of u)b(h)});let A=s.stdio[3];for(let i of[s.stdin,A])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let F of u)F(b);for(let F of H)F({code:i??null,signal:h||null})};s.on("exit",(i,h)=>setTimeout(()=>G(i,h),20)),s.on("error",i=>{for(let h of u)h(`\r
55
+ [terminal] `+i.message+`\r
56
+ `);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{A.write("R "+i+" "+h+`
57
+ `)}catch{}},kill:(i="SIGHUP")=>{try{A.write("K "+(V[i]||1)+`
58
+ `)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>C.statSync(d),chmod:a=(d,e)=>C.chmodSync(d,e)}={}){let d=[];for(let e of[P.join(t,"prebuilds",n+"-"+o,"spawn-helper"),P.join(t,"build","Release","spawn-helper")]){let r;try{r=c(e)}catch{continue}if(!(!r.isFile()||(r.mode&73)===73))try{a(e,r.mode&511|493),d.push(e)}catch{}}return d}function rt(t,n){if(!n)return t;let o=t;return{get kind(){return o.kind},spawn(c){try{return o.spawn(c)}catch(a){if(o!==t)throw a;return o=n(),o.spawn(c)}}}}function K({requireImpl:t=q(import.meta.url),python:n=et(),env:o=process.env}={}){let c=null;if(o.LEFTOFF_TERM_BACKEND!=="python")try{let a=t("node-pty");try{nt(P.dirname(t.resolve("node-pty/package.json")))}catch{}return rt(st(a),n?()=>U(n):null)}catch(a){c=a}return n?U(n):{kind:null,why:"No terminal backend: node-pty isn’t installed"+(c&&!/Cannot find module/.test(c.message)?" ("+String(c.message).split(`
59
+ `)[0]+")":"")+" and there’s no python3. Run `leftoff upgrade`, or install python3."}}var X=process.argv[2],l;try{l=JSON.parse(y.readFileSync(X,"utf8"))}catch(t){process.stderr.write("pty-host: no spec: "+t.message+`
60
+ `),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),N=W.join(B,_+".json"),ot=l.bufferMax||2*1024*1024,E=t=>process.stderr.write(new Date().toISOString()+" "+_+" "+t+`
61
+ `);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var I=K({env:l.env||process.env});I.kind||(E("no backend: "+I.why),process.exit(3));var f;try{f=I.spawn({file:l.file,args:l.args||[],cwd:l.cwd,env:l.env||process.env,cols:l.cols||80,rows:l.rows||24})}catch(t){E("spawn failed: "+t.message),process.exit(4)}var k=[],M=0,x=0,m=!1,O=null,S=null,J=!1,w=l.cols||80,g=l.rows||24,L={v:1,id:_,hostPid:process.pid,pid:f.pid||null,cwd:l.cwd,createdAt:new Date().toISOString(),cols:w,rows:g,backend:I.kind,...l.meta||{}},D=()=>{try{let t=N+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,N)}catch(t){E("meta: "+t.message)}},T=new Set,R=t=>{let n=JSON.stringify(t)+`
62
+ `;for(let o of T)if(o.ready)try{o.write(n)}catch{}},$=()=>[...T].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),M+=t.length,x+=t.length;M>ot&&k.length>1;)M-=k[0].length,k.shift();R({t:"out",d:t,end:x})});var ct=null;f.onExit(({code:t,signal:n})=>{m=!0,O=t??null,S=n||null,E("program exited ("+O+(S?", "+S:"")+")"),D(),R({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of T)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,N])try{y.rmSync(t,{force:!0})}catch{}setTimeout(()=>process.exit(0),50)}try{y.rmSync(v,{force:!0})}catch{}var z=it.createServer(t=>{t.setEncoding("utf8");let n="";T.add(t),t.on("data",c=>{n+=c;let a;for(;(a=n.indexOf(`
63
+ `))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-M,p=typeof e.since=="number"&&e.since>=s&&e.since<=x?e.since:s;t.write(JSON.stringify({t:"hello",id:_,pid:f.pid||null,start:s,end:x,exited:m,code:O,signal:S,cols:w,rows:g,meta:L})+`
64
+ `);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
65
+ `),m&&t.write(JSON.stringify({t:"exit",code:O,signal:S})+`
66
+ `),R({t:"clients",list:$()})}else if(e.t==="in"){if(!m)try{f.write(String(e.d||""))}catch{}}else if(e.t==="resize"){let r=Math.max(2,Math.min(1e3,Number(e.c)||w)),s=Math.max(1,Math.min(500,Number(e.r)||g));if(r!==w||s!==g){w=r,g=s;try{f.resize(w,g)}catch{}}}else if(e.t==="kill")if(J=!0,m)Y();else{try{f.kill(e.sig||"SIGHUP")}catch{}setTimeout(()=>{if(!m)try{f.kill("SIGKILL")}catch{}},3e3).unref()}else if(e.t==="meta"&&e.patch&&typeof e.patch=="object"){for(let[r,s]of Object.entries(e.patch))/^(title|kind|sessionId|run|startCwd|renamed)$/.test(r)&&(L[r]=s);D()}}});let o=()=>{T.delete(t)&&R({t:"clients",list:$()})};t.on("close",o),t.on("error",o)}),lt=process.umask(127);z.listen(v,()=>{process.umask(lt);try{y.chmodSync(v,384)}catch{}D(),E("listening ("+I.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
package/docs/SECURITY.md CHANGED
@@ -94,7 +94,7 @@ A relayed request is replayed against the Mac's own board from inside the daemon
94
94
 
95
95
  When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
96
96
 
97
- Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
97
+ Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback, or since 0.9.18 another app's Caddy on port 443 that imports Leftoff's site file, which turns away any client not on loopback by its address; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
98
98
 
99
99
  ### Live link (0.9.8 beta)
100
100
 
@@ -0,0 +1,52 @@
1
+ # macOS permission prompts
2
+
3
+ ## What the prompt means
4
+
5
+ > **"node" would like to access data from other apps.**
6
+ > Keeping app data separate makes it easier to manage your privacy and security.
7
+
8
+ macOS 14 and later ask this when a program reads inside another app's data: `~/Library/Containers/<app>`, `~/Library/Group Containers/<group>`, and on macOS 15 and later an app's own folder in `~/Library/Application Support`. The privacy service is called App Data (`kTCCServiceSystemPolicyAppData`).
9
+
10
+ macOS asks on behalf of the *responsible* program. A program started by the login service is responsible for everything it starts, and so is a terminal app for its shells. Before Leftoff 1.0 the login service started `node` itself. So anything Leftoff ran showed up as "node": a headless Claude Code or Codex session, a board terminal, git. Claude Code's command sandbox asks for App Data on its own when it sets up.
11
+
12
+ Two things made it repeat:
13
+ - A bare `node` binary is remembered by its path, and a Homebrew or nvm upgrade puts node at a new path.
14
+ - macOS keeps no "Don't Allow" for this service, so after Don't Allow it simply asks again.
15
+
16
+ ## What Leftoff does about it (1.0)
17
+
18
+ - **Leftoff's own code stays out of other apps' data.** No walk, listing, git call or file read goes into `~/Library` except the two places Leftoff uses: its own `Application Support/Leftoff` and `LaunchAgents`. In particular:
19
+ - the board terminal's "Match my Ghostty" reads Ghostty's folder only once you pick that theme, not every time the menu opens;
20
+ - Syncthing's `config.xml` is read every 10 minutes instead of every minute;
21
+ - a git repo rooted at your home folder (dotfiles) never lets git walk `~/Library`.
22
+ - **The rest asks as "Leftoff", once.** On a Mac the login service now starts a small launcher inside `~/Library/Application Support/Leftoff/Agent/Leftoff.app` (bundle id `dev.leftoff.Leftoff`). That launcher runs node as its child, so macOS names Leftoff, and an Allow is kept for the app across Leftoff and node upgrades.
23
+ - The app is built on your Mac once, with the Command Line Tools' compiler, and signed ad hoc. It is never rebuilt by an upgrade.
24
+ - Without the Command Line Tools the service runs node directly, as before, and `leftoff doctor` says so (`xcode-select --install`, then `leftoff update`).
25
+
26
+ After `leftoff update`, expect at most one prompt that says "Leftoff". Click **Allow**.
27
+
28
+ ## If you clicked Don't Allow
29
+
30
+ macOS doesn't keep a Don't Allow for App Data, so the next request asks again; click Allow then.
31
+
32
+ To take back an Allow and be asked again (no sudo, only your own user's answers):
33
+
34
+ tccutil reset SystemPolicyAppData dev.leftoff.Leftoff
35
+
36
+ `tccutil reset SystemPolicyAppData` without the id resets the answer for every app.
37
+
38
+ ## See who is asking
39
+
40
+ leftoff doctor --tcc
41
+
42
+ This reads macOS's privacy log for the last 2 hours, then watches for a minute while you use Leftoff. It prints each request: who it was asked for (Leftoff, node, a terminal), which program touched the data, and whether a prompt showed. Options: `--seconds=120`, `--since=6h`, `--all` for every privacy service. It needs an admin account, never sudo; some fields may read `<private>`.
43
+
44
+ To watch it by hand:
45
+
46
+ log stream --style compact --predicate 'subsystem == "com.apple.TCC" AND eventMessage CONTAINS "AUTHREQ"'
47
+
48
+ `leftoff doctor` (without `--tcc`) says whether the login service runs as Leftoff.app.
49
+
50
+ ## Rebuilding
51
+
52
+ `leftoff update --force` rebuilds Leftoff.app. A new build is a new code signature, so macOS asks once more.
@@ -0,0 +1,43 @@
1
+ # Test plans from merged PRs
2
+
3
+ Merging 50 PRs shouldn't need 50 reviews to feel safe. A test plan turns what Leftoff already knows about a batch of merged work into one short manual script: what to click, what you should see, and why it's on the list, ordered by risk and fitted to the time you have.
4
+
5
+ ## Where to start one
6
+
7
+ - A project's page: **Test plan**, next to Decisions and Ask.
8
+ - A repo's or a project's decision log: **Test plan**.
9
+ - The PRs page: pick merged PRs, then **Test plan for these**.
10
+ - The terminal: `leftoff testplan --project acme` (or `--repo web`), with `--since 14d` and `--minutes 15`, `30` or `60`. It prints Markdown.
11
+
12
+ The window is the last 7 days unless you say otherwise. The budget is 30 minutes; 15 and 60 are the other choices, picked for each plan (it isn't a setting).
13
+
14
+ ## What it reads
15
+
16
+ Only Leftoff's own notes, plus what the PRs page already reads with `gh` where it's signed in:
17
+
18
+ - the PRs merged in the window that belong to the project or repo (or the ones you picked), each with its sessions;
19
+ - each PR's sessions' newest checkpoint (Goal, Progress, Decided);
20
+ - the walkthrough kept for its session: steps, Look / Assumption / Untested callouts, weak-test flags, spots, and the decisions a step follows or departs from. A PR with no kept walkthrough is not walked through on its own; it's read from its files, checkpoints and decisions and marked **no walkthrough**. **Walk through the N without one first** runs the usual walkthrough for each, one at a time;
21
+ - the decisions these PRs accepted, superseded or departed from.
22
+
23
+ ## What it makes
24
+
25
+ Up to about 12 checks that fit the budget. Each has a title, where to go (a screen, command or endpoint), at most 5 steps, what you should see, why it's on the list (with chips for the PRs, steps, checkpoints and decisions it comes from), minutes, and its risk as a shape and a word (▲ High, ◆ Medium, ▽ Low). Checks are ordered by risk, then by how many PRs each covers; checks on the same flow sit together.
26
+
27
+ These always become checks, or are listed under **Not covered** with the reason: every Look and Untested callout on a Core step, every weak test (the test isn't trusted, so the behaviour gets a manual check), every decision a PR departs from or that was superseded in the window ("D-b replaced D-a on Nov 2: confirm the new behaviour, and that the old one is gone"), and every migration, config or auth change. A last line says how many of the PRs have at least one check.
28
+
29
+ Every citation is checked against what Leftoff gave the model. A tag it made up is dropped, and a check left with none is dropped too, with a note under **What was checked in the answer**.
30
+
31
+ ## How it runs
32
+
33
+ The same way Ask does: Claude Code headless and read-only (Read, Grep and Glob, nothing that changes a file, the same budget and timeout), never a session on the board. Without Claude Code it uses your summary model with a smaller pack; with neither, it makes the plan from the rules above. Starting one from the board follows Ask's rule for a model run: this Mac's own browser, or a paired device with **Trust my paired devices** on.
34
+
35
+ ## While you test
36
+
37
+ Tick a check **Checked** or **Failed**; "3 of 9 checked" shows at the top. A failed check takes a one-line note, which goes into the export, and offers **Propose a decision**: what you learned goes into the repo's decision log as a proposed correction, linked to the plan, for you to accept or not later. It is never accepted on its own.
38
+
39
+ **Copy as Markdown** gives a checklist with checkboxes; **Download .html** gives one self-contained file in the walkthrough export's look.
40
+
41
+ ## Where it's kept
42
+
43
+ On the Mac that wrote it, in `~/.config/checkpoints/testplans/`, the newest 100 for 90 days, each deletable. Never in the synced folder. The list shows your other Macs' plans too, read over the peer link. Nothing is posted to GitHub or sent to anyone.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.1",
3
+ "version": "0.9.18-beta.11",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
package/mod/hooks/core.js CHANGED
@@ -506,6 +506,10 @@ export function renderCheckpointMd(cp) {
506
506
  // Informative / long checkpoints (v0.4.6): sections below the three lines.
507
507
  // Older readers only pick up the **Label:** lines, so they still work.
508
508
  const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
509
+ // 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
510
+ // sections, so an older reader (no section open yet) skips the heading and its lines.
511
+ const decided = cleanDecided(cp.decided)
512
+ if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
509
513
  for (const k of SECTION_ORDER) {
510
514
  const v = cp[k]
511
515
  if (k === 'recap') { if (v) lines.push('', '### ' + SECTION_TITLES[k], oneLine(v).replace(/^\*+/, '')); continue }
@@ -683,7 +687,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
683
687
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
684
688
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
685
689
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
686
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort']
690
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort', 'decisionsGiven']
687
691
 
688
692
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
689
693
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -1389,10 +1393,16 @@ const CHECKPOINT_BASE = [
1389
1393
  'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
1390
1394
  'Describe only what the transcript shows; the lines above it (repo, branch, title, what was asked, previous checkpoint) are background and may be out of date.',
1391
1395
  ]
1396
+ // 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
1397
+ const DECIDED_SECTION = [
1398
+ 'Only if the transcript shows a decision (a choice between real alternatives that the developer made or approved, a plan they approved, a correction they gave such as "no, do X instead", or a trade-off the agent chose and said so), add after those lines a "Decided:" heading and one "- " line per decision, at most 4:',
1399
+ '- <what was decided, in the words the transcript uses> · why: <the reason given, one line> · over: <alternative>; <alternative> · from: <asked | plan | correction | agent>',
1400
+ 'A task done, a file edited or a routine step is not a decision, and neither is a one-off choice about this task right now (who does a step, whether to commit, push or deploy now). Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
1401
+ ]
1392
1402
  const INFORMATIVE_SECTIONS = [
1393
1403
  'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
1394
- 'Decisions:',
1395
- '- <a decision made in the session and the reason, one line each, at most 4>',
1404
+ 'Approach notes:',
1405
+ '- <how the work is being done: the approach taken, a pattern or constraint it follows; one line each, at most 4. Not a choice between alternatives: those go only under Decided>',
1396
1406
  'Open questions:',
1397
1407
  '- <an unresolved question or uncertainty, at most 3>',
1398
1408
  'Files:',
@@ -1409,9 +1419,9 @@ const LONG_SECTIONS = [
1409
1419
  '3. <the step after that>',
1410
1420
  ]
1411
1421
  export const CHECKPOINT_SYSTEMS = {
1412
- short: [...CHECKPOINT_BASE, 'Write only those five lines.', 'Optionally a 6th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 90 words total.'].join('\n'),
1413
- informative: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 280 words total.'].join('\n'),
1414
- long: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 630 words total.'].join('\n'),
1422
+ short: [...CHECKPOINT_BASE, ...DECIDED_SECTION, 'Write only those five lines (and Decided, when there is one).', 'Optionally a 6th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 90 words total.'].join('\n'),
1423
+ informative: [...CHECKPOINT_BASE, ...DECIDED_SECTION, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 280 words total.'].join('\n'),
1424
+ long: [...CHECKPOINT_BASE, ...DECIDED_SECTION, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 630 words total.'].join('\n'),
1415
1425
  }
1416
1426
  // The short prompt, as before v0.4.6 (kept under its old name for anything importing it).
1417
1427
  export const CHECKPOINT_SYSTEM = CHECKPOINT_SYSTEMS.short
@@ -1463,28 +1473,210 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
1463
1473
  head.push('Previous checkpoint: ' + prev, 'Files touched since then: ' + (files.length ? files.join(', ') : 'none'))
1464
1474
  if (Array.isArray(uncommitted)) head.push('Uncommitted files now: ' + (uncommitted.length ? uncommitted.slice(0, 12).join(', ') + (uncommitted.length > 12 ? ' and ' + (uncommitted.length - 12) + ' more' : '') : 'none (everything is committed)'))
1465
1475
  if (userNote) head.push('Note from the developer: ' + userNote)
1476
+ // 2026-11-03: already read from its questions and plans, no model needed; redacted like the prompt, since they're saved
1477
+ const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
1478
+ const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
1479
+ if (found.length) head.push('Decisions already recorded from the session\'s questions and plans (don\'t repeat them): ' + found.map((d) => '“' + clip(d.text, 120) + '”').join('; '))
1466
1480
  if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
1467
1481
  if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
1468
1482
  const slice = transcriptSlice(messages, { maxInputChars, cwd })
1469
1483
  const patterns = (cfg.redact && cfg.redact.patterns) || []
1470
1484
  const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
1471
- return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens }
1485
+ return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
1486
+ }
1487
+
1488
+ // ---------------------------------------------------------------- decisions (2026-11-03)
1489
+ //
1490
+ // A decision is a choice between real alternatives that someone made or
1491
+ // approved in a session: a question you answered from its options, a plan you
1492
+ // approved, a correction you gave, or a trade-off the agent chose and said so.
1493
+ // Not a task done or a file edited. A checkpoint with none is normal.
1494
+ // In a checkpoint each is one line under "### Decided":
1495
+ // - <what was decided> · why: <one line> · over: <alt>; <alt> · from: asked
1496
+ // Both "·" and "|" separate the labelled parts (a model may write either).
1497
+ export const DECIDED_TITLE = 'Decided'
1498
+ export const DECIDED_MAX = 8
1499
+ export const DECISION_SOURCES = ['asked', 'plan', 'correction', 'agent']
1500
+ const DEC_PART = /\s+[·|]\s+(why|over|instead of|alternatives|from|source)\s*:\s*/i
1501
+
1502
+ export function parseDecidedLine(line) {
1503
+ let t = String(line == null ? '' : line).replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').replace(/\*\*/g, '').trim()
1504
+ if (!t || NONE_RE.test(t)) return null
1505
+ const d = { text: '', why: '', alternatives: [], source: 'agent' }
1506
+ const m0 = t.match(/^\[(asked|plan|correction|agent)\]\s*/i) // "[asked] …" also reads
1507
+ if (m0) { d.source = m0[1].toLowerCase(); t = t.slice(m0[0].length) }
1508
+ const parts = t.split(DEC_PART)
1509
+ d.text = parts[0].trim()
1510
+ for (let i = 1; i + 1 < parts.length; i += 2) {
1511
+ const k = parts[i].toLowerCase(), v = parts[i + 1].trim()
1512
+ if (k === 'why') d.why = v
1513
+ else if (k === 'from' || k === 'source') { const src = v.toLowerCase().replace(/[^a-z]/g, ''); if (DECISION_SOURCES.includes(src)) d.source = src }
1514
+ else d.alternatives = v.split(/\s*;\s*/).map((x) => x.trim()).filter((x) => x && !NONE_RE.test(x))
1515
+ }
1516
+ return cleanDecided([d])[0] || null
1517
+ }
1518
+
1519
+ export function renderDecidedLine(d) {
1520
+ const safe = (x) => oneLine(x).replace(/\s+[·|]\s+/g, ' - ')
1521
+ return '- ' + safe(d.text) + (d.why ? ' · why: ' + safe(d.why) : '') + (d.alternatives && d.alternatives.length ? ' · over: ' + d.alternatives.map((a) => safe(a).replace(/;/g, ',')).join('; ') : '') + ' · from: ' + (d.source || 'agent')
1522
+ }
1523
+
1524
+ // Any list -> [{ text, why, alternatives, source }], one line each, deduplicated by text.
1525
+ export function cleanDecided(list) {
1526
+ const out = [], seen = new Set()
1527
+ for (const x of Array.isArray(list) ? list : []) {
1528
+ if (!x || typeof x !== 'object') continue
1529
+ const text = clip(oneLine(x.text).replace(/^["“]|["”]$/g, ''), 240)
1530
+ if (!text || NONE_RE.test(text)) continue
1531
+ const k = decisionNorm(text)
1532
+ if (seen.has(k)) continue
1533
+ seen.add(k)
1534
+ const alternatives = (Array.isArray(x.alternatives) ? x.alternatives : []).map((a) => clip(oneLine(a), 120)).filter(Boolean).slice(0, 6)
1535
+ out.push({ text, why: clip(oneLine(x.why), 240), alternatives, source: DECISION_SOURCES.includes(x.source) ? x.source : 'agent' })
1536
+ if (out.length >= DECIDED_MAX) break
1537
+ }
1538
+ return out
1539
+ }
1540
+ export const decisionNorm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
1541
+
1542
+ // A decision's key in a repo's log: the same decision from the same session is
1543
+ // the same entry wherever it was read (the mod or the daemon). 12 hex characters.
1544
+ export function decisionKey(sessionId, text) {
1545
+ const str = String(sessionId || '') + '\n' + decisionNorm(text)
1546
+ let h1 = 0x811c9dc5, h2 = 0x01000193 ^ 0x5bd1e995
1547
+ for (let i = 0; i < str.length; i++) {
1548
+ const c = str.charCodeAt(i)
1549
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0
1550
+ h2 = Math.imul(h2 ^ c, 0x5bd1e995) >>> 0
1551
+ h2 = (h2 ^ (h2 >>> 15)) >>> 0
1552
+ }
1553
+ return (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')).slice(0, 12)
1554
+ }
1555
+
1556
+ // "User has answered your questions: "Q"="A", "Q2"="B". You can now continue…" -> { Q: A }
1557
+ function questionAnswers(text, questions) {
1558
+ const out = {}
1559
+ const t = String(text || '')
1560
+ for (let i = 0; i < questions.length; i++) {
1561
+ const q = questions[i]
1562
+ const at = t.indexOf('"' + q + '"="')
1563
+ if (at < 0) continue
1564
+ const from = at + q.length + 4
1565
+ let end = -1
1566
+ for (let j = i + 1; j < questions.length && end < 0; j++) { const n = t.indexOf('", "' + questions[j] + '"="', from); if (n >= 0) end = n }
1567
+ if (end < 0) { const m = t.slice(from).search(/"(?:\.\s|\.?$|,\s*"[^"]*"=")/); end = m >= 0 ? from + m : t.length }
1568
+ out[q] = t.slice(from, end).trim()
1569
+ }
1570
+ return out
1571
+ }
1572
+
1573
+ const PLAN_OK = /(?:approved your plan|user has approved|plan (?:was )?approved)/i
1574
+ function planHeadline(plan) {
1575
+ const lines = String(plan || '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean)
1576
+ const h = lines.find((l) => /^#{1,3}\s+\S/.test(l)) || lines[0] || ''
1577
+ return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
1578
+ }
1579
+
1580
+ // 2026-11-05: a question about one task right now isn't a decision for the repo: who does it ("You update
1581
+ // it" / "I'll handle it"), or whether to go ahead with a commit, push, deploy and the like. Never recorded.
1582
+ const ACTOR_OPT = /^(?:you|i|i'll|i will|i'd|me|let me|we|we'll|leave it|someone)\b/i
1583
+ const NOW_Q = /\b(?:go ahead|proceed|right now|commit|push|merge|deploy|ship|publish|release|restart|open (?:a|the) pr|create (?:a|the) pr|who (?:should|will|does))\b/i
1584
+ const YESNO_OPT = /^(?:yes|no|y|n|ok|okay|sure|go ahead|do it|proceed|not now|later|skip|wait|stop|hold off|cancel|continue)\b/i
1585
+ export function isOneOffQuestion(q) {
1586
+ const labels = (Array.isArray(q && q.options) ? q.options : []).map((o) => String((o && o.label) || o || '').replace(/\s*\(Recommended\)\s*/i, '').trim()).filter(Boolean)
1587
+ if (labels.length < 2) return false
1588
+ if (labels.every((l) => ACTOR_OPT.test(l))) return true
1589
+ return NOW_Q.test(String((q && q.question) || '')) && labels.every((l) => YESNO_OPT.test(l))
1590
+ }
1591
+
1592
+ // The transcript's own decisions, oldest first, and the answers it later changed:
1593
+ // a question asked again (same header, else the same question) and answered
1594
+ // differently keeps only the last answer; the earlier ones are `reversed` (texts).
1595
+ function readStated(messages) {
1596
+ const out = [], at = new Map(), replaced = new Set()
1597
+ for (const m of messages || []) {
1598
+ if (!m || m.role !== 'assistant') continue
1599
+ for (const u of m.toolUses || []) {
1600
+ const name = u && (u.tool || u.name)
1601
+ if (!u || u.isError || !u.input) continue
1602
+ if (name === 'AskUserQuestion' && Array.isArray(u.input.questions) && u.text) {
1603
+ const qs = u.input.questions.filter((q) => q && q.question && Array.isArray(q.options) && q.options.length >= 2)
1604
+ const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
1605
+ for (const q of qs) {
1606
+ const a = ans[q.question]
1607
+ if (!a || isOneOffQuestion(q)) continue
1608
+ const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
1609
+ const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
1610
+ const chosen = picked.length ? picked.join(', ') : a
1611
+ const opt = q.options.find((o) => o && o.label === picked[0])
1612
+ const d = {
1613
+ text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
1614
+ why: opt && opt.description ? opt.description : '',
1615
+ alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
1616
+ source: 'asked',
1617
+ }
1618
+ const topic = 'q:' + decisionNorm(q.header || q.question)
1619
+ const was = at.get(topic)
1620
+ if (was != null && decisionNorm(out[was].text) !== decisionNorm(d.text)) { replaced.add(out[was].text); out[was] = null }
1621
+ if (was != null && out[was]) continue // the same answer again
1622
+ at.set(topic, out.length); out.push(d)
1623
+ }
1624
+ }
1625
+ if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
1626
+ const head = planHeadline(u.input.plan)
1627
+ if (head) out.push({ text: clip('Plan approved: ' + head, 240), why: '', alternatives: [], source: 'plan' })
1628
+ }
1629
+ }
1630
+ }
1631
+ const found = out.filter(Boolean)
1632
+ const kept = new Set(found.map((d) => decisionNorm(d.text)))
1633
+ return { found, reversed: [...replaced].filter((t) => !kept.has(decisionNorm(t))) }
1634
+ }
1635
+
1636
+ // The decisions a transcript states outright, with no model: a question you
1637
+ // answered from its options (AskUserQuestion, the board's Needs you), its other
1638
+ // options as the alternatives, only its last answer; a plan you approved
1639
+ // (ExitPlanMode). Messages are src/transcript.js's shape, which is also
1640
+ // $.session.messages()'s.
1641
+ export function decisionsFromMessages(messages) {
1642
+ return cleanDecided(readStated(messages).found)
1643
+ }
1644
+
1645
+ // 2026-11-05: what the log settles on its own (src/decisions.js settleStated): the stated decisions,
1646
+ // redacted as checkpoints save them, and the texts of answers changed later in the session.
1647
+ export function statedDecisions(messages, patterns = []) {
1648
+ const red = (t) => redact(String(t || ''), patterns || [])
1649
+ const { found, reversed } = readStated(messages)
1650
+ return {
1651
+ found: cleanDecided(found).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) })),
1652
+ reversed: reversed.map((t) => red(clip(oneLine(t), 240))),
1653
+ }
1654
+ }
1655
+
1656
+ // The checkpoint's decisions: what the transcript states outright first, then the model's.
1657
+ // earlier: decisions already in this session's previous checkpoints (by text), left out.
1658
+ export function mergeDecided(found, fromModel, { earlier = [] } = {}) {
1659
+ const seen = new Set((earlier || []).map((d) => decisionNorm(d && d.text)))
1660
+ return cleanDecided([...(found || []), ...(fromModel || [])]).filter((d) => !seen.has(decisionNorm(d.text)))
1472
1661
  }
1473
1662
 
1474
1663
  // The extra sections of an informative / long checkpoint, by the heading a
1475
1664
  // model (or a checkpoint file) uses for them.
1476
1665
  const SECTION_KEYS = {
1477
- 'decisions': 'decisions', 'key decisions': 'decisions',
1666
+ // 1i (testplan): the section is "Approach notes" now (real choices go only under Decided); a checkpoint
1667
+ // written before, or a model still saying "Decisions:" / "Key decisions", reads into the same field.
1668
+ 'approach notes': 'decisions', 'approach': 'decisions', 'decisions': 'decisions', 'key decisions': 'decisions',
1478
1669
  'open questions': 'questions', 'questions': 'questions',
1479
1670
  'files': 'files', 'files touched': 'files',
1480
1671
  'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
1481
1672
  'recap': 'recap',
1482
1673
  'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
1674
+ 'decided': 'decided', // 2026-11-03 decision memory
1483
1675
  }
1484
- export const SECTION_TITLES = { decisions: 'Key decisions', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1676
+ export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1485
1677
  const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
1486
1678
  const NONE_RE = /^(none|n\/a|nothing|-|—|none yet|no(ne)? (open )?questions?)\.?$/i
1487
- const LIMITS = { decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1679
+ const LIMITS = { decided: DECIDED_MAX, decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1488
1680
 
1489
1681
  // "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
1490
1682
  export function parseFileNote(text) {
@@ -1495,7 +1687,7 @@ export function parseFileNote(text) {
1495
1687
  return p ? { path: p[1], why: '' } : { path: '', why: t }
1496
1688
  }
1497
1689
 
1498
- function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1690
+ function emptyExtras() { return { decided: [], decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1499
1691
  export function hasExtras(cp) {
1500
1692
  return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
1501
1693
  }
@@ -1510,6 +1702,7 @@ function parseSections(lines) {
1510
1702
  if (!v || NONE_RE.test(v)) return
1511
1703
  if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
1512
1704
  if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
1705
+ if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
1513
1706
  ex[k].push(v)
1514
1707
  }
1515
1708
  for (const raw of lines) {
@@ -1528,8 +1721,11 @@ function parseSections(lines) {
1528
1721
  if (item) push(cur, item[1])
1529
1722
  else if (cur === 'recap') push(cur, line)
1530
1723
  else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
1724
+ else if (cur === 'decided') continue // a decision is one bullet line; loose prose under the heading is not one
1531
1725
  else push(cur, line)
1532
1726
  }
1727
+ // 1i: a line a model put under both Decided and the approach notes is a decision: it stays under Decided only
1728
+ if (ex.decided.length && ex.decisions.length) { const dn = new Set(ex.decided.map((d) => decisionNorm(d.text))); ex.decisions = ex.decisions.filter((t) => !dn.has(decisionNorm(String(t).split(/\s+·\s+|\s+[-—–]\s+because\b/)[0]))) }
1533
1729
  for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
1534
1730
  ex.recap = clip(ex.recap, 1600)
1535
1731
  return ex
@@ -1707,6 +1903,8 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1707
1903
  else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
1708
1904
  else if (lastEdit >= 0 && lastCommit < lastEdit) next = 'Commit, or keep going.'
1709
1905
  const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
1906
+ const found = decisionsFromMessages(ms_) // 2026-11-03: only what the transcript states outright; the heuristic guesses none
1907
+ if (found.length) base.decided = found
1710
1908
  const len = normalizeLength(length)
1711
1909
  if (len === 'short') return base
1712
1910
  const ex = emptyExtras()
@@ -1733,7 +1931,7 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1733
1931
  ].filter(Boolean).join(' ')
1734
1932
  ex.nextSteps = next ? [next] : []
1735
1933
  }
1736
- return { ...base, ...ex }
1934
+ return { ...base, ...ex, decided: base.decided || [] }
1737
1935
  }
1738
1936
 
1739
1937
  // Fallback title: {repo}@{branch} · {first 6 words of the first prompt}
@@ -1750,7 +1948,7 @@ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own
1750
1948
  export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
1751
1949
 
1752
1950
  export function cleanTitle(text) {
1753
- const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1951
+ const t = stripChannel(String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '')).split('\n').map((l) => l.trim()).find(Boolean) || '' // 0.9.18: a model that echoes the Live wrapper
1754
1952
  return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1755
1953
  }
1756
1954