dsh-theone 0.3.21 → 0.3.23

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,113 @@
1
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
2
+ import type { RouteView } from './types.ts';
3
+ /** Where reports go: a small Cloudflare Worker run by TheOne's maintainer (server/feedback). */
4
+ export declare const FEEDBACK_URL = "https://feedback.yulid.org/v1/reports";
5
+ export declare const FEEDBACK_EMAIL = "theone@yulid.org";
6
+ export declare const FEEDBACK_LIMITS: {
7
+ readonly description: 4000;
8
+ readonly contact: 200;
9
+ readonly routes: 30;
10
+ readonly replyText: 8000;
11
+ readonly body: 60000;
12
+ };
13
+ /** Strings as they may leave this computer: secrets, emails, addresses and the home folder removed. */
14
+ export declare function scrub(value: unknown, home?: string): unknown;
15
+ /** Recent routes without their text or topics: what happened, how, how long, and any error code. */
16
+ export declare function routeDigest(routes: readonly RouteView[], now?: number): {
17
+ corrected?: boolean | undefined;
18
+ error?: string | undefined;
19
+ ms?: number | undefined;
20
+ model?: string | undefined;
21
+ via?: "rules" | "llm" | undefined;
22
+ minutesAgo: number;
23
+ action: import("./types.ts").Action;
24
+ reason: string;
25
+ status: "planned" | "running" | "completed" | "failed";
26
+ }[];
27
+ /**
28
+ * One exchange in a session: the user message `messageId` (or, failing that, the latest user message
29
+ * with the same text) and every assistant text up to the next user message.
30
+ */
31
+ export declare function exchange(events: readonly SessionEvent[], messageId: string, sameText?: string): {
32
+ user: string;
33
+ reply: string;
34
+ steps: number;
35
+ } | undefined;
36
+ /**
37
+ * Whether main chat showed what the topic's own session said, and signs of garbled text in either:
38
+ * replacement characters (U+FFFD), stray control characters, and long runs of one repeated character.
39
+ */
40
+ export declare function compareReplies(main: string, worker: string): {
41
+ main: {
42
+ chars: number;
43
+ replacement: number;
44
+ control: number;
45
+ repeatedRuns: number;
46
+ };
47
+ worker: {
48
+ chars: number;
49
+ replacement: number;
50
+ control: number;
51
+ repeatedRuns: number;
52
+ };
53
+ firstDifference?: number | undefined;
54
+ identical: boolean;
55
+ };
56
+ /** The part of a report about one message: always the comparison, the texts only when the user agreed. */
57
+ export declare function replySection(route: RouteView, main: ReturnType<typeof exchange>, worker: ReturnType<typeof exchange>, includeText: boolean, now?: number): {
58
+ topicSessionReply?: string | undefined;
59
+ mainChatReply?: string | undefined;
60
+ message?: string | undefined;
61
+ comparison?: {
62
+ main: {
63
+ chars: number;
64
+ replacement: number;
65
+ control: number;
66
+ repeatedRuns: number;
67
+ };
68
+ worker: {
69
+ chars: number;
70
+ replacement: number;
71
+ control: number;
72
+ repeatedRuns: number;
73
+ };
74
+ firstDifference?: number | undefined;
75
+ identical: boolean;
76
+ } | undefined;
77
+ topicSteps?: number | undefined;
78
+ mainSteps?: number | undefined;
79
+ route: {
80
+ corrected?: boolean | undefined;
81
+ error?: string | undefined;
82
+ ms?: number | undefined;
83
+ model?: string | undefined;
84
+ via?: "rules" | "llm" | undefined;
85
+ minutesAgo: number;
86
+ action: import("./types.ts").Action;
87
+ reason: string;
88
+ status: "planned" | "running" | "completed" | "failed";
89
+ };
90
+ found: {
91
+ mainChat: boolean;
92
+ topicSession: boolean;
93
+ };
94
+ };
95
+ export interface FeedbackDraft {
96
+ v: 1;
97
+ app: 'theone';
98
+ version: string;
99
+ diagnostics: Record<string, unknown>;
100
+ reply?: Record<string, unknown>;
101
+ }
102
+ export interface FeedbackReport extends FeedbackDraft {
103
+ lang: 'zh' | 'en';
104
+ description: string;
105
+ contact?: string;
106
+ }
107
+ /**
108
+ * The report to send: the draft the user saw plus what they typed. The draft comes back from the
109
+ * page, so it is checked and scrubbed again here; anything malformed is refused, not repaired.
110
+ */
111
+ export declare function finalReport(input: Record<string, unknown>, version: string, home?: string): FeedbackReport;
112
+ /** Send a report; resolves to its id, or throws RATE_LIMITED, REJECTED, UNREACHABLE or SERVER_ERROR. */
113
+ export declare function sendReport(report: FeedbackReport, url?: string, fetcher?: typeof fetch): Promise<string>;
@@ -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
+ }
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;
@@ -39,6 +40,8 @@ export interface Config {
39
40
  topicCards?: boolean;
40
41
  /** Where notices are read from; for testing. */
41
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;
42
45
  }
