dsh-reference-anything 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/reference.js CHANGED
@@ -8,21 +8,17 @@
8
8
  * the loop durably appends, so injected material is logged by construction and
9
9
  * a replay reconstructs the request exactly.
10
10
  *
11
- * Two schemes are expanded. `dsh-ref:` names material outside the harness and
12
- * is resolved through `ctx.references`. `dsh-session:` names another harness
13
- * session and is delegated to `ctx.sessionReferenceResolver`, which owns
14
- * session-surface semantics — compaction folding, shadowed events, cited event
15
- * seqs — that do not generalize to an outside conversation.
11
+ * This plugin owns only `dsh-ref:`, which names material outside the harness
12
+ * and is resolved through `ctx.references`. Native file and DSH-session
13
+ * references are owned end-to-end by their official DSH services.
16
14
  *
17
15
  * @module dsh-reference-anything/reference
18
16
  */
19
17
  import z from '@deepseek-ai/schemastery';
20
18
  import { boundContextSummary, createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm';
21
- import { SESSION_REFERENCE_SCHEME, parseSessionReferenceText, } from '@deepseek-ai/dsh-session-reference';
22
19
  import { ReferenceAnythingError } from "./errors.js";
23
20
  import { renderDeferredReferences } from "./render.js";
24
21
  import { mayContainReference, parseReferenceText } from "./uri.js";
25
- import { WORKSPACE_REFERENCE_SCHEME, parseWorkspaceReferenceText, renderWorkspaceReferences } from "./workspace.js";
26
22
  /** Cordis plugin name used by loader diagnostics. */
27
23
  export const name = 'reference-mention';
28
24
  /** The registry this expander reads through. */
@@ -32,7 +28,6 @@ export const MAX_REFERENCES = 3;
32
28
  /** Runtime schema for {@link Config}. */
33
29
  export const Config = z.object({
34
30
  maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES),
35
- serveSessionScheme: z.boolean().default(true),
36
31
  });
37
32
  /**
38
33
  * Register the pre-step mention expander.
@@ -41,7 +36,6 @@ export const Config = z.object({
41
36
  */
