opencode-clm 0.1.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.
@@ -0,0 +1,722 @@
1
+ // Adapted from pi-clm src/continuity.ts and the live_context_annotate / live_context_recall
2
+ // tools in pi-clm src/index.ts (MIT, Copyright 2026 Emanuel Casco).
3
+ //
4
+ // Pi stores annotations as custom session entries and points at the session entry that
5
+ // produced a mirror block; recall re-reads that entry. OpenCode gives plugins no session
6
+ // store and no entry ids, so here:
7
+ // - annotations are appended to `annotations.jsonl` in the session directory (mode 0600,
8
+ // created on first write); the latest valid snapshot per id wins;
9
+ // - a source is named by its mirror block id (`3-ab12cd34ef56`), and the annotation keeps a
10
+ // bounded snapshot of the rendered source text with its hashes, so recall and pins work
11
+ // after the block has been edited out of the context.
12
+ // Model-facing text is rewritten for this package.
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { constants } from "node:fs";
15
+ import { mkdir, open, readFile } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+
18
+ import type { ToolContext, ToolDefinition, tool } from "@opencode-ai/plugin";
19
+
20
+ import { digestMessages, renderMessage } from "./context-document.ts";
21
+ import type { LiveContextMessage } from "./types.ts";
22
+
23
+ export const ANNOTATIONS_FILE = "annotations.jsonl";
24
+ export const ANNOTATE_TOOL = "clm_annotate";
25
+ export const RECALL_TOOL = "clm_recall";
26
+ export const MAX_PIN_SOURCE_TOKENS = 8_000;
27
+ export const DEFAULT_RECALL_TOKENS = 2_000;
28
+ export const MIN_RECALL_TOKENS = 128;
29
+ export const MAX_RECALL_TOKENS = 8_000;
30
+ /** Room reserved for the recall header, which counts against `maxTokens`. */
31
+ export const RECALL_HEADER_TOKENS = 256;
32
+ /**
33
+ * Snapshot bound for continuity / archive sources, so a full snapshot plus the recall header
34
+ * fits MAX_RECALL_TOKENS. Pins keep their whole text (at most MAX_PIN_SOURCE_TOKENS); recall
35
+ * of a pin near that bound can cut its tail, but the pin itself carries the full text.
36
+ */
37
+ export const MAX_SNAPSHOT_TOKENS = MAX_RECALL_TOKENS - RECALL_HEADER_TOKENS;
38
+ export const CONTINUITY_SIZE_WARNING_TOKENS = 8_000;
39
+ export const MAX_LISTED_ANNOTATIONS = 50;
40
+ export const TITLE_MAX = 120;
41
+ export const TEXT_MAX = 400;
42
+
43
+ export type LiveContextRetention = "pin" | "continuity" | "archive";
44
+
45
+ export interface AnnotationSource {
46
+ sessionId: string;
47
+ /** Mirror block id at creation, e.g. `3-ab12cd34ef56`. */
48
+ blockId: string;
49
+ /** Projection revision the block belonged to. */
50
+ revision: number;
51
+ role: string;
52
+ /** Digest of the source message (`digestMessages([message])`): detects a pin already in context. */
53
+ contentHash: string;
54
+ /** sha256 of `text`: detects a damaged snapshot. */
55
+ textHash: string;
56
+ /** Rendered source text, or its head when `truncated`. */
57
+ text: string;
58
+ /** Estimated tokens of the full rendered source. */
59
+ tokens: number;
60
+ truncated: boolean;
61
+ }
62
+
63
+ /**
64
+ * A full append-only annotation snapshot. Resolving appends a new snapshot with the same id
65
+ * rather than rewriting the earlier line.
66
+ */
67
+ export interface LiveContextAnnotation {
68
+ version: 1;
69
+ id: string;
70
+ source: AnnotationSource;
71
+ title: string;
72
+ reason: string;
73
+ futureAction: string;
74
+ retention: LiveContextRetention;
75
+ createdAt: string;
76
+ resolvedAt?: string;
77
+ resolution?: string;
78
+ }
79
+
80
+ export interface RecallFormatResult {
81
+ text: string;
82
+ truncated: boolean;
83
+ totalTokens: number;
84
+ returnedTokens: number;
85
+ }
86
+
87
+ export type TokenEstimator = (text: string) => number;
88
+
89
+ /** Rough default: four characters per token. */
90
+ export const estimateTextTokens: TokenEstimator = (text) => Math.ceil(text.length / 4);
91
+
92
+ /** Warn once per aggregate-note threshold crossing; re-arm after it shrinks below. */
93
+ export class ContinuitySizeTracker {
94
+ private warned = false;
95
+
96
+ constructor(readonly threshold = CONTINUITY_SIZE_WARNING_TOKENS) {}
97
+
98
+ reset(): void {
99
+ this.warned = false;
100
+ }
101
+
102
+ observe(tokens: number): boolean {
103
+ if (!Number.isFinite(tokens) || tokens < this.threshold) {
104
+ this.warned = false;
105
+ return false;
106
+ }
107
+ if (this.warned) return false;
108
+ this.warned = true;
109
+ return true;
110
+ }
111
+ }
112
+
113
+ const RETENTIONS = new Set<LiveContextRetention>(["pin", "continuity", "archive"]);
114
+ const ANNOTATION_ID_RE = /^lc-[a-f0-9]{12}$/;
115
+ const HASH_RE = /^[a-f0-9]{64}$/;
116
+
117
+ function sha256(text: string): string {
118
+ return createHash("sha256").update(text).digest("hex");
119
+ }
120
+
121
+ export function sourceContentHash(message: LiveContextMessage): string {
122
+ return digestMessages([message]);
123
+ }
124
+
125
+ function isCount(value: unknown): value is number {
126
+ return Number.isInteger(value) && (value as number) >= 0;
127
+ }
128
+
129
+ export function isLiveContextAnnotation(value: unknown): value is LiveContextAnnotation {
130
+ if (!value || typeof value !== "object") return false;
131
+ const annotation = value as Partial<LiveContextAnnotation>;
132
+ const source = annotation.source as Partial<AnnotationSource> | undefined;
133
+ return (
134
+ annotation.version === 1 &&
135
+ typeof annotation.id === "string" &&
136
+ ANNOTATION_ID_RE.test(annotation.id) &&
137
+ Boolean(source) &&
138
+ typeof source === "object" &&
139
+ typeof source.sessionId === "string" &&
140
+ typeof source.blockId === "string" &&
141
+ isCount(source.revision) &&
142
+ typeof source.role === "string" &&
143
+ typeof source.contentHash === "string" &&
144
+ HASH_RE.test(source.contentHash) &&
145
+ typeof source.textHash === "string" &&
146
+ HASH_RE.test(source.textHash) &&
147
+ typeof source.text === "string" &&
148
+ isCount(source.tokens) &&
149
+ typeof source.truncated === "boolean" &&
150
+ typeof annotation.title === "string" &&
151
+ typeof annotation.reason === "string" &&
152
+ typeof annotation.futureAction === "string" &&
153
+ typeof annotation.retention === "string" &&
154
+ RETENTIONS.has(annotation.retention as LiveContextRetention) &&
155
+ typeof annotation.createdAt === "string" &&
156
+ (annotation.resolvedAt === undefined || typeof annotation.resolvedAt === "string") &&
157
+ (annotation.resolution === undefined || typeof annotation.resolution === "string")
158
+ );
159
+ }
160
+
161
+ /** Latest valid snapshot for each id wins; order is first appearance. */
162
+ export function reconstructAnnotations(values: Iterable<unknown>): LiveContextAnnotation[] {
163
+ const latest = new Map<string, LiveContextAnnotation>();
164
+ for (const value of values) {
165
+ if (isLiveContextAnnotation(value)) latest.set(value.id, value);
166
+ }
167
+ return [...latest.values()];
168
+ }
169
+
170
+ export function activeContinuityAnnotations(
171
+ annotations: LiveContextAnnotation[],
172
+ ): LiveContextAnnotation[] {
173
+ return annotations.filter(
174
+ (annotation) => annotation.resolvedAt === undefined && annotation.retention !== "archive",
175
+ );
176
+ }
177
+
178
+ /** Longest head of `text` whose estimate fits `maxTokens` (binary search; estimator assumed monotonic). */
179
+ function headWithin(text: string, maxTokens: number, estimate: TokenEstimator, suffix = ""): string {
180
+ let low = 0;
181
+ let high = text.length;
182
+ while (low < high) {
183
+ const middle = Math.ceil((low + high) / 2);
184
+ if (estimate(`${text.slice(0, middle)}${suffix}`) <= maxTokens) low = middle;
185
+ else high = middle - 1;
186
+ }
187
+ // Defensive step-down for estimators that are not perfectly monotonic.
188
+ while (low > 0 && estimate(`${text.slice(0, low)}${suffix}`) > maxTokens) {
189
+ low = Math.max(0, low - Math.max(1, Math.ceil(low / 20)));
190
+ }
191
+ return text.slice(0, low);
192
+ }
193
+
194
+ /**
195
+ * Snapshot a source message. A pin larger than MAX_PIN_SOURCE_TOKENS is refused and a pin
196
+ * is never truncated; other retentions keep the head that fits MAX_SNAPSHOT_TOKENS.
197
+ */
198
+ export function snapshotSource(options: {
199
+ sessionId: string;
200
+ blockId: string;
201
+ revision: number;
202
+ message: LiveContextMessage;
203
+ retention: LiveContextRetention;
204
+ estimateTokens?: TokenEstimator;
205
+ }): AnnotationSource {
206
+ const estimate = options.estimateTokens ?? estimateTextTokens;
207
+ const full = renderMessage(options.message);
208
+ const tokens = estimate(full);
209
+ if (options.retention === "pin" && tokens > MAX_PIN_SOURCE_TOKENS) {
210
+ throw new Error(
211
+ `Block ${options.blockId} is about ${tokens} tokens; a pin holds at most ${MAX_PIN_SOURCE_TOKENS}. Use continuity or archive.`,
212
+ );
213
+ }
214
+ const truncated = options.retention !== "pin" && tokens > MAX_SNAPSHOT_TOKENS;
215
+ const text = truncated ? headWithin(full, MAX_SNAPSHOT_TOKENS, estimate) : full;
216
+ return {
217
+ sessionId: options.sessionId,
218
+ blockId: options.blockId,
219
+ revision: options.revision,
220
+ role: options.message.role,
221
+ contentHash: sourceContentHash(options.message),
222
+ textHash: sha256(text),
223
+ text,
224
+ tokens,
225
+ truncated,
226
+ };
227
+ }
228
+
229
+ export function createAnnotation(options: {
230
+ existingIds: Iterable<string>;
231
+ source: AnnotationSource;
232
+ title: string;
233
+ reason: string;
234
+ futureAction: string;
235
+ retention: LiveContextRetention;
236
+ createdAt?: string;
237
+ }): LiveContextAnnotation {
238
+ const existingIds = new Set(options.existingIds);
239
+ let id: string;
240
+ do id = `lc-${randomBytes(6).toString("hex")}`;
241
+ while (existingIds.has(id));
242
+ return {
243
+ version: 1,
244
+ id,
245
+ source: { ...options.source },
246
+ title: options.title,
247
+ reason: options.reason,
248
+ futureAction: options.futureAction,
249
+ retention: options.retention,
250
+ createdAt: options.createdAt ?? new Date().toISOString(),
251
+ };
252
+ }
253
+
254
+ export function resolveAnnotation(
255
+ annotation: LiveContextAnnotation,
256
+ resolution: string | undefined,
257
+ resolvedAt = new Date().toISOString(),
258
+ ): LiveContextAnnotation {
259
+ const resolved: LiveContextAnnotation = {
260
+ ...annotation,
261
+ source: { ...annotation.source },
262
+ resolvedAt,
263
+ };
264
+ if (resolution) resolved.resolution = resolution;
265
+ return resolved;
266
+ }
267
+
268
+ /** Throws when the stored snapshot no longer matches its hash. */
269
+ export function validateSnapshot(annotation: LiveContextAnnotation): string {
270
+ if (sha256(annotation.source.text) !== annotation.source.textHash) {
271
+ throw new Error(`The stored source of ${annotation.id} failed its hash check.`);
272
+ }
273
+ return annotation.source.text;
274
+ }
275
+
276
+ const PIN_CLOSE_RE = /<\/(clm-pinned-source)/gi;
277
+
278
+ /** Neutralise closing tags inside pinned text so it cannot end its own wrapper. */
279
+ export function escapePinnedText(text: string): string {
280
+ return text.replace(PIN_CLOSE_RE, "<\\/$1");
281
+ }
282
+
283
+ function compactLine(value: string, maxLength: number): string {
284
+ const oneLine = value.replace(/\s+/g, " ").trim();
285
+ return oneLine.length <= maxLength ? oneLine : `${oneLine.slice(0, Math.max(0, maxLength - 1))}…`;
286
+ }
287
+
288
+ /**
289
+ * The plugin-managed message appended after the projected conversation on every request:
290
+ * one entry per active pin / continuity annotation, plus the exact text of each pin whose
291
+ * source is no longer in the projected conversation. Undefined when nothing is active.
292
+ */
293
+ export function formatContinuityMessage(options: {
294
+ annotations: LiveContextAnnotation[];
295
+ effectiveMessages: LiveContextMessage[];
296
+ }): string | undefined {
297
+ const active = activeContinuityAnnotations(options.annotations);
298
+ if (active.length === 0) return undefined;
299
+ const effectiveHashes = active.some((annotation) => annotation.retention === "pin")
300
+ ? new Set(options.effectiveMessages.map(sourceContentHash))
301
+ : new Set<string>();
302
+ const sections = [
303
+ "[CLM CONTINUITY: kept by the plugin outside the context mirror; editing the mirror does not change it]",
304
+ ];
305
+ for (const annotation of active) {
306
+ sections.push(
307
+ `- [${annotation.id}] ${annotation.retention}: ${compactLine(annotation.title, TITLE_MAX)}`,
308
+ ` Why: ${compactLine(annotation.reason, 240)}`,
309
+ ` Next: ${compactLine(annotation.futureAction, 240)}`,
310
+ ` Source: block ${annotation.source.blockId} (${annotation.source.role}) · full text via ${RECALL_TOOL}({ id: "${annotation.id}" })`,
311
+ );
312
+ if (annotation.retention !== "pin") continue;
313
+ if (effectiveHashes.has(annotation.source.contentHash)) {
314
+ sections.push(" The pinned source is still in the conversation above.");
315
+ continue;
316
+ }
317
+ try {
318
+ const text = validateSnapshot(annotation);
319
+ sections.push(
320
+ ` Pinned source text (${annotation.source.role}):`,
321
+ " <clm-pinned-source>",
322
+ escapePinnedText(text),
323
+ " </clm-pinned-source>",
324
+ );
325
+ } catch (error) {
326
+ sections.push(` Pin unavailable: ${error instanceof Error ? error.message : String(error)}`);
327
+ }
328
+ }
329
+ return sections.join("\n");
330
+ }
331
+
332
+ export function continuitySizeNoticeText(
333
+ tokens: number,
334
+ threshold = CONTINUITY_SIZE_WARNING_TOKENS,
335
+ ): string {
336
+ return (
337
+ `[CLM] Active pin and continuity annotations add about ${tokens} estimated tokens to every request` +
338
+ ` (warning at ${threshold}), and editing the mirror cannot shrink them.` +
339
+ ` Resolve finished annotations with ${ANNOTATE_TOOL}, and use archive for sources you only need to recall.`
340
+ );
341
+ }
342
+
343
+ export function clampRecallTokens(maxTokens: number | undefined): number {
344
+ const requested =
345
+ maxTokens !== undefined && Number.isFinite(maxTokens) ? Math.floor(maxTokens) : DEFAULT_RECALL_TOKENS;
346
+ return Math.max(MIN_RECALL_TOKENS, Math.min(MAX_RECALL_TOKENS, requested));
347
+ }
348
+
349
+ export function formatRecall(options: {
350
+ annotation: LiveContextAnnotation;
351
+ maxTokens?: number;
352
+ estimateTokens?: TokenEstimator;
353
+ }): RecallFormatResult {
354
+ const { annotation } = options;
355
+ const estimate = options.estimateTokens ?? estimateTextTokens;
356
+ const maxTokens = clampRecallTokens(options.maxTokens);
357
+ const body = validateSnapshot(annotation);
358
+ const header = [
359
+ `[clm recall ${annotation.id}]`,
360
+ `source: session ${annotation.source.sessionId} · block ${annotation.source.blockId} · revision ${annotation.source.revision}`,
361
+ `hash: ${annotation.source.contentHash}`,
362
+ `retention: ${annotation.retention}${annotation.resolvedAt ? ` · resolved ${annotation.resolvedAt}` : ""}`,
363
+ `title: ${compactLine(annotation.title, TITLE_MAX)}`,
364
+ ...(annotation.source.truncated
365
+ ? [`stored: the first part of a ${annotation.source.tokens}-token source; the rest was not kept`]
366
+ : []),
367
+ "",
368
+ `--- source text (${annotation.source.role}) ---`,
369
+ ].join("\n");
370
+ const full = `${header}\n${body}`;
371
+ const totalTokens = estimate(full);
372
+ if (totalTokens <= maxTokens) {
373
+ return { text: full, truncated: false, totalTokens, returnedTokens: totalTokens };
374
+ }
375
+
376
+ const marker = "\n\n[source truncated to maxTokens; ask for a larger maxTokens to see more]";
377
+ const head = headWithin(body, maxTokens, (candidate) => estimate(`${header}\n${candidate}`), marker);
378
+ let text = `${header}\n${head}${marker}`;
379
+ if (estimate(text) > maxTokens) {
380
+ const fallbackMarker = "\n[recall truncated]";
381
+ const fallback = headWithin(full, maxTokens, estimate, fallbackMarker);
382
+ text = `${fallback}${fallbackMarker}`;
383
+ if (estimate(text) > maxTokens) text = "";
384
+ }
385
+ return { text, truncated: true, totalTokens, returnedTokens: estimate(text) };
386
+ }
387
+
388
+ export interface AnnotationStoreOptions {
389
+ estimateTokens?: TokenEstimator;
390
+ now?: () => Date;
391
+ }
392
+
393
+ export interface LoadedAnnotations {
394
+ annotations: LiveContextAnnotation[];
395
+ /** Lines that were not valid annotation JSON (e.g. a torn last line); ignored. */
396
+ skipped: number;
397
+ }
398
+
399
+ export interface CreateAnnotationInput {
400
+ sessionId: string;
401
+ blockId: string;
402
+ revision: number;
403
+ message: LiveContextMessage;
404
+ title: string;
405
+ reason: string;
406
+ futureAction: string;
407
+ retention: LiveContextRetention;
408
+ }
409
+
410
+ /**
411
+ * Create / resolve tails per resolved `annotations.jsonl` path, shared by every store
412
+ * instance in this process, so a caller may build a new store per tool call.
413
+ */
414
+ const fileQueues = new Map<string, Promise<unknown>>();
415
+
416
+ /** Append-only `annotations.jsonl` in one session directory. */
417
+ export class AnnotationStore {
418
+ readonly filePath: string;
419
+ private readonly estimate: TokenEstimator;
420
+ private readonly now: () => Date;
421
+
422
+ constructor(
423
+ readonly directory: string,
424
+ options: AnnotationStoreOptions = {},
425
+ ) {
426
+ this.filePath = resolve(directory, ANNOTATIONS_FILE);
427
+ this.estimate = options.estimateTokens ?? estimateTextTokens;
428
+ this.now = options.now ?? (() => new Date());
429
+ }
430
+
431
+ async load(): Promise<LoadedAnnotations> {
432
+ let text: string;
433
+ try {
434
+ text = await readFile(this.filePath, "utf8");
435
+ } catch (error) {
436
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { annotations: [], skipped: 0 };
437
+ throw error;
438
+ }
439
+ const values: unknown[] = [];
440
+ let skipped = 0;
441
+ for (const line of text.split("\n")) {
442
+ if (!line.trim()) continue;
443
+ try {
444
+ const value: unknown = JSON.parse(line);
445
+ if (isLiveContextAnnotation(value)) values.push(value);
446
+ else skipped++;
447
+ } catch {
448
+ skipped++;
449
+ }
450
+ }
451
+ return { annotations: reconstructAnnotations(values), skipped };
452
+ }
453
+
454
+ async list(): Promise<LiveContextAnnotation[]> {
455
+ return (await this.load()).annotations;
456
+ }
457
+
458
+ async active(): Promise<LiveContextAnnotation[]> {
459
+ return activeContinuityAnnotations(await this.list());
460
+ }
461
+
462
+ async get(id: string): Promise<LiveContextAnnotation> {
463
+ const annotation = (await this.list()).find((candidate) => candidate.id === id);
464
+ if (!annotation) throw new Error(`Annotation ${id} does not exist in this session.`);
465
+ return annotation;
466
+ }
467
+
468
+ create(input: CreateAnnotationInput): Promise<LiveContextAnnotation> {
469
+ return this.serialize(async () => {
470
+ const source = snapshotSource({ ...input, estimateTokens: this.estimate });
471
+ const annotation = createAnnotation({
472
+ existingIds: (await this.list()).map((candidate) => candidate.id),
473
+ source,
474
+ title: input.title,
475
+ reason: input.reason,
476
+ futureAction: input.futureAction,
477
+ retention: input.retention,
478
+ createdAt: this.now().toISOString(),
479
+ });
480
+ await this.append(annotation);
481
+ return annotation;
482
+ });
483
+ }
484
+
485
+ resolve(id: string, resolution?: string): Promise<LiveContextAnnotation> {
486
+ return this.serialize(async () => {
487
+ const existing = await this.get(id);
488
+ if (existing.resolvedAt) throw new Error(`Annotation ${id} is already resolved.`);
489
+ const resolved = resolveAnnotation(existing, resolution, this.now().toISOString());
490
+ await this.append(resolved);
491
+ return resolved;
492
+ });
493
+ }
494
+
495
+ async recall(
496
+ id: string,
497
+ maxTokens?: number,
498
+ ): Promise<RecallFormatResult & { annotation: LiveContextAnnotation }> {
499
+ const annotation = await this.get(id);
500
+ return { annotation, ...formatRecall({ annotation, maxTokens, estimateTokens: this.estimate }) };
501
+ }
502
+
503
+ /**
504
+ * One write per record, framed `\n<json>\n`: a torn earlier append then ends at the
505
+ * leading newline instead of swallowing this record. `load()` skips the blank lines.
506
+ * O_NOFOLLOW refuses a symlink planted at the file path.
507
+ */
508
+ private async append(annotation: LiveContextAnnotation): Promise<void> {
509
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
510
+ const handle = await open(
511
+ this.filePath,
512
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW,
513
+ 0o600,
514
+ );
515
+ try {
516
+ await handle.write(`\n${JSON.stringify(annotation)}\n`);
517
+ } finally {
518
+ await handle.close();
519
+ }
520
+ }
521
+
522
+ /** Create and resolve read, then append: run them one at a time per file. */
523
+ private serialize<T>(task: () => Promise<T>): Promise<T> {
524
+ const key = this.filePath;
525
+ const run = (fileQueues.get(key) ?? Promise.resolve()).then(task, task);
526
+ const tail = run.catch(() => undefined);
527
+ fileQueues.set(key, tail);
528
+ void tail.then(() => {
529
+ if (fileQueues.get(key) === tail) fileQueues.delete(key);
530
+ });
531
+ return run;
532
+ }
533
+ }
534
+
535
+ // ---- OpenCode tools ------------------------------------------------------------------
536
+
537
+ export type ToolSchema = typeof tool.schema;
538
+
539
+ export const ANNOTATE_DESCRIPTION = [
540
+ "Create, resolve, or list continuity annotations for this session.",
541
+ "create: name a block id from the context mirror (the id= value in a CTX_TURN header, e.g. 3-ab12cd34ef56) and a retention:",
542
+ `- pin: the exact source text stays in every request until resolved (at most ${MAX_PIN_SOURCE_TOKENS} tokens);`,
543
+ "- continuity: every request carries the title, reason and next action, and the source stays recallable;",
544
+ "- archive: nothing is carried; the source is only recallable.",
545
+ "Annotate a block before you remove or shorten it in the mirror when later work depends on its exact text. Pin only text that must stay word-for-word visible; prefer continuity or archive.",
546
+ "resolve: pass the annotation id (and an optional resolution) once the obligation is done, so it stops costing tokens.",
547
+ "list: show the most recent annotations.",
548
+ ].join("\n");
549
+
550
+ export const RECALL_DESCRIPTION =
551
+ `Return the stored source text of an annotation by id, checked against its hash. ` +
552
+ `Output is bounded to maxTokens estimated tokens (${MIN_RECALL_TOKENS}-${MAX_RECALL_TOKENS}, default ${DEFAULT_RECALL_TOKENS}); ` +
553
+ "request the smallest bound that serves the task.";
554
+
555
+ export function annotateArgs(z: ToolSchema) {
556
+ return {
557
+ action: z.enum(["create", "resolve", "list"]),
558
+ source: z.string().optional().describe("create: block id from the context mirror, e.g. 3-ab12cd34ef56"),
559
+ id: z.string().optional().describe("resolve: annotation id, e.g. lc-0123456789ab"),
560
+ title: z.string().min(1).max(TITLE_MAX).optional().describe("create: short label"),
561
+ reason: z.string().min(1).max(TEXT_MAX).optional().describe("create: why the source matters"),
562
+ futureAction: z.string().min(1).max(TEXT_MAX).optional().describe("create: what to do with it later"),
563
+ retention: z.enum(["pin", "continuity", "archive"]).optional().describe("create: pin, continuity or archive"),
564
+ resolution: z.string().min(1).max(TEXT_MAX).optional().describe("resolve: outcome"),
565
+ };
566
+ }
567
+
568
+ export function recallArgs(z: ToolSchema) {
569
+ return {
570
+ id: z.string().describe("Annotation id, e.g. lc-0123456789ab"),
571
+ maxTokens: z
572
+ .number()
573
+ .int()
574
+ .min(MIN_RECALL_TOKENS)
575
+ .max(MAX_RECALL_TOKENS)
576
+ .optional()
577
+ .describe(`Upper bound on returned tokens (default ${DEFAULT_RECALL_TOKENS})`),
578
+ };
579
+ }
580
+
581
+ export interface AnnotateArgs {
582
+ action: "create" | "resolve" | "list";
583
+ source?: string;
584
+ id?: string;
585
+ title?: string;
586
+ reason?: string;
587
+ futureAction?: string;
588
+ retention?: LiveContextRetention;
589
+ resolution?: string;
590
+ }
591
+
592
+ export interface RecallArgs {
593
+ id: string;
594
+ maxTokens?: number;
595
+ }
596
+
597
+ /** A block of the mirror the session last rendered. */
598
+ export interface ContinuityBlockSource {
599
+ message: LiveContextMessage;
600
+ revision: number;
601
+ }
602
+
603
+ export interface ContinuityToolOptions {
604
+ /** `tool.schema` from @opencode-ai/plugin (zod). Passed in so this module has no runtime import of the plugin package. */
605
+ schema: ToolSchema;
606
+ store(sessionID: string): AnnotationStore | Promise<AnnotationStore>;
607
+ /** Look up a block id in the session's current mirror. */
608
+ block(
609
+ sessionID: string,
610
+ blockId: string,
611
+ ): ContinuityBlockSource | undefined | Promise<ContinuityBlockSource | undefined>;
612
+ /** Called after create / resolve, e.g. to refresh a cached continuity message. */
613
+ onChange?(sessionID: string): void | Promise<void>;
614
+ }
615
+
616
+ function requiredText(value: string | undefined, name: string): string {
617
+ const text = value?.replace(/\s+/g, " ").trim();
618
+ if (!text) throw new Error(`${name} is required.`);
619
+ return text;
620
+ }
621
+
622
+ function optionalText(value: string | undefined): string | undefined {
623
+ return value?.replace(/\s+/g, " ").trim() || undefined;
624
+ }
625
+
626
+ export function formatAnnotationList(annotations: LiveContextAnnotation[]): string {
627
+ if (annotations.length === 0) return "No annotations in this session.";
628
+ const shown = annotations.slice(-MAX_LISTED_ANNOTATIONS);
629
+ const lines = shown.map((annotation) => {
630
+ const status = annotation.resolvedAt ? `resolved ${annotation.resolvedAt}` : "active";
631
+ return `[${annotation.id}] ${annotation.retention} · ${status} · ${annotation.title}\n block ${annotation.source.blockId} · next: ${annotation.futureAction}`;
632
+ });
633
+ if (annotations.length > shown.length) lines.unshift(`${annotations.length - shown.length} older annotations omitted.`);
634
+ return lines.join("\n");
635
+ }
636
+
637
+ async function executeAnnotate(
638
+ options: ContinuityToolOptions,
639
+ args: AnnotateArgs,
640
+ context: ToolContext,
641
+ ) {
642
+ const store = await options.store(context.sessionID);
643
+ if (args.action === "list") {
644
+ const annotations = await store.list();
645
+ return {
646
+ title: `${annotations.length} annotations`,
647
+ output: formatAnnotationList(annotations),
648
+ metadata: { total: annotations.length },
649
+ };
650
+ }
651
+ if (args.action === "resolve") {
652
+ const resolved = await store.resolve(requiredText(args.id, "id"), optionalText(args.resolution));
653
+ await options.onChange?.(context.sessionID);
654
+ return {
655
+ title: `Resolved ${resolved.id}`,
656
+ output: `Resolved ${resolved.id}: ${resolved.title}`,
657
+ metadata: { id: resolved.id },
658
+ };
659
+ }
660
+ if (args.action !== "create") throw new Error(`Unknown action ${String(args.action)}.`);
661
+ const blockId = requiredText(args.source, "source");
662
+ const title = requiredText(args.title, "title");
663
+ const reason = requiredText(args.reason, "reason");
664
+ const futureAction = requiredText(args.futureAction, "futureAction");
665
+ const retention = args.retention;
666
+ if (!retention || !RETENTIONS.has(retention)) throw new Error("retention is required: pin, continuity or archive.");
667
+ const block = await options.block(context.sessionID, blockId);
668
+ if (!block) throw new Error(`Block ${blockId} is not in the current context mirror.`);
669
+ const annotation = await store.create({
670
+ sessionId: context.sessionID,
671
+ blockId,
672
+ revision: block.revision,
673
+ message: block.message,
674
+ title,
675
+ reason,
676
+ futureAction,
677
+ retention,
678
+ });
679
+ await options.onChange?.(context.sessionID);
680
+ const note = annotation.source.truncated
681
+ ? ` Only the first part of the ${annotation.source.tokens}-token source was kept.`
682
+ : "";
683
+ return {
684
+ title: `Created ${annotation.id}`,
685
+ output: `Created ${retention} annotation ${annotation.id} for ${annotation.source.role} block ${blockId}.${note}`,
686
+ metadata: { id: annotation.id, retention, truncated: annotation.source.truncated },
687
+ };
688
+ }
689
+
690
+ async function executeRecall(options: ContinuityToolOptions, args: RecallArgs, context: ToolContext) {
691
+ const store = await options.store(context.sessionID);
692
+ const recalled = await store.recall(requiredText(args.id, "id"), args.maxTokens);
693
+ return {
694
+ title: `Recall ${recalled.annotation.id}`,
695
+ output: recalled.text,
696
+ metadata: {
697
+ id: recalled.annotation.id,
698
+ truncated: recalled.truncated,
699
+ totalTokens: recalled.totalTokens,
700
+ returnedTokens: recalled.returnedTokens,
701
+ },
702
+ };
703
+ }
704
+
705
+ /**
706
+ * OpenCode tool definitions, keyed by tool name, for the plugin's `tool` hook. `store` may
707
+ * return a new AnnotationStore per call: writes are serialised per file path, not per instance.
708
+ */
709
+ export function continuityTools(options: ContinuityToolOptions): Record<string, ToolDefinition> {
710
+ return {
711
+ [ANNOTATE_TOOL]: {
712
+ description: ANNOTATE_DESCRIPTION,
713
+ args: annotateArgs(options.schema),
714
+ execute: (args, context) => executeAnnotate(options, args as unknown as AnnotateArgs, context),
715
+ },
716
+ [RECALL_TOOL]: {
717
+ description: RECALL_DESCRIPTION,
718
+ args: recallArgs(options.schema),
719
+ execute: (args, context) => executeRecall(options, args as unknown as RecallArgs, context),
720
+ },
721
+ };
722
+ }