dsh-theone 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ import { redactRoutingText } from "./routing-policy.js";
2
+ /** Where reports go: a small Cloudflare Worker run by TheOne's maintainer (server/feedback). */
3
+ export const FEEDBACK_URL = 'https://feedback.yulid.org/v1/reports';
4
+ export const FEEDBACK_EMAIL = 'theone@yulid.org';
5
+ export const FEEDBACK_LIMITS = { description: 4000, contact: 200, routes: 30, replyText: 8000, body: 60000 };
6
+ const MINUTE = 60000;
7
+ /** Strings as they may leave this computer: secrets, emails, addresses and the home folder removed. */
8
+ export function scrub(value, home) {
9
+ if (typeof value === 'string') {
10
+ let text = redactRoutingText(value);
11
+ if (home && home.length > 1)
12
+ text = text.split(home).join('~');
13
+ return text;
14
+ }
15
+ if (Array.isArray(value))
16
+ return value.map(item => scrub(item, home));
17
+ if (value && typeof value === 'object')
18
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item, home)]));
19
+ return value;
20
+ }
21
+ /** A decision's reason when it is one of TheOne's own codes; a model's free-text reason may quote the message. */
22
+ function reasonCode(reason) {
23
+ return /^[a-z][\w:-]{0,59}$/i.test(reason) ? reason : 'model';
24
+ }
25
+ /** Recent routes without their text or topics: what happened, how, how long, and any error code. */
26
+ export function routeDigest(routes, now = Date.now()) {
27
+ return routes.slice(0, FEEDBACK_LIMITS.routes).map(route => ({
28
+ minutesAgo: Math.max(0, Math.round((now - route.at) / MINUTE)),
29
+ action: route.decision.action,
30
+ reason: reasonCode(route.decision.reason),
31
+ status: route.status,
32
+ ...(route.receipt ? { via: route.receipt.mode, ...(route.receipt.model ? { model: route.receipt.model } : {}),
33
+ ...(route.receipt.elapsedMs !== undefined ? { ms: route.receipt.elapsedMs } : {}), ...(route.receipt.errorCode ? { error: route.receipt.errorCode } : {}) } : {}),
34
+ ...(route.correctedTo ? { corrected: route.correctedTo !== route.decision.contextId } : {}),
35
+ }));
36
+ }
37
+ const blockText = (blocks) => blocks.flatMap(block => block.type === 'text' && block.text ? [block.text] : []).join('');
38
+ /**
39
+ * One exchange in a session: the user message `messageId` (or, failing that, the latest user message
40
+ * with the same text) and every assistant text up to the next user message.
41
+ */
42
+ export function exchange(events, messageId, sameText) {
43
+ let start = events.findIndex(event => event.type === 'user/message' && event.data.id === messageId);
44
+ if (start < 0 && sameText !== undefined) {
45
+ for (let index = events.length - 1; index >= 0; index--) {
46
+ const event = events[index];
47
+ if (event.type === 'user/message' && event.data.source.kind === 'user' && blockText(event.data.content).trim() === sameText.trim()) {
48
+ start = index;
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ if (start < 0)
54
+ return undefined;
55
+ const first = events[start];
56
+ if (first.type !== 'user/message')
57
+ return undefined;
58
+ const replies = [];
59
+ for (const event of events.slice(start + 1)) {
60
+ if (event.type === 'user/message' && event.data.source.kind === 'user')
61
+ break;
62
+ if (event.type === 'assistant/message')
63
+ replies.push(blockText(event.data.message.content));
64
+ }
65
+ return { user: blockText(first.data.content), reply: replies.join(''), steps: replies.length };
66
+ }
67
+ const count = (text, pattern) => (text.match(pattern) ?? []).length;
68
+ /**
69
+ * Whether main chat showed what the topic's own session said, and signs of garbled text in either:
70
+ * replacement characters (U+FFFD), stray control characters, and long runs of one repeated character.
71
+ */
72
+ export function compareReplies(main, worker) {
73
+ let first = 0;
74
+ while (first < main.length && first < worker.length && main[first] === worker[first])
75
+ first++;
76
+ const identical = main === worker;
77
+ const signs = (text) => ({ chars: text.length, replacement: count(text, /�/g),
78
+ control: count(text, /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g), repeatedRuns: count(text, /(.)\1{19,}/gsu) });
79
+ return { identical, ...(identical ? {} : { firstDifference: first }), main: signs(main), worker: signs(worker) };
80
+ }
81
+ /** Text around the first difference, so a long reply still shows where the two part ways. */
82
+ function excerpt(text, around) {
83
+ if (text.length <= FEEDBACK_LIMITS.replyText)
84
+ return text;
85
+ const start = around === undefined ? 0 : Math.max(0, Math.min(text.length - FEEDBACK_LIMITS.replyText, around - FEEDBACK_LIMITS.replyText / 2));
86
+ return (start ? '…' : '') + text.slice(start, start + FEEDBACK_LIMITS.replyText) + (start + FEEDBACK_LIMITS.replyText < text.length ? '…' : '');
87
+ }
88
+ /** The part of a report about one message: always the comparison, the texts only when the user agreed. */
89
+ export function replySection(route, main, worker, includeText, now = Date.now()) {
90
+ const comparison = main && worker ? compareReplies(main.reply, worker.reply) : undefined;
91
+ return {
92
+ route: routeDigest([route], now)[0],
93
+ found: { mainChat: !!main, topicSession: !!worker },
94
+ ...(main ? { mainSteps: main.steps } : {}), ...(worker ? { topicSteps: worker.steps } : {}),
95
+ ...(comparison ? { comparison } : {}),
96
+ ...(includeText ? {
97
+ message: (main ?? worker)?.user.slice(0, 2000),
98
+ ...(main ? { mainChatReply: excerpt(main.reply, comparison?.firstDifference) } : {}),
99
+ ...(worker ? { topicSessionReply: excerpt(worker.reply, comparison?.firstDifference) } : {}),
100
+ } : {}),
101
+ };
102
+ }
103
+ /**
104
+ * The report to send: the draft the user saw plus what they typed. The draft comes back from the
105
+ * page, so it is checked and scrubbed again here; anything malformed is refused, not repaired.
106
+ */
107
+ export function finalReport(input, version, home) {
108
+ const draft = input.draft;
109
+ const description = typeof input.description === 'string' ? input.description.trim() : '';
110
+ const contact = typeof input.contact === 'string' ? input.contact.trim() : '';
111
+ if (!draft || typeof draft !== 'object' || draft.v !== 1 || draft.app !== 'theone')
112
+ throw new Error('INVALID_INPUT');
113
+ if (!draft.diagnostics || typeof draft.diagnostics !== 'object' || Array.isArray(draft.diagnostics))
114
+ throw new Error('INVALID_INPUT');
115
+ if (draft.reply != null && (typeof draft.reply !== 'object' || Array.isArray(draft.reply)))
116
+ throw new Error('INVALID_INPUT');
117
+ if (!description)
118
+ throw new Error('DESCRIPTION_REQUIRED');
119
+ if (description.length > FEEDBACK_LIMITS.description || contact.length > FEEDBACK_LIMITS.contact)
120
+ throw new Error('TOO_LARGE');
121
+ const report = { v: 1, app: 'theone', version, lang: input.lang === 'zh' ? 'zh' : 'en', description,
122
+ ...(contact ? { contact } : {}),
123
+ diagnostics: scrub(draft.diagnostics, home),
124
+ ...(draft.reply ? { reply: scrub(draft.reply, home) } : {}) };
125
+ if (JSON.stringify(report).length > FEEDBACK_LIMITS.body)
126
+ throw new Error('TOO_LARGE');
127
+ return report;
128
+ }
129
+ /** Send a report; resolves to its id, or throws RATE_LIMITED, REJECTED, UNREACHABLE or SERVER_ERROR. */
130
+ export async function sendReport(report, url = FEEDBACK_URL, fetcher = fetch) {
131
+ let response;
132
+ try {
133
+ response = await fetcher(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report), signal: AbortSignal.timeout(15000) });
134
+ }
135
+ catch {
136
+ throw new Error('UNREACHABLE');
137
+ }
138
+ if (response.status === 429)
139
+ throw new Error('RATE_LIMITED');
140
+ if (response.status >= 400 && response.status < 500)
141
+ throw new Error('REJECTED');
142
+ const body = await response.json().catch(() => undefined);
143
+ if (!response.ok || typeof body?.id !== 'string' || !/^FB-[A-Z0-9]{4,12}$/.test(body.id))
144
+ throw new Error('SERVER_ERROR');
145
+ return body.id;
146
+ }
@@ -34,6 +34,15 @@ export declare class HistoryCatalog {
34
34
  get incomplete(): boolean;
35
35
  refresh(): Promise<void>;
36
36
  private scan;
37
+ /** DSH's archive set, when a workspace registry is present. Empty means nothing is archived. */
38
+ private archivedSessionIds;
39
+ /**
40
+ * The scan only walks the conversations that still exist, so a topic whose sessions were deleted
41
+ * or archived would otherwise stay in the routing candidates forever. Hiding is recomputed whole
42
+ * on every scan, which keeps it reversible: restoring or unarchiving a conversation brings its
43
+ * topic back, and no topic is removed from the directory behind the reader's back.
44
+ */
45
+ private reconcile;
37
46
  /**
38
47
  * Up to 16 topics worth showing the classifier. `contexts` may carry learned terms; `prior`
39
48
  * favours topics used recently, often, or together with the current one.
@@ -108,8 +108,10 @@ export class HistoryCatalog {
108
108
  this.schedule(1000); }
109
109
  async close() { this.abort.abort(); clearTimeout(this.timer); await this.run?.catch(() => { }); }
110
110
  snapshot() {
111
+ const hidden = this.store.hiddenReasons();
111
112
  return { status: { ...this.status }, groups: this.store.groups(),
112
- contexts: this.store.contexts().map(context => ({ ...context, sourceSessionIds: this.store.sources(context.id) })) };
113
+ contexts: this.store.contexts().map(context => ({ ...context, sourceSessionIds: this.store.sources(context.id),
114
+ ...(hidden.has(context.id) ? { hidden: hidden.get(context.id) } : {}) })) };
113
115
  }
114
116
  get incomplete() { return !this.status.lastCompletedAt || (this.settledPending ?? 1) > 0 || !!this.status.searchUnavailable; }
115
117
  refresh() {
@@ -137,6 +139,7 @@ export class HistoryCatalog {
137
139
  return;
138
140
  }
139
141
  this.status.pending = records.length;
142
+ const archived = this.archivedSessionIds();
140
143
  let budget = this.batchBudget;
141
144
  for (const record of records) {
142
145
  signal.throwIfAborted();
@@ -146,6 +149,12 @@ export class HistoryCatalog {
146
149
  this.status.pending--;
147
150
  continue;
148
151
  }
152
+ // An archived conversation still sits on disk, but its topic must not be pulled back in.
153
+ if (archived.has(sessionId)) {
154
+ this.status.skipped++;
155
+ this.status.pending--;
156
+ continue;
157
+ }
149
158
  const live = this.ctx.agents.get(sessionId);
150
159
  if (live && live.status !== 'idle') {
151
160
  // Its turn in progress is indexed once it settles; what was already indexed stays usable meanwhile.
@@ -227,15 +236,52 @@ export class HistoryCatalog {
227
236
  }
228
237
  }
229
238
  this.settledPending = this.status.pending;
239
+ this.reconcile(records, archived);
230
240
  this.status.lastCompletedAt = Date.now();
231
241
  }
242
+ /** DSH's archive set, when a workspace registry is present. Empty means nothing is archived. */
243
+ archivedSessionIds() {
244
+ try {
245
+ const registry = this.ctx.get('workspaceRegistry');
246
+ const ids = registry?.archivedSessionIds;
247
+ return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : []);
248
+ }
249
+ catch {
250
+ // A registry that is not ready yet must never fail a scan.
251
+ return new Set();
252
+ }
253
+ }
254
+ /**
255
+ * The scan only walks the conversations that still exist, so a topic whose sessions were deleted
256
+ * or archived would otherwise stay in the routing candidates forever. Hiding is recomputed whole
257
+ * on every scan, which keeps it reversible: restoring or unarchiving a conversation brings its
258
+ * topic back, and no topic is removed from the directory behind the reader's back.
259
+ */
260
+ reconcile(records, archived) {
261
+ const live = new Set(records.map(record => record.header.id));
262
+ const hidden = [];
263
+ for (const context of this.store.contexts()) {
264
+ const conversations = [context.workingSessionId, ...this.store.sources(context.id)];
265
+ // Only conversations the catalog has actually seen count: a Worker session exists on disk only
266
+ // once TheOne has run it, so an untouched placeholder must not look like a lost conversation.
267
+ const known = conversations.filter(sessionId => this.store.indexState(sessionId) !== undefined);
268
+ if (known.length && known.every(sessionId => archived.has(sessionId)))
269
+ hidden.push({ id: context.id, reason: 'archived' });
270
+ // Nothing left to read: every conversation is off disk, and at least one of them was indexed.
271
+ else if (known.length && !conversations.some(sessionId => live.has(sessionId)))
272
+ hidden.push({ id: context.id, reason: 'orphaned' });
273
+ }
274
+ this.store.replaceHidden(hidden);
275
+ this.status.hidden = hidden.length;
276
+ }
232
277
  /**
233
278
  * Up to 16 topics worth showing the classifier. `contexts` may carry learned terms; `prior`
234
279
  * favours topics used recently, often, or together with the current one.
235
280
  */
236
281
  async candidates(text, currentId, signal, hints = {}) {
237
282
  this.status.searchUnavailable = false;
238
- const all = hints.contexts ?? this.store.contexts();
283
+ const hidden = this.store.hiddenReasons();
284
+ const all = (hints.contexts ?? this.store.contexts()).filter(context => !hidden.has(context.id));
239
285
  if (all.length <= 16)
240
286
  return all;
241
287
  const normalized = text.toLowerCase();
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ import { type SettingsSnapshot } from './settings-types.ts';
11
11
  import { type LinkScope } from './linkage.ts';
12
12
  import { Updater } from './update.ts';
13
13
  import { NoticeBoard } from './notices.ts';
14
+ import { type FeedbackDraft } from './feedback.ts';
14
15
  export interface Config {
15
16
  databasePath?: string;
16
17
  contextsPath?: string;
@@ -31,8 +32,16 @@ export interface Config {
31
32
  routeNotice?: 'hidden' | 'switch' | 'all';
32
33
  /** Show notices the maintainer publishes (read from a static file; nothing is sent). */
33
34
  notices?: boolean;
35
+ /** Experimental: topics share confirmed facts (a figure, a decision) with evidence and versions. */
36
+ factLinks?: boolean;
37
+ /** With factLinks: after each turn, a small model call proposes facts the Worker did not record. */
38
+ factExtraction?: boolean;
39
+ /** A routing card per topic, written in the background after its first replies; on unless turned off (tests). */
40
+ topicCards?: boolean;
34
41
  /** Where notices are read from; for testing. */
35
42
  noticeUrl?: string;
43
+ /** Where problem reports are sent when the user presses Send; "off" leaves only copy and email. For testing. */
44
+ feedbackUrl?: string;
36
45
  }
37
46
  declare module '@deepseek-ai/cordis' {
38
47
  interface Context {
@@ -99,6 +108,12 @@ export default class TheOne extends Service {
99
108
  private editTopics;
100
109
  /** Existing DSH sessions a topic can take as history: not main chats and not topics' own Workers. */
101
110
  private attachableSessions;
111
+ /**
112
+ * What a problem report carries before the user adds their words: versions, settings and how
113
+ * routing went, without message text or topic names. With `messageId`, also how that message's
114
+ * reply in main chat compares with its topic session, and the texts themselves if `includeReply`.
115
+ */
116
+ feedbackDraft(messageId: string | undefined, includeReply: boolean): Promise<FeedbackDraft>;
102
117
  /** Read only public options; never read or return the API key environment value. */
103
118
  settingsSnapshot(): Promise<SettingsSnapshot>;
104
119
  private routerFor;
@@ -148,6 +163,12 @@ export default class TheOne extends Service {
148
163
  * Cross-topic reference for a Worker about to start: the recent main chat after a topic switch,
149
164
  * and the news of related topics (plus those the request itself named). Undefined when empty.
150
165
  */
166
+ /** Confirmed facts this message might use (see fact-flow). */
167
+ private factCandidates;
168
+ /** What a topic is told about facts before its Worker answers (see fact-flow). */
169
+ private factDelivery;
170
+ /** The topic's own recorded facts for its Worker's descriptor; empty unless shared facts are on. */
171
+ private ownFacts;
151
172
  private briefingFor;
152
173
  /**
153
174
  * No one views a Worker session, so its approval questions would fail closed. Ask in the
@@ -165,6 +186,8 @@ export default class TheOne extends Service {
165
186
  private forwardQuestions;
166
187
  /** Capability is scoped to the exact owned Worker; the model cannot select another Context. */
167
188
  private registerWorkerTools;
189
+ /** Recording facts with evidence, and looking up other topics' confirmed facts (shared facts only). */
190
+ private registerFactTools;
168
191
  /** Mirror the routed Worker's steps into the main chat; tools execute exclusively in the Worker. */
169
192
  answer(options: GenerateOptions): AsyncIterable<StreamChunk>;
170
193
  /** The message answered just before `inputId` in this main chat, with the topic it went to. */
@@ -173,6 +196,16 @@ export default class TheOne extends Service {
173
196
  private reroute;
174
197
  /** Learning from corrections in flight; tests and shutdown can wait for it. */
175
198
  learning: Promise<void>;
199
+ /** Fact extractions in flight, one queue per topic so they commit in turn order. */
200
+ private readonly extractions;
201
+ /** All extractions in flight; tests and shutdown can wait for it. */
202
+ get extracting(): Promise<void>;
203
+ /** Queue an extraction of the turn that just ended in `worker`'s topic. */
204
+ private queueExtraction;
205
+ /** Queue a routing card for this topic, after any extraction already queued for it. */
206
+ private queueCard;
207
+ private writeCard;
208
+ private extractFacts;
176
209
  /**
177
210
  * The user moved a message to another topic: remember it, and teach both topics. The right topic
178
211
  * gains the message's distinctive terms and the wrong one loses them; `weight` is lower for
@@ -181,7 +214,10 @@ export default class TheOne extends Service {
181
214
  private applyCorrection;
182
215
  /** Ask the selected model, thinking off, for the few terms that tie a message to its topic. */
183
216
  private pickTerms;
184
- /** Topics with the terms corrections taught them added to their own keywords. */
217
+ /**
218
+ * Topics as routing sees them: with the terms corrections taught them, and when each was last
219
+ * active. Topics set aside (untouched for longer than this user usually comes back) go last.
220
+ */
185
221
  private routingContexts;
186
222
  /**
187
223
  * Favour topics used recently or often, and those linked to the current one, when narrowing