opencode-courier 0.7.3 → 0.8.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.
Files changed (52) hide show
  1. package/README.md +109 -135
  2. package/dist/index.js +23 -4
  3. package/dist/memory/domain.d.ts +119 -0
  4. package/dist/memory/domain.js +84 -0
  5. package/dist/memory/events.d.ts +26 -0
  6. package/dist/memory/events.js +46 -0
  7. package/dist/memory/forget.d.ts +10 -0
  8. package/dist/memory/forget.js +20 -0
  9. package/dist/memory/index.d.ts +10 -0
  10. package/dist/memory/index.js +11 -0
  11. package/dist/memory/note.d.ts +37 -0
  12. package/dist/memory/note.js +29 -0
  13. package/dist/memory/ports.d.ts +44 -0
  14. package/dist/memory/ports.js +1 -0
  15. package/dist/memory/recall.d.ts +27 -0
  16. package/dist/memory/recall.js +48 -0
  17. package/dist/memory/record.d.ts +7 -0
  18. package/dist/memory/record.js +92 -0
  19. package/dist/memory/rpc-handlers.d.ts +134 -0
  20. package/dist/memory/rpc-handlers.js +23 -0
  21. package/dist/memory/rpc.d.ts +227 -0
  22. package/dist/memory/rpc.js +60 -0
  23. package/dist/memory/store.d.ts +10 -0
  24. package/dist/memory/store.js +90 -0
  25. package/dist/memory/sweep.d.ts +4 -0
  26. package/dist/memory/sweep.js +12 -0
  27. package/dist/memory/text.d.ts +27 -0
  28. package/dist/memory/text.js +95 -0
  29. package/dist/memory/tools.d.ts +16 -0
  30. package/dist/memory/tools.js +78 -0
  31. package/dist/memory/tui.d.ts +73 -0
  32. package/dist/person/rpc.d.ts +4 -4
  33. package/dist/platform/json.d.ts +1 -0
  34. package/dist/platform/json.js +1 -0
  35. package/dist/rpc.d.ts +1 -0
  36. package/dist/rpc.js +1 -0
  37. package/dist/skill.js +10 -3
  38. package/dist/tree/context.d.ts +2 -2
  39. package/dist/tree/context.js +4 -3
  40. package/dist/tree/index.d.ts +6 -5
  41. package/dist/tree/index.js +5 -4
  42. package/dist/tree/ports.d.ts +4 -2
  43. package/dist/tree/roster.d.ts +17 -0
  44. package/dist/tree/roster.js +24 -0
  45. package/dist/tree/rpc-handlers.d.ts +18 -18
  46. package/dist/tree/rpc.d.ts +18 -18
  47. package/dist/tree/status.d.ts +5 -5
  48. package/dist/tree/subtree.d.ts +2 -2
  49. package/dist/tree/text.js +2 -1
  50. package/dist/tree/tui.d.ts +11 -3
  51. package/dist/tui.js +544 -121
  52. package/package.json +1 -1