43
46
  declare module '@deepseek-ai/cordis' {
44
47
  interface Context {
@@ -105,11 +108,27 @@ export default class TheOne extends Service {
105
108
  private editTopics;
106
109
  /** Existing DSH sessions a topic can take as history: not main chats and not topics' own Workers. */
107
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>;
108
117
  /** Read only public options; never read or return the API key environment value. */
109
118
  settingsSnapshot(): Promise<SettingsSnapshot>;
110
119
  private routerFor;
111
120
  /** Saved settings take effect for the next message; only the background catalog waits for a restart. */
112
121
  private applySettings;
122
+ /**
123
+ * DSH saves any session's model choice as its global default, so main chat choosing TheOne used
124
+ * to make every new ordinary chat a TheOne chat. TheOne must not change the user's settings:
125
+ * before main chat picks its model the default is held, and put back right after.
126
+ */
127
+ private heldDefault?;
128
+ holdDefaultModel(): void;
129
+ restoreDefaultModel(): Promise<void>;
130
+ /** Once: undo the default earlier versions left as TheOne, back to the model in use before it. */
131
+ private repairDefaultModel;
113
132
  /** Capture before Web saves the gateway itself as DSH's new default. */
114
133
  captureDefaultModel(): void;
115
134
  private backingModel;
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ import { applyExtraction, EXTRACT_PROMPT, extractionPayload, factCandidates, fac
25
25
  import { activityLabel, CARD_AFTER_REPLIES, CARD_PROMPT, cardPayload, learnDormancy, parseCard } from "./topic-memory.js";
26
26
  import { PACKAGE_NAME, Updater } from "./update.js";
27
27
  import { NOTICE_URL, NoticeBoard } from "./notices.js";
28
+ import { exchange, FEEDBACK_EMAIL, FEEDBACK_LIMITS, FEEDBACK_URL, finalReport, replySection, routeDigest, scrub, sendReport } from "./feedback.js";
29
+ import { createRequire } from 'node:module';
28
30
  function redactDescriptor(text) {
29
31
  return text.replace(/\bsk-[a-zA-Z0-9_-]{16,}|\bBearer\s+\S+/gi, '[REDACTED]')
30
32
  .replace(/((?:api[_ -]?key|password|密码|密钥)\s*[:=:]\s*)\S+/gi, '$1[REDACTED]');
@@ -139,6 +141,15 @@ function readOwnVersion() {
139
141
  return '0.0.0';
140
142
  }
141
143
  }
144
+ /** DSH's own version, as far as its packages say; undefined when it cannot be read. */
145
+ function readDshVersion() {
146
+ try {
147
+ return String(createRequire(import.meta.url)('@deepseek-ai/dsh-agent/package.json').version ?? '') || undefined;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
142
153
  function readDescriptors(path) {
143
154
  const value = JSON.parse(readFileSync(path, 'utf8'));
144
155
  if (!Array.isArray(value))
@@ -176,6 +187,7 @@ export default class TheOne extends Service {
176
187
  factExtraction: z.boolean().default(false),
177
188
  topicCards: z.boolean().default(true),
178
189
  noticeUrl: z.string(),
190
+ feedbackUrl: z.string(),
179
191
  });
180
192
  store;
181
193
  catalog;
@@ -254,6 +266,7 @@ export default class TheOne extends Service {
254
266
  if (!!config.workerProvider !== !!config.workerModel)
255
267
  throw new Error('Set both workerProvider and workerModel, or neither');
256
268
  this.captureDefaultModel();
269
+ this.repairDefaultModel();
257
270
  this.router = this.routerFor(config.routerMode);
258
271
  if (config.historyCatalog ?? true) {
259
272
  this.catalog = new HistoryCatalog(ctx, this.store, () => this.backingModel(), config.catalogIntervalMs);
@@ -581,6 +594,11 @@ export default class TheOne extends Service {
581
594
  await mkdir(this.gatewayDirectory, { recursive: true });
582
595
  return Response.json({ cwd: this.gatewayDirectory }, { headers: { 'cache-control': 'no-store' } });
583
596
  } }));
597
+ // Main chat is about to pick its model; DSH would save that as the user's default.
598
+ scope.effect(() => connection.fetch.register({ path: '/api/theone/gateway/hold', methods: ['POST'], requestBody: 'buffered', fetch: async () => {
599
+ this.holdDefaultModel();
600
+ return Response.json({ held: true });
601
+ } }));
584
602
  scope.effect(() => connection.fetch.register({ path: '/api/theone/gateway/prepare', methods: ['POST'], requestBody: 'buffered', fetch: async (request) => {
585
603
  let value;
586
604
  try {
@@ -611,6 +629,7 @@ export default class TheOne extends Service {
611
629
  await scope.workspaceRegistry.unarchiveSession(id);
612
630
  const locale = 'locale' in value && typeof value.locale === 'string' ? value.locale.slice(0, 16) : 'en';
613
631
  this.welcomeGateway(id, locale);
632
+ await this.restoreDefaultModel().catch(error => console.warn('TheOne could not restore DSH\'s default model.', error));
614
633
  return Response.json({ prepared: true, workspaceId: null });
615
634
  } }));
616
635
  });
@@ -697,6 +716,34 @@ export default class TheOne extends Service {
697
716
  const dismissed = this.store.dismissedNotices();
698
717
  return Response.json({ notices: (await this.noticeBoard.notices()).filter(notice => !dismissed.has(notice.id)) }, { headers: { 'cache-control': 'no-store' } });
699
718
  } }));
719
+ // Problem reports: a draft to show the user in full, then sent only when they press Send.
720
+ child.effect(() => connection.fetch.register({ path: '/api/theone/feedback', methods: ['POST'], requestBody: 'buffered', fetch: async (request) => {
721
+ let row;
722
+ try {
723
+ row = await request.json();
724
+ }
725
+ catch {
726
+ return Response.json({ error: 'INVALID_INPUT' }, { status: 400 });
727
+ }
728
+ const url = this.config.feedbackUrl || FEEDBACK_URL;
729
+ try {
730
+ if (row?.action === 'draft') {
731
+ const messageId = typeof row.messageId === 'string' ? row.messageId : undefined;
732
+ return Response.json({ draft: await this.feedbackDraft(messageId, row.includeReply === true), email: FEEDBACK_EMAIL, direct: url !== 'off' }, { headers: { 'cache-control': 'no-store' } });
733
+ }
734
+ if (row?.action === 'send') {
735
+ if (url === 'off')
736
+ return Response.json({ error: 'DIRECT_OFF' }, { status: 400 });
737
+ const report = finalReport(row, this.updater.current, homedir());
738
+ return Response.json({ id: await sendReport(report, url) }, { headers: { 'cache-control': 'no-store' } });
739
+ }
740
+ return Response.json({ error: 'INVALID_INPUT' }, { status: 400 });
741
+ }
742
+ catch (error) {
743
+ const code = error instanceof Error && /^[A-Z_]+$/.test(error.message) ? error.message : 'FEEDBACK_FAILED';
744
+ return Response.json({ error: code }, { status: ['UNREACHABLE', 'SERVER_ERROR', 'RATE_LIMITED', 'REJECTED'].includes(code) ? 502 : 400 });
745
+ }
746
+ } }));
700
747
  child.effect(() => connection.fetch.register({ path: '/api/theone/sessions', methods: ['GET'], requestBody: 'buffered', fetch: async () => Response.json({ sessions: await this.attachableSessions() }, { headers: { 'cache-control': 'no-store' } }) }));
701
748
  // The user's corrections to topic linking always take precedence over what was learned.
702
749
  child.effect(() => connection.fetch.register({ path: '/api/theone/links', methods: ['POST'], requestBody: 'buffered', fetch: async (request) => {
@@ -736,6 +783,9 @@ export default class TheOne extends Service {
736
783
  }
737
784
  if (!value || typeof value !== 'object' || !('contextId' in value) || typeof value.contextId !== 'string' || !this.store.contexts().some(c => c.id === value.contextId))
738
785
  return Response.json({ error: 'UNKNOWN_CONTEXT' }, { status: 400 });
786
+ // Its conversations are gone or archived: mounting it would only start an empty Worker.
787
+ if (this.store.hiddenReasons().has(value.contextId))
788
+ return Response.json({ error: 'TOPIC_HIDDEN' }, { status: 409 });
739
789
  if (this.active || this.reservedGateway)
740
790
  return Response.json({ error: 'GATEWAY_BUSY' }, { status: 409 });
741
791
  this.store.mount(this.config.gatewayKey, value.contextId);
@@ -830,6 +880,52 @@ export default class TheOne extends Service {
830
880
  return { id: record.header.id, title: title || new Date(record.header.createdAt).toISOString().slice(0, 16).replace('T', ' '), createdAt: record.header.createdAt };
831
881
  });
832
882
  }
883
+ /**
884
+ * What a problem report carries before the user adds their words: versions, settings and how
885
+ * routing went, without message text or topic names. With `messageId`, also how that message's
886
+ * reply in main chat compares with its topic session, and the texts themselves if `includeReply`.
887
+ */
888
+ async feedbackDraft(messageId, includeReply) {
889
+ const c = this.config;
890
+ const settings = await this.settingsSnapshot().catch(() => undefined);
891
+ const values = settings?.values;
892
+ const status = this.catalog?.snapshot().status;
893
+ const update = await this.updater.status().catch(() => undefined);
894
+ const routes = this.store.recentRoutes(c.gatewayKey, FEEDBACK_LIMITS.routes);
895
+ const diagnostics = {
896
+ theone: this.updater.current, dsh: readDshVersion() ?? null, node: process.version, platform: `${process.platform}-${process.arch}`,
897
+ model: settings?.model ? { provider: settings.model.provider, model: settings.model.model } : null,
898
+ ...(settings?.modelUnavailable ? { modelUnavailable: true } : {}),
899
+ settings: values ? { routerMode: values.routerMode, linkScope: values.linkScope, routeNotice: values.routeNotice, notices: values.notices,
900
+ historyCatalog: values.historyCatalog, factLinks: values.factLinks, factExtraction: values.factExtraction, topicCards: c.topicCards !== false,
901
+ maxResponseChars: values.maxResponseChars, maxDescriptorChars: values.maxDescriptorChars,
902
+ pinnedModel: !!(values.workerProvider && values.workerModel), manualCatalog: !!values.contextsPath } : null,
903
+ topics: { total: this.store.contexts().length, hidden: this.store.hiddenReasons().size, workspaces: this.store.groups().length },
904
+ catalog: status ? { running: status.running, scanned: status.scanned, indexed: status.indexed, skipped: status.skipped, failed: status.failed,
905
+ pending: status.pending, ...(status.searchUnavailable ? { searchUnavailable: true } : {}) } : { off: true },
906
+ routing: { stats: this.store.routeStats(c.gatewayKey), recent: routeDigest(routes) },
907
+ update: update ? { source: update.source, ...(update.latest ? { latest: update.latest } : {}), ...(update.state ? { state: update.state } : {}),
908
+ ...(update.error ? { error: update.error } : {}) } : null,
909
+ busy: this.active,
910
+ };
911
+ let reply;
912
+ if (messageId) {
913
+ const route = routes.find(item => item.messageId === messageId);
914
+ const stored = this.store.route(messageId);
915
+ if (!route || !stored || !this.store.isGateway(stored.gatewayId))
916
+ throw new Error('UNKNOWN_MESSAGE');
917
+ const read = (id) => this.ctx.sessionQuery.readSession(SessionId(id)).then(log => log.events, () => undefined);
918
+ const mainEvents = await read(stored.gatewayId);
919
+ const main = mainEvents && exchange(mainEvents, messageId);
920
+ const sessionId = this.store.contexts().find(context => context.id === route.decision.contextId)?.workingSessionId;
921
+ const workerEvents = sessionId ? await read(sessionId) : undefined;
922
+ const worker = workerEvents && exchange(workerEvents, messageId, main?.user);
923
+ reply = replySection(route, main, worker, includeReply);
924
+ }
925
+ // Scrubbed here too, so what the user reads is exactly what is sent.
926
+ return { v: 1, app: 'theone', version: this.updater.current, diagnostics: scrub(diagnostics, homedir()),
927
+ ...(reply ? { reply: scrub(reply, homedir()) } : {}) };
928
+ }
833
929
  /** Read only public options; never read or return the API key environment value. */
834
930
  async settingsSnapshot() {
835
931
  const c = this.config;
@@ -888,6 +984,34 @@ export default class TheOne extends Service {
888
984
  catch { /* Released during shutdown. */ }
889
985
  this.store.seed(descriptors);
890
986
  }
987
+ /**
988
+ * DSH saves any session's model choice as its global default, so main chat choosing TheOne used
989
+ * to make every new ordinary chat a TheOne chat. TheOne must not change the user's settings:
990
+ * before main chat picks its model the default is held, and put back right after.
991
+ */
992
+ heldDefault;
993
+ holdDefaultModel() {
994
+ const current = this.ctx.agentDefaultModel.currentSelection();
995
+ // A default the user set to TheOne themselves stays as it is.
996
+ this.heldDefault = current.provider && current.provider !== 'theone' ? current : undefined;
997
+ }
998
+ async restoreDefaultModel() {
999
+ const held = this.heldDefault;
1000
+ this.heldDefault = undefined;
1001
+ // Saves run in order, so this lands after the one DSH queued for main chat's selection.
1002
+ if (held)
1003
+ await this.ctx.agentDefaultModel.saveSelection?.(held);
1004
+ }
1005
+ /** Once: undo the default earlier versions left as TheOne, back to the model in use before it. */
1006
+ repairDefaultModel() {
1007
+ const current = this.ctx.agentDefaultModel.currentSelection();
1008
+ if (current.provider !== 'theone')
1009
+ return;
1010
+ const before = this.store.rememberedModel(this.config.gatewayKey);
1011
+ if (!before || !this.store.markOnce('default-model-restored'))
1012
+ return;
1013
+ void this.ctx.agentDefaultModel.saveSelection?.(before)?.catch(error => console.warn('TheOne could not restore DSH\'s default model.', error));
1014
+ }
891
1015
  /** Capture before Web saves the gateway itself as DSH's new default. */
892
1016
  captureDefaultModel() {
893
1017
  const current = this.ctx.agentDefaultModel.currentSelection();
package/dist/store.d.ts CHANGED
@@ -250,6 +250,8 @@ export declare class ContextStore {
250
250
  private purgeFacts;
251
251
  /** Notices the user closed; they are not shown again on any browser. */
252
252
  dismissNotice(id: string, now?: number): void;
253
+ /** True the first time `name` is marked, false ever after: for one-time repairs. */
254
+ markOnce(name: string, now?: number): boolean;
253
255
  dismissedNotices(): Set<string>;
254
256
  /**
255
257
  * Topics kept in the directory but hidden from routing and briefings: every conversation they can
package/dist/store.js CHANGED
@@ -95,6 +95,7 @@ export class ContextStore {
95
95
  context_id TEXT NOT NULL, term TEXT NOT NULL, weight REAL NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(context_id, term)
96
96
  );
97
97
  CREATE TABLE IF NOT EXISTS dismissed_notices (id TEXT PRIMARY KEY, dismissed_at INTEGER NOT NULL);
98
+ CREATE TABLE IF NOT EXISTS plugin_flags (name TEXT PRIMARY KEY, set_at INTEGER NOT NULL);
98
99
  CREATE TABLE IF NOT EXISTS route_details (
99
100
  message_id TEXT PRIMARY KEY, excerpt TEXT NOT NULL, receipt TEXT, corrected_to TEXT, corrected_at INTEGER
100
101
  );
@@ -932,6 +933,10 @@ export class ContextStore {
932
933
  dismissNotice(id, now = Date.now()) {
933
934
  this.db.prepare('INSERT OR IGNORE INTO dismissed_notices VALUES (?, ?)').run(id, now);
934
935
  }
936
+ /** True the first time `name` is marked, false ever after: for one-time repairs. */
937
+ markOnce(name, now = Date.now()) {
938
+ return this.db.prepare('INSERT OR IGNORE INTO plugin_flags VALUES (?, ?)').run(name, now).changes === 1;
939
+ }
935
940
  dismissedNotices() {
936
941
  return new Set(this.db.prepare('SELECT id FROM dismissed_notices').all().map(row => String(row.id)));
937
942
  }
package/package.json CHANGED
@@ -1,9 +1,22 @@
1
1
  {
2
2
  "name": "dsh-theone",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Automatic context routing for DeepSeek Harness",
6
+ "description": "One chat for everything in DeepSeek Harness (DSH): no more hunting for old conversations. Each message goes to the topic it belongs to, with its own context.",
7
+ "keywords": [
8
+ "deepseek-harness",
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "deepseek",
12
+ "ai-chat",
13
+ "chat-organizer",
14
+ "conversation-management",
15
+ "chat-history",
16
+ "context-management",
17
+ "conversation-routing",
18
+ "productivity"
19
+ ],
7
20
  "main": "./dist/index.js",
8
21
  "types": "./dist/index.d.ts",
9
22
  "exports": {
@@ -57,7 +70,8 @@
57
70
  "pack:plugin": "npm run build && node scripts/pack-plugin.mjs",
58
71
  "install:local": "npm run build && node scripts/install-local.mjs",
59
72
  "start:local": "node scripts/start-local.mjs",
60
- "eval:test": "node --test eval/test/*.test.mjs && python3 -m unittest discover -s eval/test"
73
+ "eval:test": "node --test eval/test/*.test.mjs && python3 -m unittest discover -s eval/test",
74
+ "feedback:test": "node --test server/feedback/test/*.test.mjs"
61
75
  },
62
76
  "dependencies": {
63
77
  "@deepseek-ai/schemastery": "3.18.4"
@@ -106,5 +120,9 @@
106
120
  "repository": {
107
121
  "type": "git",
108
122
  "url": "git+https://github.com/YunongDai2005/dsh-theone.git"
123
+ },
124
+ "bugs": {
125
+ "url": "https://github.com/YunongDai2005/dsh-theone/issues",
126
+ "email": "theone@yulid.org"
109
127
  }
110
128
  }