dsh-reference-anything 0.3.1 → 0.3.3

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 (107) hide show
  1. package/LICENSE +22 -22
  2. package/NOTICE.md +52 -25
  3. package/README.md +342 -284
  4. package/cordis.patch.yml +89 -18
  5. package/lib/client.js +13534 -521
  6. package/lib/contract.d.ts +155 -3
  7. package/lib/contract.js +68 -4
  8. package/lib/download-directory.d.ts +18 -0
  9. package/lib/download-directory.js +55 -0
  10. package/lib/errors.d.ts +4 -0
  11. package/lib/host.d.ts +96 -3
  12. package/lib/host.js +88 -5
  13. package/lib/index.d.ts +22 -2
  14. package/lib/index.js +57 -7
  15. package/lib/opencli.d.ts +5 -3
  16. package/lib/opencli.js +13 -6
  17. package/lib/openlist/host.d.ts +157 -0
  18. package/lib/openlist/host.js +873 -0
  19. package/lib/openlist/index.d.ts +84 -0
  20. package/lib/openlist/index.js +507 -0
  21. package/lib/render.d.ts +12 -7
  22. package/lib/render.js +27 -21
  23. package/lib/sources/cloud-drive/cache.d.ts +60 -0
  24. package/lib/sources/cloud-drive/cache.js +107 -0
  25. package/lib/sources/cloud-drive/index.d.ts +190 -0
  26. package/lib/sources/cloud-drive/index.js +526 -0
  27. package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
  28. package/lib/sources/cloud-drive/providers/http.js +73 -0
  29. package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
  30. package/lib/sources/cloud-drive/providers/openlist.js +535 -0
  31. package/lib/sources/cloud-drive/registry.d.ts +53 -0
  32. package/lib/sources/cloud-drive/registry.js +101 -0
  33. package/lib/sources/cloud-drive/types.d.ts +169 -0
  34. package/lib/sources/cloud-drive/types.js +17 -0
  35. package/lib/sources/file.d.ts +2 -0
  36. package/lib/sources/file.js +2 -0
  37. package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
  38. package/lib/sources/local-agent/adapters/claude-code.js +171 -0
  39. package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
  40. package/lib/sources/local-agent/adapters/codex.js +204 -0
  41. package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
  42. package/lib/sources/local-agent/adapters/cursor.js +121 -0
  43. package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
  44. package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
  45. package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
  46. package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
  47. package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
  48. package/lib/sources/local-agent/adapters/hermes.js +85 -0
  49. package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
  50. package/lib/sources/local-agent/adapters/kimi.js +261 -0
  51. package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
  52. package/lib/sources/local-agent/adapters/openclaw.js +120 -0
  53. package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
  54. package/lib/sources/local-agent/adapters/opencode.js +236 -0
  55. package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
  56. package/lib/sources/local-agent/adapters/pi.js +242 -0
  57. package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
  58. package/lib/sources/local-agent/adapters/qoder.js +93 -0
  59. package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
  60. package/lib/sources/local-agent/adapters/reasonix.js +90 -0
  61. package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
  62. package/lib/sources/local-agent/adapters/shared.js +415 -0
  63. package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
  64. package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
  65. package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
  66. package/lib/sources/local-agent/adapters/zcode.js +159 -0
  67. package/lib/sources/local-agent/index.d.ts +222 -0
  68. package/lib/sources/local-agent/index.js +656 -0
  69. package/lib/sources/local-agent/page.d.ts +55 -0
  70. package/lib/sources/local-agent/page.js +83 -0
  71. package/lib/sources/local-agent/path.d.ts +8 -0
  72. package/lib/sources/local-agent/path.js +43 -0
  73. package/lib/sources/local-agent/registry.d.ts +68 -0
  74. package/lib/sources/local-agent/registry.js +116 -0
  75. package/lib/sources/local-agent/scan.d.ts +175 -0
  76. package/lib/sources/local-agent/scan.js +472 -0
  77. package/lib/sources/local-agent/sqlite.d.ts +45 -0
  78. package/lib/sources/local-agent/sqlite.js +113 -0
  79. package/lib/sources/local-agent/store.d.ts +93 -0
  80. package/lib/sources/local-agent/store.js +113 -0
  81. package/lib/sources/local-agent/types.d.ts +338 -0
  82. package/lib/sources/local-agent/types.js +20 -0
  83. package/lib/sources/web-chat/index.d.ts +5 -1
  84. package/lib/sources/web-chat/index.js +47 -8
  85. package/lib/store/spec.d.ts +50 -0
  86. package/lib/store/spec.js +2 -2
  87. package/lib/sync/index.d.ts +1 -0
  88. package/lib/sync/index.js +28 -8
  89. package/lib/tool.d.ts +5 -0
  90. package/lib/tool.js +418 -45
  91. package/lib/typert.js +13 -1
  92. package/lib/types.d.ts +28 -2
  93. package/lib/update.d.ts +6 -0
  94. package/lib/update.js +29 -1
  95. package/lib/web.js +5 -1
  96. package/lib/wire.d.ts +80 -0
  97. package/lib/wire.js +30 -1
  98. package/opencli-plugin/chatgpt.js +30 -27
  99. package/opencli-plugin/claude.js +12 -8
  100. package/opencli-plugin/common.js +266 -187
  101. package/opencli-plugin/deepseek.js +8 -5
  102. package/opencli-plugin/gemini.js +42 -38
  103. package/opencli-plugin/grok.js +22 -21
  104. package/opencli-plugin/kimi.js +34 -31
  105. package/opencli-plugin/opencli-plugin.json +6 -6
  106. package/opencli-plugin/package.json +8 -8
  107. package/package.json +200 -185
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Reference source over transcripts other coding agents leave on disk.
3
+ *
4
+ * Claude Code and Codex both write every session to a JSONL file under the home
5
+ * directory. Those files are the record of work the user already did, and until
6
+ * now the only way to bring one into a DSH session was to copy it in by hand.
7
+ * This registers them as referenceable items instead: the `@` menu lists them,
8
+ * and the body is read only when the model asks for it by reference.
9
+ *
10
+ * Reference-only by design. Nothing here writes into DSH's session store —
11
+ * importing a transcript is a different feature with different failure modes,
12
+ * and conflating the two would make every listed transcript a pending mutation.
13
+ *
14
+ * @module dsh-reference-anything/local-agent
15
+ */
16
+ import { realpath, stat } from 'node:fs/promises';
17
+ import { homedir } from 'node:os';
18
+ import { basename, relative, resolve, sep } from 'node:path';
19
+ import { Context, Service } from '@deepseek-ai/cordis';
20
+ import z from '@deepseek-ai/schemastery';
21
+ import { ReferenceAnythingError } from "../../errors.js";
22
+ import { compareMatches, scoreTitle } from "../../search.js";
23
+ import { DEFAULT_CONVERT_OPTIONS } from "./adapters/shared.js";
24
+ import { cursorExpired, cursorStillValid, decodeAgentCursor, encodeAgentCursor } from "./page.js";
25
+ import { inWorkspace } from "./path.js";
26
+ import { AGENT_ADAPTERS, AGENT_KINDS, DEFAULT_AGENT_KIND, adapterFor, expandRoot, isAgentKind, isQueryKind, } from "./registry.js";
27
+ import { SESSION_SEPARATOR, listTranscripts, probeEnds, readSessionInfo, readSessionTurns, readTurns } from "./scan.js";
28
+ import { AgentBookmarkStore, localAgentDomainSpec } from "./store.js";
29
+ import { isQueryAdapter } from "./types.js";
30
+ import { sliceTurns } from "../../window.js";
31
+ /** Cordis plugin name used by loader diagnostics. */
32
+ export const name = 'reference-local-agent';
33
+ /** The services this source is built on. */
34
+ export const inject = ['references', 'storageDomain'];
35
+ /** Registry id, and the `source` half of every reference this source owns. */
36
+ export const LOCAL_AGENT_SOURCE_ID = 'local-agent';
37
+ /** Transcripts probed per `list()` call, so a cold cache costs a bounded read. */
38
+ const BACKFILL_PER_LIST = 24;
39
+ /** Probes running at once during backfill. */
40
+ const BACKFILL_CONCURRENCY = 8;
41
+ /**
42
+ * Runtime schema for {@link Config}.
43
+ *
44
+ * Roots are `~/`-prefixed strings expanded against the process's own home
45
+ * rather than paths resolved at authoring time, so a profile written on one
46
+ * machine still points somewhere sensible on another — and so an external
47
+ * overlay describing them cannot smuggle in an evaluated expression.
48
+ */
49
+ export const Config = z.object({
50
+ // Derived from the registry rather than restated: a format is supported
51
+ // exactly when an adapter reads it, and a list written by hand here would
52
+ // start rejecting kinds the moment one is added.
53
+ agents: z.array(z.union(AGENT_KINDS)).default([...AGENT_KINDS]),
54
+ extraRoots: z.array(z.object({
55
+ kind: z.union(AGENT_KINDS).default(DEFAULT_AGENT_KIND),
56
+ path: z.string().default(''),
57
+ })).default([]),
58
+ scope: z.union(['workspace', 'all']).default('all'),
59
+ maxTranscripts: z.natural().default(200),
60
+ maxReadTurns: z.natural().default(20),
61
+ maxScanBytes: z.natural().default(32 * 1024 * 1024),
62
+ headLines: z.natural().default(40),
63
+ tailLines: z.natural().default(20),
64
+ maxProbeBytes: z.natural().default(2 * 1024 * 1024),
65
+ maxLineBytes: z.natural().default(1024 * 1024),
66
+ directoryTtlMs: z.natural().default(5000),
67
+ includeThinking: z.boolean().default(false),
68
+ toolCalls: z.union(['full', 'elide', 'summarize', 'drop']).default('full'),
69
+ includeSidechains: z.boolean().default(false),
70
+ stripEnvironmentPreamble: z.boolean().default(true),
71
+ // The one switch that keeps `node:sqlite` out of the process entirely.
72
+ // Clearing the three database formats out of `agents` has the same effect,
73
+ // but this states the intent in one place — and it is the setting to reach
74
+ // for on a runtime whose SQLite build is not to be trusted.
75
+ sqlite: z.boolean().default(true),
76
+ maxSessionRecords: z.natural().default(2000),
77
+ });
78
+ /** Lists and reads other agents' transcripts from the local filesystem. */
79
+ export class LocalAgentService extends Service {
80
+ static inject = inject;
81
+ static Config = Config;
82
+ id = LOCAL_AGENT_SOURCE_ID;
83
+ /**
84
+ * Gated: these are other sessions' conversations, often from other projects.
85
+ * The model may read one only after its user named it.
86
+ */
87
+ requiresGrant = true;
88
+ settings;
89
+ adapters;
90
+ roots;
91
+ convert;
92
+ cache;
93
+ store;
94
+ constructor(ctx, config = {}) {
95
+ super(ctx, 'referenceLocalAgents');
96
+ this.settings = { ...defaults(), ...definedOnly(config) };
97
+ this.adapters = new Map(AGENT_ADAPTERS.map(adapter => [adapter.kind, adapter]));
98
+ this.roots = resolveRoots(this.settings, homedir());
99
+ this.convert = {
100
+ ...DEFAULT_CONVERT_OPTIONS,
101
+ includeThinking: this.settings.includeThinking,
102
+ toolCalls: this.settings.toolCalls,
103
+ toolResults: this.settings.toolCalls,
104
+ includeSidechains: this.settings.includeSidechains,
105
+ stripEnvironmentPreamble: this.settings.stripEnvironmentPreamble,
106
+ };
107
+ }
108
+ async [Service.init]() {
109
+ const domain = await this.ctx.storageDomain.open(localAgentDomainSpec);
110
+ this.ctx.effect(() => () => domain.close(), 'reference-local-agent.domainClose');
111
+ this.store = new AgentBookmarkStore(domain);
112
+ this.ctx.effect(() => this.ctx.references.registerSource(this), 'reference-local-agent.registerSource()');
113
+ }
114
+ /**
115
+ * @returns whether any configured root is a readable directory.
116
+ */
117
+ async available() {
118
+ const checks = await Promise.all(this.roots.map(async (root) => {
119
+ try {
120
+ return (await stat(root.path)).isDirectory();
121
+ }
122
+ catch {
123
+ // An agent that is not installed has no directory; that is ordinary.
124
+ return false;
125
+ }
126
+ }));
127
+ return checks.includes(true);
128
+ }
129
+ /** Replace UI-managed extra roots while retaining every deployment root. */
130
+ setAgentDirectories(directories) {
131
+ this.roots = resolveRoots({
132
+ ...this.settings,
133
+ extraRoots: [
134
+ ...this.settings.extraRoots,
135
+ ...Object.entries(directories).map(([kind, path]) => ({ kind: kind, path })),
136
+ ],
137
+ }, homedir());
138
+ this.cache = undefined;
139
+ }
140
+ /** Count transcripts recognized by the current roots, grouped by format. */
141
+ async stats(signal) {
142
+ const counts = new Map(AGENT_KINDS.map(kind => [kind, 0]));
143
+ for (const row of await this.scan(signal))
144
+ counts.set(row.kind, (counts.get(row.kind) ?? 0) + 1);
145
+ return AGENT_KINDS.map(kind => ({ kind, conversations: counts.get(kind) ?? 0 }));
146
+ }
147
+ /**
148
+ * List transcripts whose title or opening prompt matches `query`.
149
+ *
150
+ * Matching never touches a transcript's body. Ranking on body text would
151
+ * require reading every candidate on a keystroke, and an excerpt of one would
152
+ * put a conversation the user has not named in front of the model — the same
153
+ * invariant the web-chat source holds.
154
+ * @param query - case-insensitive substring; empty means the most recent.
155
+ * @param limit - maximum items to return.
156
+ * @param signal - cancellation from the caller.
157
+ * @returns matching transcripts, most relevant first, at most `limit`.
158
+ */
159
+ list(query, limit, signal) {
160
+ return this.listIn(query, limit, process.cwd(), signal);
161
+ }
162
+ /**
163
+ * The same discovery, for a caller that knows whose workspace it is asking on.
164
+ *
165
+ * {@link list} has nowhere to learn that from and falls back to the host
166
+ * process's own directory, which is right for a CLI started inside the project
167
+ * and wrong for a Web server serving sessions rooted anywhere on the machine.
168
+ * The `@` menu comes through here instead and passes the session's own cwd, so
169
+ * `scope: 'workspace'` means the user's workspace rather than the server's.
170
+ * @param query - case-insensitive substring; empty means the most recent.
171
+ * @param limit - maximum items to return.
172
+ * @param cwd - the asking session's working directory.
173
+ * @param signal - cancellation from the caller.
174
+ * @returns matching transcripts, most relevant first, at most `limit`.
175
+ */
176
+ listForWorkspace(query, limit, cwd, signal) {
177
+ return this.listIn(query, limit, cwd, signal);
178
+ }
179
+ async listIn(query, limit, cwd, signal) {
180
+ signal?.throwIfAborted();
181
+ const enabled = this.enabledAgentKinds();
182
+ const entries = (await this.scan(signal)).filter(entry => enabled.has(entry.kind));
183
+ const known = await this.describe(entries, signal);
184
+ const workspace = this.settings.scope === 'workspace' ? cwd : undefined;
185
+ const needle = query.trim();
186
+ const ranked = [];
187
+ for (const { descriptor, bookmark } of known) {
188
+ // A transcript known to hold no conversation is not offered: picking it
189
+ // could only ever produce an error. Un-probed rows have no bookmark and
190
+ // are still listed — absence of evidence is not emptiness.
191
+ if (bookmark?.empty === true)
192
+ continue;
193
+ if (workspace !== undefined && !inWorkspace(bookmark?.cwd ?? '', workspace))
194
+ continue;
195
+ const label = labelFor(descriptor, bookmark);
196
+ const match = needle === '' ? undefined : bestMatch(label, bookmark?.firstPrompt ?? '', needle);
197
+ if (needle !== '' && match === undefined)
198
+ continue;
199
+ ranked.push({
200
+ summary: this.summarize(descriptor, bookmark, label),
201
+ ...match === undefined ? {} : { match },
202
+ mtimeMs: descriptor.mtimeMs,
203
+ });
204
+ }
205
+ ranked.sort((left, right) => {
206
+ if (left.match !== undefined && right.match !== undefined) {
207
+ const byMatch = compareMatches(left.match, right.match);
208
+ if (byMatch !== 0)
209
+ return byMatch;
210
+ }
211
+ return right.mtimeMs - left.mtimeMs;
212
+ });
213
+ return ranked.slice(0, limit).map(entry => entry.summary);
214
+ }
215
+ /** Durable discovery policy. Reads stay independent so granted old refs work. */
216
+ enabledAgentKinds() {
217
+ const history = this.ctx.get('referenceChatHistory');
218
+ const configured = history?.store.settings.enabledAgents ?? AGENT_KINDS;
219
+ return new Set(configured);
220
+ }
221
+ /**
222
+ * Read one window of turns from a transcript.
223
+ * @param ref - a reference this source owns; `ref.id` is `kind:relPath`.
224
+ * @param window - which turns to return.
225
+ * @param signal - cancellation from the caller.
226
+ * @returns the requested turns and their position in the conversation.
227
+ */
228
+ async read(ref, window, signal) {
229
+ signal?.throwIfAborted();
230
+ const { adapter, descriptor, sessionId } = await this.locate(ref.id);
231
+ const cursor = window.cursor === undefined ? undefined : decodeAgentCursor(window.cursor, ref.id);
232
+ if (cursor !== undefined && !cursorStillValid(cursor, descriptor.size))
233
+ throw cursorExpired();
234
+ // A caller may ask for more than this deployment is willing to project;
235
+ // returning fewer is already legal, and `hasOlder` stays honest because it
236
+ // is derived from the limit actually used.
237
+ const limit = Math.max(1, Math.min(window.limit, this.settings.maxReadTurns));
238
+ const bounds = resolveBefore(window, cursor);
239
+ const convert = window.detail === 'summary'
240
+ ? { ...this.convert, toolCalls: 'elide', toolResults: 'drop' }
241
+ : this.convert;
242
+ const page = isQueryAdapter(adapter)
243
+ ? await this.readSession(adapter, descriptor, sessionId ?? '', limit, bounds.before, convert, signal)
244
+ : await readTurns(descriptor.path, descriptor.size, adapter, convert, { limit, ...bounds }, { maxScanBytes: this.settings.maxScanBytes, maxLineBytes: this.settings.maxLineBytes }, signal);
245
+ if (page.items.length === 0 && descriptor.size > 0 && window.before === undefined && cursor === undefined) {
246
+ throw new ReferenceAnythingError(`transcript ${JSON.stringify(ref.id)} holds no conversation; it is an aborted session, still being written, or in a shape this adapter does not understand`, 'REFERENCE_READ_FAILED');
247
+ }
248
+ // Same split `describe()` makes: a database-backed session is named by the
249
+ // row that was just read, and a file-backed one by its stored bookmark.
250
+ const bookmark = descriptor.session === undefined
251
+ ? this.store?.fresh(descriptor.kind, descriptor.relPath, descriptor.mtimeMs, descriptor.size)
252
+ : sessionBookmark(descriptor, descriptor.session);
253
+ const label = labelFor(descriptor, bookmark);
254
+ const nextCursor = nextCursorFor(ref.id, descriptor.size, page);
255
+ return {
256
+ ref,
257
+ label,
258
+ origin: descriptor.path,
259
+ updatedAt: descriptor.mtimeMs,
260
+ provider: this.adapters.get(descriptor.kind)?.displayName ?? descriptor.kind,
261
+ body: {
262
+ kind: 'conversation',
263
+ items: page.items.map(turn => ({ role: turn.role, text: turn.text })),
264
+ startIndex: page.startIndex,
265
+ ...page.totalTurns === undefined ? {} : { totalTurns: page.totalTurns },
266
+ hasOlder: page.hasOlder,
267
+ ...nextCursor === undefined ? {} : { nextCursor },
268
+ },
269
+ // Two different losses, both meaning turns nobody here can reach: a
270
+ // compaction the other agent performed, and a file too large to stream.
271
+ partial: page.compacted || page.anchored,
272
+ capturedAt: Date.now(),
273
+ };
274
+ }
275
+ /**
276
+ * Read one window of turns out of a database-backed session.
277
+ *
278
+ * A database is not streamable the way a JSONL file is: there is no byte
279
+ * offset that means "the turn before this one", so the whole conversation is
280
+ * materialized under a record cap and then sliced. That is affordable for the
281
+ * same reason the cap exists — the cap is what bounds the memory, and it
282
+ * counts from the *end*, so the turns nearest the present are the ones kept.
283
+ * @param adapter - the adapter that knows this schema.
284
+ * @param descriptor - the database and session to read.
285
+ * @param sessionId - which conversation inside the database.
286
+ * @param limit - turns to return.
287
+ * @param before - exclusive upper bound in turn indices.
288
+ * @param signal - cancellation from the caller.
289
+ * @returns the same page shape a streamed read produces.
290
+ */
291
+ async readSession(adapter, descriptor, sessionId, limit, before, convert, signal) {
292
+ const session = await readSessionTurns(descriptor.path, adapter, sessionId, convert, this.settings.maxSessionRecords, signal);
293
+ const slice = sliceTurns(session.items, { limit, ...before === undefined ? {} : { before } });
294
+ // `sliceTurns` speaks the package's wider wire type, which now admits
295
+ // `document` for file referents. A transcript never produces one — these
296
+ // items are the ParsedTurns handed in a line above — so the guard is a
297
+ // narrowing, not a filter with real work to do.
298
+ const items = slice.items.flatMap(item => item.role === 'document' ? [] : [{ role: item.role, text: item.text }]);
299
+ return {
300
+ items,
301
+ startIndex: slice.startIndex,
302
+ // A capped read never saw the first message, so the count it can offer is
303
+ // of what it materialized — which is not the conversation's length.
304
+ ...session.truncated ? {} : { totalTurns: slice.totalTurns },
305
+ hasOlder: slice.hasOlder || session.truncated,
306
+ compacted: session.compacted,
307
+ // `anchored` means "could not see the beginning", which a capped read
308
+ // could not; it is what makes the snapshot report itself partial.
309
+ anchored: session.truncated && !slice.hasOlder,
310
+ };
311
+ }
312
+ /** Reuse one directory scan across a typing burst. */
313
+ async scan(signal) {
314
+ const now = Date.now();
315
+ const cached = this.cache;
316
+ if (cached !== undefined && now - cached.scannedAt < this.settings.directoryTtlMs)
317
+ return cached.entries;
318
+ const entries = await listTranscripts(this.roots, this.adapters, this.settings.maxTranscripts, signal);
319
+ this.cache = { scannedAt: now, entries };
320
+ await this.prune(entries);
321
+ return entries;
322
+ }
323
+ /**
324
+ * Drop bookmarks whose transcripts are gone, but only after a complete scan.
325
+ *
326
+ * A scan that hit `maxTranscripts` reports the newest files, not all of
327
+ * them. Treating that list as everything on disk would evict the bookmark of
328
+ * every older transcript that is still there, and each would be re-probed
329
+ * the next time it surfaced — turning a cap on listing into a cache that can
330
+ * never warm.
331
+ */
332
+ async prune(entries) {
333
+ const store = this.store;
334
+ if (store === undefined || entries.length >= this.settings.maxTranscripts)
335
+ return;
336
+ await store.forgetMissing(new Set(entries
337
+ // Session descriptors never had a bookmark written, so they contribute no
338
+ // live keys — and including them would be harmless but misleading.
339
+ .filter(entry => entry.session === undefined)
340
+ .map(entry => AgentBookmarkStore.key(entry.kind, entry.relPath))));
341
+ }
342
+ /**
343
+ * Pair each transcript with what is known about it, probing a bounded few.
344
+ *
345
+ * A transcript with no bookmark yet renders under a fallback label and is
346
+ * picked up by a later call, because probing all of them at once would put an
347
+ * unbounded read behind a keystroke.
348
+ */
349
+ async describe(entries, signal) {
350
+ const store = this.store;
351
+ const paired = entries.map(descriptor => ({
352
+ descriptor,
353
+ // A database-backed session needs no bookmark and gets none: its `session`
354
+ // row already carries the title, cwd, and times a probe would have to read
355
+ // a file to find. Persisting one would also be unsound — the bookmark's
356
+ // validity test is the *file's* mtime and size, which move whenever any
357
+ // other conversation in the same database does.
358
+ bookmark: descriptor.session === undefined
359
+ ? store?.fresh(descriptor.kind, descriptor.relPath, descriptor.mtimeMs, descriptor.size)
360
+ : sessionBookmark(descriptor, descriptor.session),
361
+ }));
362
+ if (store === undefined)
363
+ return paired;
364
+ const stale = paired.filter(entry => entry.bookmark === undefined).slice(0, BACKFILL_PER_LIST);
365
+ let next = 0;
366
+ await Promise.all(Array.from({ length: Math.min(BACKFILL_CONCURRENCY, stale.length) }, async () => {
367
+ while (next < stale.length) {
368
+ const entry = stale[next];
369
+ next += 1;
370
+ if (entry === undefined)
371
+ break;
372
+ signal?.throwIfAborted();
373
+ const bookmark = await this.probe(entry.descriptor, signal);
374
+ if (bookmark === undefined)
375
+ continue;
376
+ entry.bookmark = bookmark;
377
+ await store.remember(bookmark);
378
+ }
379
+ }));
380
+ return paired;
381
+ }
382
+ /** Read a transcript's two ends and fold them into a bookmark. */
383
+ async probe(descriptor, signal) {
384
+ const adapter = this.adapters.get(descriptor.kind);
385
+ // A database has no two ends to probe; `describe` pairs those descriptors
386
+ // with a bookmark of their own and never reaches this.
387
+ if (adapter === undefined || isQueryAdapter(adapter))
388
+ return undefined;
389
+ let ends;
390
+ try {
391
+ ends = await probeEnds(descriptor.path, descriptor.size, {
392
+ headLines: this.settings.headLines,
393
+ tailLines: this.settings.tailLines,
394
+ maxProbeBytes: this.settings.maxProbeBytes,
395
+ }, signal);
396
+ }
397
+ catch {
398
+ // A transcript being rewritten under us is not a reason to fail the menu.
399
+ return undefined;
400
+ }
401
+ const head = adapter.head(ends.headLines, ends.tailLines);
402
+ return {
403
+ kind: descriptor.kind,
404
+ relPath: descriptor.relPath,
405
+ mtimeMs: descriptor.mtimeMs,
406
+ size: descriptor.size,
407
+ title: head.title ?? '',
408
+ cwd: head.cwd ?? '',
409
+ firstPrompt: head.firstPrompt ?? '',
410
+ createdAt: head.createdAt ?? 0,
411
+ empty: ends.complete && foldsToNothing(adapter, ends.headLines, this.convert),
412
+ indexedAt: Date.now(),
413
+ };
414
+ }
415
+ summarize(descriptor, bookmark, label) {
416
+ return {
417
+ ref: { source: LOCAL_AGENT_SOURCE_ID, id: referenceId(descriptor.kind, descriptor.relPath) },
418
+ label,
419
+ origin: descriptor.path,
420
+ updatedAt: descriptor.mtimeMs,
421
+ provider: this.adapters.get(descriptor.kind)?.displayName ?? descriptor.kind,
422
+ ...bookmark === undefined ? { partial: true } : {},
423
+ };
424
+ }
425
+ /**
426
+ * Resolve a reference id to a transcript inside a configured root.
427
+ *
428
+ * Containment is checked against the resolved real path, so neither a `..`
429
+ * segment nor a symlink out of a root can reach a file the deployment never
430
+ * offered.
431
+ */
432
+ async locate(id) {
433
+ const parsed = parseReferenceId(id);
434
+ const adapter = parsed === undefined ? undefined : this.adapters.get(parsed.kind);
435
+ if (parsed === undefined || adapter === undefined) {
436
+ throw new ReferenceAnythingError(`${JSON.stringify(id)} does not name a transcript this source can read`, 'REFERENCE_NOT_FOUND');
437
+ }
438
+ // Only a database-backed id is split, so a real filename containing `#`
439
+ // keeps its meaning for every format that walks files.
440
+ const split = isQueryAdapter(adapter) ? splitSession(parsed.relPath) : undefined;
441
+ const relPath = split?.relPath ?? parsed.relPath;
442
+ // One database holds many conversations, so an id naming only the file
443
+ // names nothing readable — as does one whose separator has nothing after it.
444
+ if (isQueryAdapter(adapter) && (split === undefined || split.sessionId === '')) {
445
+ throw new ReferenceAnythingError(`${JSON.stringify(id)} names a database but no conversation inside it`, 'REFERENCE_NOT_FOUND');
446
+ }
447
+ for (const root of this.roots) {
448
+ if (root.kind !== parsed.kind)
449
+ continue;
450
+ const candidate = resolve(root.path, relPath);
451
+ let real;
452
+ let realRoot;
453
+ try {
454
+ real = await realpath(candidate);
455
+ realRoot = await realpath(root.path);
456
+ }
457
+ catch {
458
+ continue;
459
+ }
460
+ if (real !== realRoot && !real.startsWith(realRoot.endsWith(sep) ? realRoot : `${realRoot}${sep}`)) {
461
+ throw new ReferenceAnythingError(`transcript ${JSON.stringify(id)} resolves outside its configured root`, 'REFERENCE_NOT_FOUND');
462
+ }
463
+ const stats = await stat(real).catch(() => undefined);
464
+ if (stats === undefined || !stats.isFile())
465
+ continue;
466
+ const found = relative(realRoot, real);
467
+ // The row that describes the conversation, which for a database is the
468
+ // only thing that can name it: no bookmark is ever persisted for one.
469
+ const session = split === undefined || !isQueryAdapter(adapter)
470
+ ? undefined
471
+ : await readSessionInfo(real, adapter, split.sessionId);
472
+ return {
473
+ adapter,
474
+ descriptor: {
475
+ kind: parsed.kind,
476
+ path: real,
477
+ // The id is rebuilt from what was resolved, so the descriptor the
478
+ // caller gets round-trips back to the reference it came from.
479
+ relPath: split === undefined ? found : `${found}${SESSION_SEPARATOR}${split.sessionId}`,
480
+ mtimeMs: stats.mtimeMs,
481
+ size: stats.size,
482
+ ...session === undefined ? {} : { session },
483
+ },
484
+ ...split === undefined ? {} : { sessionId: split.sessionId },
485
+ };
486
+ }
487
+ throw new ReferenceAnythingError(`no transcript named ${JSON.stringify(id)} exists under the configured roots`, 'REFERENCE_NOT_FOUND');
488
+ }
489
+ }
490
+ /**
491
+ * Register the local agent reference source.
492
+ * @param ctx - the mounting context.
493
+ * @param config - validated deployment settings.
494
+ */
495
+ export function apply(ctx, config = {}) {
496
+ const agents = config.agents ?? defaults().agents;
497
+ const extraRoots = config.extraRoots ?? [];
498
+ if (agents.length === 0 && extraRoots.length === 0) {
499
+ throw new ReferenceAnythingError('reference-local-agent needs at least one entry in "agents" or "extraRoots"; mounting it with neither can never resolve a reference', 'REFERENCE_INVALID_CONFIG');
500
+ }
501
+ ctx.plugin(LocalAgentService, config);
502
+ }
503
+ /** Compose the reference id from the two parts that identify a transcript. */
504
+ export function referenceId(kind, relPath) {
505
+ return `${kind}:${relPath.split(sep).join('/')}`;
506
+ }
507
+ /**
508
+ * Split a database-backed relative path into the file and the session in it.
509
+ *
510
+ * Only the last separator counts: a database's own filename is far more likely
511
+ * to contain a `#` than a session id is, and taking the last one keeps the id
512
+ * unambiguous either way.
513
+ * @param relPath - the relative path half of a reference id.
514
+ * @returns the database path and session id, or undefined when none was named.
515
+ */
516
+ export function splitSession(relPath) {
517
+ const at = relPath.lastIndexOf(SESSION_SEPARATOR);
518
+ if (at < 0)
519
+ return undefined;
520
+ return { relPath: relPath.slice(0, at), sessionId: relPath.slice(at + 1) };
521
+ }
522
+ /**
523
+ * Describe a database-backed session from the row that already describes it.
524
+ *
525
+ * Shaped as an {@link AgentBookmark} so ranking, workspace scoping, and
526
+ * labelling need no second code path — but never written to the store, because
527
+ * its validity test is about a file and this is about one conversation inside
528
+ * one.
529
+ * @param descriptor - the session's descriptor.
530
+ * @param session - the row discovery read.
531
+ * @returns a bookmark that is true for exactly this listing.
532
+ */
533
+ function sessionBookmark(descriptor, session) {
534
+ return {
535
+ kind: descriptor.kind,
536
+ relPath: descriptor.relPath,
537
+ mtimeMs: descriptor.mtimeMs,
538
+ size: descriptor.size,
539
+ title: session.title,
540
+ cwd: session.cwd,
541
+ // A session row carries no opening prompt, so ranking matches the title
542
+ // alone here rather than reading messages behind a keystroke.
543
+ firstPrompt: '',
544
+ createdAt: session.createdAt,
545
+ empty: false,
546
+ indexedAt: Date.now(),
547
+ };
548
+ }
549
+ /** Split a reference id back into its parts, or undefined when it is not one. */
550
+ export function parseReferenceId(id) {
551
+ const at = id.indexOf(':');
552
+ if (at <= 0)
553
+ return undefined;
554
+ const kind = id.slice(0, at);
555
+ const relPath = id.slice(at + 1);
556
+ if (!isAgentKind(kind) || relPath === '')
557
+ return undefined;
558
+ return { kind, relPath: relPath.split('/').join(sep) };
559
+ }
560
+ /** Every configured directory, tagged with the agent that writes into it. */
561
+ function resolveRoots(settings, home) {
562
+ const roots = [];
563
+ const seen = new Set();
564
+ const add = (kind, path) => {
565
+ // With SQLite off, the database formats get no root at all — which is what
566
+ // keeps `node:sqlite` from ever being imported, since nothing below the
567
+ // scan asks for a driver until a matching file has been walked to.
568
+ if (!settings.sqlite && isQueryKind(kind))
569
+ return;
570
+ const key = `${kind}\0${path}`;
571
+ if (seen.has(key))
572
+ return;
573
+ seen.add(key);
574
+ roots.push({ kind, path });
575
+ };
576
+ for (const kind of settings.agents) {
577
+ const adapter = adapterFor(kind);
578
+ if (adapter === undefined)
579
+ continue;
580
+ for (const path of adapter.defaultRoots(home))
581
+ add(kind, path);
582
+ }
583
+ for (const extra of settings.extraRoots) {
584
+ const path = expandRoot(extra.path, home);
585
+ if (path !== undefined)
586
+ add(extra.kind, path);
587
+ }
588
+ return roots;
589
+ }
590
+ /** Where a read should start, given an explicit bound or a continuation. */
591
+ function resolveBefore(window, cursor) {
592
+ if (cursor?.kind === 'offset')
593
+ return { beforeOffset: cursor.offset };
594
+ if (cursor?.kind === 'index')
595
+ return { before: cursor.index };
596
+ return window.before === undefined ? {} : { before: window.before };
597
+ }
598
+ /** The token that would fetch the page before this one, if there is one. */
599
+ function nextCursorFor(id, size, page) {
600
+ if (!page.hasOlder)
601
+ return undefined;
602
+ if (page.anchored) {
603
+ return page.startOffset === undefined
604
+ ? undefined
605
+ : encodeAgentCursor(id, { kind: 'offset', size, offset: page.startOffset });
606
+ }
607
+ return encodeAgentCursor(id, { kind: 'index', size, index: page.startIndex });
608
+ }
609
+ /** The best match across the two fields ranking is allowed to see. */
610
+ function bestMatch(label, firstPrompt, needle) {
611
+ const onLabel = scoreTitle(label, needle);
612
+ const onPrompt = firstPrompt === '' ? undefined : scoreTitle(firstPrompt, needle);
613
+ if (onLabel === undefined)
614
+ return onPrompt;
615
+ if (onPrompt === undefined)
616
+ return onLabel;
617
+ return compareMatches(onLabel, onPrompt) <= 0 ? onLabel : onPrompt;
618
+ }
619
+ /**
620
+ * Name one transcript.
621
+ *
622
+ * Falls back to the filename rather than to nothing: an un-probed transcript
623
+ * still has to be pickable, and it will carry its real title next time.
624
+ */
625
+ /**
626
+ * Whether folding every record of a transcript produces no turn at all.
627
+ *
628
+ * Runs the same fold `read()` does, so the answer cannot drift from what a read
629
+ * would actually return — the alternative, inferring emptiness from a missing
630
+ * title, would hide any conversation whose opening prompt sits past the probe's
631
+ * line clip. Only meaningful over a complete set of lines.
632
+ * @param adapter - the format's adapter.
633
+ * @param lines - every record in the transcript.
634
+ * @param options - the same projection settings `read()` uses.
635
+ * @returns whether the transcript is devoid of conversation.
636
+ */
637
+ function foldsToNothing(adapter, lines, options) {
638
+ const state = adapter.createState();
639
+ for (const line of lines) {
640
+ if (adapter.step(line, state, options).length > 0)
641
+ return false;
642
+ }
643
+ return adapter.flush(state, options).length === 0;
644
+ }
645
+ function labelFor(descriptor, bookmark) {
646
+ const title = bookmark?.title.trim() ?? '';
647
+ return title === '' ? basename(descriptor.relPath) : title;
648
+ }
649
+ /** The schema's own defaults, as a plain object. */
650
+ function defaults() {
651
+ return Config({});
652
+ }
653
+ /** Drop absent keys so they do not overwrite defaults with undefined. */
654
+ function definedOnly(config) {
655
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
656
+ }