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.
- package/README.md +43 -19
- package/README.zh.md +44 -19
- package/dist/client-locales.d.ts +33 -0
- package/dist/client-locales.js +66 -0
- package/dist/client.js +407 -41
- package/dist/feedback.d.ts +113 -0
- package/dist/feedback.js +146 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +124 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +5 -0
- package/package.json +21 -3
|
@@ -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>;
|
package/dist/feedback.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
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
|
}
|