opencode-courier 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -136
- package/dist/index.js +23 -4
- package/dist/kernel/protocol.d.ts +2 -0
- package/dist/kernel/protocol.js +9 -1
- package/dist/memory/domain.d.ts +188 -0
- package/dist/memory/domain.js +209 -0
- package/dist/memory/events.d.ts +26 -0
- package/dist/memory/events.js +46 -0
- package/dist/memory/forget.d.ts +10 -0
- package/dist/memory/forget.js +20 -0
- package/dist/memory/index.d.ts +11 -0
- package/dist/memory/index.js +12 -0
- package/dist/memory/note.d.ts +37 -0
- package/dist/memory/note.js +29 -0
- package/dist/memory/pin.d.ts +8 -0
- package/dist/memory/pin.js +13 -0
- package/dist/memory/ports.d.ts +55 -0
- package/dist/memory/ports.js +1 -0
- package/dist/memory/recall.d.ts +27 -0
- package/dist/memory/recall.js +67 -0
- package/dist/memory/record.d.ts +7 -0
- package/dist/memory/record.js +124 -0
- package/dist/memory/rpc-handlers.d.ts +179 -0
- package/dist/memory/rpc-handlers.js +25 -0
- package/dist/memory/rpc.d.ts +299 -0
- package/dist/memory/rpc.js +71 -0
- package/dist/memory/store.d.ts +14 -0
- package/dist/memory/store.js +174 -0
- package/dist/memory/sweep.d.ts +7 -0
- package/dist/memory/sweep.js +16 -0
- package/dist/memory/text.d.ts +35 -0
- package/dist/memory/text.js +116 -0
- package/dist/memory/tools.d.ts +16 -0
- package/dist/memory/tools.js +78 -0
- package/dist/memory/tui.d.ts +82 -0
- package/dist/person/rpc.d.ts +4 -4
- package/dist/platform/json.d.ts +1 -0
- package/dist/platform/json.js +1 -0
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.js +1 -0
- package/dist/skill.js +10 -3
- package/dist/tree/context.d.ts +2 -2
- package/dist/tree/context.js +4 -3
- package/dist/tree/index.d.ts +6 -5
- package/dist/tree/index.js +5 -4
- package/dist/tree/mascot.js +1 -1
- package/dist/tree/ports.d.ts +4 -2
- package/dist/tree/roster.d.ts +17 -0
- package/dist/tree/roster.js +24 -0
- package/dist/tree/rpc-handlers.d.ts +20 -20
- package/dist/tree/rpc.d.ts +20 -20
- package/dist/tree/status.d.ts +5 -5
- package/dist/tree/subtree.d.ts +2 -2
- package/dist/tree/text.js +2 -1
- package/dist/tree/tui.d.ts +11 -3
- package/dist/tui.js +612 -123
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** The memory's model-facing texts: what courier_recall and courier_note say. */
|
|
2
|
+
import { clipText, clipWords } from "../kernel/protocol.js";
|
|
3
|
+
import { LESSON_MAX, oneLine, PART_STATUSES, splitProse } from "./domain.js";
|
|
4
|
+
/** The tool's name, which the skill and the role part name too. */
|
|
5
|
+
export const RECALL_TOOL = "courier_recall";
|
|
6
|
+
/** The lesson tool's name, which the skill's last step names. */
|
|
7
|
+
export const NOTE_TOOL = "courier_note";
|
|
8
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
/** A duration in milliseconds as a model reads it: seconds under half a minute, else whole minutes, else hours and minutes. */
|
|
11
|
+
export function duration(ms) {
|
|
12
|
+
const minutes = Math.round(ms / 60_000);
|
|
13
|
+
if (minutes < 1)
|
|
14
|
+
return `${Math.max(0, Math.round(ms / 1000))} s`;
|
|
15
|
+
if (minutes < 60)
|
|
16
|
+
return `${minutes} min`;
|
|
17
|
+
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
|
18
|
+
}
|
|
19
|
+
/** How long ago a record last moved: a duration under a day, else whole days, which a record kept for months is read in. */
|
|
20
|
+
export function age(ms) {
|
|
21
|
+
const days = Math.floor(ms / DAY_MS);
|
|
22
|
+
return days < 1 ? duration(ms) : count(days, "day");
|
|
23
|
+
}
|
|
24
|
+
/** How a listing counts the parts of a record: by status, in the order of `PART_STATUSES`, plus how many were isolated and how many spawns were refused. */
|
|
25
|
+
function partsLine(parts) {
|
|
26
|
+
if (!parts.length)
|
|
27
|
+
return "no parts";
|
|
28
|
+
const statuses = PART_STATUSES.flatMap((status) => {
|
|
29
|
+
const n = parts.filter((part) => part.status === status).length;
|
|
30
|
+
return n ? [`${n} ${status}`] : [];
|
|
31
|
+
}).join(", ");
|
|
32
|
+
const isolated = parts.filter((part) => part.isolated).length;
|
|
33
|
+
const refused = parts.reduce((n, part) => n + (part.refused ?? 0), 0);
|
|
34
|
+
const deepest = Math.max(...parts.map((part) => part.depth));
|
|
35
|
+
return [
|
|
36
|
+
`${count(parts.length, "part")}: ${statuses}`,
|
|
37
|
+
...(deepest > 1 ? [`${deepest} levels deep`] : []),
|
|
38
|
+
...(isolated ? [`${isolated} isolated`] : []),
|
|
39
|
+
...(refused ? [`${count(refused, "spawn")} refused`] : []),
|
|
40
|
+
].join(", ");
|
|
41
|
+
}
|
|
42
|
+
const day = (at) => new Date(at).toISOString().slice(0, 10);
|
|
43
|
+
/** A lesson as the listing and the note's result show it: dated, and from which depth when a session below the top wrote it. */
|
|
44
|
+
export function lessonLine(lesson) {
|
|
45
|
+
const from = lesson.depth === undefined ? "" : `, from depth ${lesson.depth}`;
|
|
46
|
+
return `lesson (${day(lesson.at)}${from}): "${clipText(oneLine(lesson.text), LESSON_MAX)}"`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One record as the listing shows it: its top, when it started, its title, its split line and its latest revision as prose,
|
|
50
|
+
* its parts, how long it ran, how long ago it last moved, what it cost, and whether the person pinned it.
|
|
51
|
+
*/
|
|
52
|
+
export function recordLine(record, top, now) {
|
|
53
|
+
const cost = record.parts.reduce((sum, part) => sum + (part.cost ?? 0), 0);
|
|
54
|
+
const costed = record.parts.some((part) => part.cost !== undefined);
|
|
55
|
+
const revised = record.revisions?.at(-1);
|
|
56
|
+
return [
|
|
57
|
+
`- ${record.sessionID}${record.sessionID === top ? " (this tree)" : ""} ${day(record.startedAt)}: "${clipText(oneLine(record.title), 60)}"`,
|
|
58
|
+
clipWords(splitProse(record.split), 160),
|
|
59
|
+
...(revised ? [`then, after ${count(revised.parts, "part")}: ${clipWords(splitProse(revised.text), 160)}`] : []),
|
|
60
|
+
partsLine(record.parts),
|
|
61
|
+
`ran ${duration(record.movedAt - record.startedAt)}`,
|
|
62
|
+
`last moved ${age(now - record.movedAt)} ago`,
|
|
63
|
+
...(costed ? [`$${cost.toFixed(2)}`] : []),
|
|
64
|
+
record.finished ? "finished" : "not finished",
|
|
65
|
+
...(record.pinned ? ["pinned"] : []),
|
|
66
|
+
...(record.recalled ? [`recalled${record.recalled.at === undefined ? " by a part" : ""}`] : []),
|
|
67
|
+
...(record.lesson ? [lessonLine(record.lesson)] : []),
|
|
68
|
+
].join(" · ");
|
|
69
|
+
}
|
|
70
|
+
/** What the listing says of pinned records, when it holds one. */
|
|
71
|
+
const PINNED = "A record marked \"pinned\" is one the person chose to keep and listed first: weigh it before the rest.";
|
|
72
|
+
/** What courier_recall says when the memory option is off. */
|
|
73
|
+
export const RECALL_OFF = "Memory is off: the plugin's memory option is false, so no record of a session tree is kept or listed in this project. Decide from the task alone.";
|
|
74
|
+
/**
|
|
75
|
+
* The listing: every record of the project, the pinned first and then newest first, the caller's own tree marked, each
|
|
76
|
+
* aged against `now`.
|
|
77
|
+
*/
|
|
78
|
+
export function recallListText(records, top, now) {
|
|
79
|
+
const others = records.filter((record) => record.sessionID !== top);
|
|
80
|
+
const pinned = records.some((record) => record.pinned);
|
|
81
|
+
const intro = others.length
|
|
82
|
+
? `${count(others.length, "earlier tree")} recorded in this project, ${pinned ? "pinned first, then newest first" : "newest first"}; weigh how they were split and how their parts went before you decide. ` +
|
|
83
|
+
"A record holds what the plugin saw, not the work, and a lesson what its orchestrator concluded, next to the facts that bear it out. " +
|
|
84
|
+
"The facts are as they stood when the tree last moved: \"not finished\" with an old \"last moved\" is a stale record, not a tree running now. " +
|
|
85
|
+
"courier_recall with a sessionID gives one in full, with each part's status, duration, cost and context use." +
|
|
86
|
+
(pinned ? ` ${PINNED}` : "")
|
|
87
|
+
: "No earlier tree is recorded in this project: a tree's record is written by a courier_recall call and as each turn in it ends, and kept 90 days. Decide from the task alone.";
|
|
88
|
+
return [intro, ...records.map((record) => recordLine(record, top, now))].join("\n");
|
|
89
|
+
}
|
|
90
|
+
/** One record in full, as JSON: durations in milliseconds, times in epoch milliseconds, context use as a share of the model's window. */
|
|
91
|
+
export function recallRecordText(record) {
|
|
92
|
+
return `Record of the tree under ${record.sessionID}, in project ${record.projectID} (durations in milliseconds, context use as a share of the model's window):\n${JSON.stringify(record, null, 2)}`;
|
|
93
|
+
}
|
|
94
|
+
export const recallMissingText = (sessionID) => `No record of a tree whose top session is ${sessionID} in this project. courier_recall without sessionID lists the records there are.`;
|
|
95
|
+
/** What a lesson is for, which the tool's description and the note's result both say. */
|
|
96
|
+
const NOT_A_RULE = "A lesson that holds every time belongs in AGENTS.md or the skill, which only the person puts there: tell them instead of noting it.";
|
|
97
|
+
/** What courier_note says once the lesson is written: where, and what it replaced. */
|
|
98
|
+
export function noteText(top, lesson, replaced) {
|
|
99
|
+
return [
|
|
100
|
+
`Noted on the record of the tree under ${top} as its ${lessonLine(lesson)}.`,
|
|
101
|
+
...(replaced ? [`It replaces the ${lessonLine(replaced)}.`] : []),
|
|
102
|
+
`${RECALL_TOOL} lists it after the tree's facts; a second ${NOTE_TOOL} replaces it. ${NOT_A_RULE}`,
|
|
103
|
+
].join(" ");
|
|
104
|
+
}
|
|
105
|
+
/** What courier_note says when the memory option is off. */
|
|
106
|
+
export const NOTE_OFF = "Not noted: the plugin's memory option is false, so no record of a session tree is kept in this project to write a lesson on.";
|
|
107
|
+
export const noteLeafRefusal = (depth, maxDepth) => `Not noted: this session is a leaf, at depth ${depth} of a session tree that goes at most ${maxDepth} deep (maxDepth). A lesson is ` +
|
|
108
|
+
"written by the session that verified the tree, the top or one that started sessions; put what you learned in your report instead.";
|
|
109
|
+
/** What a lesson holds, which the empty and the long refusal both ask for. */
|
|
110
|
+
const ONE_LINE = "what the next orchestrator in this project should know about running a tree like this one, with the job it comes from and the reason";
|
|
111
|
+
export const NOTE_EMPTY = `Not noted: text is empty. Write one line, ${ONE_LINE}, or nothing.`;
|
|
112
|
+
/** The refusal of a lesson longer than `LESSON_MAX` as one line: nothing is written, and the earlier lesson stays. */
|
|
113
|
+
export const noteLongRefusal = (length) => `Not noted: the lesson is ${length} characters, at most ${LESSON_MAX}; write it shorter. Nothing was written, and the earlier lesson, ` +
|
|
114
|
+
`if there was one, stays. Keep one line, ${ONE_LINE}, and leave out what the record's facts already say.`;
|
|
115
|
+
export const noteUnrecordedRefusal = (top) => `Not noted: the tree under ${top} has no record in this project to write a lesson on. A record is written by a ${RECALL_TOOL} call ` +
|
|
116
|
+
"and as each turn in the tree ends, so call it once first, or note once a session you started has reported.";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { type ToolEditor } from "../kernel/tool.js";
|
|
3
|
+
import type { MemoryPorts, MemoryState } from "./ports.js";
|
|
4
|
+
export declare const RecallInput: Schema.Struct<{
|
|
5
|
+
readonly sessionID: Schema.optional<Schema.String>;
|
|
6
|
+
}>;
|
|
7
|
+
export declare const NoteInput: Schema.Struct<{
|
|
8
|
+
readonly text: Schema.String;
|
|
9
|
+
}>;
|
|
10
|
+
/** What the memory's tools run on: one instance's ports and the process's shared state. */
|
|
11
|
+
export interface MemoryToolPorts {
|
|
12
|
+
readonly memory: MemoryPorts;
|
|
13
|
+
readonly state: Pick<MemoryState, "tops">;
|
|
14
|
+
}
|
|
15
|
+
/** Adds `courier_recall` and `courier_note`, direct tools rather than code-mode ones. */
|
|
16
|
+
export declare function addMemoryTools(tools: ToolEditor, ports: MemoryToolPorts): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { describeFailure } from "../kernel/protocol.js";
|
|
3
|
+
import { rethrow } from "../kernel/tool.js";
|
|
4
|
+
import { LESSON_MAX } from "./domain.js";
|
|
5
|
+
import { note } from "./note.js";
|
|
6
|
+
import { recall } from "./recall.js";
|
|
7
|
+
import { NOTE_EMPTY, NOTE_OFF, NOTE_TOOL, noteLeafRefusal, noteLongRefusal, noteText, noteUnrecordedRefusal, RECALL_OFF, RECALL_TOOL, recallListText, recallMissingText, recallRecordText } from "./text.js";
|
|
8
|
+
export const RecallInput = Schema.Struct({
|
|
9
|
+
sessionID: Schema.optional(Schema.String.annotate({ description: "The top session of a listed record, for that record in full; without it, one line per record of this project." })),
|
|
10
|
+
});
|
|
11
|
+
export const NoteInput = Schema.Struct({
|
|
12
|
+
text: Schema.String.annotate({
|
|
13
|
+
description: `The lesson, one line of at most ${LESSON_MAX} characters (longer is refused, not clipped): what the next orchestrator in this ` +
|
|
14
|
+
"project should know about running a tree like this one, how it was split, verified or briefed, or what the person decided, " +
|
|
15
|
+
"with the job it comes from and the reason.",
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
/** Adds `courier_recall` and `courier_note`, direct tools rather than code-mode ones. */
|
|
19
|
+
export function addMemoryTools(tools, ports) {
|
|
20
|
+
tools.add({
|
|
21
|
+
name: RECALL_TOOL,
|
|
22
|
+
options: { codemode: false },
|
|
23
|
+
description: "Look once at what earlier session trees in this project did, before you decide whether and how to split a task: " +
|
|
24
|
+
"one line per tree, those the person pinned first and then newest first, with how its top split the task, how each part went (its status, how long it ran, " +
|
|
25
|
+
"what it cost, how full its context got) and whether a spawn was refused; with sessionID, one tree's record in full. " +
|
|
26
|
+
"Records hold no report bodies or files. Call it once at the start, not in a loop, and not to check on sessions you " +
|
|
27
|
+
"started: their reports wake you.",
|
|
28
|
+
input: RecallInput,
|
|
29
|
+
execute: async (input, context) => {
|
|
30
|
+
const result = await recall(ports.memory, ports.state, context.sessionID, input).catch(rethrow(RECALL_TOOL));
|
|
31
|
+
switch (result.kind) {
|
|
32
|
+
case "off":
|
|
33
|
+
return { content: RECALL_OFF, metadata: { memory: false } };
|
|
34
|
+
case "list":
|
|
35
|
+
return { content: recallListText(result.records, result.top, ports.memory.now()), metadata: { projectID: result.projectID, top: result.top, records: result.records } };
|
|
36
|
+
case "record":
|
|
37
|
+
return { content: recallRecordText(result.record), metadata: { projectID: result.projectID, record: result.record } };
|
|
38
|
+
case "missing":
|
|
39
|
+
return { content: recallMissingText(result.sessionID), metadata: { projectID: result.projectID, sessionID: result.sessionID, found: false } };
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
tools.add({
|
|
44
|
+
name: NOTE_TOOL,
|
|
45
|
+
options: { codemode: false },
|
|
46
|
+
description: "Write the one lesson of your session tree for the next orchestrator in this project, once you have verified and " +
|
|
47
|
+
"integrated the work: what it should know about running a tree like this one that the record's facts do not say. How " +
|
|
48
|
+
"it was split (which parts collided, what was too small to hand off), how it was verified (what a part got wrong and " +
|
|
49
|
+
"what caught it), how the parts were briefed, or what the person decided. Name the job it comes from and the reason, " +
|
|
50
|
+
"so that a lesson for one kind of job can be told from one that holds everywhere. One line, at most " +
|
|
51
|
+
`${LESSON_MAX} characters; a longer one is refused, not clipped, so write it again shorter. Kept on your own tree's ` +
|
|
52
|
+
"record and listed by courier_recall after its facts, dated; a second call replaces the first. From the top session " +
|
|
53
|
+
"or one that started sessions, never a leaf. No code, no secrets, and not a rule that holds every time: that belongs " +
|
|
54
|
+
"in AGENTS.md or the skill, for the person to put there, so tell them instead.",
|
|
55
|
+
input: NoteInput,
|
|
56
|
+
execute: async (input, context) => {
|
|
57
|
+
const result = await note(ports.memory, context.sessionID, input).catch(rethrow(NOTE_TOOL));
|
|
58
|
+
// A refusal is the tool's failure, as courier_spawn's are, so the step ends on it and the model reads why.
|
|
59
|
+
const refuse = (text) => describeFailure(NOTE_TOOL, new Error(text));
|
|
60
|
+
switch (result.kind) {
|
|
61
|
+
case "noted": {
|
|
62
|
+
const { projectID, top, lesson, replaced } = result;
|
|
63
|
+
return { content: noteText(top, lesson, replaced), metadata: { projectID, top, lesson, ...(replaced ? { replaced } : {}) } };
|
|
64
|
+
}
|
|
65
|
+
case "off":
|
|
66
|
+
throw refuse(NOTE_OFF);
|
|
67
|
+
case "leaf":
|
|
68
|
+
throw refuse(noteLeafRefusal(result.depth, result.maxDepth));
|
|
69
|
+
case "empty":
|
|
70
|
+
throw refuse(NOTE_EMPTY);
|
|
71
|
+
case "long":
|
|
72
|
+
throw refuse(noteLongRefusal(result.length));
|
|
73
|
+
case "unrecorded":
|
|
74
|
+
throw refuse(noteUnrecordedRefusal(result.top));
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import { type Forgotten, type MemoryLesson, type MemoryPart, type MemoryReads, type MemoryRecord, type Pinned } from "./rpc.js";
|
|
3
|
+
type Context = Plugin.Context;
|
|
4
|
+
/** The command that opens the memory view: `/courier memory`, or the palette. */
|
|
5
|
+
export declare const MEMORY_COMMAND = "courier.memory";
|
|
6
|
+
/** The memory view's line of the `/courier` command's usage. */
|
|
7
|
+
export declare const MEMORY_USAGE = "/courier memory lists this project's tree records, to read, pin or forget";
|
|
8
|
+
/** What the view says, in a toast, with the memory option off in the project asked. */
|
|
9
|
+
export declare const MEMORY_OFF = "Memory is off in this project: the plugin's memory option is false, so no record of a session tree is kept or listed here.";
|
|
10
|
+
/** What the view says, in a toast, when the project has no record: when none was written yet, or the last was forgotten. */
|
|
11
|
+
export declare const NO_RECORDS = "No record of a session tree in this project: one is written as each turn in a tree ends, and kept 90 days.";
|
|
12
|
+
/** The most characters of a lesson a list row's line under it shows; the record in full has it whole. */
|
|
13
|
+
export declare const LESSON_LINE_MAX = 160;
|
|
14
|
+
/** The most characters of a title a list row shows, so that "(this session)" after it fits the host's 61. */
|
|
15
|
+
export declare const ROW_TITLE_MAX = 46;
|
|
16
|
+
/** The group of the records moved on the current local day, over the others' local dates, as the host's Sessions dialog has it. */
|
|
17
|
+
export declare const TODAY = "Today";
|
|
18
|
+
/** The group of the records the person pinned, above the days, shown only while there is one. */
|
|
19
|
+
export declare const PINNED = "Pinned";
|
|
20
|
+
/** The widest a row of the lesson in a record in full is; a longer lesson wraps over rows. */
|
|
21
|
+
export declare const LESSON_ROW = 90;
|
|
22
|
+
/** A record's name, as its rows and confirms show it: its title on one line, else its top session. */
|
|
23
|
+
export declare const nameOf: (record: MemoryRecord) => string;
|
|
24
|
+
/** A record's parts counted by status, in the order of `PART_STATUSES`: "3 parts: 2 done, 1 partial"; "no parts" with none. */
|
|
25
|
+
export declare function partsCounted(parts: ReadonlyArray<MemoryPart>): string;
|
|
26
|
+
/** The parts' costs summed, when any is known. */
|
|
27
|
+
export declare function costOf(record: MemoryRecord): number | undefined;
|
|
28
|
+
/** A list row's title: the tree's title, its top session's id without one; the open session's own tree says so. */
|
|
29
|
+
export declare function recordTitle(record: MemoryRecord, open?: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* A list row's group: `Pinned` for a pinned record, `Today` for a tree last moved on the local day of `now`, else that
|
|
32
|
+
* local date; the RPC's order, pinned first and then by the last move, keeps the groups in that order.
|
|
33
|
+
*/
|
|
34
|
+
export declare function recordCategory(record: MemoryRecord, now?: number): string;
|
|
35
|
+
/** How often other trees' courier_recall calls listed the record and opened it in full: "shown 7 times, opened once"; "shown 7 times" alone when none opened it. */
|
|
36
|
+
export declare function readsLine(reads: MemoryReads): string;
|
|
37
|
+
/** A lesson as the view shows it: dated on the local day, from which depth when a session below the top wrote it, and its text. */
|
|
38
|
+
export declare function lessonHeading(lesson: MemoryLesson): string;
|
|
39
|
+
/** A list row's description: its parts by status, how long the tree ran, what its parts cost, that it is not finished when it is not, and how often other trees read it. */
|
|
40
|
+
export declare function recordDescription(record: MemoryRecord): string;
|
|
41
|
+
/** The lines under a list row: the lesson, dated and from which depth, clipped; none without a lesson. */
|
|
42
|
+
export declare function recordDetails(record: MemoryRecord): string[];
|
|
43
|
+
/** One part as the record in full describes it: its status, depth, isolation, group, duration, cost, context use and spawns refused. */
|
|
44
|
+
export declare function partLine(part: MemoryPart): string;
|
|
45
|
+
/** `text` in rows of at most `width` characters, broken at spaces where it can be. */
|
|
46
|
+
export declare function wrap(text: string, width: number): string[];
|
|
47
|
+
/** A row of a record in full: a fact of the tree, one of its parts, or a row of its lesson (0 its heading), which `x` forgets alone. */
|
|
48
|
+
export type Line = {
|
|
49
|
+
readonly kind: "fact";
|
|
50
|
+
readonly key: string;
|
|
51
|
+
} | {
|
|
52
|
+
readonly kind: "part";
|
|
53
|
+
readonly sessionID: string;
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: "lesson";
|
|
56
|
+
readonly row: number;
|
|
57
|
+
};
|
|
58
|
+
/** Where the cursor goes in a record in full after its lesson is forgotten: the lesson's first row, or the row that replaced it. */
|
|
59
|
+
export declare const lessonLine: (record: MemoryRecord) => Line;
|
|
60
|
+
/** The records the list's cursor tries, in order, after one is forgotten, kept or left: it, unless forgotten, then those after it, then those before it, nearest first. */
|
|
61
|
+
export declare function nearby(sessionIDs: ReadonlyArray<string>, sessionID: string, forgotten: boolean): string[];
|
|
62
|
+
/** The rows of a record in full, in three categories: the tree's facts, its parts and its lesson. */
|
|
63
|
+
export declare function detailOptions(record: MemoryRecord): Array<{
|
|
64
|
+
title: string;
|
|
65
|
+
value: Line;
|
|
66
|
+
category: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** What the confirm asks before a forget: the record named, with the local day it last moved, and what goes and what stays. */
|
|
70
|
+
export declare function forgetMessage(record: MemoryRecord, lesson: boolean): string;
|
|
71
|
+
/** The toast after a forget, as the server reports it. */
|
|
72
|
+
export declare function forgottenLine(record: MemoryRecord, outcome: Forgotten["outcome"]): string;
|
|
73
|
+
/** The toast after a pin, as the server reports it. */
|
|
74
|
+
export declare function pinnedLine(record: MemoryRecord, outcome: Pinned["outcome"]): string;
|
|
75
|
+
/**
|
|
76
|
+
* Adds the memory view: the project's records (the open session's location, else the TUI's, else the server's default) under
|
|
77
|
+
* `Pinned`, `Today` and the days they last moved; `enter` shows one in full, `p` pins or unpins one, `x` forgets one or its lesson after a confirm.
|
|
78
|
+
*/
|
|
79
|
+
export declare function addMemoryView(ctx: Context): {
|
|
80
|
+
open: () => Promise<void>;
|
|
81
|
+
};
|
|
82
|
+
export {};
|
package/dist/person/rpc.d.ts
CHANGED
|
@@ -106,8 +106,8 @@ export declare const personRpc: {
|
|
|
106
106
|
readonly sessions: readonly {
|
|
107
107
|
readonly sessionID: string;
|
|
108
108
|
readonly title: string;
|
|
109
|
-
readonly parentID: string;
|
|
110
109
|
readonly depth: number;
|
|
110
|
+
readonly parentID: string;
|
|
111
111
|
readonly group?: string | undefined;
|
|
112
112
|
}[];
|
|
113
113
|
readonly permissions: readonly {
|
|
@@ -140,8 +140,8 @@ export declare const personRpc: {
|
|
|
140
140
|
readonly sessions: readonly {
|
|
141
141
|
readonly sessionID: string;
|
|
142
142
|
readonly title: string;
|
|
143
|
-
readonly parentID: string;
|
|
144
143
|
readonly depth: number;
|
|
144
|
+
readonly parentID: string;
|
|
145
145
|
readonly group?: string | undefined;
|
|
146
146
|
}[];
|
|
147
147
|
readonly permissions: readonly {
|
|
@@ -227,11 +227,11 @@ export declare const personRpc: {
|
|
|
227
227
|
}>;
|
|
228
228
|
readonly output: import("effect/StandardSchema").StandardSchemaV1<{
|
|
229
229
|
readonly answered: boolean;
|
|
230
|
-
readonly by?: "
|
|
230
|
+
readonly by?: "message" | "result" | undefined;
|
|
231
231
|
readonly problem?: string | undefined;
|
|
232
232
|
}, {
|
|
233
233
|
readonly answered: boolean;
|
|
234
|
-
readonly by?: "
|
|
234
|
+
readonly by?: "message" | "result" | undefined;
|
|
235
235
|
readonly problem?: string | undefined;
|
|
236
236
|
}> & Schema.Struct<{
|
|
237
237
|
readonly answered: Schema.Boolean;
|
package/dist/platform/json.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Readers for untyped JSON, such as a webhook payload or a plugin option: the value when it has the type, else nothing. */
|
|
2
2
|
export declare const str: (value: unknown) => string | undefined;
|
|
3
3
|
export declare const num: (value: unknown) => number | undefined;
|
|
4
|
+
export declare const finite: (value: unknown) => number | undefined;
|
|
4
5
|
export declare const obj: (value: unknown) => Record<string, any>;
|
|
5
6
|
/** Epoch milliseconds from a session time, a `DateTime` in OpenCode's API and a number in tests; NaN for none. */
|
|
6
7
|
export declare function millis(value: unknown): number;
|
package/dist/platform/json.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Readers for untyped JSON, such as a webhook payload or a plugin option: the value when it has the type, else nothing. */
|
|
2
2
|
export const str = (value) => (typeof value === "string" ? value : undefined);
|
|
3
3
|
export const num = (value) => (typeof value === "number" && Number.isInteger(value) ? value : undefined);
|
|
4
|
+
export const finite = (value) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
|
4
5
|
export const obj = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
5
6
|
/** Epoch milliseconds from a session time, a `DateTime` in OpenCode's API and a number in tests; NaN for none. */
|
|
6
7
|
export function millis(value) {
|
package/dist/rpc.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { treeRpc, type Row, type Stopped, type Summary, type Tree, type TreeRpc, type Wait } from "./tree/rpc.js";
|
|
2
2
|
export { personRpc, type Answered, type Asks, type PersonRpc } from "./person/rpc.js";
|
|
3
|
+
export { memoryRpc, type Forgotten as MemoryForgotten, type MemoryLesson, type MemoryPart, type MemoryReads, type MemoryRecord, type MemoryRpc, type Pinned as MemoryPinned, type Records } from "./memory/rpc.js";
|
package/dist/rpc.js
CHANGED
package/dist/skill.js
CHANGED
|
@@ -10,8 +10,9 @@ export const SKILL_CONTENT = [
|
|
|
10
10
|
"How to split a task across OpenCode sessions with opencode-courier, and bring the results back up. Your brief, or the " +
|
|
11
11
|
"opencode-courier role line in your system prompt, names your depth in the session tree and its limits.",
|
|
12
12
|
"",
|
|
13
|
-
"1. Decide: split or do.
|
|
14
|
-
"
|
|
13
|
+
"1. Decide: split or do. First call courier_recall once: it lists how earlier trees in this project were split and how " +
|
|
14
|
+
"each part went, so weigh that; one call, before you decide, never in a loop. Split when the task has 2 or more " +
|
|
15
|
+
"independent, substantial parts touching separate files or areas; do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole " +
|
|
15
16
|
"task: it only adds a hop. What you read and write yourself stays in your context for every later turn, so keep the " +
|
|
16
17
|
"deciding, checking and integrating, and hand off the long reading and writing. An implementation task with one tightly " +
|
|
17
18
|
"coupled core usually still has independent parts: finding out what the change needs (a session with isolate: false " +
|
|
@@ -44,7 +45,13 @@ export const SKILL_CONTENT = [
|
|
|
44
45
|
"7. Integrate: merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts " +
|
|
45
46
|
"yourself, run the checks once more, then courier_cleanup the isolated sessions you are done with.",
|
|
46
47
|
"8. Report upward if a session started you: one courier_send to it, with one status for the whole (done only when every " +
|
|
47
|
-
"part is verified and integrated; partial, blocked or failed otherwise) and the artifacts. At the top,
|
|
48
|
+
"part is verified and integrated; partial, blocked or failed otherwise) and the artifacts. At the top, before you reply " +
|
|
49
|
+
"to the person: if there is something the next tree in this project should know about running a tree like this one (how " +
|
|
50
|
+
"it was split, what a part got wrong and what caught it, what a brief should have said, what the person decided), one " +
|
|
51
|
+
"courier_note with that one line; none when there is nothing. Name the job it comes from and the reason, so that a " +
|
|
52
|
+
"lesson for one kind of job is not taken for every job; the tool refuses a line over its limit rather than clipping " +
|
|
53
|
+
"it, so write it again shorter. A rule that holds every time goes to AGENTS.md or the skill, through the person, not " +
|
|
54
|
+
"into a note. Then reply to the person.",
|
|
48
55
|
"",
|
|
49
56
|
"When a part goes wrong:",
|
|
50
57
|
"- A failed turn (failed=\"...\") or a silent end (ended=\"without-report\", quoting the session's last reply): message " +
|
package/dist/tree/context.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface Shaped {
|
|
|
9
9
|
}>;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
|
|
13
|
-
* from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
|
|
12
|
+
* The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn, and the tools
|
|
13
|
+
* named `hiddenFromLeaf`, from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
|
|
14
14
|
*/
|
|
15
15
|
export declare function shapeContext(ports: ContextPorts, event: Shaped): Promise<void>;
|
package/dist/tree/context.js
CHANGED
|
@@ -4,8 +4,8 @@ import { ROLE_PREFIX, rolePart } from "./text.js";
|
|
|
4
4
|
/** How many sessions' depths an instance remembers. */
|
|
5
5
|
const ROLES_MAX = 1000;
|
|
6
6
|
/**
|
|
7
|
-
* The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
|
|
8
|
-
* from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
|
|
7
|
+
* The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn, and the tools
|
|
8
|
+
* named `hiddenFromLeaf`, from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
|
|
9
9
|
*/
|
|
10
10
|
export async function shapeContext(ports, event) {
|
|
11
11
|
try {
|
|
@@ -13,7 +13,8 @@ export async function shapeContext(ports, event) {
|
|
|
13
13
|
if (depth === null && !event.tools.courier_spawn)
|
|
14
14
|
return;
|
|
15
15
|
if (depth !== null && depth >= ports.limits.maxDepth)
|
|
16
|
-
|
|
16
|
+
for (const tool of ["courier_spawn", ...(ports.hiddenFromLeaf ?? [])])
|
|
17
|
+
delete event.tools[tool];
|
|
17
18
|
if (!event.system.some((part) => part.text.startsWith(ROLE_PREFIX)))
|
|
18
19
|
event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
|
|
19
20
|
}
|
package/dist/tree/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
|
|
2
2
|
export { shapeContext } from "./context.js";
|
|
3
|
-
export { lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
3
|
+
export { busy, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
4
4
|
export { together, treeEvents, waitsOn } from "./events.js";
|
|
5
5
|
export { deliverReleased } from "./group.js";
|
|
6
|
-
export
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
6
|
+
export { contextOf } from "./panel.js";
|
|
7
|
+
export type { CleanupPorts, CourierPorts, ModelWindow, Pending, PendingPrompt, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
|
|
8
|
+
export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS, type ReportState } from "./roster.js";
|
|
9
|
+
export { subtree, type Below } from "./subtree.js";
|
|
9
10
|
export { registerTreeRpc } from "./rpc-handlers.js";
|
|
10
11
|
export { addTreeTools } from "./tools.js";
|
|
11
|
-
export { wakeMissed } from "./wake.js";
|
|
12
|
+
export { stalledAmong, wakeMissed } from "./wake.js";
|
package/dist/tree/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// What the rest of the plugin uses of the session tree: its tools, RPC, event module, context hook and tick jobs
|
|
2
|
-
// for `src/index.ts`,
|
|
2
|
+
// for `src/index.ts`, the lineage and subtree queries and types `person/` reads, and what `memory/` reads of each session.
|
|
3
3
|
export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
|
|
4
4
|
export { shapeContext } from "./context.js";
|
|
5
|
-
export { lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
5
|
+
export { busy, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
6
6
|
export { together, treeEvents, waitsOn } from "./events.js";
|
|
7
7
|
export { deliverReleased } from "./group.js";
|
|
8
|
-
export {
|
|
8
|
+
export { contextOf } from "./panel.js";
|
|
9
|
+
export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS } from "./roster.js";
|
|
9
10
|
export { subtree } from "./subtree.js";
|
|
10
11
|
export { registerTreeRpc } from "./rpc-handlers.js";
|
|
11
12
|
export { addTreeTools } from "./tools.js";
|
|
12
|
-
export { wakeMissed } from "./wake.js";
|
|
13
|
+
export { stalledAmong, wakeMissed } from "./wake.js";
|
package/dist/tree/mascot.js
CHANGED
|
@@ -95,7 +95,7 @@ export function digby(rise, { top = 6, eyes = "open", slump = false, shift = 0,
|
|
|
95
95
|
span(head + 2, 8, "k", (column) => !eyeColumns.includes(column), lean(2));
|
|
96
96
|
span(head + 4, 2, "a", undefined, lean(4));
|
|
97
97
|
span(10, SIZE, ".", (column) => column >= 6 && column <= 9);
|
|
98
|
-
span(10, top, mound, (column) => column >= 6 && column <= 9, shift);
|
|
98
|
+
span(10, top, mound, (column) => column + shift >= 6 && column + shift <= 9, shift);
|
|
99
99
|
span(11, 10, mound, undefined, shift);
|
|
100
100
|
span(12, 12, mound, undefined, shift);
|
|
101
101
|
for (const row of [13, 14, 15])
|
package/dist/tree/ports.d.ts
CHANGED
|
@@ -286,8 +286,8 @@ export interface ExecutionError {
|
|
|
286
286
|
}
|
|
287
287
|
export interface WatchPorts {
|
|
288
288
|
readonly storage: RosterStorage;
|
|
289
|
-
/** `context` reads the last reply of a session that ended its turn without reporting. */
|
|
290
|
-
readonly session: Pick<Context["session"], "synthetic" | "context">;
|
|
289
|
+
/** `context` reads the last reply of a session that ended its turn without reporting; `get` is what every member joins with too. */
|
|
290
|
+
readonly session: Pick<Context["session"], "synthetic" | "context" | "get">;
|
|
291
291
|
readonly event: Pick<Context["event"], "subscribe">;
|
|
292
292
|
/** The permission domains of every loaded location, which the `Waits` suppliers ask; an isolated child's are in its worktree's. */
|
|
293
293
|
readonly permissions: () => Iterable<Pick<Context["permission"], "list">>;
|
|
@@ -316,5 +316,7 @@ export interface ContextPorts {
|
|
|
316
316
|
readonly limits: Limits;
|
|
317
317
|
/** Each session's depth once looked up: null for one nobody spawned that has started none, until it starts one. */
|
|
318
318
|
readonly roles: Map<string, number | null>;
|
|
319
|
+
/** Tools of other modules hidden from a leaf alongside courier_spawn; `src/index.ts` names courier_recall and courier_note. */
|
|
320
|
+
readonly hiddenFromLeaf?: ReadonlyArray<string>;
|
|
319
321
|
readonly log: (message: string) => void;
|
|
320
322
|
}
|
package/dist/tree/roster.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Status } from "../kernel/protocol.js";
|
|
1
2
|
import type { RosterEntry, RosterStorage } from "./ports.js";
|
|
2
3
|
/**
|
|
3
4
|
* Entries older than this are dropped when their parent's roster is read, and on plugin setup,
|
|
@@ -11,6 +12,22 @@ export declare function allEntries(storage: RosterStorage): Promise<RosterEntry[
|
|
|
11
12
|
* courier_spawn did not start; empty when courier_spawn did not start the session.
|
|
12
13
|
*/
|
|
13
14
|
export declare function lineage(storage: RosterStorage, sessionID: string): Promise<RosterEntry[]>;
|
|
15
|
+
/** `lineage` by the reverse index alone: empty, without scanning the roster, when it does not name the session. */
|
|
16
|
+
export declare function indexedLineage(storage: RosterStorage, sessionID: string): Promise<RosterEntry[]>;
|
|
17
|
+
/** The sessions a session started, oldest first, expired entries included. */
|
|
18
|
+
export declare function childrenOf(storage: RosterStorage, parentID: string): Promise<RosterEntry[]>;
|
|
19
|
+
/**
|
|
20
|
+
* What another module reads of a spawned session's report: `known` once a prompt was noted, whether it `owes` one, how
|
|
21
|
+
* its wait `ended` when not by a report, the `status` of its last report, and whether courier_stop `stopped` it since its last prompt.
|
|
22
|
+
*/
|
|
23
|
+
export interface ReportState {
|
|
24
|
+
readonly known: boolean;
|
|
25
|
+
readonly owes: boolean;
|
|
26
|
+
readonly ended?: "failed" | "interrupted";
|
|
27
|
+
readonly status?: Status;
|
|
28
|
+
readonly stopped: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function reportState(storage: RosterStorage, sessionID: string): Promise<ReportState>;
|
|
14
31
|
/**
|
|
15
32
|
* The top of a spawned session's lineage, where its permission requests and questions go, since the
|
|
16
33
|
* person is there. Throws unless the session was spawned and `callerID` is that top; `what` names it.
|
package/dist/tree/roster.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { isStatus } from "../kernel/protocol.js";
|
|
2
3
|
import { forgetChild, forgetReports } from "./forget.js";
|
|
4
|
+
import { stoppedSincePrompt } from "./report.js";
|
|
3
5
|
import { childStore } from "./store.js";
|
|
4
6
|
/**
|
|
5
7
|
* Entries older than this are dropped when their parent's roster is read, and on plugin setup,
|
|
@@ -17,6 +19,28 @@ export async function allEntries(storage) {
|
|
|
17
19
|
export async function lineage(storage, sessionID) {
|
|
18
20
|
return childStore(storage).lineage(sessionID);
|
|
19
21
|
}
|
|
22
|
+
/** `lineage` by the reverse index alone: empty, without scanning the roster, when it does not name the session. */
|
|
23
|
+
export async function indexedLineage(storage, sessionID) {
|
|
24
|
+
return childStore(storage).indexedLineage(sessionID);
|
|
25
|
+
}
|
|
26
|
+
/** The sessions a session started, oldest first, expired entries included. */
|
|
27
|
+
export async function childrenOf(storage, parentID) {
|
|
28
|
+
return childStore(storage).children(parentID);
|
|
29
|
+
}
|
|
30
|
+
export async function reportState(storage, sessionID) {
|
|
31
|
+
const children = childStore(storage);
|
|
32
|
+
const [{ prompt, settled }, stopped] = await Promise.all([children.notes(sessionID, ["prompt", "settled"]), stoppedSincePrompt(children, sessionID)]);
|
|
33
|
+
const known = prompt.at !== undefined;
|
|
34
|
+
const done = known && settled.at !== undefined && settled.at >= prompt.at;
|
|
35
|
+
const by = settled.by;
|
|
36
|
+
return {
|
|
37
|
+
known,
|
|
38
|
+
owes: known && !done,
|
|
39
|
+
...(done && (by === "failed" || by === "interrupted") ? { ended: by } : {}),
|
|
40
|
+
...(isStatus(settled.status) ? { status: settled.status } : {}),
|
|
41
|
+
stopped,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
20
44
|
/**
|
|
21
45
|
* The top of a spawned session's lineage, where its permission requests and questions go, since the
|
|
22
46
|
* person is there. Throws unless the session was spawned and `callerID` is that top; `what` names it.
|