opencode-courier 0.8.0 → 0.10.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.
@@ -1,12 +1,18 @@
1
1
  import { finite, num, obj, str } from "../platform/json.js";
2
2
  import { scanEntries } from "../platform/storage.js";
3
- import { isPartStatus, lessonText, NOT_STATED } from "./domain.js";
4
- // The memory's storage: the only file that knows its key and layout. A record is read leniently, as one written by
5
- // hand with `e2e/kv.ts set` is: what is missing takes a default, a part without a session id is left out.
3
+ import { isPartStatus, lessonText, MAX_REVISIONS, NOT_STATED, noteRead, sumReads, } from "./domain.js";
4
+ // The memory's storage: the only file that knows its keys and layout. A record is read leniently, as one written by hand
5
+ // with `e2e/kv.ts set` is; beside it, under its key and each with its own writer, lie the person's pin and each reader tree's reads.
6
6
  const PREFIX = "memory/";
7
7
  export const recordKey = (projectID, sessionID) => `${PREFIX}${projectID}/${sessionID}`;
8
8
  /** The key prefix of a project's records. */
9
9
  export const projectPrefix = (projectID) => `${PREFIX}${projectID}/`;
10
+ /** The key prefix of what lies beside a record: its pin and its reads. */
11
+ const besidePrefix = (projectID, sessionID) => `${recordKey(projectID, sessionID)}/`;
12
+ /** The person's pin on a record, `{ at }`; the key's presence pins. */
13
+ export const pinKey = (projectID, sessionID) => `${besidePrefix(projectID, sessionID)}pin`;
14
+ /** The reads of a record by the tree under `by`, `{ shown, read, at }`, written only by that tree's sessions. */
15
+ export const readsKey = (projectID, sessionID, by) => `${besidePrefix(projectID, sessionID)}reads/${by}`;
10
16
  function readPart(value) {
11
17
  const given = obj(value);
12
18
  const sessionID = str(given.sessionID);
@@ -47,7 +53,20 @@ function readLesson(value, movedAt) {
47
53
  const depth = num(given.depth);
48
54
  return { text, at: num(given.at) ?? movedAt, ...(depth !== undefined && Number.isInteger(depth) && depth > 0 ? { depth } : {}) };
49
55
  }
50
- /** A record as stored, or undefined for a value that is not one. */
56
+ /** The revisions as `{ text, at, parts }`, the newest `MAX_REVISIONS`, leaving out an entry without a text, a time or a count of parts. */
57
+ function readRevisions(value) {
58
+ if (!Array.isArray(value))
59
+ return [];
60
+ const revisions = value.flatMap((entry) => {
61
+ const given = obj(entry);
62
+ const text = str(given.text)?.trim();
63
+ const at = num(given.at);
64
+ const parts = num(given.parts);
65
+ return text && at !== undefined && parts !== undefined && parts >= 0 ? [{ text, at, parts }] : [];
66
+ });
67
+ return revisions.slice(-MAX_REVISIONS);
68
+ }
69
+ /** A record as stored, or undefined for a value that is not one; its reads and pin lie beside it (`beside`). */
51
70
  export function readRecord(value) {
52
71
  const given = obj(value);
53
72
  if (!Array.isArray(given.parts))
@@ -55,36 +74,101 @@ export function readRecord(value) {
55
74
  const recalled = readRecalled(given.recalled);
56
75
  const movedAt = num(given.movedAt) ?? num(given.startedAt) ?? 0;
57
76
  const lesson = readLesson(given.lesson, movedAt);
77
+ const revisions = readRevisions(given.revisions);
58
78
  return {
59
79
  startedAt: num(given.startedAt) ?? 0,
60
80
  movedAt,
61
81
  title: str(given.title) ?? "",
62
82
  split: str(given.split) ?? NOT_STATED,
83
+ ...(revisions.length ? { revisions } : {}),
63
84
  finished: given.finished === true,
64
85
  ...(recalled ? { recalled } : {}),
65
86
  ...(lesson ? { lesson } : {}),
66
87
  parts: given.parts.map(readPart).filter((part) => part !== undefined),
67
88
  };
68
89
  }
90
+ /** One tree's reads as `{ shown, read, at }`; a count missing is 0, an entry without a time is none. */
91
+ function readReads(value) {
92
+ const given = obj(value);
93
+ const at = num(given.at);
94
+ if (at === undefined)
95
+ return undefined;
96
+ const count = (field) => Math.max(0, Math.floor(num(given[field]) ?? 0));
97
+ return { shown: count("shown"), read: count("read"), at };
98
+ }
99
+ /** The pin as `{ at }`: any value under the key pins, dated 0 when it says no time. */
100
+ const readPinned = (value) => ({ at: num(obj(value).at) ?? 0 });
101
+ /** A record with what lies beside it: its readers' reads as one, and its pin. */
102
+ function beside(record, { pinned, reads }) {
103
+ const { parts, ...facts } = record;
104
+ const summed = sumReads(reads);
105
+ return { ...facts, ...(summed ? { reads: summed } : {}), ...(pinned ? { pinned } : {}), parts };
106
+ }
107
+ /** The key's path under `memory/`, split: `[projectID, sessionID]` a record, then `pin`, or `reads` and the reader. */
108
+ const pathOf = (prefix, key) => `${prefix.slice(PREFIX.length)}${key}`.split("/");
109
+ /** Sorts a scan's entries by record: the record's value, when the scan held it, and what lies beside it. */
110
+ function group(prefix, entries) {
111
+ const groups = new Map();
112
+ const of = (projectID, sessionID) => {
113
+ const id = `${projectID}/${sessionID}`;
114
+ const found = groups.get(id) ?? { beside: { reads: [] } };
115
+ groups.set(id, found);
116
+ return found;
117
+ };
118
+ for (const { key, value } of entries) {
119
+ const [projectID, sessionID, kind, by] = pathOf(prefix, key);
120
+ if (!projectID || !sessionID)
121
+ continue;
122
+ if (kind === undefined)
123
+ of(projectID, sessionID).record = value;
124
+ else if (kind === "pin" && by === undefined)
125
+ of(projectID, sessionID).beside.pinned = readPinned(value);
126
+ else if (kind === "reads" && by) {
127
+ const reads = readReads(value);
128
+ if (reads)
129
+ of(projectID, sessionID).beside.reads.push(reads);
130
+ }
131
+ }
132
+ return [...groups].map(([id, found]) => {
133
+ const [projectID, sessionID] = id.split("/");
134
+ return { projectID, sessionID, ...found };
135
+ });
136
+ }
69
137
  /** The `MemoryStore` over the plugin's storage. */
70
138
  export function memoryStore(storage) {
71
- const under = async (prefix) => {
72
- const entries = await scanEntries(storage, prefix);
73
- return entries.flatMap(({ key, value }) => {
74
- const parts = `${prefix.slice(PREFIX.length)}${key}`.split("/");
75
- const record = parts.length === 2 ? readRecord(value) : undefined;
76
- return record ? [{ ...record, projectID: parts[0], sessionID: parts[1] }] : [];
77
- });
78
- };
139
+ const scanned = async (prefix) => group(prefix, await scanEntries(storage, prefix));
140
+ const under = async (prefix) => (await scanned(prefix)).flatMap(({ projectID, sessionID, record: value, beside: others }) => {
141
+ const record = readRecord(value);
142
+ return record ? [{ ...beside(record, others), projectID, sessionID }] : [];
143
+ });
79
144
  return {
80
- get: async (projectID, sessionID) => readRecord(await storage.get(recordKey(projectID, sessionID))),
81
- async put(projectID, sessionID, record) {
145
+ async get(projectID, sessionID) {
146
+ const [value, others] = await Promise.all([storage.get(recordKey(projectID, sessionID)), scanned(besidePrefix(projectID, sessionID))]);
147
+ const record = readRecord(value);
148
+ return record && beside(record, others[0]?.beside ?? { reads: [] });
149
+ },
150
+ // What lies beside the record is never written with it, so no refresh or note can write over a pin or a count.
151
+ async put(projectID, sessionID, { reads: _reads, pinned: _pinned, ...record }) {
82
152
  await storage.set(recordKey(projectID, sessionID), { ...record, parts: record.parts.map((part) => ({ ...part })) });
83
153
  },
84
154
  async remove(projectID, sessionID) {
85
- await storage.remove(recordKey(projectID, sessionID));
155
+ const others = await scanEntries(storage, besidePrefix(projectID, sessionID));
156
+ await Promise.all([storage.remove(recordKey(projectID, sessionID)), ...others.map(({ key }) => storage.remove(`${besidePrefix(projectID, sessionID)}${key}`))]);
157
+ },
158
+ async noteRead(projectID, sessionID, by, kind, at) {
159
+ const key = readsKey(projectID, sessionID, by);
160
+ const reads = noteRead(readReads(await storage.get(key)), kind, at);
161
+ await storage.set(key, { ...reads });
162
+ return reads;
163
+ },
164
+ async pin(projectID, sessionID, pinned) {
165
+ if (pinned)
166
+ await storage.set(pinKey(projectID, sessionID), { ...pinned });
167
+ else
168
+ await storage.remove(pinKey(projectID, sessionID));
86
169
  },
87
170
  ofProject: (projectID) => under(projectPrefix(projectID)),
88
171
  all: () => under(PREFIX),
172
+ strays: async () => (await scanned(PREFIX)).filter(({ record }) => record === undefined).map(({ projectID, sessionID }) => ({ projectID, sessionID })),
89
173
  };
90
174
  }
@@ -1,4 +1,7 @@
1
1
  import type { Storage } from "../platform/storage.js";
2
2
  import { type StoredRecord } from "./domain.js";
3
- /** On loading: drops, per project, the records past `MAX_RECORDS` or `RETENTION_MS` at `now`. Returns those dropped. */
3
+ /**
4
+ * On loading: drops, per project, the records `keep` does not at `now`, the pinned never among them, and the reads or
5
+ * pin left beside a record removed meanwhile. Returns the records dropped.
6
+ */
4
7
  export declare function sweepRecords(storage: Storage, now: number): Promise<StoredRecord[]>;
@@ -1,12 +1,16 @@
1
1
  import { keep } from "./domain.js";
2
2
  import { memoryStore } from "./store.js";
3
- /** On loading: drops, per project, the records past `MAX_RECORDS` or `RETENTION_MS` at `now`. Returns those dropped. */
3
+ /**
4
+ * On loading: drops, per project, the records `keep` does not at `now`, the pinned never among them, and the reads or
5
+ * pin left beside a record removed meanwhile. Returns the records dropped.
6
+ */
4
7
  export async function sweepRecords(storage, now) {
5
8
  const store = memoryStore(storage);
6
9
  const byProject = new Map();
7
10
  for (const record of await store.all())
8
11
  byProject.set(record.projectID, [...(byProject.get(record.projectID) ?? []), record]);
9
12
  const dropped = [...byProject.values()].flatMap((records) => keep(records, now).dropped);
10
- await Promise.all(dropped.map((record) => store.remove(record.projectID, record.sessionID)));
13
+ const strays = await store.strays();
14
+ await Promise.all([...dropped, ...strays].map((record) => store.remove(record.projectID, record.sessionID)));
11
15
  return dropped;
12
16
  }
@@ -5,14 +5,22 @@ export declare const RECALL_TOOL = "courier_recall";
5
5
  export declare const NOTE_TOOL = "courier_note";
6
6
  /** A duration in milliseconds as a model reads it: seconds under half a minute, else whole minutes, else hours and minutes. */
7
7
  export declare function duration(ms: number): string;
8
+ /** How long ago a record last moved: a duration under a day, else whole days, which a record kept for months is read in. */
9
+ export declare function age(ms: number): string;
8
10
  /** 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. */
9
11
  export declare function lessonLine(lesson: Lesson): string;
10
- /** One record as the listing shows it: its top, when it started, its title, its split line, its parts, how long it ran and what it cost. */
11
- export declare function recordLine(record: StoredRecord, top: string): string;
12
+ /**
13
+ * One record as the listing shows it: its top, when it started, its title, its split line and its latest revision as prose,
14
+ * its parts, how long it ran, how long ago it last moved, what it cost, and whether the person pinned it.
15
+ */
16
+ export declare function recordLine(record: StoredRecord, top: string, now: number): string;
12
17
  /** What courier_recall says when the memory option is off. */
13
18
  export declare 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.";
14
- /** The listing: every record of the project, newest first, the caller's own tree marked. */
15
- export declare function recallListText(records: ReadonlyArray<StoredRecord>, top: string): string;
19
+ /**
20
+ * The listing: every record of the project, the pinned first and then newest first, the caller's own tree marked, each
21
+ * aged against `now`.
22
+ */
23
+ export declare function recallListText(records: ReadonlyArray<StoredRecord>, top: string, now: number): string;
16
24
  /** One record in full, as JSON: durations in milliseconds, times in epoch milliseconds, context use as a share of the model's window. */
17
25
  export declare function recallRecordText(record: StoredRecord): string;
18
26
  export declare const recallMissingText: (sessionID: string) => string;
@@ -1,11 +1,12 @@
1
1
  /** The memory's model-facing texts: what courier_recall and courier_note say. */
2
- import { clipText } from "../kernel/protocol.js";
3
- import { LESSON_MAX, oneLine, PART_STATUSES } from "./domain.js";
2
+ import { clipText, clipWords } from "../kernel/protocol.js";
3
+ import { LESSON_MAX, oneLine, PART_STATUSES, splitProse } from "./domain.js";
4
4
  /** The tool's name, which the skill and the role part name too. */
5
5
  export const RECALL_TOOL = "courier_recall";
6
6
  /** The lesson tool's name, which the skill's last step names. */
7
7
  export const NOTE_TOOL = "courier_note";
8
8
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
9
+ const DAY_MS = 86_400_000;
9
10
  /** A duration in milliseconds as a model reads it: seconds under half a minute, else whole minutes, else hours and minutes. */
10
11
  export function duration(ms) {
11
12
  const minutes = Math.round(ms / 60_000);
@@ -15,6 +16,11 @@ export function duration(ms) {
15
16
  return `${minutes} min`;
16
17
  return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
17
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
+ }
18
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. */
19
25
  function partsLine(parts) {
20
26
  if (!parts.length)
@@ -39,32 +45,47 @@ export function lessonLine(lesson) {
39
45
  const from = lesson.depth === undefined ? "" : `, from depth ${lesson.depth}`;
40
46
  return `lesson (${day(lesson.at)}${from}): "${clipText(oneLine(lesson.text), LESSON_MAX)}"`;
41
47
  }
42
- /** One record as the listing shows it: its top, when it started, its title, its split line, its parts, how long it ran and what it cost. */
43
- export function recordLine(record, top) {
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) {
44
53
  const cost = record.parts.reduce((sum, part) => sum + (part.cost ?? 0), 0);
45
54
  const costed = record.parts.some((part) => part.cost !== undefined);
55
+ const revised = record.revisions?.at(-1);
46
56
  return [
47
57
  `- ${record.sessionID}${record.sessionID === top ? " (this tree)" : ""} ${day(record.startedAt)}: "${clipText(oneLine(record.title), 60)}"`,
48
- clipText(oneLine(record.split), 160),
58
+ clipWords(splitProse(record.split), 160),
59
+ ...(revised ? [`then, after ${count(revised.parts, "part")}: ${clipWords(splitProse(revised.text), 160)}`] : []),
49
60
  partsLine(record.parts),
50
- duration(record.movedAt - record.startedAt),
61
+ `ran ${duration(record.movedAt - record.startedAt)}`,
62
+ `last moved ${age(now - record.movedAt)} ago`,
51
63
  ...(costed ? [`$${cost.toFixed(2)}`] : []),
52
64
  record.finished ? "finished" : "not finished",
65
+ ...(record.pinned ? ["pinned"] : []),
53
66
  ...(record.recalled ? [`recalled${record.recalled.at === undefined ? " by a part" : ""}`] : []),
54
67
  ...(record.lesson ? [lessonLine(record.lesson)] : []),
55
68
  ].join(" · ");
56
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.";
57
72
  /** What courier_recall says when the memory option is off. */
58
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.";
59
- /** The listing: every record of the project, newest first, the caller's own tree marked. */
60
- export function recallListText(records, top) {
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) {
61
79
  const others = records.filter((record) => record.sessionID !== top);
80
+ const pinned = records.some((record) => record.pinned);
62
81
  const intro = others.length
63
- ? `${count(others.length, "earlier tree")} recorded in this project, newest first; weigh how they were split and how their parts went before you decide. ` +
64
- "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: " +
65
- "courier_recall with a sessionID gives one in full, with each part's status, duration, cost and context use."
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}` : "")
66
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.";
67
- return [intro, ...records.map((record) => recordLine(record, top))].join("\n");
88
+ return [intro, ...records.map((record) => recordLine(record, top, now))].join("\n");
68
89
  }
69
90
  /** One record in full, as JSON: durations in milliseconds, times in epoch milliseconds, context use as a share of the model's window. */
70
91
  export function recallRecordText(record) {
@@ -21,7 +21,7 @@ export function addMemoryTools(tools, ports) {
21
21
  name: RECALL_TOOL,
22
22
  options: { codemode: false },
23
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, newest first, with how its top split the task, how each part went (its status, how long it ran, " +
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
25
  "what it cost, how full its context got) and whether a spawn was refused; with sessionID, one tree's record in full. " +
26
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
27
  "started: their reports wake you.",
@@ -32,7 +32,7 @@ export function addMemoryTools(tools, ports) {
32
32
  case "off":
33
33
  return { content: RECALL_OFF, metadata: { memory: false } };
34
34
  case "list":
35
- return { content: recallListText(result.records, result.top), metadata: { projectID: result.projectID, top: result.top, records: result.records } };
35
+ return { content: recallListText(result.records, result.top, ports.memory.now()), metadata: { projectID: result.projectID, top: result.top, records: result.records } };
36
36
  case "record":
37
37
  return { content: recallRecordText(result.record), metadata: { projectID: result.projectID, record: result.record } };
38
38
  case "missing":
@@ -1,10 +1,10 @@
1
1
  import type { Plugin } from "@opencode/plugin/tui";
2
- import { type Forgotten, type MemoryLesson, type MemoryPart, type MemoryRecord } from "./rpc.js";
2
+ import { type Forgotten, type MemoryLesson, type MemoryPart, type MemoryReads, type MemoryRecord, type Pinned } from "./rpc.js";
3
3
  type Context = Plugin.Context;
4
4
  /** The command that opens the memory view: `/courier memory`, or the palette. */
5
5
  export declare const MEMORY_COMMAND = "courier.memory";
6
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 or forget";
7
+ export declare const MEMORY_USAGE = "/courier memory lists this project's tree records, to read, pin or forget";
8
8
  /** What the view says, in a toast, with the memory option off in the project asked. */
9
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
10
  /** What the view says, in a toast, when the project has no record: when none was written yet, or the last was forgotten. */
@@ -15,6 +15,8 @@ export declare const LESSON_LINE_MAX = 160;
15
15
  export declare const ROW_TITLE_MAX = 46;
16
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
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";
18
20
  /** The widest a row of the lesson in a record in full is; a longer lesson wraps over rows. */
19
21
  export declare const LESSON_ROW = 90;
20
22
  /** A record's name, as its rows and confirms show it: its title on one line, else its top session. */
@@ -25,11 +27,16 @@ export declare function partsCounted(parts: ReadonlyArray<MemoryPart>): string;
25
27
  export declare function costOf(record: MemoryRecord): number | undefined;
26
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. */
27
29
  export declare function recordTitle(record: MemoryRecord, open?: string): string;
28
- /** A list row's group: `Today` for a tree last moved on the local day of `now`, else that local date; the RPC's order, by the last move, keeps the groups newest first. */
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
+ */
29
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;
30
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. */
31
38
  export declare function lessonHeading(lesson: MemoryLesson): string;
32
- /** A list row's description: its parts by status, how long the tree ran, what its parts cost, and that it is not finished when it is not. */
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. */
33
40
  export declare function recordDescription(record: MemoryRecord): string;
34
41
  /** The lines under a list row: the lesson, dated and from which depth, clipped; none without a lesson. */
35
42
  export declare function recordDetails(record: MemoryRecord): string[];
@@ -63,9 +70,11 @@ export declare function detailOptions(record: MemoryRecord): Array<{
63
70
  export declare function forgetMessage(record: MemoryRecord, lesson: boolean): string;
64
71
  /** The toast after a forget, as the server reports it. */
65
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;
66
75
  /**
67
76
  * Adds the memory view: the project's records (the open session's location, else the TUI's, else the server's default) under
68
- * `Today` and the local days they last moved; `enter` shows one in full, `x` forgets one or its lesson after a confirm, `esc` goes back and closes.
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.
69
78
  */
70
79
  export declare function addMemoryView(ctx: Context): {
71
80
  open: () => Promise<void>;
package/dist/rpc.d.ts CHANGED
@@ -1,3 +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 MemoryRecord, type MemoryRpc, type Records } from "./memory/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/skill.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { Plugin } from "@opencode/plugin";
2
2
  /** Why a model loads the skill, listed with every session's available skills. */
3
3
  export declare const SKILL_DESCRIPTION: string;
4
- /** The skill's text; it names no limits, which the role part and the brief give for the session's own tree. */
4
+ /**
5
+ * The skill's text; it names no limits, which the role part and the brief give for the session's own tree.
6
+ * Steps 3, 4 and 6 repeat the brief's split rule on purpose: the root has no brief, so only the skill gives it.
7
+ */
5
8
  export declare const SKILL_CONTENT: string;
6
9
  /** The editor OpenCode hands a skill transform. */
7
10
  export type SkillEditor = Parameters<Parameters<Plugin.Context["skill"]["transform"]>[0]>[0];
package/dist/skill.js CHANGED
@@ -1,34 +1,43 @@
1
- import { SKILL_ID, SPLIT_LINE } from "./kernel/protocol.js";
1
+ import { ONE_PART_LINE, SKILL_ID, SPLIT_LINE } from "./kernel/protocol.js";
2
2
  // The courier-orchestrate skill: the playbook a session loads with its skill tool.
3
3
  /** Why a model loads the skill, listed with every session's available skills. */
4
4
  export const SKILL_DESCRIPTION = "Load at the start of any implementation task that may have independent parts, or when a session started you, to " +
5
5
  "decide whether to split it across OpenCode sessions with courier_spawn and state the decision, and how: plan the " +
6
6
  "parts, start them, end your turn, read the reports, verify the work, integrate it and report upward, and what to do " +
7
7
  "when a part goes wrong.";
8
- /** The skill's text; it names no limits, which the role part and the brief give for the session's own tree. */
8
+ /**
9
+ * The skill's text; it names no limits, which the role part and the brief give for the session's own tree.
10
+ * Steps 3, 4 and 6 repeat the brief's split rule on purpose: the root has no brief, so only the skill gives it.
11
+ */
9
12
  export const SKILL_CONTENT = [
10
13
  "How to split a task across OpenCode sessions with opencode-courier, and bring the results back up. Your brief, or the " +
11
14
  "opencode-courier role line in your system prompt, names your depth in the session tree and its limits.",
12
15
  "",
13
16
  "1. Decide: split or do. First call courier_recall once: it lists how earlier trees in this project were split and how " +
14
17
  "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 " +
16
- "task: it only adds a hop. What you read and write yourself stays in your context for every later turn, so keep the " +
17
- "deciding, checking and integrating, and hand off the long reading and writing. An implementation task with one tightly " +
18
- "coupled core usually still has independent parts: finding out what the change needs (a session with isolate: false " +
19
- "reads the code, or a library's or a host's source, tries things and reports what it found), its e2e scenario, its " +
20
- "documentation, sometimes a second test file, and a review of the core by a session with fresh context, which reads the " +
21
- "committed diff against the task and the repository's rules and reports its findings (isolate: false, it edits nothing). " +
22
- "Weigh each of them. When the other parts depend on names and texts the core fixes, split in stages: the core first, " +
23
- "yourself when it is small or by one session when it is big, merged and committed before the rest start; then, if 2 or " +
24
- "more parts remain, one session per remaining part, in one group. A part that is itself big and divisible is split again " +
25
- "by the session that gets it; at the deepest level a session does its part itself. " +
26
- `Say which in one line of your reply, before you start: ${SPLIT_LINE}, so that a reasoned decision to do it ` +
27
- "yourself can be told from one never made.",
18
+ "independent, substantial parts touching separate files or areas; do it yourself when it is small, sequential or " +
19
+ "tightly coupled. What you read and write yourself stays in your context for every later turn, so keep the deciding, " +
20
+ "checking and integrating, and hand off the long reading and writing. Only the root, the session nobody started, may " +
21
+ "start exactly one session for the whole task, and only when the task is heavy (long reading, many edits, long runs) " +
22
+ "and its own context is worth keeping: it still verifies and integrates that session's report itself (steps 5 to 7), " +
23
+ "and does a small task itself. Every other session never starts exactly one session for the whole task: it only adds " +
24
+ "a hop, and a chain of single hops uses up the tree's depth. A part that is itself big and divisible is split again by " +
25
+ "the session that gets it; at the deepest level a session does its part itself. " +
26
+ `Say which in one line of your reply, before you start, so that a reasoned decision to do it yourself can be told ` +
27
+ `from one never made: ${SPLIT_LINE}; the root has a third form, ${ONE_PART_LINE}.`,
28
+ " When the core is coupled: an implementation task with one tightly coupled core usually still has independent parts: " +
29
+ "finding out what the change needs (a session with isolate: false reads the code, or a library's or a host's source, " +
30
+ "tries things and reports what it found), its e2e scenario, its documentation, sometimes a second test file, and a " +
31
+ "review of the core by a session with fresh context, which reads the committed diff against the task and the " +
32
+ "repository's rules and reports its findings (isolate: false, it edits nothing). Weigh each of them. When the other " +
33
+ "parts depend on names and texts the core fixes, split in stages: the core first, yourself when it is small or by one " +
34
+ "session when it is big, merged and committed before the rest start; then, if 2 or more parts remain, one session per " +
35
+ "remaining part, in one group. A staged core is one part of the task, not the whole task, so the rule against starting exactly one " +
36
+ "session for the whole task does not apply to it when 2 or more parts follow it.",
28
37
  "2. Plan the parts so that no two edit the same files. Write each task so the session can do it without you: what to " +
29
38
  "produce and where, how to check it, what to report. Say if it may split its part further, and to run its checks " +
30
39
  "through the project's scripts (step 6): each permission request of a session below you wakes you and waits on the person.",
31
- "3. Start them: one courier_spawn per part, all with the same group name, so you are woken once with every report; " +
40
+ "3. Start them: one courier_spawn per part that runs at once, all with the same group name, so you are woken once with every report; " +
32
41
  "isolate: true for each part that edits files in parallel with another (commit your own work first if you are isolated " +
33
42
  "yourself). For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
34
43
  "4. End your turn: reply without calling more tools. Do not poll with courier_status, courier_children, courier_tree or " +
@@ -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])
@@ -60,7 +60,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
60
60
  readonly isolated: boolean;
61
61
  readonly parentID: string;
62
62
  readonly created: number;
63
- readonly state: "stalled" | "running" | "idle" | "gone" | "waiting";
63
+ readonly state: "gone" | "stalled" | "running" | "idle" | "waiting";
64
64
  readonly waits: readonly ({
65
65
  readonly type: "permission";
66
66
  readonly action: string;
@@ -102,7 +102,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
102
102
  readonly isolated: boolean;
103
103
  readonly parentID: string;
104
104
  readonly created: number;
105
- readonly state: "stalled" | "running" | "idle" | "gone" | "waiting";
105
+ readonly state: "gone" | "stalled" | "running" | "idle" | "waiting";
106
106
  readonly waits: readonly ({
107
107
  readonly type: "permission";
108
108
  readonly action: string;
@@ -229,12 +229,12 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
229
229
  readonly by: import("effect/Schema").String;
230
230
  }>;
231
231
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
232
- readonly outcome: "removed" | "gone" | "forgotten";
232
+ readonly outcome: "gone" | "removed" | "forgotten";
233
233
  } | {
234
234
  readonly outcome: "kept";
235
235
  readonly reason: string;
236
236
  }, {
237
- readonly outcome: "removed" | "gone" | "forgotten";
237
+ readonly outcome: "gone" | "removed" | "forgotten";
238
238
  } | {
239
239
  readonly outcome: "kept";
240
240
  readonly reason: string;
@@ -227,7 +227,7 @@ export declare const treeRpc: {
227
227
  readonly isolated: boolean;
228
228
  readonly parentID: string;
229
229
  readonly created: number;
230
- readonly state: "stalled" | "running" | "idle" | "gone" | "waiting";
230
+ readonly state: "gone" | "stalled" | "running" | "idle" | "waiting";
231
231
  readonly waits: readonly ({
232
232
  readonly type: "permission";
233
233
  readonly action: string;
@@ -269,7 +269,7 @@ export declare const treeRpc: {
269
269
  readonly isolated: boolean;
270
270
  readonly parentID: string;
271
271
  readonly created: number;
272
- readonly state: "stalled" | "running" | "idle" | "gone" | "waiting";
272
+ readonly state: "gone" | "stalled" | "running" | "idle" | "waiting";
273
273
  readonly waits: readonly ({
274
274
  readonly type: "permission";
275
275
  readonly action: string;
@@ -415,12 +415,12 @@ export declare const treeRpc: {
415
415
  readonly by: Schema.String;
416
416
  }>;
417
417
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
418
- readonly outcome: "removed" | "gone" | "forgotten";
418
+ readonly outcome: "gone" | "removed" | "forgotten";
419
419
  } | {
420
420
  readonly outcome: "kept";
421
421
  readonly reason: string;
422
422
  }, {
423
- readonly outcome: "removed" | "gone" | "forgotten";
423
+ readonly outcome: "gone" | "removed" | "forgotten";
424
424
  } | {
425
425
  readonly outcome: "kept";
426
426
  readonly reason: string;
package/dist/tree/text.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** The tree's model-facing texts: the child brief, the role part, refusals, reports, notices and tool results. */
2
2
  import { obj, str } from "../platform/json.js";
3
- import { clipText, defuse, END_TURN, SKILL_ID, SPLIT_LINE, STATUSES } from "../kernel/protocol.js";
3
+ import { clipText, defuse, END_TURN, ROOT_SPLIT_LINE, SKILL_ID, SPLIT_LINE, STATUSES } from "../kernel/protocol.js";
4
4
  // The envelope and the child brief.
5
5
  /** How a text names the statuses a report may carry. */
6
6
  const STATUS_LIST = `${STATUSES.slice(0, -1).join(", ")} or ${STATUSES.at(-1)}`;
@@ -47,10 +47,12 @@ function splitRule(parentID, depth, limits) {
47
47
  `You are at depth ${depth} of a session tree that goes at most ${limits.maxDepth} deep. Split your task or do it yourself:`,
48
48
  "- Split it when it has 2 or more independent parts, each substantial and touching separate files or areas: start one " +
49
49
  `session per part with courier_spawn, at most ${limits.maxChildren} of yours running at once and ${limits.maxTotal} in the whole tree.`,
50
- "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task.",
50
+ "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task; a core you stage before the rest is one part of it, not the whole, when 2 or more parts follow it.",
51
51
  `- Say which in one line of your reply: ${SPLIT_LINE}.`,
52
- "- If you split, you orchestrate: end your turn while they work, check and integrate each part yourself when it " +
53
- `reports (never hand that checking to another session), then send ${parentID} one combined report.`,
52
+ "- If you split, you orchestrate: start the parts that run at once in one group, with the same group name for each, " +
53
+ "then end your turn while they work. Do not poll with courier_status, courier_children, courier_tree or sleep: the group's " +
54
+ "reports wake you. Check and integrate each part yourself when it reports (never hand that checking to another " +
55
+ `session), then send ${parentID} one combined report.`,
54
56
  ];
55
57
  }
56
58
  /** How every part the context hook adds begins, so a second copy of the plugin does not add its own. */
@@ -65,14 +67,15 @@ export function rolePart(depth, limits) {
65
67
  if (depth === null)
66
68
  return (`${ROLE_PREFIX} root, no sessions started yet. Before a task that touches several files or areas, load the ` +
67
69
  `${SKILL_ID} skill, call courier_recall once for how earlier trees in this project were split and how they went, ` +
68
- `and decide whether to split it across sessions (doing it yourself is fine), and say ` +
69
- `which in one line of your reply: ${SPLIT_LINE}`);
70
+ `and decide whether to split it across sessions (doing it yourself is fine; so is handing a heavy task, with long ` +
71
+ `reading, many edits or long runs, whole to one session to keep your context, but never a small one), and say ` +
72
+ `which in one line of your reply: ${ROOT_SPLIT_LINE}`);
70
73
  if (depth === 0)
71
74
  return (`${ROLE_PREFIX} root orchestrator. You started sessions with courier_spawn, and they may start their own, ` +
72
75
  `down to ${maxDepth} levels below you. ${budget} Check and integrate what each one reports yourself.`);
73
76
  if (depth < maxDepth)
74
- return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Split your task with courier_spawn or ` +
75
- `do it yourself, as your brief says; sessions can be started ${count(maxDepth - depth, "level")} below you. ${budget}`);
77
+ return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Before you split, load the ${SKILL_ID} ` +
78
+ `skill. Split your task with courier_spawn or do it yourself, as your brief says; sessions can be started ${count(maxDepth - depth, "level")} below you. ${budget}`);
76
79
  return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
77
80
  "task yourself and report with courier_send to the session that started you.");
78
81
  }