dsh-theone 0.3.20 → 0.3.21
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 +73 -2
- package/README.zh.md +72 -2
- package/dist/catalog-types.d.ts +5 -0
- package/dist/client-locales.d.ts +6 -0
- package/dist/client-locales.js +12 -0
- package/dist/client.js +19 -4
- 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/history-catalog.d.ts +9 -0
- package/dist/history-catalog.js +48 -2
- package/dist/index.d.ts +28 -1
- package/dist/index.js +229 -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 +3 -2
package/dist/facts.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
2
|
+
export type FactKind = 'fact' | 'decision' | 'artifact';
|
|
3
|
+
export type FactStatus = 'confirmed' | 'proposed' | 'retracted';
|
|
4
|
+
export type Speaker = 'user' | 'assistant' | 'tool';
|
|
5
|
+
export declare const FACT_KINDS: readonly FactKind[];
|
|
6
|
+
/** Where a version came from. `seq` is null when the quoted words were not found in the session. */
|
|
7
|
+
export interface FactEvidence {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
seq: number | null;
|
|
10
|
+
speaker: Speaker | 'unverified';
|
|
11
|
+
quote: string;
|
|
12
|
+
/** For a confirmation such as "ok, go with that": the assistant's proposal it accepted. */
|
|
13
|
+
accepts?: {
|
|
14
|
+
seq: number;
|
|
15
|
+
quote: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** A fact as it stands now: its identity and its current version. */
|
|
19
|
+
export interface FactView {
|
|
20
|
+
id: string;
|
|
21
|
+
contextId: string;
|
|
22
|
+
key: string;
|
|
23
|
+
label: string;
|
|
24
|
+
aliases: string[];
|
|
25
|
+
kind: FactKind;
|
|
26
|
+
version: number;
|
|
27
|
+
status: FactStatus;
|
|
28
|
+
value: string | null;
|
|
29
|
+
evidence: FactEvidence;
|
|
30
|
+
origin: 'worker' | 'extractor';
|
|
31
|
+
createdAt: number;
|
|
32
|
+
deletedAt?: number;
|
|
33
|
+
mergedFrom?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Every size limit of the feature in one place; each is a hard cap on characters or items. */
|
|
36
|
+
export declare const FACT_LIMITS: {
|
|
37
|
+
readonly label: 60;
|
|
38
|
+
readonly value: 300;
|
|
39
|
+
readonly quote: 200;
|
|
40
|
+
readonly aliases: 6;
|
|
41
|
+
readonly alias: 60;
|
|
42
|
+
readonly recordItems: 8;
|
|
43
|
+
readonly routerBudget: 1200;
|
|
44
|
+
readonly routerLabel: 40;
|
|
45
|
+
readonly routerValue: 80;
|
|
46
|
+
readonly routerItems: 12;
|
|
47
|
+
readonly imports: 5;
|
|
48
|
+
readonly briefingBudget: 1200;
|
|
49
|
+
readonly noticeBudget: 600;
|
|
50
|
+
readonly ownBudget: 1500;
|
|
51
|
+
readonly ownItems: 20;
|
|
52
|
+
readonly lookupItems: 10;
|
|
53
|
+
readonly extractionBudget: 6000;
|
|
54
|
+
readonly extractionFacts: 1500;
|
|
55
|
+
};
|
|
56
|
+
/** Text that may be stored or shown: credentials and personal addresses removed, then shortened. */
|
|
57
|
+
export declare const safe: (text: string, max: number) => string;
|
|
58
|
+
/**
|
|
59
|
+
* The name two writes must share to be the same fact. Case, width and spacing are folded, and only
|
|
60
|
+
* wrapping quotes and punctuation are dropped, so `C++`, `C#` and `.NET` stay distinct.
|
|
61
|
+
*/
|
|
62
|
+
export declare function factKey(label: string): string;
|
|
63
|
+
/** Folded text for finding a quote inside a message: width, case, spacing and thousands separators. */
|
|
64
|
+
export declare function matchText(text: string): string;
|
|
65
|
+
/** Literal evidence, with complete numeric tokens and units; similarity is for ranking, not proof. */
|
|
66
|
+
export declare function states(source: string, value: string): boolean;
|
|
67
|
+
/** "ok, go with that": an explicit acceptance, without a refusal and not itself a question. */
|
|
68
|
+
export declare function accepts(text: string): boolean;
|
|
69
|
+
/** A retraction needs words withdrawing or reopening a value, not merely any user quote. */
|
|
70
|
+
export declare function retracts(text: string): boolean;
|
|
71
|
+
export interface EvidenceEvent {
|
|
72
|
+
seq: number;
|
|
73
|
+
speaker: Speaker;
|
|
74
|
+
text: string;
|
|
75
|
+
toolResult?: boolean;
|
|
76
|
+
isError?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The messages of a topic session that can serve as evidence, newest last: the user's own messages,
|
|
80
|
+
* the assistant's answers and tool activity. TheOne's reference material for the session (its
|
|
81
|
+
* descriptor, cross-topic briefings) is not the user speaking and never counts.
|
|
82
|
+
*/
|
|
83
|
+
export declare function evidenceEvents(events: readonly SessionEvent[], limit?: number): EvidenceEvent[];
|
|
84
|
+
/** The newest event containing `quote` (folded), optionally only from one speaker or before a point. */
|
|
85
|
+
export declare function findQuote(events: readonly EvidenceEvent[], quote: string, options?: {
|
|
86
|
+
speaker?: Speaker;
|
|
87
|
+
before?: number;
|
|
88
|
+
}): EvidenceEvent | undefined;
|
|
89
|
+
export interface Verdict {
|
|
90
|
+
status: 'confirmed' | 'proposed';
|
|
91
|
+
evidence: FactEvidence;
|
|
92
|
+
reason?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Decide from the session itself whether a value was confirmed. The model only points at words; the
|
|
96
|
+
* speaker and the position come from the session. Confirmed means: the user stated the value; or the
|
|
97
|
+
* user explicitly accepted an earlier assistant proposal that stated it; or, for an artifact, a tool
|
|
98
|
+
* produced it. Anything else is a proposal and stays inside its topic.
|
|
99
|
+
*/
|
|
100
|
+
export declare function verify(input: {
|
|
101
|
+
sessionId: string;
|
|
102
|
+
events: readonly EvidenceEvent[];
|
|
103
|
+
kind: FactKind;
|
|
104
|
+
value: string;
|
|
105
|
+
quote: string;
|
|
106
|
+
acceptsQuote?: string;
|
|
107
|
+
}): Verdict;
|
|
108
|
+
/** One fact as other topics are shown it: `【topic】label = value (version n, confirmed at …)`. */
|
|
109
|
+
export declare function factLine(fact: FactView, topicTitle: string): string;
|
|
110
|
+
/** Lines up to a character budget, whole lines only. */
|
|
111
|
+
export declare function within(lines: string[], budget: number): string[];
|
|
112
|
+
export interface FactCandidate {
|
|
113
|
+
id: string;
|
|
114
|
+
topic: string;
|
|
115
|
+
label: string;
|
|
116
|
+
kind: FactKind;
|
|
117
|
+
value: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Facts from other topics that the message may draw on, best first. Ranking only: whether a fact may
|
|
121
|
+
* actually be delivered is decided again at delivery, against the final topic and current settings.
|
|
122
|
+
*/
|
|
123
|
+
export declare function rankCandidates(facts: readonly (FactView & {
|
|
124
|
+
topicTitle: string;
|
|
125
|
+
related: number;
|
|
126
|
+
lastUsedAt?: number;
|
|
127
|
+
})[], text: string, now?: number): FactCandidate[];
|
package/dist/facts.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { redactRoutingText, textFeatures } from "./routing-policy.js";
|
|
2
|
+
export const FACT_KINDS = ['fact', 'decision', 'artifact'];
|
|
3
|
+
/** Every size limit of the feature in one place; each is a hard cap on characters or items. */
|
|
4
|
+
export const FACT_LIMITS = {
|
|
5
|
+
label: 60, value: 300, quote: 200, aliases: 6, alias: 60, recordItems: 8,
|
|
6
|
+
routerBudget: 1200, routerLabel: 40, routerValue: 80, routerItems: 12, imports: 5,
|
|
7
|
+
briefingBudget: 1200, noticeBudget: 600, ownBudget: 1500, ownItems: 20, lookupItems: 10,
|
|
8
|
+
extractionBudget: 6000, extractionFacts: 1500,
|
|
9
|
+
};
|
|
10
|
+
const clip = (text, max) => text.length > max ? text.slice(0, Math.max(0, max - 1)) + '…' : text;
|
|
11
|
+
/** Text that may be stored or shown: credentials and personal addresses removed, then shortened. */
|
|
12
|
+
export const safe = (text, max) => clip(redactRoutingText(text).trim(), max);
|
|
13
|
+
/**
|
|
14
|
+
* The name two writes must share to be the same fact. Case, width and spacing are folded, and only
|
|
15
|
+
* wrapping quotes and punctuation are dropped, so `C++`, `C#` and `.NET` stay distinct.
|
|
16
|
+
*/
|
|
17
|
+
export function factKey(label) {
|
|
18
|
+
return label.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
|
|
19
|
+
.replace(/^[\s"'“”‘’「」『』《》〈〉()()[\]【】,;:!?,。;:!?、]+/, '')
|
|
20
|
+
.replace(/[\s"'“”‘’「」『』《》〈〉()()[\]【】.,;:!?,。;:!?、]+$/, '');
|
|
21
|
+
}
|
|
22
|
+
/** Folded text for finding a quote inside a message: width, case, spacing and thousands separators. */
|
|
23
|
+
export function matchText(text) {
|
|
24
|
+
return redactRoutingText(text).normalize('NFKC').toLowerCase().replace(/(\d)[,,](?=\d{3}\b)/g, '$1').replace(/\s+/g, '');
|
|
25
|
+
}
|
|
26
|
+
/** Literal evidence, with complete numeric tokens and units; similarity is for ranking, not proof. */
|
|
27
|
+
export function states(source, value) {
|
|
28
|
+
const s = matchText(source), v = matchText(value);
|
|
29
|
+
if (!v || !s)
|
|
30
|
+
return false;
|
|
31
|
+
const present = new Set(numberTokens(source));
|
|
32
|
+
if (numberTokens(value).some(number => !present.has(number)))
|
|
33
|
+
return false;
|
|
34
|
+
return s.includes(v);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whole numbers as written, read before spaces are folded away (so "12 3pm" is 12 and 3, not 123).
|
|
38
|
+
* A sign only where no digit precedes it, so "2026-10-05" holds 10 and 05, not -10 and -05.
|
|
39
|
+
*/
|
|
40
|
+
function numberTokens(text) {
|
|
41
|
+
return redactRoutingText(text).normalize('NFKC').replace(/(\d)[,,](?=\d{3}\b)/g, '$1').match(/(?<![\d.])[+-]?\d+(?:\.\d+)?/g) ?? [];
|
|
42
|
+
}
|
|
43
|
+
const NEGATION = /(不行|不可以|不好|不要|不用|不能|不是|不再|不同意|不采用|不选|别这样|算了|再想想|\bno\b|\bnot\b|\bdon'?t\b|\bnope\b|rather than|instead of)/i;
|
|
44
|
+
const UNCERTAIN = /(未定|没定|没(?:有)?确定|未确认|未确定|不确定|暂定|待定|建议|假设|如果|或许|可能|\b(might|maybe|perhaps|suggest|suppose|if|undecided)\b)/i;
|
|
45
|
+
const QUESTION = /[??]|(吗|呢)\s*[。!!]?\s*$/;
|
|
46
|
+
const ACCEPTANCE = /((?:^|[\s,,。.!!;;])(?:好的?|行|可以|确定|定了)(?=$|[\s,,。.!!;;]|就|按)|就按|就这样|就用|就它|同意|就这么定|没问题|按你说的|\bok(ay)?\b|\byes\b|\bsure\b|sounds good|go with|let'?s (do|go|use)|\bagreed?\b|\bdeal\b|works for me|that works)/i;
|
|
47
|
+
/** Naming the assistant's suggestion ("就按你的建议", "your suggestion") is how an acceptance points at it. */
|
|
48
|
+
const THEIR_SUGGESTION = /((?:你|您)(?:的|刚才的|说的)?(?:建议|提议|方案))|\byour (?:suggestion|proposal|idea)\b/gi;
|
|
49
|
+
/** "ok, go with that": an explicit acceptance, without a refusal and not itself a question. */
|
|
50
|
+
export function accepts(text) {
|
|
51
|
+
return ACCEPTANCE.test(text) && !NEGATION.test(text) && !UNCERTAIN.test(text.replace(THEIR_SUGGESTION, '')) && !QUESTION.test(text.trim());
|
|
52
|
+
}
|
|
53
|
+
/** A retraction needs words withdrawing or reopening a value, not merely any user quote. */
|
|
54
|
+
export function retracts(text) {
|
|
55
|
+
return !QUESTION.test(text.trim()) && /(撤回|取消|不再|不要|不用|未定|没定|没(?:有)?确定|未确认|未确定|不确定|先不定|待定|\b(withdraw|withdrawn|cancel|cancelled|canceled|undecided)\b|no longer|is off|not (yet )?(settled|decided))/i.test(text);
|
|
56
|
+
}
|
|
57
|
+
/** Check the actual sentence, including words a shortened model quote might have omitted. */
|
|
58
|
+
function assertsValue(text, value, quote) {
|
|
59
|
+
const needle = matchText(quote);
|
|
60
|
+
const clauses = text.replace(/(\d)[,,](?=\d{3}\b)/g, '$1').split(/(?<=[。!?!?;;\n])/).flatMap(sentence => sentence.split(/[,,]/).filter(clause => states(clause, value) &&
|
|
61
|
+
(matchText(clause).includes(needle) || needle.includes(matchText(clause))))
|
|
62
|
+
.map(clause => ({ text: clause, question: QUESTION.test(sentence.trim()) })));
|
|
63
|
+
return clauses.length > 0 && clauses.every(clause => !clause.question && !NEGATION.test(clause.text) && !UNCERTAIN.test(clause.text));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The messages of a topic session that can serve as evidence, newest last: the user's own messages,
|
|
67
|
+
* the assistant's answers and tool activity. TheOne's reference material for the session (its
|
|
68
|
+
* descriptor, cross-topic briefings) is not the user speaking and never counts.
|
|
69
|
+
*/
|
|
70
|
+
export function evidenceEvents(events, limit = 200) {
|
|
71
|
+
const text = (blocks) => blocks.flatMap(block => block.type === 'text' && block.text ? [block.text] : []).join('\n');
|
|
72
|
+
// TheOne's own tools repeat the quotes they are given; a call citing itself proves nothing.
|
|
73
|
+
const own = new Set(events.flatMap(event => event.type === 'tool/call' && event.data.name.startsWith('theone_') ? [String(event.data.callId)] : []));
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const event of events) {
|
|
76
|
+
if (event.type === 'user/message' && event.data.source.kind === 'user')
|
|
77
|
+
result.push({ seq: event.seq, speaker: 'user', text: text(event.data.content) });
|
|
78
|
+
else if (event.type === 'assistant/message')
|
|
79
|
+
result.push({ seq: event.seq, speaker: 'assistant', text: text(event.data.message.content) });
|
|
80
|
+
else if (event.type === 'tool/result' && !own.has(String(event.data.message.source.callId)))
|
|
81
|
+
result.push({ seq: event.seq, speaker: 'tool', text: text(event.data.message.content), toolResult: true, isError: event.data.message.isError === true });
|
|
82
|
+
}
|
|
83
|
+
return result.filter(item => item.text.trim()).slice(-limit);
|
|
84
|
+
}
|
|
85
|
+
/** The newest event containing `quote` (folded), optionally only from one speaker or before a point. */
|
|
86
|
+
export function findQuote(events, quote, options = {}) {
|
|
87
|
+
const needle = matchText(quote);
|
|
88
|
+
if (needle.length < 2)
|
|
89
|
+
return undefined;
|
|
90
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
91
|
+
const event = events[index];
|
|
92
|
+
if (options.before !== undefined && event.seq >= options.before)
|
|
93
|
+
continue;
|
|
94
|
+
if (options.speaker && event.speaker !== options.speaker)
|
|
95
|
+
continue;
|
|
96
|
+
if (matchText(event.text).includes(needle))
|
|
97
|
+
return event;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Decide from the session itself whether a value was confirmed. The model only points at words; the
|
|
103
|
+
* speaker and the position come from the session. Confirmed means: the user stated the value; or the
|
|
104
|
+
* user explicitly accepted an earlier assistant proposal that stated it; or, for an artifact, a tool
|
|
105
|
+
* produced it. Anything else is a proposal and stays inside its topic.
|
|
106
|
+
*/
|
|
107
|
+
export function verify(input) {
|
|
108
|
+
const quote = safe(input.quote, FACT_LIMITS.quote);
|
|
109
|
+
// The user's own message wins over an echo of it (the assistant repeating what the user said).
|
|
110
|
+
const hit = findQuote(input.events, input.quote, { speaker: 'user' }) ?? findQuote(input.events, input.quote);
|
|
111
|
+
if (!hit)
|
|
112
|
+
return { status: 'proposed', reason: 'quote-not-found', evidence: { sessionId: input.sessionId, seq: null, speaker: 'unverified', quote } };
|
|
113
|
+
const evidence = { sessionId: input.sessionId, seq: hit.seq, speaker: hit.speaker, quote };
|
|
114
|
+
if (hit.speaker === 'user' && states(input.quote, input.value) && assertsValue(hit.text, input.value, input.quote))
|
|
115
|
+
return { status: 'confirmed', evidence };
|
|
116
|
+
if (hit.speaker === 'user' && input.acceptsQuote && accepts(input.quote) && accepts(hit.text)) {
|
|
117
|
+
const proposal = findQuote(input.events, input.acceptsQuote, { speaker: 'assistant', before: hit.seq });
|
|
118
|
+
const latest = input.events.findLast(event => event.speaker === 'assistant' && event.seq < hit.seq);
|
|
119
|
+
if (proposal && proposal.seq === latest?.seq && states(input.acceptsQuote, input.value))
|
|
120
|
+
return { status: 'confirmed', evidence: { ...evidence, accepts: { seq: proposal.seq, quote: safe(input.acceptsQuote, FACT_LIMITS.quote) } } };
|
|
121
|
+
return { status: 'proposed', reason: 'proposal-not-found', evidence };
|
|
122
|
+
}
|
|
123
|
+
if (input.kind === 'artifact' && hit.speaker === 'tool' && hit.toolResult && !hit.isError && states(hit.text, input.value))
|
|
124
|
+
return { status: 'confirmed', evidence };
|
|
125
|
+
return { status: 'proposed', reason: hit.speaker === 'user' ? 'value-not-in-quote' : `said-by-${hit.speaker}`, evidence };
|
|
126
|
+
}
|
|
127
|
+
/** One fact as other topics are shown it: `【topic】label = value (version n, confirmed at …)`. */
|
|
128
|
+
export function factLine(fact, topicTitle) {
|
|
129
|
+
const at = new Date(fact.createdAt).toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
|
|
130
|
+
const confirmedBy = fact.evidence.speaker === 'tool' ? '工具验证于' : '用户确认于';
|
|
131
|
+
return `- 【${safe(topicTitle, 40)}】${safe(fact.label, FACT_LIMITS.label)} = ${safe(fact.value ?? '', FACT_LIMITS.value)}(第 ${fact.version} 版,${confirmedBy} ${at})`;
|
|
132
|
+
}
|
|
133
|
+
/** Lines up to a character budget, whole lines only. */
|
|
134
|
+
export function within(lines, budget) {
|
|
135
|
+
const kept = [];
|
|
136
|
+
let used = 0;
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (used + line.length + 1 > budget)
|
|
139
|
+
break;
|
|
140
|
+
kept.push(line);
|
|
141
|
+
used += line.length + 1;
|
|
142
|
+
}
|
|
143
|
+
return kept;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Facts from other topics that the message may draw on, best first. Ranking only: whether a fact may
|
|
147
|
+
* actually be delivered is decided again at delivery, against the final topic and current settings.
|
|
148
|
+
*/
|
|
149
|
+
export function rankCandidates(facts, text, now = Date.now()) {
|
|
150
|
+
const query = textFeatures(text);
|
|
151
|
+
const lower = text.toLowerCase();
|
|
152
|
+
const scored = facts.flatMap(fact => {
|
|
153
|
+
const words = textFeatures([fact.label, ...fact.aliases, fact.value ?? ''].join(' '));
|
|
154
|
+
let shared = 0;
|
|
155
|
+
for (const feature of words)
|
|
156
|
+
if (query.has(feature))
|
|
157
|
+
shared++;
|
|
158
|
+
const lexical = words.size ? shared / Math.sqrt(words.size * Math.max(1, query.size)) : 0;
|
|
159
|
+
const named = fact.topicTitle && lower.includes(fact.topicTitle.toLowerCase()) ? 0.3 : 0;
|
|
160
|
+
const fresh = 0.1 * 0.5 ** ((now - (fact.lastUsedAt ?? fact.createdAt)) / (14 * 86400000));
|
|
161
|
+
const score = lexical + named + Math.min(0.3, fact.related * 0.1) + fresh;
|
|
162
|
+
return lexical > 0 || named > 0 ? [{ fact, score }] : [];
|
|
163
|
+
}).sort((a, b) => b.score - a.score);
|
|
164
|
+
const result = [];
|
|
165
|
+
let used = 2; // The serialized array's brackets and commas count towards the router budget too.
|
|
166
|
+
for (const { fact } of scored) {
|
|
167
|
+
if (result.length >= FACT_LIMITS.routerItems)
|
|
168
|
+
break;
|
|
169
|
+
const item = { id: fact.id, topic: clip(fact.topicTitle, 40), label: clip(fact.label, FACT_LIMITS.routerLabel), kind: fact.kind, value: safe(fact.value ?? '', FACT_LIMITS.routerValue) };
|
|
170
|
+
const size = JSON.stringify(item).length + (result.length ? 1 : 0);
|
|
171
|
+
if (used + size > FACT_LIMITS.routerBudget)
|
|
172
|
+
break;
|
|
173
|
+
result.push(item);
|
|
174
|
+
used += size;
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
@@ -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
|
@@ -31,6 +31,12 @@ export interface Config {
|
|
|
31
31
|
routeNotice?: 'hidden' | 'switch' | 'all';
|
|
32
32
|
/** Show notices the maintainer publishes (read from a static file; nothing is sent). */
|
|
33
33
|
notices?: boolean;
|
|
34
|
+
/** Experimental: topics share confirmed facts (a figure, a decision) with evidence and versions. */
|
|
35
|
+
factLinks?: boolean;
|
|
36
|
+
/** With factLinks: after each turn, a small model call proposes facts the Worker did not record. */
|
|
37
|
+
factExtraction?: boolean;
|
|
38
|
+
/** A routing card per topic, written in the background after its first replies; on unless turned off (tests). */
|
|
39
|
+
topicCards?: boolean;
|
|
34
40
|
/** Where notices are read from; for testing. */
|
|
35
41
|
noticeUrl?: string;
|
|
36
42
|
}
|
|
@@ -148,6 +154,12 @@ export default class TheOne extends Service {
|
|
|
148
154
|
* Cross-topic reference for a Worker about to start: the recent main chat after a topic switch,
|
|
149
155
|
* and the news of related topics (plus those the request itself named). Undefined when empty.
|
|
150
156
|
*/
|
|
157
|
+
/** Confirmed facts this message might use (see fact-flow). */
|
|
158
|
+
private factCandidates;
|
|
159
|
+
/** What a topic is told about facts before its Worker answers (see fact-flow). */
|
|
160
|
+
private factDelivery;
|
|
161
|
+
/** The topic's own recorded facts for its Worker's descriptor; empty unless shared facts are on. */
|
|
162
|
+
private ownFacts;
|
|
151
163
|
private briefingFor;
|
|
152
164
|
/**
|
|
153
165
|
* No one views a Worker session, so its approval questions would fail closed. Ask in the
|
|
@@ -165,6 +177,8 @@ export default class TheOne extends Service {
|
|
|
165
177
|
private forwardQuestions;
|
|
166
178
|
/** Capability is scoped to the exact owned Worker; the model cannot select another Context. */
|
|
167
179
|
private registerWorkerTools;
|
|
180
|
+
/** Recording facts with evidence, and looking up other topics' confirmed facts (shared facts only). */
|
|
181
|
+
private registerFactTools;
|
|
168
182
|
/** Mirror the routed Worker's steps into the main chat; tools execute exclusively in the Worker. */
|
|
169
183
|
answer(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
170
184
|
/** The message answered just before `inputId` in this main chat, with the topic it went to. */
|
|
@@ -173,6 +187,16 @@ export default class TheOne extends Service {
|
|
|
173
187
|
private reroute;
|
|
174
188
|
/** Learning from corrections in flight; tests and shutdown can wait for it. */
|
|
175
189
|
learning: Promise<void>;
|
|
190
|
+
/** Fact extractions in flight, one queue per topic so they commit in turn order. */
|
|
191
|
+
private readonly extractions;
|
|
192
|
+
/** All extractions in flight; tests and shutdown can wait for it. */
|
|
193
|
+
get extracting(): Promise<void>;
|
|
194
|
+
/** Queue an extraction of the turn that just ended in `worker`'s topic. */
|
|
195
|
+
private queueExtraction;
|
|
196
|
+
/** Queue a routing card for this topic, after any extraction already queued for it. */
|
|
197
|
+
private queueCard;
|
|
198
|
+
private writeCard;
|
|
199
|
+
private extractFacts;
|
|
176
200
|
/**
|
|
177
201
|
* The user moved a message to another topic: remember it, and teach both topics. The right topic
|
|
178
202
|
* gains the message's distinctive terms and the wrong one loses them; `weight` is lower for
|
|
@@ -181,7 +205,10 @@ export default class TheOne extends Service {
|
|
|
181
205
|
private applyCorrection;
|
|
182
206
|
/** Ask the selected model, thinking off, for the few terms that tie a message to its topic. */
|
|
183
207
|
private pickTerms;
|
|
184
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Topics as routing sees them: with the terms corrections taught them, and when each was last
|
|
210
|
+
* active. Topics set aside (untouched for longer than this user usually comes back) go last.
|
|
211
|
+
*/
|
|
185
212
|
private routingContexts;
|
|
186
213
|
/**
|
|
187
214
|
* Favour topics used recently or often, and those linked to the current one, when narrowing
|