@@ -0,0 +1,84 @@
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`.
4
+ */
5
+ import { STATUSES } from "../kernel/protocol.js";
6
+ import { obj } from "../platform/json.js";
7
+ /** How many records a project keeps, the newest by `movedAt`. */
8
+ export const MAX_RECORDS = 30;
9
+ /** Records that last moved longer ago than this are dropped when the plugin loads. */
10
+ export const RETENTION_MS = 90 * 24 * 60 * 60_000;
11
+ /** A record's `split` while its top has stated no split line. */
12
+ export const NOT_STATED = "not stated";
13
+ /** The most characters a lesson holds: courier_note refuses what is longer, and a record written by hand is clipped when read. */
14
+ export const LESSON_MAX = 300;
15
+ /**
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.
18
+ */
19
+ export const PART_STATUSES = [...STATUSES, "interrupted", "stopped", "stalled", "out"];
20
+ export const isPartStatus = (value) => typeof value === "string" && PART_STATUSES.includes(value);
21
+ /** The `memory` option: whether records are kept and listed; on unless `false`. */
22
+ export function readMemory(options) {
23
+ const value = obj(options).memory;
24
+ if (value === undefined || typeof value === "boolean")
25
+ return { memory: value !== false, problems: [] };
26
+ return { memory: true, problems: [`memory must be true or false, not ${JSON.stringify(value)}; using true`] };
27
+ }
28
+ /** A line beginning with `split:` after any markdown (whitespace, emphasis, heading, quote and list marks). */
29
+ const STARTS_SPLIT = /^[\s*_#>`-]*split:/i;
30
+ /** A line holding `split:` as a word. */
31
+ const HOLDS_SPLIT = /\bsplit:/i;
32
+ /** 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. */
33
+ export function splitLineOf(text) {
34
+ const lines = text.split("\n");
35
+ return (lines.find((line) => STARTS_SPLIT.test(line)) ?? lines.find((line) => HOLDS_SPLIT.test(line)))?.trim();
36
+ }
37
+ /** A part's status by `Standing`; one whose report state is not known is still out. */
38
+ export function partStatus({ stopped, stalled, report }) {
39
+ if (stopped)
40
+ return "stopped";
41
+ if (stalled)
42
+ return "stalled";
43
+ if (!report?.known || report.owes)
44
+ return "out";
45
+ if (report.ended)
46
+ return report.ended;
47
+ return report.status ?? "done";
48
+ }
49
+ /**
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.
52
+ */
53
+ export function mergeParts(previous, current) {
54
+ const now = new Map(current.map((part) => [part.sessionID, part]));
55
+ const kept = previous.map((part) => now.get(part.sessionID) ?? part);
56
+ const known = new Set(previous.map((part) => part.sessionID));
57
+ return [...kept, ...current.filter((part) => !known.has(part.sessionID))];
58
+ }
59
+ /** `recalled` after one more call at `at`, the top's first call noted when `byTop`. */
60
+ export function noteRecall(recalled, byTop, at) {
61
+ const calls = (recalled?.calls ?? 0) + 1;
62
+ const first = recalled?.at ?? (byTop ? at : undefined);
63
+ return first === undefined ? { calls } : { at: first, calls };
64
+ }
65
+ /** A text as the one line a lesson is: whitespace and line breaks collapsed to spaces, trimmed. */
66
+ export const oneLine = (text) => text.replace(/\s+/g, " ").trim();
67
+ /**
68
+ * A hand-written lesson's text as one line, clipped at `LESSON_MAX`; `clipped` is the length it had when it was clipped.
69
+ * courier_note does not clip: it refuses a line over `LESSON_MAX` (`note.ts`).
70
+ */
71
+ export function lessonText(text) {
72
+ const line = oneLine(text);
73
+ if (line.length <= LESSON_MAX)
74
+ return { text: line };
75
+ return { text: line.slice(0, LESSON_MAX).trimEnd(), clipped: line.length };
76
+ }
77
+ /** The records a project keeps at `now`: the newest `MAX_RECORDS` by `movedAt`, none older than `RETENTION_MS`; the rest are dropped. */
78
+ 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)) };
82
+ }
83
+ /** Records newest first, by when each last moved. */
84
+ export const newestFirst = (records) => records.toSorted((a, b) => b.movedAt - a.movedAt);
@@ -0,0 +1,26 @@
1
+ import type { EventModule, EventPorts } from "../platform/events.js";
2
+ import type { MemoryState, RefreshPorts } from "./ports.js";
3
+ /** OpenCode's `session.execution.*` events, as the memory reads them. */
4
+ interface Execution {
5
+ readonly id: string;
6
+ readonly created?: number;
7
+ readonly data: {
8
+ readonly sessionID: string;
9
+ readonly reason?: string;
10
+ };
11
+ }
12
+ /** The ports the event module runs on: the watcher's, which every hub-v2 member joins with. */
13
+ export type MemoryEventPorts = RefreshPorts & EventPorts;
14
+ /**
15
+ * The top of the tree a session is in: by the reverse index for a spawned one, else the session itself when it called
16
+ * courier_recall or has started sessions; undefined for a session in no tree.
17
+ */
18
+ export declare function topOf(ports: Pick<RefreshPorts, "storage">, state: Pick<MemoryState, "tops">, sessionID: string): Promise<string | undefined>;
19
+ /** A turn ended in a tree: its record is written again, once however many subscriptions see the event. Returns the top refreshed. */
20
+ export declare function noteTurnEnd(ports: RefreshPorts, state: MemoryState, event: Execution): Promise<string | undefined>;
21
+ /**
22
+ * Keeps each tree's record current: after the tree's and the person's notices of a turn's end have gone out, and the
23
+ * report state it settled is written, the record of the tree it belongs to is rebuilt. A failure is logged, never thrown.
24
+ */
25
+ export declare function memoryEvents(state: MemoryState): EventModule<MemoryEventPorts>;
26
+ export {};
@@ -0,0 +1,46 @@
1
+ import { claim } from "../platform/bounded.js";
2
+ import { childrenOf, indexedLineage } from "../tree/index.js";
3
+ import { refresh } from "./record.js";
4
+ const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
5
+ /**
6
+ * The top of the tree a session is in: by the reverse index for a spawned one, else the session itself when it called
7
+ * courier_recall or has started sessions; undefined for a session in no tree.
8
+ */
9
+ export async function topOf(ports, state, sessionID) {
10
+ const chain = await indexedLineage(ports.storage, sessionID);
11
+ if (chain.length)
12
+ return chain.at(-1).parentID;
13
+ if (state.tops.has(sessionID))
14
+ return sessionID;
15
+ return (await childrenOf(ports.storage, sessionID)).length ? sessionID : undefined;
16
+ }
17
+ /** A turn ended in a tree: its record is written again, once however many subscriptions see the event. Returns the top refreshed. */
18
+ export async function noteTurnEnd(ports, state, event) {
19
+ // A shutdown's interruption is resumed on the next start: nothing ended.
20
+ if (event.data.reason === "shutdown")
21
+ return undefined;
22
+ const top = await topOf(ports, state, event.data.sessionID);
23
+ // Claimed after the lookup, so other sessions' turns do not crowd `seen`; released on a failure, as a write is
24
+ // the same twice, so the hub's other subscription may still make it.
25
+ if (top === undefined || !claim(state.seen, event.id))
26
+ return undefined;
27
+ try {
28
+ await refresh(ports, state, top, event.created ?? ports.now(), event.data.sessionID);
29
+ }
30
+ catch (error) {
31
+ state.seen.delete(event.id);
32
+ throw error;
33
+ }
34
+ return top;
35
+ }
36
+ /**
37
+ * Keeps each tree's record current: after the tree's and the person's notices of a turn's end have gone out, and the
38
+ * report state it settled is written, the record of the tree it belongs to is rebuilt. A failure is logged, never thrown.
39
+ */
40
+ export function memoryEvents(state) {
41
+ return {
42
+ after: (ports, event) => TURN_ENDS.has(event.type)
43
+ ? noteTurnEnd(ports, state, event).then(() => undefined, (error) => ports.log(`courier memory: could not record the tree of ${event.data?.sessionID}: ${String(error)}`))
44
+ : undefined,
45
+ };
46
+ }
@@ -0,0 +1,10 @@
1
+ import type { MemoryPorts } from "./ports.js";
2
+ /** What forgetting did: the record is gone, only its lesson is, or there was nothing to forget. */
3
+ export type Forgotten = "record" | "lesson" | "none";
4
+ /**
5
+ * Forgets the record of the tree under `sessionID` in the ports' project, as the memory view's `x` does, or with
6
+ * `lesson` only its lesson, writing the record back without it. Whatever the memory option says: it only removes.
7
+ */
8
+ export declare function forget(ports: Pick<MemoryPorts, "storage" | "projectID">, sessionID: string, { lesson }?: {
9
+ lesson?: boolean | undefined;
10
+ }): Promise<Forgotten>;
@@ -0,0 +1,20 @@
1
+ import { memoryStore } from "./store.js";
2
+ /**
3
+ * Forgets the record of the tree under `sessionID` in the ports' project, as the memory view's `x` does, or with
4
+ * `lesson` only its lesson, writing the record back without it. Whatever the memory option says: it only removes.
5
+ */
6
+ export async function forget(ports, sessionID, { lesson = false } = {}) {
7
+ const store = memoryStore(ports.storage);
8
+ const record = await store.get(ports.projectID, sessionID);
9
+ if (!record)
10
+ return "none";
11
+ if (!lesson) {
12
+ await store.remove(ports.projectID, sessionID);
13
+ return "record";
14
+ }
15
+ if (!record.lesson)
16
+ return "none";
17
+ const { lesson: _dropped, ...kept } = record;
18
+ await store.put(ports.projectID, sessionID, kept);
19
+ return "lesson";
20
+ }
@@ -0,0 +1,10 @@
1
+ export { LESSON_MAX, MAX_RECORDS, NOT_STATED, readMemory, RETENTION_MS } from "./domain.js";
2
+ export { memoryEvents, type MemoryEventPorts } from "./events.js";
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";
5
+ export { recall, type Recall, type RecallInput } from "./recall.js";
6
+ export { refresh } from "./record.js";
7
+ export { registerMemoryRpc } from "./rpc-handlers.js";
8
+ export { sweepRecords } from "./sweep.js";
9
+ export { NOTE_TOOL, RECALL_TOOL } from "./text.js";
10
+ export { addMemoryTools, type MemoryToolPorts } from "./tools.js";
@@ -0,0 +1,11 @@
1
+ // What the rest of the plugin uses of the memory: its tools, event module, RPC, option and sweep for `src/index.ts`,
2
+ // and the record's types.
3
+ export { LESSON_MAX, MAX_RECORDS, NOT_STATED, readMemory, RETENTION_MS } from "./domain.js";
4
+ export { memoryEvents } from "./events.js";
5
+ export { note } from "./note.js";
6
+ export { recall } from "./recall.js";
7
+ export { refresh } from "./record.js";
8
+ export { registerMemoryRpc } from "./rpc-handlers.js";
9
+ export { sweepRecords } from "./sweep.js";
10
+ export { NOTE_TOOL, RECALL_TOOL } from "./text.js";
11
+ export { addMemoryTools } from "./tools.js";
@@ -0,0 +1,37 @@
1
+ import { type Lesson } from "./domain.js";
2
+ import type { MemoryPorts } from "./ports.js";
3
+ export interface NoteInput {
4
+ readonly text: string;
5
+ }
6
+ /**
7
+ * Why courier_note wrote nothing: the option is off, the caller is a leaf, its text is empty or longer than `LESSON_MAX`
8
+ * (`length` is what it had as one line), or its tree has no record to write on.
9
+ */
10
+ export type Refusal = {
11
+ readonly kind: "off";
12
+ } | {
13
+ readonly kind: "leaf";
14
+ readonly depth: number;
15
+ readonly maxDepth: number;
16
+ } | {
17
+ readonly kind: "empty";
18
+ } | {
19
+ readonly kind: "long";
20
+ readonly length: number;
21
+ } | {
22
+ readonly kind: "unrecorded";
23
+ readonly top: string;
24
+ };
25
+ /** What courier_note gives: the lesson written on the caller's tree's record, with the one it replaced, or why it was not. */
26
+ export type Noted = {
27
+ readonly kind: "noted";
28
+ readonly projectID: string;
29
+ readonly top: string;
30
+ readonly lesson: Lesson;
31
+ readonly replaced?: Lesson;
32
+ } | Refusal;
33
+ /**
34
+ * courier_note: writes `text`, as one line of at most `LESSON_MAX` characters (longer is refused, never clipped), as the lesson
35
+ * on the record of the caller's own tree in this project, replacing the one there; the top is the top of the caller's lineage, or itself.
36
+ */
37
+ export declare function note(ports: MemoryPorts, callerID: string, input: NoteInput): Promise<Noted>;
@@ -0,0 +1,29 @@
1
+ import { lineage } from "../tree/index.js";
2
+ import { LESSON_MAX, oneLine } from "./domain.js";
3
+ import { memoryStore } from "./store.js";
4
+ /**
5
+ * courier_note: writes `text`, as one line of at most `LESSON_MAX` characters (longer is refused, never clipped), as the lesson
6
+ * on the record of the caller's own tree in this project, replacing the one there; the top is the top of the caller's lineage, or itself.
7
+ */
8
+ export async function note(ports, callerID, input) {
9
+ if (!ports.memory)
10
+ return { kind: "off" };
11
+ const chain = await lineage(ports.storage, callerID);
12
+ const depth = chain.length;
13
+ if (depth >= ports.maxDepth)
14
+ return { kind: "leaf", depth, maxDepth: ports.maxDepth };
15
+ const text = oneLine(typeof input.text === "string" ? input.text : "");
16
+ if (!text)
17
+ return { kind: "empty" };
18
+ if (text.length > LESSON_MAX)
19
+ return { kind: "long", length: text.length };
20
+ const top = chain.at(-1)?.parentID ?? callerID;
21
+ const store = memoryStore(ports.storage);
22
+ const { projectID } = ports;
23
+ const record = await store.get(projectID, top);
24
+ if (!record)
25
+ return { kind: "unrecorded", top };
26
+ const lesson = { text, at: ports.now(), ...(depth ? { depth } : {}) };
27
+ await store.put(projectID, top, { ...record, lesson });
28
+ return { kind: "noted", projectID, top, lesson, ...(record.lesson ? { replaced: record.lesson } : {}) };
29
+ }
@@ -0,0 +1,44 @@
1
+ /** The memory's ports: what refreshing a record and courier_recall need of the plugin context, and what the process shares. */
2
+ import type { Plugin } from "@opencode/plugin";
3
+ import type { Storage } from "../platform/storage.js";
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";
7
+ type Context = Plugin.Context;
8
+ /** What refreshing a record runs on: every hub-v2 member's watch ports have these, so the event module runs through any. */
9
+ export interface RefreshPorts {
10
+ readonly storage: Storage;
11
+ readonly session: Pick<Context["session"], "get" | "context">;
12
+ readonly now: () => number;
13
+ readonly log: (message: string) => void;
14
+ }
15
+ /** What courier_recall and courier_note run on: one instance's ports. */
16
+ export interface MemoryPorts extends RefreshPorts {
17
+ /** The project of the plugin's location, whose records the tools read and write. */
18
+ readonly projectID: string;
19
+ /** The `memory` option: off, and the tools write and list nothing. */
20
+ readonly memory: boolean;
21
+ /** The tree's `maxDepth` limit: a session at it is a leaf, which courier_note refuses. */
22
+ readonly maxDepth: number;
23
+ }
24
+ /** What every plugin instance in the process shares of the memory, under a fixed key outside the hub. */
25
+ export interface MemoryState {
26
+ /** The context windows of the models known, by `<providerID>/<modelID>`, filled as each instance loads. */
27
+ readonly windows: Map<string, ModelWindow>;
28
+ /** The directories of the locations whose instance has memory off: no record is written for a tree whose top runs there. */
29
+ readonly off: Set<string>;
30
+ /** The top sessions that called courier_recall, whose turn ends refresh their record before they have started a session. */
31
+ readonly tops: Set<string>;
32
+ /** The turn ends recorded, so the hub's two subscriptions refresh once. */
33
+ readonly seen: Set<string>;
34
+ }
35
+ /** The records, by project and top session. */
36
+ export interface MemoryStore {
37
+ get(projectID: string, sessionID: string): Promise<TreeRecord | undefined>;
38
+ put(projectID: string, sessionID: string, record: TreeRecord): Promise<void>;
39
+ remove(projectID: string, sessionID: string): Promise<void>;
40
+ /** A project's records, in key order. */
41
+ ofProject(projectID: string): Promise<StoredRecord[]>;
42
+ /** Every project's records. */
43
+ all(): Promise<StoredRecord[]>;
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { type StoredRecord } from "./domain.js";
2
+ import type { MemoryPorts, MemoryState } from "./ports.js";
3
+ export interface RecallInput {
4
+ readonly sessionID?: string;
5
+ }
6
+ /** What courier_recall gives: the option is off, the project's records with the caller's tree named, one record in full, or none under that session. */
7
+ export type Recall = {
8
+ readonly kind: "off";
9
+ } | {
10
+ readonly kind: "list";
11
+ readonly projectID: string;
12
+ readonly top: string;
13
+ readonly records: ReadonlyArray<StoredRecord>;
14
+ } | {
15
+ readonly kind: "record";
16
+ readonly projectID: string;
17
+ readonly record: StoredRecord;
18
+ } | {
19
+ readonly kind: "missing";
20
+ readonly projectID: string;
21
+ readonly sessionID: string;
22
+ };
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.
26
+ */
27
+ export declare function recall(ports: MemoryPorts, state: Pick<MemoryState, "tops">, callerID: string, input: RecallInput): Promise<Recall>;
@@ -0,0 +1,48 @@
1
+ import { addBounded, SEEN_MAX } from "../platform/bounded.js";
2
+ import { finite, millis } from "../platform/json.js";
3
+ import { lineage } from "../tree/index.js";
4
+ import { newestFirst, NOT_STATED, noteRecall } from "./domain.js";
5
+ import { memoryStore } from "./store.js";
6
+ /** The top of the caller's tree: the top of its lineage, or itself. */
7
+ async function topOfCaller(ports, callerID) {
8
+ return (await lineage(ports.storage, callerID)).at(-1)?.parentID ?? callerID;
9
+ }
10
+ /** Notes the call on the caller's tree's record, which is written now if the tree has none yet, so the call is remembered before any session is started. */
11
+ async function note(ports, top, byTop) {
12
+ const store = memoryStore(ports.storage);
13
+ const now = ports.now();
14
+ const previous = await store.get(ports.projectID, top);
15
+ const recalled = noteRecall(previous?.recalled, byTop, now);
16
+ if (previous)
17
+ return store.put(ports.projectID, top, { ...previous, recalled });
18
+ const info = await ports.session.get({ sessionID: top }).catch(() => undefined);
19
+ await store.put(ports.projectID, top, {
20
+ startedAt: finite(millis(info?.time.created)) ?? now,
21
+ movedAt: now,
22
+ title: info?.title ?? "",
23
+ split: NOT_STATED,
24
+ finished: false,
25
+ recalled,
26
+ parts: [],
27
+ });
28
+ }
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.
32
+ */
33
+ export async function recall(ports, state, callerID, input) {
34
+ if (!ports.memory)
35
+ return { kind: "off" };
36
+ const top = await topOfCaller(ports, callerID);
37
+ await note(ports, top, top === callerID);
38
+ addBounded(state.tops, top, SEEN_MAX);
39
+ const store = memoryStore(ports.storage);
40
+ const { projectID } = ports;
41
+ // Some models send null, or an empty string, for an optional field they leave out.
42
+ const wanted = typeof input.sessionID === "string" ? input.sessionID.trim() : "";
43
+ if (wanted) {
44
+ const record = await store.get(projectID, wanted);
45
+ return record ? { kind: "record", projectID, record: { ...record, projectID, sessionID: wanted } } : { kind: "missing", projectID, sessionID: wanted };
46
+ }
47
+ return { kind: "list", projectID, top, records: newestFirst(await store.ofProject(projectID)) };
48
+ }
@@ -0,0 +1,7 @@
1
+ import { type TreeRecord } from "./domain.js";
2
+ import type { MemoryState, RefreshPorts } from "./ports.js";
3
+ /**
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.
6
+ */
7
+ export declare function refresh(ports: RefreshPorts, state: Pick<MemoryState, "windows" | "off">, top: string, at: number, ended?: string): Promise<TreeRecord | undefined>;
@@ -0,0 +1,92 @@
1
+ import { finite, isNotFound, millis } from "../platform/json.js";
2
+ import { busy, contextOf, reportState, stalledAmong, subtree } from "../tree/index.js";
3
+ import { mergeParts, NOT_STATED, partStatus, splitLineOf } from "./domain.js";
4
+ import { memoryStore } from "./store.js";
5
+ /** The text of a session's replies, in order, for its split line. */
6
+ const repliesOf = (messages) => messages
7
+ .flatMap((message) => (message.type === "assistant" ? message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])) : []))
8
+ .join("\n");
9
+ /** How many courier_spawn calls of a session failed, refused by a limit mostly. */
10
+ const refusedIn = (messages) => messages.reduce((n, message) => (message.type === "assistant" ? n + message.content.filter((part) => part.type === "tool" && part.name === "courier_spawn" && part.state.status === "error").length : n), 0);
11
+ /** A part's context use and refused spawns, as its messages show them. */
12
+ function useOf(messages, windows) {
13
+ const context = contextOf(messages, windows);
14
+ const refused = refusedIn(messages);
15
+ return { ...(context !== undefined ? { context: Math.round(context * 100) / 100 } : {}), ...(refused ? { refused } : {}) };
16
+ }
17
+ /** A part's use as last recorded. */
18
+ const keptUse = (last) => ({
19
+ ...(last?.context !== undefined ? { context: last.context } : {}),
20
+ ...(last?.refused ? { refused: last.refused } : {}),
21
+ });
22
+ /**
23
+ * One session of the tree as its record keeps it, without its use when its messages were not read (only when its turn
24
+ * `ended` or it is `known` to no record yet), and whether it runs now.
25
+ */
26
+ async function part(ports, windows, { entry, depth }, stalled, at, known, ended) {
27
+ const { sessionID } = entry;
28
+ const [info, use, report] = await Promise.all([
29
+ ports.session.get({ sessionID }).catch(() => undefined),
30
+ ended || !known ? ports.session.context({ sessionID }).then((messages) => useOf(messages, windows), () => undefined) : undefined,
31
+ reportState(ports.storage, sessionID).catch(() => undefined),
32
+ ]);
33
+ const running = info !== undefined && busy(info.time);
34
+ const created = finite(millis(info?.time.created)) ?? entry.createdAt;
35
+ const end = info && !running && info.time.idle !== undefined ? millis(info.time.idle) : at;
36
+ const cost = finite(info?.cost);
37
+ const record = {
38
+ sessionID,
39
+ title: info?.title ?? entry.title,
40
+ depth,
41
+ isolated: entry.isolated,
42
+ ...(entry.group ? { group: entry.group } : {}),
43
+ status: partStatus({ stopped: report?.stopped ?? false, stalled, report }),
44
+ duration: Math.max(0, end - created),
45
+ ...(cost !== undefined ? { cost } : {}),
46
+ };
47
+ return { record, use, running };
48
+ }
49
+ /**
50
+ * Writes the record of the tree under `top` as it stands at `at`, after the turn of `ended`; a part the roster has forgotten
51
+ * keeps its last recorded state. Nothing for a top unknown to OpenCode or in a location with memory off; returns the record.
52
+ */
53
+ export async function refresh(ports, state, top, at, ended) {
54
+ let info;
55
+ try {
56
+ info = await ports.session.get({ sessionID: top });
57
+ }
58
+ catch (error) {
59
+ if (isNotFound(error))
60
+ return undefined;
61
+ throw error;
62
+ }
63
+ if (state.off.has(info.location.directory))
64
+ return undefined;
65
+ const store = memoryStore(ports.storage);
66
+ const [previous, { nodes }] = await Promise.all([store.get(info.projectID, top), subtree(ports.storage, top)]);
67
+ // The split line is in the top's own replies, so only its own turn end can change one stated already.
68
+ const replies = ended === top || (previous?.split ?? NOT_STATED) === NOT_STATED ? ports.session.context({ sessionID: top }).then(repliesOf, () => "") : "";
69
+ const stalled = await stalledAmong(ports, nodes.map((node) => node.entry.sessionID));
70
+ const windows = [...state.windows.values()];
71
+ const known = new Set(previous?.parts.map((one) => one.sessionID));
72
+ const read = await Promise.all(nodes.map((node) => part(ports, windows, node, stalled.has(node.entry.sessionID), at, known.has(node.entry.sessionID), node.entry.sessionID === ended)));
73
+ const split = splitLineOf(await replies);
74
+ // Read again just before the write: a courier_recall or courier_note call, or another turn end's refresh, wrote meanwhile.
75
+ const latest = (await store.get(info.projectID, top).catch(() => previous)) ?? previous;
76
+ const last = new Map(latest?.parts.map((one) => [one.sessionID, one]));
77
+ const parts = mergeParts(latest?.parts ?? [], read.map(({ record, use }) => ({ ...record, ...(use ?? keptUse(last.get(record.sessionID))) })));
78
+ const recalled = latest?.recalled ?? previous?.recalled;
79
+ const lesson = latest?.lesson ?? previous?.lesson;
80
+ const record = {
81
+ startedAt: finite(millis(info.time.created)) ?? previous?.startedAt ?? at,
82
+ movedAt: at,
83
+ title: info.title ?? previous?.title ?? "",
84
+ split: split ?? latest?.split ?? NOT_STATED,
85
+ finished: !busy(info.time) && !read.some(({ running }) => running) && parts.every((one) => one.status !== "out" && one.status !== "stalled"),
86
+ ...(recalled ? { recalled } : {}),
87
+ ...(lesson ? { lesson } : {}),
88
+ parts,
89
+ };
90
+ await store.put(info.projectID, top, record);
91
+ return record;
92
+ }
@@ -0,0 +1,134 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import type { MemoryPorts } from "./ports.js";
3
+ import { type Records } from "./rpc.js";
4
+ /**
5
+ * The records of the ports' project, newest first, as the memory view lists them; nothing, and `memory` false, with the
6
+ * option off. Read from the store alone: unlike courier_recall, a look from the terminal is noted on no record.
7
+ */
8
+ export declare function records(ports: Pick<MemoryPorts, "storage" | "projectID" | "memory">): Promise<Records>;
9
+ /** Serves the memory's RPC at the plugin's location, after the tree's and the person's; disposed with the plugin. */
10
+ export declare function registerMemoryRpc(rpc: Pick<Plugin.Context["rpc"], "register">, ports: Pick<MemoryPorts, "storage" | "projectID" | "memory">): Promise<import("@opencode/plugin/promise/rpc").RpcRegistration<{
11
+ readonly id: "courier-memory";
12
+ readonly methods: {
13
+ readonly records: {
14
+ readonly input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
15
+ readonly output: import("effect/StandardSchema").StandardSchemaV1<{
16
+ readonly memory: boolean;
17
+ readonly projectID: string;
18
+ readonly records: readonly {
19
+ readonly sessionID: string;
20
+ readonly split: string;
21
+ readonly title: string;
22
+ readonly movedAt: number;
23
+ readonly startedAt: number;
24
+ readonly finished: boolean;
25
+ readonly parts: readonly {
26
+ readonly sessionID: string;
27
+ readonly status: "done" | "partial" | "blocked" | "failed" | "interrupted" | "out" | "stopped" | "stalled";
28
+ readonly title: string;
29
+ readonly isolated: boolean;
30
+ readonly depth: number;
31
+ readonly context?: number | undefined;
32
+ readonly group?: string | undefined;
33
+ readonly cost?: number | undefined;
34
+ readonly refused?: number | undefined;
35
+ readonly duration?: number | undefined;
36
+ }[];
37
+ readonly recalled?: {
38
+ readonly calls: number;
39
+ readonly at?: number | undefined;
40
+ } | undefined;
41
+ readonly lesson?: {
42
+ readonly at: number;
43
+ readonly text: string;
44
+ readonly depth?: number | undefined;
45
+ } | undefined;
46
+ }[];
47
+ }, {
48
+ readonly memory: boolean;
49
+ readonly projectID: string;
50
+ readonly records: readonly {
51
+ readonly sessionID: string;
52
+ readonly split: string;
53
+ readonly title: string;
54
+ readonly movedAt: number;
55
+ readonly startedAt: number;
56
+ readonly finished: boolean;
57
+ readonly parts: readonly {
58
+ readonly sessionID: string;
59
+ readonly status: "done" | "partial" | "blocked" | "failed" | "interrupted" | "out" | "stopped" | "stalled";
60
+ readonly title: string;
61
+ readonly isolated: boolean;
62
+ readonly depth: number;
63
+ readonly context?: number | undefined;
64
+ readonly group?: string | undefined;
65
+ readonly cost?: number | undefined;
66
+ readonly refused?: number | undefined;
67
+ readonly duration?: number | undefined;
68
+ }[];
69
+ readonly recalled?: {
70
+ readonly calls: number;
71
+ readonly at?: number | undefined;
72
+ } | undefined;
73
+ readonly lesson?: {
74
+ readonly at: number;
75
+ readonly text: string;
76
+ readonly depth?: number | undefined;
77
+ } | undefined;
78
+ }[];
79
+ }> & import("effect/Schema").Struct<{
80
+ readonly memory: import("effect/Schema").Boolean;
81
+ readonly projectID: import("effect/Schema").String;
82
+ readonly records: import("effect/Schema").$Array<import("effect/Schema").Struct<{
83
+ readonly sessionID: import("effect/Schema").String;
84
+ readonly startedAt: import("effect/Schema").Number;
85
+ readonly movedAt: import("effect/Schema").Number;
86
+ readonly title: import("effect/Schema").String;
87
+ readonly split: import("effect/Schema").String;
88
+ readonly finished: import("effect/Schema").Boolean;
89
+ readonly recalled: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
90
+ readonly at: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
91
+ readonly calls: import("effect/Schema").Int;
92
+ }>>;
93
+ readonly lesson: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
94
+ readonly text: import("effect/Schema").String;
95
+ readonly at: import("effect/Schema").Number;
96
+ readonly depth: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
97
+ }>>;
98
+ readonly parts: import("effect/Schema").$Array<import("effect/Schema").Struct<{
99
+ readonly sessionID: import("effect/Schema").String;
100
+ readonly title: import("effect/Schema").String;
101
+ readonly depth: import("effect/Schema").Int;
102
+ readonly isolated: import("effect/Schema").Boolean;
103
+ readonly group: import("effect/Schema").optionalKey<import("effect/Schema").String>;
104
+ readonly status: import("effect/Schema").Literals<readonly ["done", "partial", "blocked", "failed", "interrupted", "stopped", "stalled", "out"]>;
105
+ readonly duration: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
106
+ readonly cost: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
107
+ readonly context: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
108
+ readonly refused: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
109
+ }>>;
110
+ }>>;
111
+ }>;
112
+ };
113
+ readonly forget: {
114
+ readonly input: import("effect/StandardSchema").StandardSchemaV1<{
115
+ readonly sessionID: string;
116
+ readonly lesson?: boolean | undefined;
117
+ }, {
118
+ readonly sessionID: string;
119
+ readonly lesson?: boolean | undefined;
120
+ }> & import("effect/Schema").Struct<{
121
+ readonly sessionID: import("effect/Schema").String;
122
+ readonly lesson: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
123
+ }>;
124
+ readonly output: import("effect/StandardSchema").StandardSchemaV1<{
125
+ readonly outcome: "record" | "lesson" | "none";
126
+ }, {
127
+ readonly outcome: "record" | "lesson" | "none";
128
+ }> & import("effect/Schema").Struct<{
129
+ readonly outcome: import("effect/Schema").Literals<readonly ["record", "lesson", "none"]>;
130
+ }>;
131
+ };
132
+ };
133
+ readonly events: {};
134
+ }>>;