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.
package/README.md CHANGED
@@ -108,7 +108,7 @@ version than the pinned one writes one line to the server log
108
108
  <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
109
109
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
110
110
  |---|---|
111
- | 0.7.3, 0.8.0 | 2.0.32 |
111
+ | 0.7.3, 0.8.0, 0.9.0, 0.10.0 | 2.0.32 |
112
112
  | 0.7.2 | 2.0.31 |
113
113
  | 0.7.1 | 2.0.30 |
114
114
  <!-- compat-table:end -->
@@ -246,13 +246,21 @@ Each spawned session's brief says when to split its task and when to do it itsel
246
246
  ## Memory
247
247
 
248
248
  The plugin keeps **one record per session tree** in the project, from facts it already has: when the
249
- tree started and last moved, the top session's title and `split:` line, and for each session started
249
+ tree started and last moved, the top session's title and first `split:` line, with up to 3 later ones
250
+ that read otherwise (when each was stated, and after how many parts), and for each session started
250
251
  in the tree its title, depth, isolation, join group, status, duration, cost, context use and spawns
251
- refused; never a report's body or artifacts. The one thing a model writes into it is **the tree's
252
+ refused; never a report's body or artifacts. A session deleted since keeps its last status and
253
+ duration, or reads `gone` when it had not settled, so it never holds its tree at `not finished`.
254
+ The one thing a model writes into it is **the tree's
252
255
  lesson**, with `courier_note`: one line of at most 300 characters, from the session that verified the
253
- tree, with the job it comes from and the reason. `courier_recall` reads the records back; the
254
- `courier-orchestrate` skill asks for one call before the split decision and one `courier_note` before
255
- the top replies. Each project keeps its newest 30 records and none older than 90 days. `"memory":
256
+ tree, with the job it comes from and the reason. `courier_recall` reads the records back, each line
257
+ showing the split line as prose (its markdown marks dropped, the asterisks of its globs kept) cut at a
258
+ word, then the latest later one (`then, after 1 part: split: 2 parts: …`), and saying how long the tree ran and how long ago it last moved; the `courier-orchestrate` skill asks for
259
+ one call before the split decision and one `courier_note` before the top replies. Each record counts
260
+ how often other trees' `courier_recall` calls listed it and opened it in full, and when last, so the
261
+ person can tell a lesson still read from one nobody reads; a record the person pins in the terminal
262
+ is listed first and never swept. Each project keeps 30 unpinned records, those with a lesson first,
263
+ and none older than 90 days. `"memory":
256
264
  false` in the plugin's `options` turns it off ([more](docs/reference.md#memory)).
257
265
 
258
266
  ## In the terminal
@@ -292,10 +300,11 @@ permission prompt or form is shown in the session, OpenCode hides the footer, so
292
300
  `ctrl+x k`.
293
301
 
294
302
  `/courier memory` (or the palette's "Courier: memory") lists the project's [tree records](#memory),
295
- newest first, grouped by the day each tree last moved, one row each with its lesson under it.
296
- `enter` shows one in full; `x` forgets the record under the row, or in a record on its lesson's rows
297
- only the lesson, after a confirm; `esc` goes back and closes. The search takes every letter but `x`
298
- ([more](docs/reference.md#the-memory-view)).
303
+ pinned first, then newest first grouped by the day each tree last moved, one row each with how often
304
+ other trees read it and its lesson under it. `enter` shows one in full; `p` pins or unpins the record,
305
+ and a pinned one is listed first and never swept; `x` forgets the record under the row, or in a
306
+ record on its lesson's rows only the lesson, after a confirm; `esc` goes back and closes. The search
307
+ takes every letter but `x` and `p` ([more](docs/reference.md#the-memory-view)).
299
308
 
300
309
  ## Native child sessions
301
310
 
@@ -11,9 +11,15 @@ export type Status = (typeof STATUSES)[number];
11
11
  export declare const isStatus: (value: unknown) => value is Status;
12
12
  /** The one line a session states its split decision in, so a transcript tells a decision from one never made. */
13
13
  export declare const SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part)";
14
+ /** The third form, for the root alone: it hands a heavy task whole to one session, to keep its own context. */
15
+ export declare const ONE_PART_LINE = "split: 1 part: <the task>, to keep my context";
16
+ /** `SPLIT_LINE` with the root's third form: the root's pointer offers it, a child's brief and role part never do. */
17
+ export declare const ROOT_SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part) or split: 1 part: <the task>, to keep my context";
14
18
  export declare const SKILL_ID = "courier-orchestrate";
15
19
  export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
16
20
  export declare function clipText(text: string, max?: number): string;
21
+ /** `clipText` cut at the last space within `max` instead, so no word is cut in two, unless that keeps less than half of `max`. */
22
+ export declare function clipWords(text: string, max: number): string;
17
23
  /** Defuses `<courier` and `</courier>` in outside text, so it cannot close the envelope or forge another. */
18
24
  export declare function defuse(text: string): string;
19
25
  /** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
@@ -10,6 +10,10 @@ export const STATUSES = ["done", "partial", "blocked", "failed"];
10
10
  export const isStatus = (value) => typeof value === "string" && STATUSES.includes(value);
11
11
  /** The one line a session states its split decision in, so a transcript tells a decision from one never made. */
12
12
  export const SPLIT_LINE = "split: no, because …; considered: <the parts you weighed> or split: <n> parts: … (one phrase per part)";
13
+ /** The third form, for the root alone: it hands a heavy task whole to one session, to keep its own context. */
14
+ export const ONE_PART_LINE = "split: 1 part: <the task>, to keep my context";
15
+ /** `SPLIT_LINE` with the root's third form: the root's pointer offers it, a child's brief and role part never do. */
16
+ export const ROOT_SPLIT_LINE = `${SPLIT_LINE} or ${ONE_PART_LINE}`;
13
17
  export const SKILL_ID = "courier-orchestrate";
14
18
  export function envelope(from, message, attributes = {}) {
15
19
  const extra = Object.entries(attributes)
@@ -19,8 +23,16 @@ export function envelope(from, message, attributes = {}) {
19
23
  }
20
24
  /** Longest piece of free text (a review body, a generic payload) copied into a delivered message. */
21
25
  const MAX_TEXT = 1500;
26
+ const clipped = (text, kept) => `${kept}… [${text.length - kept.length} more characters]`;
22
27
  export function clipText(text, max = MAX_TEXT) {
23
- return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more characters]` : text;
28
+ return text.length > max ? clipped(text, text.slice(0, max)) : text;
29
+ }
30
+ /** `clipText` cut at the last space within `max` instead, so no word is cut in two, unless that keeps less than half of `max`. */
31
+ export function clipWords(text, max) {
32
+ if (text.length <= max)
33
+ return text;
34
+ const space = text.lastIndexOf(" ", max);
35
+ return clipped(text, (space >= max / 2 ? text.slice(0, space) : text.slice(0, max)).trimEnd());
24
36
  }