42
37
  export function apply(ctx, config = {}) {
43
38
  const maxReferences = Math.min(config.maxReferences ?? MAX_REFERENCES, MAX_REFERENCES);
44
- const serveSessionScheme = config.serveSessionScheme ?? true;
45
39
  ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
46
40
  const decision = await next();
47
41
  if (decision.kind === 'reject' || signal.aborted)
@@ -49,7 +43,7 @@ export function apply(ctx, config = {}) {
49
43
  // The overwhelmingly common case is a prompt with no mentions at all, and
50
44
  // this runs on every step of every turn. Screening with substring scans
51
45
  // before any parsing keeps that case free.
52
- const target = decision.messages.findIndex(candidate => candidate.source.kind === 'user' && messageText(candidate).some(text => mentions(text, serveSessionScheme)));
46
+ const target = decision.messages.findIndex(candidate => candidate.source.kind === 'user' && messageText(candidate).some(mayContainReference));
53
47
  if (target < 0)
54
48
  return decision;
55
49
  const original = decision.messages[target];
@@ -57,7 +51,7 @@ export function apply(ctx, config = {}) {
57
51
  return decision;
58
52
  let parsed;
59
53
  try {
60
- parsed = parseMessage(original, serveSessionScheme);
54
+ parsed = parseMessage(original);
61
55
  }
62
56
  catch (error) {
63
57
  // A malformed URI inside an explicit mention is the user asking for
@@ -65,11 +59,11 @@ export function apply(ctx, config = {}) {
65
59
  // reference silently resolves to nothing.
66
60
  return entered(decision, target, [notice(describe(error))], original);
67
61
  }
68
- if (parsed.references.length === 0 && parsed.sessionReferences.length === 0 && parsed.workspaceReferences.length === 0)
62
+ if (parsed.references.length === 0)
69
63
  return decision;
70
64
  let contexts;
71
65
  try {
72
- contexts = await resolve(ctx, agent, parsed, { maxReferences, serveSessionScheme }, signal);
66
+ contexts = await resolve(ctx, agent, parsed, maxReferences, signal);
73
67
  }
74
68
  catch (error) {
75
69
  if (signal.aborted)
@@ -85,28 +79,13 @@ export function apply(ctx, config = {}) {
85
79
  * Authorize and render the named references without reading their bodies.
86
80
  * The agent decides whether the current request warrants `reference_read`.
87
81
  */
88
- async function resolve(ctx, agent, parsed, limits, signal) {
82
+ async function resolve(ctx, agent, parsed, maxReferences, signal) {
89
83
  const contexts = [];
90
- if (parsed.workspaceReferences.length > 0) {
91
- contexts.push(createUserMessage({
92
- content: [{ type: 'text', text: await renderWorkspaceReferences(agent, parsed.workspaceReferences) }],
93
- source: { kind: 'plugin', plugin: name, form: 'recall' },
94
- }));
95
- }
96
- if (parsed.sessionReferences.length > 0) {
97
- const resolver = ctx.get('sessionReferenceResolver');
98
- if (resolver === undefined) {
99
- throw new ReferenceAnythingError('this deployment cannot reference other harness sessions: the cross-session resolver is not mounted', 'SOURCE_UNAVAILABLE');
100
- }
101
- const prepared = await resolver.prepare(agent, contentOf(parsed.message), [...parsed.sessionReferences], signal);
102
- if (prepared.additionalContext !== undefined)
103
- contexts.push(prepared.additionalContext);
104
- }
105
84
  if (parsed.references.length > 0) {
106
85
  const inputs = dedupe(parsed.references);
107
- if (inputs.length > limits.maxReferences) {
108
- throw new ReferenceAnythingError(`a message may reference at most ${limits.maxReferences} outside conversation`
109
- + `${limits.maxReferences === 1 ? '' : 's'}; this one named ${inputs.length}`, 'REFERENCE_TOO_MANY');
86
+ if (inputs.length > maxReferences) {
87
+ throw new ReferenceAnythingError(`a message may reference at most ${maxReferences} outside conversation`
88
+ + `${maxReferences === 1 ? '' : 's'}; this one named ${inputs.length}`, 'REFERENCE_TOO_MANY');
110
89
  }
111
90
  for (const input of inputs)
112
91
  ctx.references.grant(String(agent.session.id), input.ref);
@@ -134,28 +113,16 @@ function entered(decision, target, contexts, message) {
134
113
  const replaced = decision.messages.with(target, message);
135
114
  return { kind: 'enter', messages: replaced.toSpliced(target, 0, ...contexts) };
136
115
  }
137
- /** Rewrite both schemes' mentions to readable text, preserving message identity. */
138
- function parseMessage(message, serveSessionScheme) {
116
+ /** Rewrite external-conversation mentions to readable text, preserving message identity. */
117
+ function parseMessage(message) {
139
118
  const references = [];
140
- const sessionReferences = [];
141
- const workspaceReferences = [];
142
119
  let changed = false;
143
120
  const content = message.content.map((block) => {
144
121
  if (block.type !== 'text')
145
122
  return block;
146
123
  const own = parseReferenceText(block.text);
147
124
  references.push(...own.references);
148
- let text = own.text;
149
- if (text.includes(WORKSPACE_REFERENCE_SCHEME)) {
150
- const workspace = parseWorkspaceReferenceText(text);
151
- workspaceReferences.push(...workspace.references);
152
- text = workspace.text;
153
- }
154
- if (serveSessionScheme && text.includes(SESSION_REFERENCE_SCHEME)) {
155
- const session = parseSessionReferenceText(text);
156
- sessionReferences.push(...session.references);
157
- text = session.text;
158
- }
125
+ const text = own.text;
159
126
  if (text === block.text)
160
127
  return block;
161
128
  changed = true;
@@ -166,8 +133,6 @@ function parseMessage(message, serveSessionScheme) {
166
133
  return {
167
134
  message: changed ? freezeMessage({ ...message, content }) : message,
168
135
  references,
169
- sessionReferences,
170
- workspaceReferences,
171
136
  };
172
137
  }
173
138
  /** First-appearance order, one entry per distinct reference. */
@@ -183,15 +148,9 @@ function dedupe(references) {
183
148
  }
184
149
  return unique;
185
150
  }
186
- function mentions(text, serveSessionScheme) {
187
- return mayContainReference(text) || text.includes(WORKSPACE_REFERENCE_SCHEME) || (serveSessionScheme && text.includes(SESSION_REFERENCE_SCHEME));
188
- }
189
151
  function messageText(message) {
190
152
  return message.content.flatMap(block => block.type === 'text' ? [block.text] : []);
191
153
  }
192
- function contentOf(message) {
193
- return message.content.map(block => ({ ...block }));
194
- }
195
154
  function notice(summary) {
196
155
  return createUserMessage({
197
156
  content: [{
@@ -94,6 +94,7 @@ export default class WebChatHistoryService extends Service implements ReferenceS
94
94
  installOpenCli(signal?: AbortSignal): Promise<OpenCliDiscovery>;
95
95
  updateSettings(value: unknown): Promise<SettingsRecord>;
96
96
  health(signal?: AbortSignal): Promise<import("../../opencli.ts").OpenCliHealth>;
97
+ quickHealth(signal?: AbortSignal): Promise<import("../../opencli.ts").OpenCliHealth>;
97
98
  profiles(signal?: AbortSignal): Promise<import("../../opencli.ts").BrowserProfile[]>;
98
99
  private runner;
99
100
  installAdapter(signal?: AbortSignal): Promise<boolean>;
@@ -229,6 +229,11 @@ export default class WebChatHistoryService extends Service {
229
229
  return new OpenCliRunner({ executable: settings.opencliPath, profile: settings.profile,
230
230
  timeoutMs: this.config.timeoutMs, maxStdoutBytes: this.config.maxStdoutBytes }).health(signal);
231
231
  }
232
+ quickHealth(signal) {
233
+ const settings = this.store.settings;
234
+ return new OpenCliRunner({ executable: settings.opencliPath, profile: settings.profile,
235
+ timeoutMs: this.config.timeoutMs, maxStdoutBytes: this.config.maxStdoutBytes }).quickHealth(signal);
236
+ }
232
237
  profiles(signal) {
233
238
  const settings = this.store.settings;
234
239
  return new OpenCliRunner({ executable: settings.opencliPath, profile: '',
@@ -192,31 +192,44 @@ export declare const referenceAnythingDomainSpec: {
192
192
  pill: "pill";
193
193
  "raw-text": "raw-text";
194
194
  }>>;
195
+ referenceUiMode: z.ZodOptional<z.ZodEnum<{
196
+ plugin: "plugin";
197
+ official: "official";
198
+ }>>;
195
199
  picker: z.ZodOptional<z.ZodObject<{
200
+ displayMode: z.ZodDefault<z.ZodEnum<{
201
+ collapse: "collapse";
202
+ "native-scroll": "native-scroll";
203
+ }>>;
196
204
  commands: z.ZodObject<{
197
205
  enabled: z.ZodBoolean;
198
206
  order: z.ZodNumber;
199
207
  limit: z.ZodNumber;
208
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
200
209
  }, z.core.$strip>;
201
210
  skills: z.ZodObject<{
202
211
  enabled: z.ZodBoolean;
203
212
  order: z.ZodNumber;
204
213
  limit: z.ZodNumber;
214
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
205
215
  }, z.core.$strip>;
206
216
  files: z.ZodObject<{
207
217
  enabled: z.ZodBoolean;
208
218
  order: z.ZodNumber;
209
219
  limit: z.ZodNumber;
220
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
210
221
  }, z.core.$strip>;
211
222
  sessions: z.ZodObject<{
212
223
  enabled: z.ZodBoolean;
213
224
  order: z.ZodNumber;
214
225
  limit: z.ZodNumber;
226
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
215
227
  }, z.core.$strip>;
216
228
  conversations: z.ZodObject<{
217
229
  enabled: z.ZodBoolean;
218
230
  order: z.ZodNumber;
219
231
  limit: z.ZodNumber;
232
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
220
233
  }, z.core.$strip>;
221
234
  }, z.core.$strip>>;
222
235
  }, z.core.$strip>;
package/lib/sync/index.js CHANGED
@@ -109,9 +109,9 @@ export class ConversationSyncManager {
109
109
  const failures = new Map();
110
110
  let succeeded = 0;
111
111
  try {
112
- await Promise.all(job.providers.map(async (provider) => {
112
+ for (const provider of job.providers) {
113
113
  if (job.controller.signal.aborted)
114
- return;
114
+ break;
115
115
  const started = Date.now();
116
116
  try {
117
117
  await this.syncProvider(job, provider, mode, options);
@@ -123,11 +123,11 @@ export class ConversationSyncManager {
123
123
  // logged-out account would otherwise permanently starve every
124
124
  // provider after it in this list.
125
125
  if (job.controller.signal.aborted)
126
- return;
126
+ break;
127
127
  failures.set(provider, describe(error));
128
128
  this.logger?.warn(`reference sync: ${provider} failed: ${describe(error)}`);
129
129
  }
130
- }));
130
+ }
131
131
  job.status = job.controller.signal.aborted ? 'cancelled'
132
132
  : failures.size === 0 ? 'complete'
133
133
  : succeeded === 0 ? 'failed' : 'partial';
package/lib/typert.js CHANGED
@@ -5,10 +5,9 @@ export const TYPERT_MANIFEST = {
5
5
  key: 'referenceAnything', exportName: 'ReferenceAnythingRemote',
6
6
  description: 'Local conversation mirror, synchronization, and settings for @Conversation.', tags: [],
7
7
  members: [
8
- { kind: 'method', name: 'workspaceSearch', signature: 'workspaceSearch(agent: Agent, signal: AbortSignal): Promise<WorkspaceEntry[]>' },
9
- { kind: 'method', name: 'sessionSearch', signature: 'sessionSearch(agent: Agent, input: SessionSearchInput, signal: AbortSignal): Promise<SessionCandidate[]>' },
10
8
  { kind: 'method', name: 'search', signature: 'search(input: SearchInput): Promise<SearchResult[]>' },
11
9
  { kind: 'method', name: 'health', signature: 'health(signal: AbortSignal): Promise<Health>' },
10
+ { kind: 'method', name: 'quickHealth', signature: 'quickHealth(signal: AbortSignal): Promise<Health>' },
12
11
  { kind: 'method', name: 'profiles', signature: 'profiles(signal: AbortSignal): Promise<BrowserProfile[]>' },
13
12
  { kind: 'method', name: 'discoverOpenCli', signature: 'discoverOpenCli(signal: AbortSignal): Promise<OpenCliDiscovery>' },
14
13
  { kind: 'method', name: 'installOpenCli', signature: 'installOpenCli(signal: AbortSignal): Promise<OpenCliDiscovery>' },
@@ -17,6 +16,7 @@ export const TYPERT_MANIFEST = {
17
16
  { kind: 'method', name: 'updateStatus', signature: 'updateStatus(signal: AbortSignal): Promise<PackageUpdateStatus>' },
18
17
  { kind: 'method', name: 'checkUpdate', signature: 'checkUpdate(signal: AbortSignal): Promise<PackageUpdateStatus>' },
19
18
  { kind: 'method', name: 'installUpdate', signature: 'installUpdate(signal: AbortSignal): Promise<PackageUpdateResult>' },
19
+ { kind: 'method', name: 'switchReferenceUiMode', signature: 'switchReferenceUiMode(input: ReferenceUiModeInput, signal: AbortSignal): Promise<ReferenceUiSwitchResult>' },
20
20
  { kind: 'method', name: 'stats', signature: 'stats(): ProviderStats[]' },
21
21
  { kind: 'method', name: 'syncStart', signature: 'syncStart(input: SyncStart): string' },
22
22
  { kind: 'method', name: 'syncStatus', signature: 'syncStatus(input: JobInput): SyncStatus | undefined' },
package/lib/web.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ReferenceAnythingRemote } from "./host.js";
2
2
  import { TYPERT_MANIFEST } from "./typert.js";
3
3
  export const name = 'reference-anything-web';
4
- export const inject = ['typert', 'referenceChatHistory', 'sessionReferenceResolver'];
4
+ export const inject = ['typert', 'referenceChatHistory'];
5
5
  export function apply(ctx) {
6
6
  new ReferenceAnythingRemote(ctx);
7
7
  ctx.effect(() => {
package/lib/wire.d.ts CHANGED
@@ -22,41 +22,57 @@ declare const pickerSourceSettingsSchema: z.ZodObject<{
22
22
  enabled: z.ZodBoolean;
23
23
  order: z.ZodNumber;
24
24
  limit: z.ZodNumber;
25
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
25
26
  }, z.core.$strip>;
26
27
  export type PickerSourceSettings = z.infer<typeof pickerSourceSettingsSchema>;
27
28
  export declare const pickerSettingsSchema: z.ZodObject<{
29
+ displayMode: z.ZodDefault<z.ZodEnum<{
30
+ collapse: "collapse";
31
+ "native-scroll": "native-scroll";
32
+ }>>;
28
33
  commands: z.ZodObject<{
29
34
  enabled: z.ZodBoolean;
30
35
  order: z.ZodNumber;
31
36
  limit: z.ZodNumber;
37
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
32
38
  }, z.core.$strip>;
33
39
  skills: z.ZodObject<{
34
40
  enabled: z.ZodBoolean;
35
41
  order: z.ZodNumber;
36
42
  limit: z.ZodNumber;
43
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
37
44
  }, z.core.$strip>;
38
45
  files: z.ZodObject<{
39
46
  enabled: z.ZodBoolean;
40
47
  order: z.ZodNumber;
41
48
  limit: z.ZodNumber;
49
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
42
50
  }, z.core.$strip>;
43
51
  sessions: z.ZodObject<{
44
52
  enabled: z.ZodBoolean;
45
53
  order: z.ZodNumber;
46
54
  limit: z.ZodNumber;
55
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
47
56
  }, z.core.$strip>;
48
57
  conversations: z.ZodObject<{
49
58
  enabled: z.ZodBoolean;
50
59
  order: z.ZodNumber;
51
60
  limit: z.ZodNumber;
61
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
52
62
  }, z.core.$strip>;
53
63
  }, z.core.$strip>;
54
64
  export type PickerSettings = z.infer<typeof pickerSettingsSchema>;
65
+ export type PickerDisplayMode = PickerSettings['displayMode'];
55
66
  export declare const inputRenderModeSchema: z.ZodEnum<{
56
67
  pill: "pill";
57
68
  "raw-text": "raw-text";
58
69
  }>;
59
70
  export type InputRenderMode = z.infer<typeof inputRenderModeSchema>;
71
+ export declare const referenceUiModeSchema: z.ZodEnum<{
72
+ plugin: "plugin";
73
+ official: "official";
74
+ }>;
75
+ export type ReferenceUiMode = z.infer<typeof referenceUiModeSchema>;
60
76
  /** Defaults used before the user saves the General section. */
61
77
  export declare function defaultPickerSettings(): PickerSettings;
62
78
  /** Picker settings have a fixed schema, so explicit comparison is cheap and stable. */
@@ -85,31 +101,44 @@ export declare const settingsRecordSchema: z.ZodObject<{
85
101
  pill: "pill";
86
102
  "raw-text": "raw-text";
87
103
  }>>;
104
+ referenceUiMode: z.ZodOptional<z.ZodEnum<{
105
+ plugin: "plugin";
106
+ official: "official";
107
+ }>>;
88
108
  picker: z.ZodOptional<z.ZodObject<{
109
+ displayMode: z.ZodDefault<z.ZodEnum<{
110
+ collapse: "collapse";
111
+ "native-scroll": "native-scroll";
112
+ }>>;
89
113
  commands: z.ZodObject<{
90
114
  enabled: z.ZodBoolean;
91
115
  order: z.ZodNumber;
92
116
  limit: z.ZodNumber;
117
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
93
118
  }, z.core.$strip>;
94
119
  skills: z.ZodObject<{
95
120
  enabled: z.ZodBoolean;
96
121
  order: z.ZodNumber;
97
122
  limit: z.ZodNumber;
123
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
98
124
  }, z.core.$strip>;
99
125
  files: z.ZodObject<{
100
126
  enabled: z.ZodBoolean;
101
127
  order: z.ZodNumber;
102
128
  limit: z.ZodNumber;
129
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
103
130
  }, z.core.$strip>;
104
131
  sessions: z.ZodObject<{
105
132
  enabled: z.ZodBoolean;
106
133
  order: z.ZodNumber;
107
134
  limit: z.ZodNumber;
135
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
108
136
  }, z.core.$strip>;
109
137
  conversations: z.ZodObject<{
110
138
  enabled: z.ZodBoolean;
111
139
  order: z.ZodNumber;
112
140
  limit: z.ZodNumber;
141
+ maxCandidates: z.ZodDefault<z.ZodNumber>;
113
142
  }, z.core.$strip>;
114
143
  }, z.core.$strip>>;
115
144
  }, z.core.$strip>;
package/lib/wire.js CHANGED
@@ -7,8 +7,10 @@ const pickerSourceSettingsSchema = z.object({
7
7
  enabled: z.boolean(),
8
8
  order: z.number().int().min(0).max(100),
9
9
  limit: z.number().int().min(1).max(50),
10
+ maxCandidates: z.number().int().min(1).max(50).default(50),
10
11
  });
11
12
  export const pickerSettingsSchema = z.object({
13
+ displayMode: z.enum(['collapse', 'native-scroll']).default('collapse'),
12
14
  commands: pickerSourceSettingsSchema,
13
15
  skills: pickerSourceSettingsSchema,
14
16
  files: pickerSourceSettingsSchema,
@@ -16,21 +18,26 @@ export const pickerSettingsSchema = z.object({
16
18
  conversations: pickerSourceSettingsSchema,
17
19
  });
18
20
  export const inputRenderModeSchema = z.enum(['pill', 'raw-text']);
21
+ export const referenceUiModeSchema = z.enum(['plugin', 'official']);
19
22
  /** Defaults used before the user saves the General section. */
20
23
  export function defaultPickerSettings() {
21
24
  return {
22
- commands: { enabled: true, order: 0, limit: 6 },
23
- skills: { enabled: true, order: 5, limit: 6 },
24
- files: { enabled: true, order: 10, limit: 6 },
25
- sessions: { enabled: true, order: 20, limit: 6 },
26
- conversations: { enabled: true, order: 30, limit: 6 },
25
+ displayMode: 'collapse',
26
+ commands: { enabled: true, order: 0, limit: 6, maxCandidates: 50 },
27
+ skills: { enabled: true, order: 5, limit: 6, maxCandidates: 50 },
28
+ files: { enabled: true, order: 10, limit: 6, maxCandidates: 50 },
29
+ sessions: { enabled: true, order: 20, limit: 6, maxCandidates: 50 },
30
+ conversations: { enabled: true, order: 30, limit: 6, maxCandidates: 50 },
27
31
  };
28
32
  }
29
33
  /** Picker settings have a fixed schema, so explicit comparison is cheap and stable. */
30
34
  export function samePickerSettings(left, right) {
31
- return Object.keys(left).every(key => left[key].enabled === right[key].enabled &&
35
+ if (left.displayMode !== right.displayMode)
36
+ return false;
37
+ return pickerSourceSchema.options.every(key => left[key].enabled === right[key].enabled &&
32
38
  left[key].order === right[key].order &&
33
- left[key].limit === right[key].limit);
39
+ left[key].limit === right[key].limit &&
40
+ left[key].maxCandidates === right[key].maxCandidates);
34
41
  }
35
42
  export const settingsRecordSchema = z.object({
36
43
  opencliPath: z.string(),
@@ -43,6 +50,8 @@ export const settingsRecordSchema = z.object({
43
50
  enabledProviders: z.array(providerSchema).default([...ALL_PROVIDERS]),
44
51
  maxReadTurns: z.number().int().min(1).max(100).default(10),
45
52
  inputRenderMode: inputRenderModeSchema.default('pill'),
53
+ // Optional keeps settings written by earlier plugin versions readable.
54
+ referenceUiMode: referenceUiModeSchema.optional(),
46
55
  // Optional keeps existing on-disk settings forward-compatible. The client
47
56
  // uses defaultPickerSettings() until the user saves the General section.
48
57
  picker: pickerSettingsSchema.optional(),
@@ -38,7 +38,7 @@ async function evaluate(page, script, args, operation) {
38
38
  return parseEvaluate(await page.evaluate(source), operation)
39
39
  }
40
40
 
41
- function assertResult(result, domain, operation) {
41
+ function assertResult(result, domain, operation) {
42
42
  if (result?.code === 'AUTH') throw new AuthRequiredError(domain, result.message || `${operation} requires login`)
43
43
  if (result?.code === 'RATE_LIMIT') {
44
44
  throw new CommandExecutionError(`${operation}: ${result.message || 'provider rate limit reached'}`)
@@ -46,9 +46,70 @@ function assertResult(result, domain, operation) {
46
46
  if (result?.ok !== true || !Array.isArray(result.rows)) {
47
47
  throw new CommandExecutionError(`${operation}: ${result?.message || 'provider adapter did not return rows'}`)
48
48
  }
49
- return result.rows
50
- }
51
-
49
+ return result.rows
50
+ }
51
+
52
+ const PROVIDER_READY_TIMEOUT_MS = 1200
53
+ const PROVIDER_READY_INITIAL_DELAY_MS = 50
54
+ const PROVIDER_READY_MAX_DELAY_MS = 400
55
+
56
+ function terminalProviderResult(result) {
57
+ return result?.ok === true || result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
58
+ }
59
+
60
+ function terminalIdentityResult(result) {
61
+ return result?.code === 'AUTH' || result?.code === 'RATE_LIMIT'
62
+ || (result?.ok === true && typeof result.identity === 'string' && result.identity.length > 0)
63
+ }
64
+
65
+ /**
66
+ * Run the provider API as the readiness probe. The happy path performs one
67
+ * evaluate immediately after navigation; transient bootstrap failures retry
68
+ * with bounded exponential backoff instead of waiting for the whole SPA DOM
69
+ * to become quiet first.
70
+ */
71
+ export async function evaluateWhenProviderReady(page, script, args, operation, options = {}) {
72
+ const timeoutMs = options.timeoutMs ?? PROVIDER_READY_TIMEOUT_MS
73
+ const maxDelayMs = options.maxDelayMs ?? PROVIDER_READY_MAX_DELAY_MS
74
+ const accepts = options.accepts ?? terminalProviderResult
75
+ const deadline = Date.now() + timeoutMs
76
+ let delayMs = options.initialDelayMs ?? PROVIDER_READY_INITIAL_DELAY_MS
77
+ let lastResult
78
+ let lastError
79
+ for (;;) {
80
+ try {
81
+ lastResult = await evaluate(page, script, args, operation)
82
+ lastError = undefined
83
+ if (accepts(lastResult)) return lastResult
84
+ } catch (error) {
85
+ lastError = error
86
+ }
87
+ const remaining = deadline - Date.now()
88
+ if (remaining <= 0) {
89
+ if (lastError) throw lastError
90
+ return lastResult
91
+ }
92
+ await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining)))
93
+ delayMs = Math.min(Math.max(delayMs * 2, 1), maxDelayMs)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Each command owns an ephemeral Browser Bridge tab. Close that tab after the
99
+ * command finishes while preserving the provider result or failure.
100
+ */
101
+ async function inTemporaryTab(page, task) {
102
+ try {
103
+ return await task()
104
+ } finally {
105
+ try {
106
+ await page.closeTab?.()
107
+ } catch {
108
+ // Preserve the provider result/error if the tab or browser closed first.
109
+ }
110
+ }
111
+ }
112
+
52
113
  export function registerProvider(config) {
53
114
  const browserSession = SYNC_BROWSER_SESSION
54
115
 
@@ -63,16 +124,18 @@ export function registerProvider(config) {
63
124
  ...browserSession,
64
125
  navigateBefore: false,
65
126
  args: [],
66
- columns: ['identity'],
67
- func: async (page) => {
68
- await page.goto(config.home, { settleMs: 500 })
69
- const result = await evaluate(page, config.whoamiScript, {}, `${config.site} whoami`)
127
+ columns: ['identity'],
128
+ func: async (page) => inTemporaryTab(page, async () => {
129
+ await page.goto(config.home, { waitUntil: 'none' })
130
+ const result = await evaluateWhenProviderReady(
131
+ page, config.whoamiScript, {}, `${config.site} whoami`, { accepts: terminalIdentityResult },
132
+ )
70
133
  if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
71
134
  if (result?.ok !== true || typeof result.identity !== 'string' || !result.identity) {
72
135
  throw new CommandExecutionError(`${config.site} whoami: ${result?.message || 'stable account identity unavailable'}`)
73
136
  }
74
137
  return [{ identity: result.identity }]
75
- },
138
+ }),
76
139
  })
77
140
 
78
141
  cli({
@@ -90,11 +153,13 @@ export function registerProvider(config) {
90
153
  help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
91
154
  }, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
92
155
  columns: SYNC_INDEX_COLUMNS,
93
- func: async (page, kwargs) => {
156
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
94
157
  const since = String(kwargs.since || '').trim()
95
158
  if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
96
- await page.goto(config.home, { settleMs: 1200 })
97
- const identity = await evaluate(page, config.whoamiScript, {}, `${config.site} sync-index identity`)
159
+ await page.goto(config.home, { waitUntil: 'none' })
160
+ const identity = await evaluateWhenProviderReady(
161
+ page, config.whoamiScript, {}, `${config.site} sync-index identity`, { accepts: terminalIdentityResult },
162
+ )
98
163
  if (identity?.code === 'AUTH') throw new AuthRequiredError(config.domain)
99
164
  if (identity?.ok !== true || typeof identity.identity !== 'string' || !identity.identity) {
100
165
  throw new CommandExecutionError(`${config.site} sync-index: ${identity?.message || 'stable account identity unavailable'}`)
@@ -110,7 +175,7 @@ export function registerProvider(config) {
110
175
  { kind: 'identity', identity: identity.identity, sinceApplied: effectiveSince },
111
176
  ...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
112
177
  ]
113
- },
178
+ }),
114
179
  })
115
180
 
116
181
  cli({
@@ -128,17 +193,17 @@ export function registerProvider(config) {
128
193
  help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
129
194
  }],
130
195
  columns: HISTORY_COLUMNS,
131
- func: async (page, kwargs) => {
196
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
132
197
  const since = String(kwargs.since || '').trim()
133
- if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
134
- await page.goto(config.home, { settleMs: 1200 })
135
- const rows = assertResult(
136
- await evaluate(page, config.historyScript, { since }, `${config.site} history-all`),
137
- config.domain,
138
- `${config.site} history-all`,
198
+ if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
199
+ await page.goto(config.home, { waitUntil: 'none' })
200
+ const rows = assertResult(
201
+ await evaluateWhenProviderReady(page, config.historyScript, { since }, `${config.site} history-all`),
202
+ config.domain,
203
+ `${config.site} history-all`,
139
204
  )
140
205
  return rows
141
- },
206
+ }),
142
207
  })
143
208
 
144
209
  cli({
@@ -156,7 +221,7 @@ export function registerProvider(config) {
156
221
  { name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
157
222
  ],
158
223
  columns: DETAIL_COLUMNS,
159
- func: async (page, kwargs) => {
224
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
160
225
  const id = String(kwargs.id || '').trim()
161
226
  if (!id) throw new ArgumentError('id must be a non-empty conversation id')
162
227
  await page.goto(config.home, { settleMs: 600 })
@@ -180,7 +245,7 @@ export function registerProvider(config) {
180
245
  const rows = assertResult(result, config.domain, `${config.site} detail`)
181
246
  if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
182
247
  return rows
183
- },
248
+ }),
184
249
  })
185
250
 
186
251
  cli({
@@ -199,7 +264,7 @@ export function registerProvider(config) {
199
264
  { name: 'maxBytes', type: 'int', default: 26214400, help: 'Maximum decoded bytes' },
200
265
  ],
201
266
  columns: ['attachmentId', 'name', 'mimeType', 'size', 'status', 'localPath'],
202
- func: async (page, kwargs) => {
267
+ func: async (page, kwargs) => inTemporaryTab(page, async () => {
203
268
  const locator = String(kwargs.locator || '').trim()
204
269
  const output = resolve(String(kwargs.output || ''))
205
270
  const maxBytes = Number(kwargs.maxBytes ?? 26_214_400)
@@ -229,18 +294,13 @@ export function registerProvider(config) {
229
294
  status: 'available',
230
295
  localPath: output,
231
296
  }]
232
- },
233
- })
297
+ }),
298
+ })
234
299
  }
235
300
 
236
- /**
237
- * Keep one stable, background Browser Bridge session per provider site.
238
- * OpenCLI maps persistent sessions to `site:<config.site>` and keeps their tab
239
- * lease after both successful and failed commands, so later sync work reuses
240
- * the same tab. If the user closes it manually, OpenCLI recreates it on demand.
241
- */
301
+ /** Browser Bridge lifecycle shared by every web-chat command. */
242
302
  export const SYNC_BROWSER_SESSION = Object.freeze({
243
- siteSession: 'persistent',
303
+ siteSession: 'ephemeral',
244
304
  defaultWindowMode: 'background',
245
305
  })
246
306
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chat-history",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "opencli": ">=1.8.6",
5
5
  "description": "Full-history read adapters used by dsh-reference-anything"
6
6
  }