@machine-made/pi-rethread 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +111 -0
- package/README.md +103 -2
- package/SECURITY.md +16 -0
- package/index.ts +375 -0
- package/package.json +66 -4
- package/prompts/kickoff.md +12 -0
- package/prompts/land.md +9 -0
- package/prompts/summary.md +11 -0
- package/prompts/write.md +21 -0
- package/rethread.ts +143 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.1] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- The package name in the repository is `@machine-made/pi-rethread`, the name 0.1.0 is published under on npm.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-10-28
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `/rethread [goal]`: the agent lands its step, writes a letter to the next session and a ledger entry (`rethread_save` tool), then a new session opens with the letter and asks for a three-line readback.
|
|
18
|
+
- `/rethread --now` (side model call writes the handoff), `--review` (edit before moving), `--cancel`.
|
|
19
|
+
- `/ledger` shows the ledger file and its entry count.
|
|
20
|
+
- Automatic rethread at `--rethread-auto-percent` (default 70) and instead of Pi's automatic compaction; once per session, with a loop guard.
|
|
21
|
+
- The four prompts are Markdown templates; a project can override each one in `.pi/rethread/`.
|
|
22
|
+
|
|
23
|
+
### Fixed (before the first release)
|
|
24
|
+
- A reply that went past the whole context window let Pi compact the old session in the middle of the handoff, so the agent wrote its letter from a lossy summary. Only Pi's recovery of a rejected request (which must retry) may compact now.
|
|
25
|
+
- The integration tests read `~/.agents/skills` and `PI_*` variables from the real environment, so their result depended on the machine. Each Pi now runs with an empty home.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
https://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction, and
|
|
10
|
+
distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
|
13
|
+
owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all other entities
|
|
16
|
+
that control, are controlled by, or are under common control with that entity.
|
|
17
|
+
For the purposes of this definition, "control" means (i) the power, direct or
|
|
18
|
+
indirect, to cause the direction or management of such entity, whether by
|
|
19
|
+
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
20
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
21
|
+
|
|
22
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
|
23
|
+
permissions granted by this License.
|
|
24
|
+
|
|
25
|
+
"Source" form shall mean the preferred form for making modifications, including
|
|
26
|
+
but not limited to software source code, documentation source, and configuration
|
|
27
|
+
files.
|
|
28
|
+
|
|
29
|
+
"Object" form shall mean any form resulting from mechanical transformation or
|
|
30
|
+
translation of a Source form, including but not limited to compiled object code,
|
|
31
|
+
generated documentation, and conversions to other media types.
|
|
32
|
+
|
|
33
|
+
"Work" shall mean the work of authorship, whether in Source or Object form, made
|
|
34
|
+
available under the License, as indicated by a copyright notice that is
|
|
35
|
+
included in or attached to the work.
|
|
36
|
+
|
|
37
|
+
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
|
38
|
+
is based on (or derived from) the Work and for which the editorial revisions,
|
|
39
|
+
annotations, elaborations, or other modifications represent, as a whole, an
|
|
40
|
+
original work of authorship.
|
|
41
|
+
|
|
42
|
+
"Contribution" shall mean any work of authorship, including the original version
|
|
43
|
+
of the Work and any modifications or additions to that Work or Derivative Works,
|
|
44
|
+
that is intentionally submitted to Licensor for inclusion in the Work by the
|
|
45
|
+
copyright owner or by an individual or Legal Entity authorized to submit on
|
|
46
|
+
behalf of the copyright owner.
|
|
47
|
+
|
|
48
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
|
49
|
+
of whom a Contribution has been received by Licensor and subsequently
|
|
50
|
+
incorporated within the Work.
|
|
51
|
+
|
|
52
|
+
2. Grant of Copyright License. Subject to the terms and conditions of this
|
|
53
|
+
License, each Contributor hereby grants to You a perpetual, worldwide,
|
|
54
|
+
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
|
|
55
|
+
reproduce, prepare Derivative Works of, publicly display, publicly perform,
|
|
56
|
+
sublicense, and distribute the Work and such Derivative Works in Source or
|
|
57
|
+
Object form.
|
|
58
|
+
|
|
59
|
+
3. Grant of Patent License. Subject to the terms and conditions of this
|
|
60
|
+
License, each Contributor hereby grants to You a perpetual, worldwide,
|
|
61
|
+
non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
|
|
62
|
+
section) patent license to make, have made, use, offer to sell, sell, import,
|
|
63
|
+
and otherwise transfer the Work.
|
|
64
|
+
|
|
65
|
+
4. Redistribution. You may reproduce and distribute copies of the Work or
|
|
66
|
+
Derivative Works thereof in any medium, with or without modifications, and in
|
|
67
|
+
Source or Object form, provided that You meet the following conditions:
|
|
68
|
+
|
|
69
|
+
(a) You must give any other recipients of the Work or Derivative Works a copy of
|
|
70
|
+
this License; and
|
|
71
|
+
|
|
72
|
+
(b) You must cause any modified files to carry prominent notices stating that
|
|
73
|
+
You changed the files; and
|
|
74
|
+
|
|
75
|
+
(c) You must retain, in the Source form of any Derivative Works that You
|
|
76
|
+
distribute, all copyright, patent, trademark, and attribution notices from the
|
|
77
|
+
Source form of the Work; and
|
|
78
|
+
|
|
79
|
+
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
|
|
80
|
+
any Derivative Works that You distribute must include a readable copy of the
|
|
81
|
+
attribution notices contained within such NOTICE file, excluding those notices
|
|
82
|
+
that do not pertain to any part of the Derivative Works, in at least one of the
|
|
83
|
+
following places: within a NOTICE text file distributed as part of the
|
|
84
|
+
Derivative Works; within the Source form or documentation, if provided along
|
|
85
|
+
with the Derivative Works; or within a display generated by the Derivative
|
|
86
|
+
Works, if and wherever such third-party notices normally appear.
|
|
87
|
+
|
|
88
|
+
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
|
89
|
+
Contribution intentionally submitted for inclusion in the Work by You to the
|
|
90
|
+
Licensor shall be under the terms and conditions of this License.
|
|
91
|
+
|
|
92
|
+
6. Trademarks. This License does not grant permission to use the trade names,
|
|
93
|
+
trademarks, service marks, or product names of the Licensor, except as required
|
|
94
|
+
for reasonable and customary use in describing the origin of the Work.
|
|
95
|
+
|
|
96
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
|
|
97
|
+
writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
98
|
+
CONDITIONS OF ANY KIND, either express or implied.
|
|
99
|
+
|
|
100
|
+
8. Limitation of Liability. In no event and under no legal theory shall any
|
|
101
|
+
Contributor be liable to You for damages, including any direct, indirect,
|
|
102
|
+
special, incidental, or consequential damages arising as a result of this
|
|
103
|
+
License or out of the use or inability to use the Work.
|
|
104
|
+
|
|
105
|
+
9. Accepting Warranty or Additional Liability. While redistributing the Work or
|
|
106
|
+
Derivative Works thereof, You may choose to offer, and charge a fee for,
|
|
107
|
+
acceptance of support, warranty, indemnity, or other liability obligations.
|
|
108
|
+
|
|
109
|
+
END OF TERMS AND CONDITIONS
|
|
110
|
+
|
|
111
|
+
Copyright 2026 Ricardo Frantz
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-rethread
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Extension for [pi](https://github.com/earendil-works/pi): when a session gets long, the agent finishes its step, writes a **letter** to the next session and a **ledger** entry, and the work continues in a **fresh session**. No lossy compaction.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
pi install npm:@machine-made/pi-rethread
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Tested with Pi 1.4.0.
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
/rethread [goal] finish, hand off, continue in a new session
|
|
15
|
+
/rethread --now skip the agent (it is stuck): a side model call writes the handoff
|
|
16
|
+
/rethread --review edit the ledger entry before moving (interactive only)
|
|
17
|
+
/rethread --cancel stop a rethread in progress
|
|
18
|
+
/ledger show the ledger file and its number of entries
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What happens
|
|
22
|
+
|
|
23
|
+
`/rethread` runs four prepared messages, each doing one job:
|
|
24
|
+
|
|
25
|
+
1. **Land.** The running agent finishes the step it is on and stops at a safe point: no half-edited
|
|
26
|
+
file, no new work, the quick check (tests, build) run once. It replies in three lines: what is
|
|
27
|
+
*verified*, what is only *assumed*, what is still broken.
|
|
28
|
+
2. **Letter.** The agent writes to the agent of the next session, in a fixed shape:
|
|
29
|
+
*Where we are*, *Do first*, *Watch out*, *Don't redo* (dead ends, so they are not tried again)
|
|
30
|
+
and *Trust* (verified vs assumed). The letter comes first: writing to a colleague forces priorities.
|
|
31
|
+
3. **Ledger.** In the same tool call, after the letter, the agent writes the durable facts:
|
|
32
|
+
Goal, Done, Decisions, Files, Commands, Open problems, Next steps. The letter is intent for the
|
|
33
|
+
next hop; the ledger is what stays true across many hops. It is appended to `.pi/ledger.md`.
|
|
34
|
+
4. **Readback.** A new session opens (linked to the old one as its parent) and receives the letter
|
|
35
|
+
and the ledger entry. Its first reply is three lines: the goal, the first action, the main risk.
|
|
36
|
+
A misunderstanding costs one short reply instead of a whole run.
|
|
37
|
+
|
|
38
|
+
The `rethread_save` tool is shown to the model only during step 2.
|
|
39
|
+
|
|
40
|
+
If the agent does not call the tool, a side model call writes the letter and the ledger from the
|
|
41
|
+
transcript instead, and the move still happens.
|
|
42
|
+
|
|
43
|
+
## Automatic
|
|
44
|
+
|
|
45
|
+
After a run, if context use is at or above `--rethread-auto-percent` (default 70), `/rethread`
|
|
46
|
+
starts by itself. When Pi would auto-compact first, the compaction is cancelled and a rethread
|
|
47
|
+
runs instead. This covers Pi's threshold and a reply that silently went past the window. No
|
|
48
|
+
automatic compaction touches the old session while the agent writes its handoff.
|
|
49
|
+
|
|
50
|
+
Left alone: manual `/compact`, and Pi's recovery when the provider rejects a request as too long.
|
|
51
|
+
That recovery compacts and retries the failed turn; without it the turn cannot run. The rethread
|
|
52
|
+
then continues, and the agent writes its letter from the compacted session.
|
|
53
|
+
|
|
54
|
+
It fires once per session. If a new session is already over the limit right after the handoff
|
|
55
|
+
(a low limit, a long ledger), the automatic rethread turns itself off for that session and says so,
|
|
56
|
+
so it never loops.
|
|
57
|
+
|
|
58
|
+
## Flags
|
|
59
|
+
|
|
60
|
+
| Flag | Default | Meaning |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `--rethread-auto-percent <n>` | `70` | Context % that starts `/rethread`; `0` turns the automatic rethread off |
|
|
63
|
+
| `--ledger-file <path>` | `.pi/ledger.md` | Ledger file, relative to the working directory |
|
|
64
|
+
|
|
65
|
+
## Your own prompts
|
|
66
|
+
|
|
67
|
+
The four messages are Markdown templates in [`prompts/`](prompts). Put a file with the same name in
|
|
68
|
+
`.pi/rethread/` of a project to replace one there:
|
|
69
|
+
|
|
70
|
+
| File | Sent to | Placeholders |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `land.md` | the old agent, step 1 | none |
|
|
73
|
+
| `write.md` | the old agent, step 2 | `{{tool}}` `{{headings}}` `{{goal_line}}` `{{previous}}` |
|
|
74
|
+
| `kickoff.md` | the new agent, step 4 | `{{ledger_path}}` `{{letter}}` `{{goal_line}}` `{{status}}` `{{ledger}}` |
|
|
75
|
+
| `summary.md` | the side model (`--now`, fallback) | `{{headings}}` |
|
|
76
|
+
|
|
77
|
+
A custom `kickoff.md` must keep a `<rethread-ledger>` tag: it is how the new session knows it was
|
|
78
|
+
started by a rethread (for the loop guard).
|
|
79
|
+
|
|
80
|
+
## The ledger
|
|
81
|
+
|
|
82
|
+
`.pi/ledger.md` is plain Markdown, newest entry last. Each entry records the old session file, the
|
|
83
|
+
status (`landed`, `partial`, `blocked`), who wrote it (the agent or a summary), the goal, the ledger
|
|
84
|
+
sections and the letter. The file is written atomically. Commit it, or add it to `.gitignore`.
|
|
85
|
+
|
|
86
|
+
## Limits
|
|
87
|
+
|
|
88
|
+
- A `/rethread --cancel` typed in the instant between two steps is queued behind the next step;
|
|
89
|
+
type it again.
|
|
90
|
+
- The new session uses the same model and tools as the old one.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
bun install
|
|
96
|
+
bun run check
|
|
97
|
+
bun run test
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The integration test runs real Pi in RPC mode with a scripted fake model, so it needs no API key.
|
|
101
|
+
|
|
102
|
+
## Licence
|
|
103
|
+
|
|
104
|
+
Apache-2.0
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
pi-rethread has not had a security review yet.
|
|
4
|
+
|
|
5
|
+
What it does: it reads and writes one Markdown file (`.pi/ledger.md`, or the path given by
|
|
6
|
+
`--ledger-file`), reads prompt templates from the package and from `.pi/rethread/` in the working
|
|
7
|
+
directory, sends prompts to the session's own model, and opens a new Pi session. It makes no network
|
|
8
|
+
call of its own and spawns no process.
|
|
9
|
+
|
|
10
|
+
Things to know:
|
|
11
|
+
- The ledger and the letter are written by the model and sent to the next session as a user message.
|
|
12
|
+
Treat them like any other model output: review the ledger if the session handled untrusted input.
|
|
13
|
+
- Project templates in `.pi/rethread/` change what is sent to the model, and they are read from
|
|
14
|
+
the working directory whoever wrote them. Check that folder in a repository you did not write.
|
|
15
|
+
|
|
16
|
+
Report issues at https://github.com/machine-made/pi-rethread/issues
|
package/index.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-rethread — end this session cleanly and continue in a new one.
|
|
3
|
+
*
|
|
4
|
+
* /rethread [goal] 1. land: the agent finishes its step and says what is verified
|
|
5
|
+
* 2. write: the agent writes a letter to the next session, then a ledger entry
|
|
6
|
+
* 3. move: the ledger is appended, a new session opens with the letter
|
|
7
|
+
* 4. readback: the new agent restates goal, first action, main risk, then starts
|
|
8
|
+
* /rethread --now skip 1-2 (agent stuck): a side model call writes letter + ledger
|
|
9
|
+
* /rethread --review edit the ledger entry before moving (interactive only)
|
|
10
|
+
* /rethread --cancel abort a rethread in progress
|
|
11
|
+
* /ledger show the ledger path and entry count
|
|
12
|
+
*
|
|
13
|
+
* Automatic: after a run settles at or above --rethread-auto-percent of the context window, or when
|
|
14
|
+
* Pi would auto-compact, /rethread starts by itself (once per session; never loops).
|
|
15
|
+
*
|
|
16
|
+
* The session switch uses Pi's documented API (ctx.newSession + withSession), as in Pi's
|
|
17
|
+
* examples/extensions/handoff.ts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
21
|
+
import { type Message, StringEnum, uuidv7 } from "@earendil-works/pi-ai";
|
|
22
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Type } from "typebox";
|
|
25
|
+
import {
|
|
26
|
+
appendEntry,
|
|
27
|
+
entryLedger,
|
|
28
|
+
fill,
|
|
29
|
+
goalLine,
|
|
30
|
+
type Handoff,
|
|
31
|
+
headingsList,
|
|
32
|
+
isKickoff,
|
|
33
|
+
loadTemplate,
|
|
34
|
+
looksLikeLedger,
|
|
35
|
+
parseArgs,
|
|
36
|
+
parseThreshold,
|
|
37
|
+
readEntries,
|
|
38
|
+
resolveLedgerPath,
|
|
39
|
+
STATUSES,
|
|
40
|
+
type Status,
|
|
41
|
+
shownPath,
|
|
42
|
+
splitSummary,
|
|
43
|
+
TOOL_NAME,
|
|
44
|
+
USAGE,
|
|
45
|
+
} from "./rethread.ts";
|
|
46
|
+
|
|
47
|
+
type Phase = "idle" | "land" | "write" | "move";
|
|
48
|
+
|
|
49
|
+
function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
|
|
50
|
+
if (entry.type === "message") return entry.message;
|
|
51
|
+
if (entry.type === "compaction") {
|
|
52
|
+
return {
|
|
53
|
+
role: "compactionSummary",
|
|
54
|
+
summary: entry.summary,
|
|
55
|
+
tokensBefore: entry.tokensBefore,
|
|
56
|
+
timestamp: new Date(entry.timestamp).getTime(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Messages of the active branch, respecting the last compaction (same rule as Pi's handoff.ts). */
|
|
63
|
+
function branchMessages(branch: SessionEntry[]): AgentMessage[] {
|
|
64
|
+
let c = -1;
|
|
65
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
66
|
+
if (branch[i].type === "compaction") {
|
|
67
|
+
c = i;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (c < 0) return branch.map(entryToMessage).filter((m) => m !== undefined);
|
|
72
|
+
const comp = branch[c];
|
|
73
|
+
const firstKept = comp.type === "compaction" ? branch.findIndex((e) => e.id === comp.firstKeptEntryId) : -1;
|
|
74
|
+
const kept = [comp, ...(firstKept >= 0 ? branch.slice(firstKept, c) : []), ...branch.slice(c + 1)];
|
|
75
|
+
return kept.map(entryToMessage).filter((m) => m !== undefined);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function rethread(pi: ExtensionAPI) {
|
|
79
|
+
pi.registerFlag("ledger-file", { type: "string", description: "Ledger file (default .pi/ledger.md)" });
|
|
80
|
+
pi.registerFlag("rethread-auto-percent", {
|
|
81
|
+
type: "string",
|
|
82
|
+
description: "Start /rethread at this context % (default 70; 0 = off)",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Per-session state. Pi runs this factory again for each new session, so it starts fresh there.
|
|
86
|
+
let phase: Phase = "idle";
|
|
87
|
+
let goal = "";
|
|
88
|
+
let review = false;
|
|
89
|
+
let saved: Omit<Handoff, "goal" | "source"> | undefined;
|
|
90
|
+
let autoFired = false;
|
|
91
|
+
let autoDisabled = false;
|
|
92
|
+
let kickoffRun = false;
|
|
93
|
+
let pendingAuto = false;
|
|
94
|
+
|
|
95
|
+
const threshold = () => parseThreshold(pi.getFlag("rethread-auto-percent"));
|
|
96
|
+
const ledgerFile = (cwd: string) => resolveLedgerPath(cwd, pi.getFlag("ledger-file"));
|
|
97
|
+
const previousLedger = (cwd: string) => {
|
|
98
|
+
const last = readEntries(ledgerFile(cwd)).at(-1);
|
|
99
|
+
return last ? entryLedger(last) : "";
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const setTool = (on: boolean) => {
|
|
103
|
+
const active = pi.getActiveTools().filter((t) => t !== TOOL_NAME);
|
|
104
|
+
pi.setActiveTools(on ? [...active, TOOL_NAME] : active);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const reset = () => {
|
|
108
|
+
phase = "idle";
|
|
109
|
+
goal = "";
|
|
110
|
+
review = false;
|
|
111
|
+
saved = undefined;
|
|
112
|
+
setTool(false);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const send = (ctx: ExtensionContext, text: string) => {
|
|
116
|
+
if (ctx.isIdle()) pi.sendUserMessage(text);
|
|
117
|
+
else pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
pi.registerTool({
|
|
121
|
+
name: TOOL_NAME,
|
|
122
|
+
label: "Rethread: save handoff",
|
|
123
|
+
description:
|
|
124
|
+
"Save the handoff for the next session: a letter to the next agent, then the ledger entry. Only call this when a [rethread] message asks you to.",
|
|
125
|
+
parameters: Type.Object({
|
|
126
|
+
status: StringEnum(STATUSES, { description: "landed | partial | blocked" }),
|
|
127
|
+
next_message: Type.String({ description: "Letter to the next session's agent. Written first." }),
|
|
128
|
+
ledger: Type.String({ description: "Self-contained ledger entry with the required headings." }),
|
|
129
|
+
}),
|
|
130
|
+
defaultActive: false,
|
|
131
|
+
async execute(_id, params) {
|
|
132
|
+
if (phase !== "write") {
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text", text: "No rethread is in progress; nothing was saved." }],
|
|
135
|
+
details: { saved: false },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const problems: string[] = [];
|
|
139
|
+
if (!params.next_message.trim()) problems.push("next_message is empty");
|
|
140
|
+
if (!looksLikeLedger(params.ledger)) problems.push("ledger is missing the required ### headings");
|
|
141
|
+
if (problems.length) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text: `Not saved: ${problems.join("; ")}. Call ${TOOL_NAME} again.` }],
|
|
144
|
+
details: { saved: false },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
saved = { status: params.status as Status, letter: params.next_message, ledger: params.ledger };
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text", text: "Saved. Stop now: the next session starts when you finish." }],
|
|
150
|
+
details: { saved: true },
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** Side model call: letter + ledger from the transcript (used by --now and as a fallback). */
|
|
156
|
+
async function summarize(ctx: ExtensionCommandContext): Promise<{ letter: string; ledger: string } | null> {
|
|
157
|
+
const messages = branchMessages(ctx.sessionManager.getBranch());
|
|
158
|
+
if (messages.length === 0 || !ctx.model) return null;
|
|
159
|
+
const prompt = [
|
|
160
|
+
"## Previous ledger entry",
|
|
161
|
+
previousLedger(ctx.cwd) || "(none)",
|
|
162
|
+
"",
|
|
163
|
+
"## Conversation",
|
|
164
|
+
serializeConversation(convertToLlm(messages)),
|
|
165
|
+
"",
|
|
166
|
+
"## User's goal for the next session",
|
|
167
|
+
goal || "(none given: continue the work)",
|
|
168
|
+
].join("\n");
|
|
169
|
+
const system = fill(loadTemplate("summary", ctx.cwd), { headings: headingsList() });
|
|
170
|
+
const run = async (signal?: AbortSignal) => {
|
|
171
|
+
const user: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() };
|
|
172
|
+
const res = await ctx.modelRegistry.complete(
|
|
173
|
+
ctx.model!,
|
|
174
|
+
{ systemPrompt: system, messages: [user] },
|
|
175
|
+
{ signal, cacheRetention: "none", sessionId: uuidv7() },
|
|
176
|
+
);
|
|
177
|
+
if (res.stopReason === "aborted" || res.stopReason === "error") return null;
|
|
178
|
+
const text = res.content
|
|
179
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
180
|
+
.map((c) => c.text)
|
|
181
|
+
.join("\n")
|
|
182
|
+
.trim();
|
|
183
|
+
return text ? splitSummary(text) : null;
|
|
184
|
+
};
|
|
185
|
+
if (ctx.mode !== "tui") return run(ctx.signal);
|
|
186
|
+
return ctx.ui.custom((tui, theme, _kb, done) => {
|
|
187
|
+
const loader = new BorderedLoader(tui, theme, "Writing the handoff...");
|
|
188
|
+
loader.onAbort = () => done(null);
|
|
189
|
+
run(loader.signal)
|
|
190
|
+
.then(done)
|
|
191
|
+
.catch((err) => {
|
|
192
|
+
console.error("rethread: summary failed:", err);
|
|
193
|
+
done(null);
|
|
194
|
+
});
|
|
195
|
+
return loader;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Step 3: append the ledger, open the new session, send the kickoff. Command context only. */
|
|
200
|
+
async function move(ctx: ExtensionCommandContext): Promise<void> {
|
|
201
|
+
phase = "move";
|
|
202
|
+
setTool(false);
|
|
203
|
+
let h: Handoff | undefined;
|
|
204
|
+
if (saved) h = { ...saved, goal, source: "agent" };
|
|
205
|
+
else {
|
|
206
|
+
ctx.ui.notify("rethread: the agent did not save a handoff; writing a summary instead", "warning");
|
|
207
|
+
const s = await summarize(ctx);
|
|
208
|
+
if (s) h = { ...s, goal, status: "partial", source: "summary" };
|
|
209
|
+
}
|
|
210
|
+
if (!h) {
|
|
211
|
+
ctx.ui.notify("rethread: no handoff could be written; staying in this session", "error");
|
|
212
|
+
reset();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (review && ctx.mode === "tui") {
|
|
216
|
+
const edited = await ctx.ui.editor("Edit the ledger entry", h.ledger);
|
|
217
|
+
if (edited === undefined) {
|
|
218
|
+
ctx.ui.notify("rethread: cancelled; nothing written", "info");
|
|
219
|
+
reset();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
h.ledger = edited;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const file = ledgerFile(ctx.cwd);
|
|
226
|
+
const oldSession = ctx.sessionManager.getSessionFile();
|
|
227
|
+
const n = appendEntry(file, oldSession, h);
|
|
228
|
+
const kickoff = fill(loadTemplate("kickoff", ctx.cwd), {
|
|
229
|
+
ledger_path: shownPath(file, ctx.cwd),
|
|
230
|
+
letter: h.letter.trim() || "(none)",
|
|
231
|
+
goal_line: goalLine(h.goal, false),
|
|
232
|
+
status: h.status,
|
|
233
|
+
ledger: h.ledger.trim(),
|
|
234
|
+
});
|
|
235
|
+
reset();
|
|
236
|
+
|
|
237
|
+
// Only plain data crosses the switch; ctx is stale afterwards.
|
|
238
|
+
const result = await ctx.newSession({
|
|
239
|
+
parentSession: oldSession,
|
|
240
|
+
withSession: async (next) => {
|
|
241
|
+
next.ui.notify(`Ledger entry ${n} written; new session started`, "info");
|
|
242
|
+
// Not awaited: the run would hold the switch open until it ends.
|
|
243
|
+
next.sendUserMessage(kickoff).catch((err) => console.error("rethread: kickoff failed:", err));
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
if (result.cancelled) ctx.ui.notify(`Ledger entry ${n} written, but the new session was cancelled`, "warning");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
pi.registerCommand("rethread", {
|
|
250
|
+
description: "Finish, write a letter and a ledger entry, continue in a new session",
|
|
251
|
+
handler: async (raw, ctx) => {
|
|
252
|
+
const a = parseArgs(raw);
|
|
253
|
+
if (a.cancel) {
|
|
254
|
+
if (phase === "idle") ctx.ui.notify("rethread: nothing to cancel", "info");
|
|
255
|
+
else {
|
|
256
|
+
reset();
|
|
257
|
+
ctx.ui.notify("rethread: cancelled", "info");
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (a.switchNow) {
|
|
262
|
+
if (phase === "write") await move(ctx);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (phase !== "idle") {
|
|
266
|
+
ctx.ui.notify(`rethread: already in progress (${phase}); /rethread --cancel to stop`, "warning");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!ctx.model) {
|
|
270
|
+
ctx.ui.notify("rethread: no model selected", "error");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (branchMessages(ctx.sessionManager.getBranch()).length === 0) {
|
|
274
|
+
ctx.ui.notify(`rethread: nothing to carry over yet. ${USAGE}`, "warning");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
goal = a.goal;
|
|
278
|
+
review = a.review;
|
|
279
|
+
if (a.now) {
|
|
280
|
+
await ctx.waitForIdle();
|
|
281
|
+
phase = "write"; // so move() accepts it; saved stays empty -> summary path
|
|
282
|
+
await move(ctx);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
phase = "land";
|
|
286
|
+
ctx.ui.notify("rethread 1/3: asking the agent to finish its step", "info");
|
|
287
|
+
send(ctx, loadTemplate("land", ctx.cwd).trim());
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
pi.registerCommand("ledger", {
|
|
292
|
+
description: "Show the ledger file and its number of entries",
|
|
293
|
+
handler: async (_args, ctx) => {
|
|
294
|
+
const file = ledgerFile(ctx.cwd);
|
|
295
|
+
const n = readEntries(file).length;
|
|
296
|
+
ctx.ui.notify(n ? `Ledger: ${file} (${n} entries)` : `No ledger yet (${file})`, "info");
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
pi.on("session_start", async () => {
|
|
301
|
+
phase = "idle";
|
|
302
|
+
saved = undefined;
|
|
303
|
+
autoFired = false;
|
|
304
|
+
autoDisabled = false;
|
|
305
|
+
kickoffRun = false;
|
|
306
|
+
pendingAuto = false;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Pi's automatic compaction becomes a rethread: at the threshold, and after a reply that
|
|
310
|
+
// silently went past the window (reason "overflow", nothing to retry). Left alone: manual
|
|
311
|
+
// /compact, and recovery of a request the provider rejected as too long (willRetry), which
|
|
312
|
+
// must compact to retry the failed turn.
|
|
313
|
+
pi.on("session_before_compact", async (event) => {
|
|
314
|
+
if (event.reason === "manual" || event.willRetry) return;
|
|
315
|
+
if (phase !== "idle") return { cancel: true }; // we are leaving this session anyway
|
|
316
|
+
if (threshold() <= 0 || autoDisabled || autoFired) return;
|
|
317
|
+
pendingAuto = true;
|
|
318
|
+
return { cancel: true };
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.on("before_agent_start", async (event) => {
|
|
322
|
+
if (event.messageType === "user" && isKickoff(event.prompt)) kickoffRun = true;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
326
|
+
// Advance a rethread in progress. Prompts sent here are deferred by Pi until settling ends.
|
|
327
|
+
if (phase === "land") {
|
|
328
|
+
phase = "write";
|
|
329
|
+
setTool(true);
|
|
330
|
+
ctx.ui.notify("rethread 2/3: asking for the letter and the ledger", "info");
|
|
331
|
+
pi.sendUserMessage(
|
|
332
|
+
fill(loadTemplate("write", ctx.cwd), {
|
|
333
|
+
tool: TOOL_NAME,
|
|
334
|
+
headings: headingsList(),
|
|
335
|
+
goal_line: goalLine(goal, true),
|
|
336
|
+
previous: previousLedger(ctx.cwd) || "(none)",
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (phase === "write") {
|
|
342
|
+
ctx.ui.notify("rethread 3/3: moving to a new session", "info");
|
|
343
|
+
pi.sendUserMessage("/rethread --switch", { expandPromptTemplates: true });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (phase !== "idle") return;
|
|
347
|
+
|
|
348
|
+
const limit = threshold();
|
|
349
|
+
const pct = ctx.getContextUsage()?.percent;
|
|
350
|
+
if (kickoffRun) {
|
|
351
|
+
kickoffRun = false;
|
|
352
|
+
// Loop guard: a new session already over the limit would rethread forever.
|
|
353
|
+
if (pendingAuto || (limit > 0 && pct != null && pct >= limit)) {
|
|
354
|
+
pendingAuto = false;
|
|
355
|
+
autoDisabled = true;
|
|
356
|
+
ctx.ui.notify(
|
|
357
|
+
`rethread: context is already ${pct != null ? `${Math.round(pct)}%` : "at Pi's compaction point"} right after the handoff; automatic rethread is off for this session. Raise --rethread-auto-percent or shorten the ledger.`,
|
|
358
|
+
"warning",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (limit <= 0 || autoFired || autoDisabled) return;
|
|
364
|
+
if (!pendingAuto && (pct == null || pct < limit)) return;
|
|
365
|
+
autoFired = true;
|
|
366
|
+
ctx.ui.notify(
|
|
367
|
+
pendingAuto
|
|
368
|
+
? "rethread: Pi would compact now; rethreading instead"
|
|
369
|
+
: `rethread: context at ${Math.round(pct!)}%; rethreading`,
|
|
370
|
+
"info",
|
|
371
|
+
);
|
|
372
|
+
pendingAuto = false;
|
|
373
|
+
pi.sendUserMessage("/rethread", { expandPromptTemplates: true });
|
|
374
|
+
});
|
|
375
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@machine-made/pi-rethread",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"author": "Ricardo Frantz",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/machine-made/pi-rethread.git"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@earendil-works/pi-agent-core": "1.4.0",
|
|
11
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
12
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
13
|
+
"@types/node": "^24.0.0",
|
|
14
|
+
"tsx": "^4.20.0",
|
|
15
|
+
"typebox": "1.3.27",
|
|
16
|
+
"typescript": "^5.9.0"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/machine-made/pi-rethread/issues"
|
|
20
|
+
},
|
|
21
|
+
"description": "Pi extension: the agent finishes its step, writes a letter and a ledger entry, and the work continues in a fresh session",
|
|
22
|
+
"files": [
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"SECURITY.md",
|
|
25
|
+
"index.ts",
|
|
26
|
+
"rethread.ts",
|
|
27
|
+
"prompts/",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"homepage": "https://github.com/machine-made/pi-rethread#readme",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"pi-package",
|
|
34
|
+
"pi-extension",
|
|
35
|
+
"pi",
|
|
36
|
+
"pi-coding-agent",
|
|
37
|
+
"llm",
|
|
38
|
+
"developer-tools",
|
|
39
|
+
"context",
|
|
40
|
+
"handoff",
|
|
41
|
+
"ledger",
|
|
42
|
+
"session"
|
|
43
|
+
],
|
|
44
|
+
"license": "Apache-2.0",
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-agent-core": "*",
|
|
47
|
+
"@earendil-works/pi-ai": "*",
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20"
|
|
53
|
+
},
|
|
54
|
+
"pi": {
|
|
55
|
+
"extensions": [
|
|
56
|
+
"./index.ts"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"check": "tsc --noEmit",
|
|
61
|
+
"test": "tsx --test tests/*.test.ts"
|
|
62
|
+
},
|
|
63
|
+
"type": "module",
|
|
64
|
+
"packageManager": "bun@1.3.13",
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
This session continues earlier work. The ledger is `{{ledger_path}}` (newest entry last); its latest entry is below.
|
|
2
|
+
|
|
3
|
+
Letter from the previous session's agent:
|
|
4
|
+
{{letter}}
|
|
5
|
+
|
|
6
|
+
{{goal_line}}
|
|
7
|
+
|
|
8
|
+
First, reply with a 3-line readback: (1) the goal, (2) your first action, (3) the main risk. Then start. Check the files the ledger names before you change them.
|
|
9
|
+
|
|
10
|
+
<rethread-ledger status="{{status}}">
|
|
11
|
+
{{ledger}}
|
|
12
|
+
</rethread-ledger>
|
package/prompts/land.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[rethread 1/2: land] This session is about to end and a fresh session will continue the work.
|
|
2
|
+
|
|
3
|
+
Land the work now:
|
|
4
|
+
- Finish the step you are on if it needs only a few more actions. Do not start anything new.
|
|
5
|
+
- Leave no half-edited file. If a change cannot be finished, revert it or leave a clear `TODO(rethread)` comment.
|
|
6
|
+
- If the project has a quick check (tests, build, lint), run it once.
|
|
7
|
+
- Then reply in at most 3 lines: what is **verified** (you ran or saw it), what is **assumed** (not checked), and anything still broken.
|
|
8
|
+
|
|
9
|
+
Do not write the handoff yet; that is the next step.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
You write a handoff so a fresh coding-agent session can continue the work with no access to the old conversation. You get the previous ledger entry (may be empty), the conversation since then, and optionally the user's goal for the next session.
|
|
2
|
+
|
|
3
|
+
Output exactly two sections and nothing else.
|
|
4
|
+
|
|
5
|
+
## Letter
|
|
6
|
+
A short message to the next agent ("You..."): **Where we are** (two sentences), **Do first** (one action), **Watch out**, **Don't redo** (failed approaches and why), **Trust** (verified vs assumed).
|
|
7
|
+
|
|
8
|
+
## Ledger
|
|
9
|
+
Self-contained durable facts with exactly these headings, in this order ("none" under an empty heading):
|
|
10
|
+
{{headings}}
|
|
11
|
+
Carry over what is still true from the previous entry; drop what is obsolete. Be specific: exact paths, commands, numbers, error messages.
|
package/prompts/write.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[rethread 2/2: write] Now write the handoff with one call to the `{{tool}}` tool. Fill the fields in this order.
|
|
2
|
+
|
|
3
|
+
**`next_message`**: a letter from you to the agent of the next session. Write it to them ("You..."), short, in this shape:
|
|
4
|
+
- **Where we are**: two sentences.
|
|
5
|
+
- **Do first**: one concrete action.
|
|
6
|
+
- **Watch out**: traps, flaky tests, things that look right but are not.
|
|
7
|
+
- **Don't redo**: approaches already tried that failed, and why.
|
|
8
|
+
- **Trust**: what is verified and what is only assumed.
|
|
9
|
+
|
|
10
|
+
**`ledger`**: durable facts that stay true across many sessions. Self-contained: the next agent has no access to this conversation. Use exactly these headings, in this order, and write "none" under a heading with nothing to say:
|
|
11
|
+
{{headings}}
|
|
12
|
+
Carry over what is still true from the previous entry; drop what is finished or obsolete. Be specific: exact paths, commands, numbers, error messages.
|
|
13
|
+
|
|
14
|
+
**`status`**: `landed` (clean stopping point), `partial` (work in progress, described in the ledger) or `blocked` (cannot continue without the user).
|
|
15
|
+
|
|
16
|
+
{{goal_line}}
|
|
17
|
+
|
|
18
|
+
Previous ledger entry:
|
|
19
|
+
{{previous}}
|
|
20
|
+
|
|
21
|
+
After the tool call, stop.
|
package/rethread.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Pure helpers for pi-rethread: templates, ledger file, kickoff. No Pi runtime imports.
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export const ENTRY_MARK = "<!-- ledger-entry -->";
|
|
7
|
+
export const DEFAULT_LEDGER = ".pi/ledger.md";
|
|
8
|
+
export const DEFAULT_AUTO_PERCENT = 70;
|
|
9
|
+
export const TOOL_NAME = "rethread_save";
|
|
10
|
+
export const KICKOFF_TAG = "rethread-ledger";
|
|
11
|
+
export const OVERRIDE_DIR = ".pi/rethread";
|
|
12
|
+
export const USAGE = "Usage: /rethread [goal for the next session] [--now] [--review] [--cancel]";
|
|
13
|
+
|
|
14
|
+
export const LEDGER_HEADINGS = ["Goal", "Done", "Decisions", "Files", "Commands", "Open problems", "Next steps"] as const;
|
|
15
|
+
export const STATUSES = ["landed", "partial", "blocked"] as const;
|
|
16
|
+
export type Status = (typeof STATUSES)[number];
|
|
17
|
+
|
|
18
|
+
export type TemplateName = "land" | "write" | "kickoff" | "summary";
|
|
19
|
+
|
|
20
|
+
const PACKAGE_PROMPTS = join(dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
21
|
+
|
|
22
|
+
/** Project override in .pi/rethread/<name>.md wins over the packaged template. */
|
|
23
|
+
export function loadTemplate(name: TemplateName, cwd: string): string {
|
|
24
|
+
const own = join(cwd, OVERRIDE_DIR, `${name}.md`);
|
|
25
|
+
return readFileSync(existsSync(own) ? own : join(PACKAGE_PROMPTS, `${name}.md`), "utf8");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Replace {{key}} placeholders; unknown keys are left as they are. */
|
|
29
|
+
export function fill(template: string, vars: Record<string, string>): string {
|
|
30
|
+
return template.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k] : m)).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const headingsList = (): string => LEDGER_HEADINGS.map((h) => `### ${h}`).join("\n");
|
|
34
|
+
|
|
35
|
+
export function goalLine(goal: string, forNextSession: boolean): string {
|
|
36
|
+
if (goal) return `The user's goal for ${forNextSession ? "the next" : "this"} session: ${goal}`;
|
|
37
|
+
return forNextSession
|
|
38
|
+
? "The user gave no goal: the next session continues the work."
|
|
39
|
+
: 'Continue with the first item under "Next steps".';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Args {
|
|
43
|
+
goal: string;
|
|
44
|
+
now: boolean;
|
|
45
|
+
review: boolean;
|
|
46
|
+
cancel: boolean;
|
|
47
|
+
switchNow: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseArgs(raw: string): Args {
|
|
51
|
+
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
52
|
+
const flags = new Set(words.filter((w) => w.startsWith("--")));
|
|
53
|
+
return {
|
|
54
|
+
goal: words.filter((w) => !w.startsWith("--")).join(" "),
|
|
55
|
+
now: flags.has("--now"),
|
|
56
|
+
review: flags.has("--review"),
|
|
57
|
+
cancel: flags.has("--cancel"),
|
|
58
|
+
switchNow: flags.has("--switch"), // internal: sent by the extension itself
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveLedgerPath(cwd: string, flag: unknown): string {
|
|
63
|
+
const p = typeof flag === "string" && flag.trim() ? flag.trim() : DEFAULT_LEDGER;
|
|
64
|
+
return isAbsolute(p) ? p : join(cwd, p);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Context percent that starts an automatic rethread; 0 means off. */
|
|
68
|
+
export function parseThreshold(flag: unknown): number {
|
|
69
|
+
if (typeof flag !== "string" || flag.trim() === "") return DEFAULT_AUTO_PERCENT;
|
|
70
|
+
const n = Number(flag);
|
|
71
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readEntries(file: string): string[] {
|
|
75
|
+
if (!existsSync(file)) return [];
|
|
76
|
+
return readFileSync(file, "utf8")
|
|
77
|
+
.split(ENTRY_MARK)
|
|
78
|
+
.slice(1)
|
|
79
|
+
.map((s) => s.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Handoff {
|
|
84
|
+
goal: string;
|
|
85
|
+
letter: string;
|
|
86
|
+
ledger: string;
|
|
87
|
+
status: Status;
|
|
88
|
+
source: "agent" | "summary";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Append one entry atomically (temp file, then rename). Returns the entry number. */
|
|
92
|
+
export function appendEntry(file: string, sessionFile: string | undefined, h: Handoff, now = new Date()): number {
|
|
93
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
94
|
+
const header = existsSync(file)
|
|
95
|
+
? readFileSync(file, "utf8")
|
|
96
|
+
: "# Ledger\n\nWritten by /rethread (pi-rethread). Newest entry last. Each entry is self-contained.\n";
|
|
97
|
+
const n = readEntries(file).length + 1;
|
|
98
|
+
const meta = [
|
|
99
|
+
`## Entry ${n} — ${now.toISOString()}`,
|
|
100
|
+
"",
|
|
101
|
+
`- From session: \`${sessionFile ? basename(sessionFile) : "in-memory"}\``,
|
|
102
|
+
`- Status: ${h.status} (written by ${h.source === "agent" ? "the agent" : "a summary"})`,
|
|
103
|
+
h.goal ? `- Goal for next session: ${h.goal.replace(/\s+/g, " ").trim()}` : undefined,
|
|
104
|
+
]
|
|
105
|
+
.filter((l) => l !== undefined)
|
|
106
|
+
.join("\n");
|
|
107
|
+
const letter = h.letter.trim() ? `\n\n### Letter to the next session\n${h.letter.trim()}` : "";
|
|
108
|
+
const text = `${header.trimEnd()}\n\n${ENTRY_MARK}\n${meta}\n\n${h.ledger.trim()}${letter}\n`;
|
|
109
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
110
|
+
writeFileSync(tmp, text, "utf8");
|
|
111
|
+
renameSync(tmp, file);
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The ledger part of a stored entry (no metadata, no letter): what the next write builds on. */
|
|
116
|
+
export function entryLedger(entry: string): string {
|
|
117
|
+
return entry
|
|
118
|
+
.replace(/^## Entry \d+ — .*$/m, "")
|
|
119
|
+
.replace(/^- (From session|Status|Goal for next session):.*$/gm, "")
|
|
120
|
+
.split(/^### Letter to the next session$/m)[0]
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Split the fallback summary ("## Letter" / "## Ledger") into its parts. */
|
|
125
|
+
export function splitSummary(text: string): { letter: string; ledger: string } {
|
|
126
|
+
const m = text.match(/^##\s*Letter\s*$([\s\S]*?)^##\s*Ledger\s*$([\s\S]*)/im);
|
|
127
|
+
if (!m) return { letter: "", ledger: text.trim() };
|
|
128
|
+
return { letter: m[1].trim(), ledger: m[2].trim() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isKickoff(prompt: string): boolean {
|
|
132
|
+
return prompt.includes(`<${KICKOFF_TAG}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A usable ledger has most of the expected headings. */
|
|
136
|
+
export function looksLikeLedger(text: string): boolean {
|
|
137
|
+
return LEDGER_HEADINGS.filter((h) => new RegExp(`^#{2,4}\\s*${h}\\b`, "mi").test(text)).length >= 4;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function shownPath(file: string, cwd: string): string {
|
|
141
|
+
const r = relative(cwd, file);
|
|
142
|
+
return r && !r.startsWith("..") ? r : file;
|
|
143
|
+
}
|