25
37
  /** Defuses `<courier` and `</courier>` in outside text, so it cannot close the envelope or forge another. */
26
38
  export function defuse(text) {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The memory's pure rules, on plain data: the option, a record's shape, each part's status, the split line, the
3
- * retention and how a refresh keeps the parts the roster has forgotten. Imports nothing of the plugin's but `kernel/` and `platform/json.ts`.
2
+ * The memory's pure rules, on plain data: the option, a record's shape, each part's status, the split line and its revisions,
3
+ * its reads and pin, the retention and how a refresh keeps the parts the roster has forgotten. Imports nothing of the plugin's but `kernel/` and `platform/json.ts`.
4
4
  */
5
5
  import { type Status } from "../kernel/protocol.js";
6
- /** How many records a project keeps, the newest by `movedAt`. */
6
+ /** How many unpinned records a project keeps: those with a lesson first, then the newest by `movedAt`. */
7
7
  export declare const MAX_RECORDS = 30;
8
- /** Records that last moved longer ago than this are dropped when the plugin loads. */
8
+ /** Unpinned records that last moved longer ago than this are dropped when the plugin loads. */
9
9
  export declare const RETENTION_MS: number;
10
10
  /** A record's `split` while its top has stated no split line. */
11
11
  export declare const NOT_STATED = "not stated";
@@ -13,11 +13,13 @@ export declare const NOT_STATED = "not stated";
13
13
  export declare const LESSON_MAX = 300;
14
14
  /**
15
15
  * How a part stands: its last report's status, how its wait ended without one (`failed`, `interrupted`), `stopped` by
16
- * courier_stop, `stalled` on a message it will not take, or `out`: it owes a report, running or not.
16
+ * courier_stop, `gone` before it settled, `stalled` on a message it will not take, or `out`: it owes a report.
17
17
  */
18
- export declare const PART_STATUSES: readonly ["done", "partial", "blocked", "failed", "interrupted", "stopped", "stalled", "out"];
18
+ export declare const PART_STATUSES: readonly ["done", "partial", "blocked", "failed", "interrupted", "stopped", "gone", "stalled", "out"];
19
19
  export type PartStatus = (typeof PART_STATUSES)[number];
20
20
  export declare const isPartStatus: (value: unknown) => value is PartStatus;
21
+ /** Whether a part has settled, as every part of a finished tree has: it neither owes a report nor is stalled. */
22
+ export declare const isSettled: (status: PartStatus) => status is "done" | "partial" | "blocked" | "failed" | "interrupted" | "stopped" | "gone";
21
23
  /** One session started in a tree, as its record keeps it; `duration` is in milliseconds, `context` a share of its model's window. */
22
24
  export interface PartRecord {
23
25
  readonly sessionID: string;
@@ -49,6 +51,29 @@ export interface Lesson {
49
51
  readonly at: number;
50
52
  readonly depth?: number;
51
53
  }
54
+ /**
55
+ * How often other trees' courier_recall calls listed the record (`shown`) or gave it in full (`read`), and when one last
56
+ * did (`at`). Kept per reader tree and summed when read; a listing to the tree's own sessions counts nothing.
57
+ */
58
+ export interface Reads {
59
+ readonly shown: number;
60
+ readonly read: number;
61
+ readonly at: number;
62
+ }
63
+ /** What a courier_recall call did with a record: listed it, or gave it in full. */
64
+ export type ReadKind = "shown" | "read";
65
+ /** The person's pin, set in the memory view: when. A pinned record is listed first and never swept. */
66
+ export interface Pinned {
67
+ readonly at: number;
68
+ }
69
+ /** How many revisions of the split line a record keeps, the newest. */
70
+ export declare const MAX_REVISIONS = 3;
71
+ /** A later split line the top stated, worded otherwise than the one before it: when, and how many parts the tree had then. */
72
+ export interface Revision {
73
+ readonly text: string;
74
+ readonly at: number;
75
+ readonly parts: number;
76
+ }
52
77
  /** What is remembered of one tree: its top session and each session started in it, never a report's body or artifacts. */
53
78
  export interface TreeRecord {
54
79
  /** When the top session was created, epoch milliseconds. */
@@ -56,13 +81,22 @@ export interface TreeRecord {
56
81
  /** When a turn in the tree last ended, or the record was first written. */
57
82
  readonly movedAt: number;
58
83
  readonly title: string;
59
- /** The top's split line, as `e2e/real-model-recursive.mjs` reads it, or `not stated`. */
84
+ /**
85
+ * The top's first split line, as `e2e/real-model-recursive.mjs` reads it, or `not stated`; a stated line takes precedence over a
86
+ * mention, which it replaces once, and is never replaced.
87
+ */
60
88
  readonly split: string;
89
+ /** Absent until the top states a split line worded otherwise than `split`; newest last, at most `MAX_REVISIONS`. */
90
+ readonly revisions?: ReadonlyArray<Revision>;
61
91
  /** Whether, at the last refresh, no session in the tree ran and every part had settled: none was out or stalled. */
62
92
  readonly finished: boolean;
63
93
  readonly recalled?: Recalled;
64
94
  /** Absent until a session of the tree that is not a leaf wrote one; a second note replaces the first. */
65
95
  readonly lesson?: Lesson;
96
+ /** Absent until another tree's courier_recall listed or read the record; kept beside the record, never written with it. */
97
+ readonly reads?: Reads;
98
+ /** Absent until the person pinned the record in the memory view; kept beside the record, never written with it. */
99
+ readonly pinned?: Pinned;
66
100
  readonly parts: ReadonlyArray<PartRecord>;
67
101
  }
68
102
  /** A record with where it is kept: its project and its top session. */
@@ -77,6 +111,29 @@ export declare function readMemory(options: unknown): {
77
111
  };
78
112
  /** The split line a text states, as the real-model checker reads it: a line beginning with `split:` after any markdown, else one holding it. */
79
113
  export declare function splitLineOf(text: string): string | undefined;
114
+ /** Whether a split line is stated, beginning with `split:` after any markdown, not a passing mention nor `not stated`. */
115
+ export declare const isStated: (line: string) => boolean;
116
+ /** The record's `split` once `text` is read: kept once stated; a mention gives way to the first stated line; `not stated` to either. */
117
+ export declare function splitOf(recorded: string, text: string): string;
118
+ /** Every line of a text that begins with `split:` after any markdown, trimmed: a passing mention of a split is none. */
119
+ export declare const splitLinesOf: (text: string) => string[];
120
+ /** A split line the top's replies state, with when its reply was created when the message says. */
121
+ export interface Stated {
122
+ readonly text: string;
123
+ readonly at?: number;
124
+ }
125
+ /**
126
+ * The revisions once the split lines `stated` are read at a refresh at `at` with `parts` parts: those `statedSince` the line recorded
127
+ * last, each appended when its words differ from the line before, so a re-read appends nothing.
128
+ */
129
+ export declare function revise(previous: Pick<TreeRecord, "split" | "revisions">, stated: ReadonlyArray<Stated>, at: number, parts: number): Revision[] | undefined;
130
+ /**
131
+ * The split and revisions once the stated line `found` replaces a mention: when `found` is among the revisions (recorded beside the
132
+ * mention before #294), the first of them was stated first and is `split`, the rest stay; else `found` is, beside them all.
133
+ */
134
+ export declare function replacingMention(found: string, revisions: ReadonlyArray<Revision> | undefined): Pick<TreeRecord, "split" | "revisions">;
135
+ /** A split line as the listing shows it: one line, without its block marks, the asterisks wrapping it or a span in it, nor an underscore or code span wrapping it. */
136
+ export declare function splitProse(split: string): string;
80
137
  /** What a part's status is read from: whether courier_stop stopped it, whether it is stalled, and its report state. */
81
138
  export interface Standing {
82
139
  readonly stopped: boolean;
@@ -91,8 +148,13 @@ export interface Standing {
91
148
  /** A part's status by `Standing`; one whose report state is not known is still out. */
92
149
  export declare function partStatus({ stopped, stalled, report }: Standing): PartStatus;
93
150
  /**
94
- * The parts of a refreshed record: those recorded before, in their order, each as read now when the roster still has it,
95
- * else as last recorded, then those first seen now, in the order the tree is drawn.
151
+ * A part whose session could not be read, from what was read of it and its last record: its title, duration and cost as
152
+ * last recorded; once OpenCode no longer knows it (`gone`), settled as read, else as last recorded, else `gone`.
153
+ */
154
+ export declare function unreadPart(read: PartRecord, last: PartRecord | undefined, gone: boolean): PartRecord;
155
+ /**
156
+ * The parts of a refreshed record: those recorded before, in their order, each as `current` has it (read now, or found
157
+ * gone), else as last recorded, then those first seen now, in the order the tree is drawn.
96
158
  */
97
159
  export declare function mergeParts(previous: ReadonlyArray<PartRecord>, current: ReadonlyArray<PartRecord>): PartRecord[];
98
160
  /** `recalled` after one more call at `at`, the top's first call noted when `byTop`. */
@@ -110,10 +172,17 @@ export declare function lessonText(text: string): {
110
172
  text: string;
111
173
  clipped: number;
112
174
  };
113
- /** The records a project keeps at `now`: the newest `MAX_RECORDS` by `movedAt`, none older than `RETENTION_MS`; the rest are dropped. */
114
- export declare function keep<T extends Pick<StoredRecord, "movedAt">>(records: ReadonlyArray<T>, now: number): {
175
+ /** One reader tree's `reads` after courier_recall listed the record or gave it in full at `at`. */
176
+ export declare function noteRead(reads: Reads | undefined, kind: ReadKind, at: number): Reads;
177
+ /** The reads of every reader tree as one: the counts summed, `at` the latest; none without a reader. */
178
+ export declare function sumReads(reads: ReadonlyArray<Reads>): Reads | undefined;
179
+ /**
180
+ * The records a project keeps at `now`: every pinned one, whatever its age, and of the rest the first `MAX_RECORDS` by
181
+ * whether each has a lesson, then by `movedAt`, none older than `RETENTION_MS`; the rest are dropped.
182
+ */
183
+ export declare function keep<T extends Pick<StoredRecord, "movedAt" | "lesson" | "pinned">>(records: ReadonlyArray<T>, now: number): {
115
184
  kept: T[];
116
185
  dropped: T[];
117
186
  };
118
- /** Records newest first, by when each last moved. */
119
- export declare const newestFirst: <T extends Pick<StoredRecord, "movedAt">>(records: ReadonlyArray<T>) => T[];
187
+ /** Records as courier_recall and the memory view list them: the pinned first, then by when each last moved, newest first. */
188
+ export declare const pinnedFirst: <T extends Pick<StoredRecord, "movedAt" | "pinned">>(records: ReadonlyArray<T>) => T[];
@@ -1,12 +1,12 @@
1
1
  /**
2
- * The memory's pure rules, on plain data: the option, a record's shape, each part's status, the split line, the
3
- * retention and how a refresh keeps the parts the roster has forgotten. Imports nothing of the plugin's but `kernel/` and `platform/json.ts`.
2
+ * The memory's pure rules, on plain data: the option, a record's shape, each part's status, the split line and its revisions,
3
+ * its reads and pin, the retention and how a refresh keeps the parts the roster has forgotten. Imports nothing of the plugin's but `kernel/` and `platform/json.ts`.
4
4
  */
5
5
  import { STATUSES } from "../kernel/protocol.js";
6
6
  import { obj } from "../platform/json.js";
7
- /** How many records a project keeps, the newest by `movedAt`. */
7
+ /** How many unpinned records a project keeps: those with a lesson first, then the newest by `movedAt`. */
8
8
  export const MAX_RECORDS = 30;
9
- /** Records that last moved longer ago than this are dropped when the plugin loads. */
9
+ /** Unpinned records that last moved longer ago than this are dropped when the plugin loads. */
10
10
  export const RETENTION_MS = 90 * 24 * 60 * 60_000;
11
11
  /** A record's `split` while its top has stated no split line. */
12
12
  export const NOT_STATED = "not stated";
@@ -14,10 +14,14 @@ export const NOT_STATED = "not stated";
14
14
  export const LESSON_MAX = 300;
15
15
  /**
16
16
  * How a part stands: its last report's status, how its wait ended without one (`failed`, `interrupted`), `stopped` by
17
- * courier_stop, `stalled` on a message it will not take, or `out`: it owes a report, running or not.
17
+ * courier_stop, `gone` before it settled, `stalled` on a message it will not take, or `out`: it owes a report.
18
18
  */
19
- export const PART_STATUSES = [...STATUSES, "interrupted", "stopped", "stalled", "out"];
19
+ export const PART_STATUSES = [...STATUSES, "interrupted", "stopped", "gone", "stalled", "out"];
20
20
  export const isPartStatus = (value) => typeof value === "string" && PART_STATUSES.includes(value);
21
+ /** Whether a part has settled, as every part of a finished tree has: it neither owes a report nor is stalled. */
22
+ export const isSettled = (status) => status !== "out" && status !== "stalled";
23
+ /** How many revisions of the split line a record keeps, the newest. */
24
+ export const MAX_REVISIONS = 3;
21
25
  /** The `memory` option: whether records are kept and listed; on unless `false`. */
22
26
  export function readMemory(options) {
23
27
  const value = obj(options).memory;
@@ -34,6 +38,101 @@ export function splitLineOf(text) {
34
38
  const lines = text.split("\n");
35
39
  return (lines.find((line) => STARTS_SPLIT.test(line)) ?? lines.find((line) => HOLDS_SPLIT.test(line)))?.trim();
36
40
  }
41
+ /** Whether a split line is stated, beginning with `split:` after any markdown, not a passing mention nor `not stated`. */
42
+ export const isStated = (line) => STARTS_SPLIT.test(line);
43
+ /** The record's `split` once `text` is read: kept once stated; a mention gives way to the first stated line; `not stated` to either. */
44
+ export function splitOf(recorded, text) {
45
+ if (isStated(recorded))
46
+ return recorded;
47
+ const found = splitLineOf(text);
48
+ return found !== undefined && (recorded === NOT_STATED || isStated(found)) ? found : recorded;
49
+ }
50
+ /** Every line of a text that begins with `split:` after any markdown, trimmed: a passing mention of a split is none. */
51
+ export const splitLinesOf = (text) => text
52
+ .split("\n")
53
+ .filter((line) => STARTS_SPLIT.test(line))
54
+ .map((line) => line.trim());
55
+ /**
56
+ * The lines after the one recorded last: after its reply, else (compacted, or unread by an overlapping refresh) those stated since its
57
+ * time; without a revision, after `split`'s first statement; all when neither is found.
58
+ */
59
+ function statedSince(previous, stated) {
60
+ const last = previous.revisions?.at(-1);
61
+ const words = (line) => splitProse(line.text);
62
+ if (!last)
63
+ return stated.slice(stated.findIndex((line) => words(line) === splitProse(previous.split)) + 1);
64
+ const inReply = stated.findLastIndex((line) => words(line) === splitProse(last.text) && line.at === last.at);
65
+ if (inReply >= 0)
66
+ return stated.slice(inReply + 1);
67
+ if (stated.some((line) => line.at !== undefined))
68
+ return stated.filter((line) => line.at !== undefined && line.at > last.at);
69
+ return stated.slice(stated.findLastIndex((line) => words(line) === splitProse(last.text)) + 1);
70
+ }
71
+ /**
72
+ * The revisions once the split lines `stated` are read at a refresh at `at` with `parts` parts: those `statedSince` the line recorded
73
+ * last, each appended when its words differ from the line before, so a re-read appends nothing.
74
+ */
75
+ export function revise(previous, stated, at, parts) {
76
+ const recorded = splitProse(previous.revisions?.at(-1)?.text ?? previous.split);
77
+ const { added } = statedSince(previous, stated).reduce(({ last, added }, line) => {
78
+ const words = splitProse(line.text);
79
+ return words === last ? { last, added } : { last: words, added: [...added, { text: line.text, at: line.at ?? at, parts }] };
80
+ }, { last: recorded, added: [] });
81
+ const revisions = [...(previous.revisions ?? []), ...added].slice(-MAX_REVISIONS);
82
+ return revisions.length ? revisions : undefined;
83
+ }
84
+ /**
85
+ * The split and revisions once the stated line `found` replaces a mention: when `found` is among the revisions (recorded beside the
86
+ * mention before #294), the first of them was stated first and is `split`, the rest stay; else `found` is, beside them all.
87
+ */
88
+ export function replacingMention(found, revisions) {
89
+ const [first, ...rest] = revisions ?? [];
90
+ if (!first || !revisions.some((one) => splitProse(one.text) === splitProse(found)))
91
+ return { split: found, ...(first ? { revisions } : {}) };
92
+ return { split: first.text, ...(rest.length ? { revisions: rest } : {}) };
93
+ }
94
+ /** Heading, quote and list marks before a line's words, each with the space markdown puts after it, unlike the `#` of `#282`. */
95
+ const BLOCK_MARKS = /^(?:\s|#+\s|>\s|[-+*]\s|\d+[.)]\s)*/;
96
+ /** A bold or bold italic span within a line, opening at a word's start and closing at its end, unlike the asterisks of `2**3` or a `**` beside a slash. */
97
+ const BOLD = /(?<=^|[\s(["'])(\*\*\*?)([^\s*/][^*]*?)(?<![\s(/])\1(?=$|[\s.,;:!?)\]"'])/gu;
98
+ /** An italic span opens only before a letter, digit, quote or code span, which tells it from the globs of `*.ts and *.md` or `src/*.ts`. */
99
+ const ITALIC = /(?<=^|[\s(["'])\*([\p{L}\p{N}\0"'][^*]*?)(?<![\s(/])\*(?=$|[\s.,;:!?)\]"'])/gu;
100
+ /** A label the line opens with in underscores or a code span (`_split:_`, `` `split:` ``), which its colon tells from `__init__`. */
101
+ const LABEL = /^(__?|`)([^\s_`][^_`]*:)\1(?=\s)/;
102
+ /** A code span, whose asterisks are its words', and the stand-in it is masked by while emphasis is taken off. */
103
+ const CODE_SPAN = /`[^`]*`/g;
104
+ const MASKED = /\0(\d+)\0/g;
105
+ /** Marks taken off a line they wrap whole, with what its words must open with; `*` may recur inside as a glob's, the others not at all. */
106
+ const STARS = [
107
+ ["***", /[^\s*/]/],
108
+ ["**", /[^\s*/]/],
109
+ ["*", /[\p{L}\p{N}\0"']/u],
110
+ ];
111
+ const WRAPS = [
112
+ ["__", /[^\s_]/],
113
+ ["_", /[^\s_]/],
114
+ ["`", /[^\s`]/],
115
+ ];
116
+ const unwrapped = (line, marks) => {
117
+ const inner = (mark) => line.slice(mark.length, -mark.length);
118
+ const wraps = ([mark, opens]) => {
119
+ const words = inner(mark);
120
+ const apart = mark === "*" ? !words.includes("* ") && !words.includes(" *") : !words.includes(mark);
121
+ return line.length > 2 * mark.length && line.startsWith(mark) && line.endsWith(mark) && opens.test(words[0]) && !/[\s(/]/.test(words.at(-1)) && apart;
122
+ };
123
+ const found = marks.find(wraps);
124
+ return found ? inner(found[0]).trim() : line;
125
+ };
126
+ /** A split line as the listing shows it: one line, without its block marks, the asterisks wrapping it or a span in it, nor an underscore or code span wrapping it. */
127
+ export function splitProse(split) {
128
+ const code = [];
129
+ const masked = oneLine(split.replaceAll("\0", ""))
130
+ .replace(BLOCK_MARKS, "")
131
+ .replace(LABEL, "$2")
132
+ .replace(CODE_SPAN, (span) => `\0${code.push(span) - 1}\0`);
133
+ const prose = unwrapped(masked, STARS).replace(BOLD, "$2").replace(ITALIC, "$1");
134
+ return unwrapped(prose.replace(MASKED, (_, i) => code[Number(i)]), WRAPS);
135
+ }
37
136
  /** A part's status by `Standing`; one whose report state is not known is still out. */
38
137
  export function partStatus({ stopped, stalled, report }) {
39
138
  if (stopped)
@@ -47,8 +146,18 @@ export function partStatus({ stopped, stalled, report }) {
47
146
  return report.status ?? "done";
48
147
  }
49
148
  /**
50
- * The parts of a refreshed record: those recorded before, in their order, each as read now when the roster still has it,
51
- * else as last recorded, then those first seen now, in the order the tree is drawn.
149
+ * A part whose session could not be read, from what was read of it and its last record: its title, duration and cost as
150
+ * last recorded; once OpenCode no longer knows it (`gone`), settled as read, else as last recorded, else `gone`.
151
+ */
152
+ export function unreadPart(read, last, gone) {
153
+ const settledLast = last && isSettled(last.status) ? last.status : "gone";
154
+ const status = !gone || isSettled(read.status) ? read.status : settledLast;
155
+ const { title, duration, cost } = last ?? read;
156
+ return { ...read, title, status, ...(duration !== undefined ? { duration } : {}), ...(cost !== undefined ? { cost } : {}) };
157
+ }
158
+ /**
159
+ * The parts of a refreshed record: those recorded before, in their order, each as `current` has it (read now, or found
160
+ * gone), else as last recorded, then those first seen now, in the order the tree is drawn.
52
161
  */
53
162
  export function mergeParts(previous, current) {
54
163
  const now = new Map(current.map((part) => [part.sessionID, part]));
@@ -74,11 +183,27 @@ export function lessonText(text) {
74
183
  return { text: line };
75
184
  return { text: line.slice(0, LESSON_MAX).trimEnd(), clipped: line.length };
76
185
  }
77
- /** The records a project keeps at `now`: the newest `MAX_RECORDS` by `movedAt`, none older than `RETENTION_MS`; the rest are dropped. */
186
+ /** One reader tree's `reads` after courier_recall listed the record or gave it in full at `at`. */
187
+ export function noteRead(reads, kind, at) {
188
+ return { shown: (reads?.shown ?? 0) + (kind === "shown" ? 1 : 0), read: (reads?.read ?? 0) + (kind === "read" ? 1 : 0), at: Math.max(reads?.at ?? 0, at) };
189
+ }
190
+ /** The reads of every reader tree as one: the counts summed, `at` the latest; none without a reader. */
191
+ export function sumReads(reads) {
192
+ if (!reads.length)
193
+ return undefined;
194
+ return reads.reduce((sum, one) => ({ shown: sum.shown + one.shown, read: sum.read + one.read, at: Math.max(sum.at, one.at) }));
195
+ }
196
+ /**
197
+ * The records a project keeps at `now`: every pinned one, whatever its age, and of the rest the first `MAX_RECORDS` by
198
+ * whether each has a lesson, then by `movedAt`, none older than `RETENTION_MS`; the rest are dropped.
199
+ */
78
200
  export function keep(records, now) {
79
- const newest = records.toSorted((a, b) => b.movedAt - a.movedAt);
80
- const kept = newest.filter((record, index) => index < MAX_RECORDS && now - record.movedAt <= RETENTION_MS);
81
- return { kept, dropped: newest.filter((record) => !kept.includes(record)) };
201
+ const pinned = records.filter((record) => record.pinned);
202
+ // Aged out first, so that a stale record takes no slot from a live one behind it in the order.
203
+ const live = records.filter((record) => !record.pinned && now - record.movedAt <= RETENTION_MS);
204
+ const ordered = live.toSorted((a, b) => Number(Boolean(b.lesson)) - Number(Boolean(a.lesson)) || b.movedAt - a.movedAt);
205
+ const kept = [...pinned, ...ordered.slice(0, MAX_RECORDS)];
206
+ return { kept, dropped: records.filter((record) => !kept.includes(record)) };
82
207
  }
83
- /** Records newest first, by when each last moved. */
84
- export const newestFirst = (records) => records.toSorted((a, b) => b.movedAt - a.movedAt);
208
+ /** Records as courier_recall and the memory view list them: the pinned first, then by when each last moved, newest first. */
209
+ export const pinnedFirst = (records) => records.toSorted((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)) || b.movedAt - a.movedAt);
@@ -1,10 +1,11 @@
1
1
  export { LESSON_MAX, MAX_RECORDS, NOT_STATED, readMemory, RETENTION_MS } from "./domain.js";
2
2
  export { memoryEvents, type MemoryEventPorts } from "./events.js";
3
3
  export { note, type Noted, type NoteInput } from "./note.js";
4
- export type { Lesson, MemoryPorts, MemoryState, PartRecord, PartStatus, Recalled, RefreshPorts, StoredRecord, TreeRecord } from "./ports.js";
4
+ export { pin, type PinOutcome } from "./pin.js";
5
+ export type { Lesson, MemoryPorts, MemoryState, PartRecord, PartStatus, Pinned, Reads, Recalled, RefreshPorts, Revision, StoredRecord, TreeRecord } from "./ports.js";
5
6
  export { recall, type Recall, type RecallInput } from "./recall.js";
6
7
  export { refresh } from "./record.js";
7
- export { registerMemoryRpc } from "./rpc-handlers.js";
8
+ export { registerMemoryRpc, type MemoryRpcPorts } from "./rpc-handlers.js";
8
9
  export { sweepRecords } from "./sweep.js";
9
10
  export { NOTE_TOOL, RECALL_TOOL } from "./text.js";
10
11
  export { addMemoryTools, type MemoryToolPorts } from "./tools.js";
@@ -3,6 +3,7 @@
3
3
  export { LESSON_MAX, MAX_RECORDS, NOT_STATED, readMemory, RETENTION_MS } from "./domain.js";
4
4
  export { memoryEvents } from "./events.js";
5
5
  export { note } from "./note.js";
6
+ export { pin } from "./pin.js";
6
7
  export { recall } from "./recall.js";
7
8
  export { refresh } from "./record.js";
8
9
  export { registerMemoryRpc } from "./rpc-handlers.js";
@@ -0,0 +1,8 @@
1
+ import type { MemoryPorts } from "./ports.js";
2
+ /** What pinning did: the record is pinned, unpinned, or there is no record to pin. */
3
+ export type PinOutcome = "pinned" | "unpinned" | "none";
4
+ /**
5
+ * Pins the record of the tree under `sessionID` in the ports' project, as the memory view's `p` does, or with `pinned`
6
+ * false unpins it; a record pinned already keeps when it was. Whatever the memory option says: a pin is the person's.
7
+ */
8
+ export declare function pin(ports: Pick<MemoryPorts, "storage" | "projectID" | "now">, sessionID: string, pinned: boolean): Promise<PinOutcome>;
@@ -0,0 +1,13 @@
1
+ import { memoryStore } from "./store.js";
2
+ /**
3
+ * Pins the record of the tree under `sessionID` in the ports' project, as the memory view's `p` does, or with `pinned`
4
+ * false unpins it; a record pinned already keeps when it was. Whatever the memory option says: a pin is the person's.
5
+ */
6
+ export async function pin(ports, sessionID, pinned) {
7
+ const store = memoryStore(ports.storage);
8
+ const record = await store.get(ports.projectID, sessionID);
9
+ if (!record)
10
+ return "none";
11
+ await store.pin(ports.projectID, sessionID, pinned ? (record.pinned ?? { at: ports.now() }) : undefined);
12
+ return pinned ? "pinned" : "unpinned";
13
+ }
@@ -2,8 +2,8 @@
2
2
  import type { Plugin } from "@opencode/plugin";
3
3
  import type { Storage } from "../platform/storage.js";
4
4
  import type { ModelWindow } from "../tree/index.js";
5
- import type { StoredRecord, TreeRecord } from "./domain.js";
6
- export type { Lesson, PartRecord, PartStatus, Recalled, StoredRecord, TreeRecord } from "./domain.js";
5
+ import type { Pinned, ReadKind, Reads, StoredRecord, TreeRecord } from "./domain.js";
6
+ export type { Lesson, PartRecord, PartStatus, Pinned, ReadKind, Reads, Recalled, Revision, StoredRecord, TreeRecord } from "./domain.js";
7
7
  type Context = Plugin.Context;
8
8
  /** What refreshing a record runs on: every hub-v2 member's watch ports have these, so the event module runs through any. */
9
9
  export interface RefreshPorts {
@@ -32,13 +32,24 @@ export interface MemoryState {
32
32
  /** The turn ends recorded, so the hub's two subscriptions refresh once. */
33
33
  readonly seen: Set<string>;
34
34
  }
35
- /** The records, by project and top session. */
35
+ /** The records, by project and top session, each read with its reads and pin, which lie beside it and are written apart. */
36
36
  export interface MemoryStore {
37
37
  get(projectID: string, sessionID: string): Promise<TreeRecord | undefined>;
38
+ /** Writes the record's facts; its `reads` and `pinned` are left out, as nothing but `noteRead` and `pin` write them. */
38
39
  put(projectID: string, sessionID: string, record: TreeRecord): Promise<void>;
40
+ /** Removes the record with its reads and pin. */
39
41
  remove(projectID: string, sessionID: string): Promise<void>;
42
+ /** Counts a listing or a full read of the record by the tree under `by` at `at`; gives that tree's reads of it. */
43
+ noteRead(projectID: string, sessionID: string, by: string, kind: ReadKind, at: number): Promise<Reads>;
44
+ /** Pins the record, or with `undefined` unpins it. */
45
+ pin(projectID: string, sessionID: string, pinned: Pinned | undefined): Promise<void>;
40
46
  /** A project's records, in key order. */
41
47
  ofProject(projectID: string): Promise<StoredRecord[]>;
42
48
  /** Every project's records. */
43
49
  all(): Promise<StoredRecord[]>;
50
+ /** The trees with reads or a pin but no record, left by a record removed while it was read or pinned. */
51
+ strays(): Promise<Array<{
52
+ projectID: string;
53
+ sessionID: string;
54
+ }>>;
44
55
  }
@@ -21,7 +21,7 @@ export type Recall = {
21
21
  readonly sessionID: string;
22
22
  };
23
23
  /**
24
- * courier_recall: notes the call on the caller's tree, then lists the project's records newest first, or gives the one
25
- * under `sessionID` in full. With the memory option off, nothing is written or read.
24
+ * courier_recall: notes the call on the caller's tree, then lists the project's records, pinned first and then newest first,
25
+ * or gives the one under `sessionID` in full, each counted on the other trees' records; with the memory option off, nothing is written or read.
26
26
  */
27
27
  export declare function recall(ports: MemoryPorts, state: Pick<MemoryState, "tops">, callerID: string, input: RecallInput): Promise<Recall>;
@@ -1,7 +1,7 @@
1
1
  import { addBounded, SEEN_MAX } from "../platform/bounded.js";
2
2
  import { finite, millis } from "../platform/json.js";
3
3
  import { lineage } from "../tree/index.js";
4
- import { newestFirst, NOT_STATED, noteRecall } from "./domain.js";
4
+ import { NOT_STATED, noteRead, noteRecall, pinnedFirst } from "./domain.js";
5
5
  import { memoryStore } from "./store.js";
6
6
  /** The top of the caller's tree: the top of its lineage, or itself. */
7
7
  async function topOfCaller(ports, callerID) {
@@ -27,8 +27,20 @@ async function note(ports, top, byTop) {
27
27
  });
28
28
  }
29
29
  /**
30
- * courier_recall: notes the call on the caller's tree, then lists the project's records newest first, or gives the one
31
- * under `sessionID` in full. With the memory option off, nothing is written or read.
30
+ * Counts, on each record of another tree, that this call listed it or gave it in full, best effort: a count lost is
31
+ * logged, never the call's failure. Gives the reads of the reader's tree on each, in order, or undefined where it failed.
32
+ */
33
+ function count(ports, top, kind, sessionIDs) {
34
+ const store = memoryStore(ports.storage);
35
+ const now = ports.now();
36
+ return Promise.all(sessionIDs.map((sessionID) => store.noteRead(ports.projectID, sessionID, top, kind, now).catch((error) => {
37
+ ports.log(`courier memory: could not count the ${kind === "shown" ? "listing" : "read"} of the record under ${sessionID}: ${String(error)}`);
38
+ return undefined;
39
+ })));
40
+ }
41
+ /**
42
+ * courier_recall: notes the call on the caller's tree, then lists the project's records, pinned first and then newest first,
43
+ * or gives the one under `sessionID` in full, each counted on the other trees' records; with the memory option off, nothing is written or read.
32
44
  */
33
45
  export async function recall(ports, state, callerID, input) {
34
46
  if (!ports.memory)
@@ -42,7 +54,14 @@ export async function recall(ports, state, callerID, input) {
42
54
  const wanted = typeof input.sessionID === "string" ? input.sessionID.trim() : "";
43
55
  if (wanted) {
44
56
  const record = await store.get(projectID, wanted);
45
- return record ? { kind: "record", projectID, record: { ...record, projectID, sessionID: wanted } } : { kind: "missing", projectID, sessionID: wanted };
57
+ if (!record)
58
+ return { kind: "missing", projectID, sessionID: wanted };
59
+ // Another tree's record is given with this read added to the reads just read; the caller's own is not counted.
60
+ const [counted] = wanted === top ? [undefined] : await count(ports, top, "read", [wanted]);
61
+ const reads = counted ? noteRead(record.reads, "read", counted.at) : record.reads;
62
+ return { kind: "record", projectID, record: { ...record, ...(reads ? { reads } : {}), projectID, sessionID: wanted } };
46
63
  }
47
- return { kind: "list", projectID, top, records: newestFirst(await store.ofProject(projectID)) };
64
+ const records = pinnedFirst(await store.ofProject(projectID));
65
+ await count(ports, top, "shown", records.flatMap((record) => (record.sessionID === top ? [] : [record.sessionID])));
66
+ return { kind: "list", projectID, top, records };
48
67
  }
@@ -2,6 +2,6 @@ import { type TreeRecord } from "./domain.js";
2
2
  import type { MemoryState, RefreshPorts } from "./ports.js";
3
3
  /**
4
4
  * Writes the record of the tree under `top` as it stands at `at`, after the turn of `ended`; a part the roster has forgotten
5
- * keeps its last recorded state. Nothing for a top unknown to OpenCode or in a location with memory off; returns the record.
5
+ * keeps its last recorded state, unless found gone before it settled. Nothing for a top unknown to OpenCode or with memory off.
6
6
  */
7
7
  export declare function refresh(ports: RefreshPorts, state: Pick<MemoryState, "windows" | "off">, top: string, at: number, ended?: string): Promise<TreeRecord | undefined>;