opencode-courier 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -136
- package/dist/index.js +23 -4
- package/dist/kernel/protocol.d.ts +2 -0
- package/dist/kernel/protocol.js +9 -1
- package/dist/memory/domain.d.ts +188 -0
- package/dist/memory/domain.js +209 -0
- package/dist/memory/events.d.ts +26 -0
- package/dist/memory/events.js +46 -0
- package/dist/memory/forget.d.ts +10 -0
- package/dist/memory/forget.js +20 -0
- package/dist/memory/index.d.ts +11 -0
- package/dist/memory/index.js +12 -0
- package/dist/memory/note.d.ts +37 -0
- package/dist/memory/note.js +29 -0
- package/dist/memory/pin.d.ts +8 -0
- package/dist/memory/pin.js +13 -0
- package/dist/memory/ports.d.ts +55 -0
- package/dist/memory/ports.js +1 -0
- package/dist/memory/recall.d.ts +27 -0
- package/dist/memory/recall.js +67 -0
- package/dist/memory/record.d.ts +7 -0
- package/dist/memory/record.js +124 -0
- package/dist/memory/rpc-handlers.d.ts +179 -0
- package/dist/memory/rpc-handlers.js +25 -0
- package/dist/memory/rpc.d.ts +299 -0
- package/dist/memory/rpc.js +71 -0
- package/dist/memory/store.d.ts +14 -0
- package/dist/memory/store.js +174 -0
- package/dist/memory/sweep.d.ts +7 -0
- package/dist/memory/sweep.js +16 -0
- package/dist/memory/text.d.ts +35 -0
- package/dist/memory/text.js +116 -0
- package/dist/memory/tools.d.ts +16 -0
- package/dist/memory/tools.js +78 -0
- package/dist/memory/tui.d.ts +82 -0
- package/dist/person/rpc.d.ts +4 -4
- package/dist/platform/json.d.ts +1 -0
- package/dist/platform/json.js +1 -0
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.js +1 -0
- package/dist/skill.js +10 -3
- package/dist/tree/context.d.ts +2 -2
- package/dist/tree/context.js +4 -3
- package/dist/tree/index.d.ts +6 -5
- package/dist/tree/index.js +5 -4
- package/dist/tree/mascot.js +1 -1
- package/dist/tree/ports.d.ts +4 -2
- package/dist/tree/roster.d.ts +17 -0
- package/dist/tree/roster.js +24 -0
- package/dist/tree/rpc-handlers.d.ts +20 -20
- package/dist/tree/rpc.d.ts +20 -20
- package/dist/tree/status.d.ts +5 -5
- package/dist/tree/subtree.d.ts +2 -2
- package/dist/tree/text.js +2 -1
- package/dist/tree/tui.d.ts +11 -3
- package/dist/tui.js +612 -123
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
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
|
+
*/
|
|
5
|
+
import { type Status } from "../kernel/protocol.js";
|
|
6
|
+
/** How many unpinned records a project keeps: those with a lesson first, then the newest by `movedAt`. */
|
|
7
|
+
export declare const MAX_RECORDS = 30;
|
|
8
|
+
/** Unpinned records that last moved longer ago than this are dropped when the plugin loads. */
|
|
9
|
+
export declare const RETENTION_MS: number;
|
|
10
|
+
/** A record's `split` while its top has stated no split line. */
|
|
11
|
+
export declare const NOT_STATED = "not stated";
|
|
12
|
+
/** The most characters a lesson holds: courier_note refuses what is longer, and a record written by hand is clipped when read. */
|
|
13
|
+
export declare const LESSON_MAX = 300;
|
|
14
|
+
/**
|
|
15
|
+
* How a part stands: its last report's status, how its wait ended without one (`failed`, `interrupted`), `stopped` by
|
|
16
|
+
* courier_stop, `gone` before it settled, `stalled` on a message it will not take, or `out`: it owes a report.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PART_STATUSES: readonly ["done", "partial", "blocked", "failed", "interrupted", "stopped", "gone", "stalled", "out"];
|
|
19
|
+
export type PartStatus = (typeof PART_STATUSES)[number];
|
|
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";
|
|
23
|
+
/** One session started in a tree, as its record keeps it; `duration` is in milliseconds, `context` a share of its model's window. */
|
|
24
|
+
export interface PartRecord {
|
|
25
|
+
readonly sessionID: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
/** 1 for a session the top started. */
|
|
28
|
+
readonly depth: number;
|
|
29
|
+
readonly isolated: boolean;
|
|
30
|
+
readonly group?: string;
|
|
31
|
+
readonly status: PartStatus;
|
|
32
|
+
/** From its start to the end of its last turn, or to the refresh while it runs. */
|
|
33
|
+
readonly duration?: number;
|
|
34
|
+
readonly cost?: number;
|
|
35
|
+
/** How full its context was at the end, 0 to 1, when its model's window is known. */
|
|
36
|
+
readonly context?: number;
|
|
37
|
+
/** How many courier_spawn calls of its own failed, refused by a limit mostly. */
|
|
38
|
+
readonly refused?: number;
|
|
39
|
+
}
|
|
40
|
+
/** Whether and when the top called courier_recall (`at`, its first call), and how many calls the tree made in all. */
|
|
41
|
+
export interface Recalled {
|
|
42
|
+
readonly at?: number;
|
|
43
|
+
readonly calls: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The one line the tree's orchestrator wrote for the next one with courier_note: when, and from which depth when it was
|
|
47
|
+
* not the top's own session (1 for a session the top started).
|
|
48
|
+
*/
|
|
49
|
+
export interface Lesson {
|
|
50
|
+
readonly text: string;
|
|
51
|
+
readonly at: number;
|
|
52
|
+
readonly depth?: number;
|
|
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
|
+
}
|
|
77
|
+
/** What is remembered of one tree: its top session and each session started in it, never a report's body or artifacts. */
|
|
78
|
+
export interface TreeRecord {
|
|
79
|
+
/** When the top session was created, epoch milliseconds. */
|
|
80
|
+
readonly startedAt: number;
|
|
81
|
+
/** When a turn in the tree last ended, or the record was first written. */
|
|
82
|
+
readonly movedAt: number;
|
|
83
|
+
readonly title: string;
|
|
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
|
+
*/
|
|
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>;
|
|
91
|
+
/** Whether, at the last refresh, no session in the tree ran and every part had settled: none was out or stalled. */
|
|
92
|
+
readonly finished: boolean;
|
|
93
|
+
readonly recalled?: Recalled;
|
|
94
|
+
/** Absent until a session of the tree that is not a leaf wrote one; a second note replaces the first. */
|
|
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;
|
|
100
|
+
readonly parts: ReadonlyArray<PartRecord>;
|
|
101
|
+
}
|
|
102
|
+
/** A record with where it is kept: its project and its top session. */
|
|
103
|
+
export interface StoredRecord extends TreeRecord {
|
|
104
|
+
readonly projectID: string;
|
|
105
|
+
readonly sessionID: string;
|
|
106
|
+
}
|
|
107
|
+
/** The `memory` option: whether records are kept and listed; on unless `false`. */
|
|
108
|
+
export declare function readMemory(options: unknown): {
|
|
109
|
+
memory: boolean;
|
|
110
|
+
problems: string[];
|
|
111
|
+
};
|
|
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. */
|
|
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;
|
|
137
|
+
/** What a part's status is read from: whether courier_stop stopped it, whether it is stalled, and its report state. */
|
|
138
|
+
export interface Standing {
|
|
139
|
+
readonly stopped: boolean;
|
|
140
|
+
readonly stalled: boolean;
|
|
141
|
+
readonly report?: {
|
|
142
|
+
readonly known: boolean;
|
|
143
|
+
readonly owes: boolean;
|
|
144
|
+
readonly ended?: "failed" | "interrupted";
|
|
145
|
+
readonly status?: Status;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** A part's status by `Standing`; one whose report state is not known is still out. */
|
|
149
|
+
export declare function partStatus({ stopped, stalled, report }: Standing): PartStatus;
|
|
150
|
+
/**
|
|
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.
|
|
158
|
+
*/
|
|
159
|
+
export declare function mergeParts(previous: ReadonlyArray<PartRecord>, current: ReadonlyArray<PartRecord>): PartRecord[];
|
|
160
|
+
/** `recalled` after one more call at `at`, the top's first call noted when `byTop`. */
|
|
161
|
+
export declare function noteRecall(recalled: Recalled | undefined, byTop: boolean, at: number): Recalled;
|
|
162
|
+
/** A text as the one line a lesson is: whitespace and line breaks collapsed to spaces, trimmed. */
|
|
163
|
+
export declare const oneLine: (text: string) => string;
|
|
164
|
+
/**
|
|
165
|
+
* A hand-written lesson's text as one line, clipped at `LESSON_MAX`; `clipped` is the length it had when it was clipped.
|
|
166
|
+
* courier_note does not clip: it refuses a line over `LESSON_MAX` (`note.ts`).
|
|
167
|
+
*/
|
|
168
|
+
export declare function lessonText(text: string): {
|
|
169
|
+
text: string;
|
|
170
|
+
clipped?: undefined;
|
|
171
|
+
} | {
|
|
172
|
+
text: string;
|
|
173
|
+
clipped: number;
|
|
174
|
+
};
|
|
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): {
|
|
184
|
+
kept: T[];
|
|
185
|
+
dropped: T[];
|
|
186
|
+
};
|
|
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[];
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
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
|
+
*/
|
|
5
|
+
import { STATUSES } from "../kernel/protocol.js";
|
|
6
|
+
import { obj } from "../platform/json.js";
|
|
7
|
+
/** How many unpinned records a project keeps: those with a lesson first, then the newest by `movedAt`. */
|
|
8
|
+
export const MAX_RECORDS = 30;
|
|
9
|
+
/** Unpinned 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, `gone` before it settled, `stalled` on a message it will not take, or `out`: it owes a report.
|
|
18
|
+
*/
|
|
19
|
+
export const PART_STATUSES = [...STATUSES, "interrupted", "stopped", "gone", "stalled", "out"];
|
|
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;
|
|
25
|
+
/** The `memory` option: whether records are kept and listed; on unless `false`. */
|
|
26
|
+
export function readMemory(options) {
|
|
27
|
+
const value = obj(options).memory;
|
|
28
|
+
if (value === undefined || typeof value === "boolean")
|
|
29
|
+
return { memory: value !== false, problems: [] };
|
|
30
|
+
return { memory: true, problems: [`memory must be true or false, not ${JSON.stringify(value)}; using true`] };
|
|
31
|
+
}
|
|
32
|
+
/** A line beginning with `split:` after any markdown (whitespace, emphasis, heading, quote and list marks). */
|
|
33
|
+
const STARTS_SPLIT = /^[\s*_#>`-]*split:/i;
|
|
34
|
+
/** A line holding `split:` as a word. */
|
|
35
|
+
const HOLDS_SPLIT = /\bsplit:/i;
|
|
36
|
+
/** 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. */
|
|
37
|
+
export function splitLineOf(text) {
|
|
38
|
+
const lines = text.split("\n");
|
|
39
|
+
return (lines.find((line) => STARTS_SPLIT.test(line)) ?? lines.find((line) => HOLDS_SPLIT.test(line)))?.trim();
|
|
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
|
+
}
|
|
136
|
+
/** A part's status by `Standing`; one whose report state is not known is still out. */
|
|
137
|
+
export function partStatus({ stopped, stalled, report }) {
|
|
138
|
+
if (stopped)
|
|
139
|
+
return "stopped";
|
|
140
|
+
if (stalled)
|
|
141
|
+
return "stalled";
|
|
142
|
+
if (!report?.known || report.owes)
|
|
143
|
+
return "out";
|
|
144
|
+
if (report.ended)
|
|
145
|
+
return report.ended;
|
|
146
|
+
return report.status ?? "done";
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
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.
|
|
161
|
+
*/
|
|
162
|
+
export function mergeParts(previous, current) {
|
|
163
|
+
const now = new Map(current.map((part) => [part.sessionID, part]));
|
|
164
|
+
const kept = previous.map((part) => now.get(part.sessionID) ?? part);
|
|
165
|
+
const known = new Set(previous.map((part) => part.sessionID));
|
|
166
|
+
return [...kept, ...current.filter((part) => !known.has(part.sessionID))];
|
|
167
|
+
}
|
|
168
|
+
/** `recalled` after one more call at `at`, the top's first call noted when `byTop`. */
|
|
169
|
+
export function noteRecall(recalled, byTop, at) {
|
|
170
|
+
const calls = (recalled?.calls ?? 0) + 1;
|
|
171
|
+
const first = recalled?.at ?? (byTop ? at : undefined);
|
|
172
|
+
return first === undefined ? { calls } : { at: first, calls };
|
|
173
|
+
}
|
|
174
|
+
/** A text as the one line a lesson is: whitespace and line breaks collapsed to spaces, trimmed. */
|
|
175
|
+
export const oneLine = (text) => text.replace(/\s+/g, " ").trim();
|
|
176
|
+
/**
|
|
177
|
+
* A hand-written lesson's text as one line, clipped at `LESSON_MAX`; `clipped` is the length it had when it was clipped.
|
|
178
|
+
* courier_note does not clip: it refuses a line over `LESSON_MAX` (`note.ts`).
|
|
179
|
+
*/
|
|
180
|
+
export function lessonText(text) {
|
|
181
|
+
const line = oneLine(text);
|
|
182
|
+
if (line.length <= LESSON_MAX)
|
|
183
|
+
return { text: line };
|
|
184
|
+
return { text: line.slice(0, LESSON_MAX).trimEnd(), clipped: line.length };
|
|
185
|
+
}
|
|
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
|
+
*/
|
|
200
|
+
export function keep(records, now) {
|
|
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)) };
|
|
207
|
+
}
|
|
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);
|
|
@@ -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,11 @@
|
|
|
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 { pin, type PinOutcome } from "./pin.js";
|
|
5
|
+
export type { Lesson, MemoryPorts, MemoryState, PartRecord, PartStatus, Pinned, Reads, Recalled, RefreshPorts, Revision, StoredRecord, TreeRecord } from "./ports.js";
|
|
6
|
+
export { recall, type Recall, type RecallInput } from "./recall.js";
|
|
7
|
+
export { refresh } from "./record.js";
|
|
8
|
+
export { registerMemoryRpc, type MemoryRpcPorts } from "./rpc-handlers.js";
|
|
9
|
+
export { sweepRecords } from "./sweep.js";
|
|
10
|
+
export { NOTE_TOOL, RECALL_TOOL } from "./text.js";
|
|
11
|
+
export { addMemoryTools, type MemoryToolPorts } from "./tools.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
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 { pin } from "./pin.js";
|
|
7
|
+
export { recall } from "./recall.js";
|
|
8
|
+
export { refresh } from "./record.js";
|
|
9
|
+
export { registerMemoryRpc } from "./rpc-handlers.js";
|
|
10
|
+
export { sweepRecords } from "./sweep.js";
|
|
11
|
+
export { NOTE_TOOL, RECALL_TOOL } from "./text.js";
|
|
12
|
+
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,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
|
+
}
|