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.
- package/README.md +109 -14
- package/README.zh.md +110 -15
- package/dist/catalog-types.d.ts +5 -0
- package/dist/client-locales.d.ts +39 -0
- package/dist/client-locales.js +78 -0
- package/dist/client.js +423 -43
- package/dist/fact-flow.d.ts +39 -0
- package/dist/fact-flow.js +135 -0
- package/dist/facts.d.ts +127 -0
- package/dist/facts.js +177 -0
- package/dist/feedback.d.ts +113 -0
- package/dist/feedback.js +146 -0
- package/dist/history-catalog.d.ts +9 -0
- package/dist/history-catalog.js +48 -2
- package/dist/index.d.ts +37 -1
- package/dist/index.js +318 -28
- package/dist/linkage.d.ts +9 -0
- package/dist/linkage.js +31 -1
- package/dist/llm-router.d.ts +8 -1
- package/dist/llm-router.js +29 -6
- package/dist/run.d.ts +2 -0
- package/dist/run.js +2 -0
- package/dist/settings-types.d.ts +6 -2
- package/dist/settings-types.js +3 -3
- package/dist/settings.js +3 -2
- package/dist/store.d.ts +104 -2
- package/dist/store.js +269 -2
- package/dist/topic-memory.d.ts +61 -0
- package/dist/topic-memory.js +78 -0
- package/dist/types.d.ts +4 -0
- package/package.json +22 -3
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
|
+
}
|
|
@@ -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.
|
package/dist/history-catalog.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|