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.
- package/LICENSE +22 -22
- package/NOTICE.md +52 -25
- package/README.md +342 -284
- package/cordis.patch.yml +89 -18
- package/lib/client.js +13534 -521
- package/lib/contract.d.ts +155 -3
- package/lib/contract.js +68 -4
- package/lib/download-directory.d.ts +18 -0
- package/lib/download-directory.js +55 -0
- package/lib/errors.d.ts +4 -0
- package/lib/host.d.ts +96 -3
- package/lib/host.js +88 -5
- package/lib/index.d.ts +22 -2
- package/lib/index.js +57 -7
- package/lib/opencli.d.ts +5 -3
- package/lib/opencli.js +13 -6
- package/lib/openlist/host.d.ts +157 -0
- package/lib/openlist/host.js +873 -0
- package/lib/openlist/index.d.ts +84 -0
- package/lib/openlist/index.js +507 -0
- package/lib/render.d.ts +12 -7
- package/lib/render.js +27 -21
- package/lib/sources/cloud-drive/cache.d.ts +60 -0
- package/lib/sources/cloud-drive/cache.js +107 -0
- package/lib/sources/cloud-drive/index.d.ts +190 -0
- package/lib/sources/cloud-drive/index.js +526 -0
- package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
- package/lib/sources/cloud-drive/providers/http.js +73 -0
- package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
- package/lib/sources/cloud-drive/providers/openlist.js +535 -0
- package/lib/sources/cloud-drive/registry.d.ts +53 -0
- package/lib/sources/cloud-drive/registry.js +101 -0
- package/lib/sources/cloud-drive/types.d.ts +169 -0
- package/lib/sources/cloud-drive/types.js +17 -0
- package/lib/sources/file.d.ts +2 -0
- package/lib/sources/file.js +2 -0
- package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
- package/lib/sources/local-agent/adapters/claude-code.js +171 -0
- package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
- package/lib/sources/local-agent/adapters/codex.js +204 -0
- package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
- package/lib/sources/local-agent/adapters/cursor.js +121 -0
- package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
- package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
- package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
- package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
- package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
- package/lib/sources/local-agent/adapters/hermes.js +85 -0
- package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
- package/lib/sources/local-agent/adapters/kimi.js +261 -0
- package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
- package/lib/sources/local-agent/adapters/openclaw.js +120 -0
- package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
- package/lib/sources/local-agent/adapters/opencode.js +236 -0
- package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
- package/lib/sources/local-agent/adapters/pi.js +242 -0
- package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
- package/lib/sources/local-agent/adapters/qoder.js +93 -0
- package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
- package/lib/sources/local-agent/adapters/reasonix.js +90 -0
- package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
- package/lib/sources/local-agent/adapters/shared.js +415 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
- package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
- package/lib/sources/local-agent/adapters/zcode.js +159 -0
- package/lib/sources/local-agent/index.d.ts +222 -0
- package/lib/sources/local-agent/index.js +656 -0
- package/lib/sources/local-agent/page.d.ts +55 -0
- package/lib/sources/local-agent/page.js +83 -0
- package/lib/sources/local-agent/path.d.ts +8 -0
- package/lib/sources/local-agent/path.js +43 -0
- package/lib/sources/local-agent/registry.d.ts +68 -0
- package/lib/sources/local-agent/registry.js +116 -0
- package/lib/sources/local-agent/scan.d.ts +175 -0
- package/lib/sources/local-agent/scan.js +472 -0
- package/lib/sources/local-agent/sqlite.d.ts +45 -0
- package/lib/sources/local-agent/sqlite.js +113 -0
- package/lib/sources/local-agent/store.d.ts +93 -0
- package/lib/sources/local-agent/store.js +113 -0
- package/lib/sources/local-agent/types.d.ts +338 -0
- package/lib/sources/local-agent/types.js +20 -0
- package/lib/sources/web-chat/index.d.ts +5 -1
- package/lib/sources/web-chat/index.js +47 -8
- package/lib/store/spec.d.ts +50 -0
- package/lib/store/spec.js +2 -2
- package/lib/sync/index.d.ts +1 -0
- package/lib/sync/index.js +28 -8
- package/lib/tool.d.ts +5 -0
- package/lib/tool.js +418 -45
- package/lib/typert.js +13 -1
- package/lib/types.d.ts +28 -2
- package/lib/update.d.ts +6 -0
- package/lib/update.js +29 -1
- package/lib/web.js +5 -1
- package/lib/wire.d.ts +80 -0
- package/lib/wire.js +30 -1
- package/opencli-plugin/chatgpt.js +30 -27
- package/opencli-plugin/claude.js +12 -8
- package/opencli-plugin/common.js +266 -187
- package/opencli-plugin/deepseek.js +8 -5
- package/opencli-plugin/gemini.js +42 -38
- package/opencli-plugin/grok.js +22 -21
- package/opencli-plugin/kimi.js +34 -31
- package/opencli-plugin/opencli-plugin.json +6 -6
- package/opencli-plugin/package.json +8 -8
- 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
|
+
}
|