dsh-chat-tide 0.0.0-stage → 0.2.2

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONTRIBUTING.md +60 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +68 -0
  5. package/README.md +67 -2
  6. package/RELEASE.md +55 -0
  7. package/assets/icon.png +0 -0
  8. package/cordis.patch.yml +35 -0
  9. package/lib/ambient.js +44 -0
  10. package/lib/api.js +199 -0
  11. package/lib/attention.js +191 -0
  12. package/lib/budget.js +37 -0
  13. package/lib/circuit.js +42 -0
  14. package/lib/client.js +543 -0
  15. package/lib/config.js +139 -0
  16. package/lib/conflict.js +44 -0
  17. package/lib/const.js +24 -0
  18. package/lib/context.js +232 -0
  19. package/lib/db.js +325 -0
  20. package/lib/dedupe.js +29 -0
  21. package/lib/egress.js +84 -0
  22. package/lib/embed-child.mjs +41 -0
  23. package/lib/embed-worker.mjs +35 -0
  24. package/lib/embed.js +309 -0
  25. package/lib/events.js +82 -0
  26. package/lib/exemplar.js +48 -0
  27. package/lib/extract.js +183 -0
  28. package/lib/facts.js +311 -0
  29. package/lib/fts.js +97 -0
  30. package/lib/images.js +57 -0
  31. package/lib/index.js +1074 -0
  32. package/lib/inject.js +107 -0
  33. package/lib/janitor.js +137 -0
  34. package/lib/llmroute.js +43 -0
  35. package/lib/merge.js +54 -0
  36. package/lib/people.js +55 -0
  37. package/lib/prefilter.js +74 -0
  38. package/lib/qqtext.js +42 -0
  39. package/lib/repair.js +119 -0
  40. package/lib/route-decision.js +303 -0
  41. package/lib/route.js +42 -0
  42. package/lib/schema.js +134 -0
  43. package/lib/scope.js +121 -0
  44. package/lib/scrub.js +23 -0
  45. package/lib/sessions.js +69 -0
  46. package/lib/settings.js +135 -0
  47. package/lib/silence.js +92 -0
  48. package/lib/status-page.js +263 -0
  49. package/lib/topics.js +72 -0
  50. package/lib/vec.js +63 -0
  51. package/package.json +92 -4
  52. package/presets/README.md +38 -0
  53. package/presets/attention/example.json +10 -0
  54. package/presets/config/profiles.json +32 -0
  55. package/presets/private.example.json +18 -0
  56. package/presets/rules/generic.json +79 -0
  57. package/presets/rules.schema.json +32 -0
  58. package/probes/01-sqlite-vec.mjs +70 -0
  59. package/probes/02-fts-bigram.mjs +68 -0
  60. package/probes/03-embedding.mjs +73 -0
  61. package/probes/04-storage.mjs +118 -0
  62. package/probes/05-facts.mjs +113 -0
  63. package/probes/06-events-egress.mjs +72 -0
  64. package/probes/07-extract.mjs +66 -0
  65. package/probes/08-context-janitor.mjs +80 -0
  66. package/probes/09-route-silence.mjs +75 -0
  67. package/probes/10-vector-pipeline.mjs +77 -0
  68. package/probes/11-llm-fallback.mjs +28 -0
  69. package/probes/12-exemplar-budget-merge.mjs +76 -0
  70. package/probes/13-circuit.mjs +45 -0
  71. package/probes/14-embed-worker.mjs +41 -0
  72. package/probes/15-backfill.mjs +57 -0
  73. package/probes/16-scope-preset.mjs +36 -0
  74. package/probes/17-prefilter.mjs +33 -0
  75. package/probes/18-qqtext.mjs +33 -0
  76. package/probes/19-repair.mjs +47 -0
  77. package/probes/20-context-message.mjs +49 -0
  78. package/probes/21-status-page.mjs +75 -0
  79. package/probes/22-retention.mjs +45 -0
  80. package/probes/23-ambient-probability.mjs +60 -0
  81. package/probes/24-inject-delta.mjs +50 -0
  82. package/probes/25-route-decision.mjs +122 -0
  83. package/probes/26-attention.mjs +52 -0
  84. package/probes/27-people-alias.mjs +42 -0
  85. package/probes/28-wake-gates.mjs +88 -0
  86. package/probes/29-scope-modes.mjs +53 -0
  87. package/probes/30-attention-vector.mjs +84 -0
  88. package/probes/31-group-speakers.mjs +58 -0
  89. package/probes/32-image-cache.mjs +54 -0
  90. package/probes/33-topic-growth.mjs +60 -0
  91. package/probes/34-integrity.mjs +80 -0
  92. package/probes/35-privacy.mjs +52 -0
  93. package/probes/36-inject-scopes.mjs +62 -0
  94. package/probes/37-inject-e2e.mjs +92 -0
  95. package/probes/38-review-fixes.mjs +74 -0
  96. package/probes/39-image-only.mjs +31 -0
  97. package/probes/40-scope-follow.mjs +40 -0
  98. package/probes/41-attention-label.mjs +35 -0
  99. package/scripts/attention-sim.mjs +54 -0
  100. package/scripts/check-dead-config.mjs +27 -0
  101. package/scripts/check-docs.mjs +71 -0
  102. package/scripts/check-naming.mjs +48 -0
  103. package/scripts/curate-report.mjs +228 -0
  104. package/scripts/doctor.mjs +110 -0
  105. package/scripts/export-private-rules.mjs +35 -0
  106. package/scripts/make-icon.mjs +114 -0
  107. package/scripts/metrics.mjs +94 -0
  108. package/scripts/pack.mjs +36 -0
  109. package/scripts/recall-check.mjs +56 -0
  110. package/scripts/run-checks.mjs +45 -0
  111. package/scripts/seed.mjs +82 -0
@@ -0,0 +1,191 @@
1
+ // 关注点(attention):只有这些主题值得主动搭话;其余只记录,不唤醒。
2
+ import { createHash } from 'node:crypto';
3
+ import { matchExpr, bigram } from './fts.js';
4
+ import { scrubIdentifiers } from './scrub.js';
5
+
6
+ // 指令腔:出现这些词的短句,多半是在吩咐助手,不是话题本身
7
+ const INSTRUCTION_RE = /留意|关注|记得|提醒|看看|看一下|安排|准备|跟进|汇报|主动|可以|的时候/;
8
+
9
+ /** 关注点标签规整:去掉主语和指令从句,保留描述性内容;不截断。 */
10
+ export function normalizeAttentionLabel(text) {
11
+ let label = String(text ?? '').trim();
12
+ if (!label) return label;
13
+ // 开头的指令动词先剥掉
14
+ for (let i = 0; i < 3; i += 1) {
15
+ const before = label;
16
+ label = label.replace(/^(记得|提醒我?|帮我|留意|关注|看看|看一下)(一下|下)?/, '').trim();
17
+ if (label === before) break;
18
+ }
19
+ // 按标点切段,丢掉指令段(至少保留一段)
20
+ const parts = label.split(/[,,;;。]/).map((x) => x.trim()).filter(Boolean);
21
+ const kept = parts.filter((x) => !INSTRUCTION_RE.test(x));
22
+ label = (kept.length ? kept : parts).join(',');
23
+ // 去掉开头的主语与泛化限定词
24
+ label = label.replace(/^(主人|我|助手|用户)(对|的|固定|通常|一般|经常|偶尔)?/, '').trim();
25
+ // 收尾标点
26
+ label = label.replace(/[,,;;。!!??\s]+$/g, '').trim();
27
+ return label.length >= 2 ? label : String(text ?? '').trim();
28
+ }
29
+
30
+ const now = () => Date.now();
31
+
32
+ /** 默认屏蔽:开发过程、插件实现、模型自身运作不算主人的关注点 */
33
+ const DEFAULT_BLOCK = ['插件', '记忆系统', 'prompt', '提示词', '模型运作', '系统设计', '时间窗', '候选集', '向量', '接口', '写入工具', '关注点机制', '关注点', '污染风险'];
34
+
35
+ function isBlocked(label, blocked) {
36
+ const t = String(label ?? '');
37
+ const list = [...DEFAULT_BLOCK, ...(blocked ?? [])];
38
+ return list.some((p) => p && t.includes(p));
39
+ }
40
+
41
+ export function attentionIdFor(label) {
42
+ return 'att-' + createHash('sha1').update(String(label).trim()).digest('hex').slice(0, 16);
43
+ }
44
+
45
+ function indexAttention(db, attentionId, text) {
46
+ try {
47
+ db.prepare('DELETE FROM attention_fts WHERE attention_id = ?').run(attentionId);
48
+ db.prepare('INSERT INTO attention_fts(attention_id, text) VALUES (?, ?)').run(attentionId, bigram(text));
49
+ } catch {
50
+ /* 索引失败不阻断 */
51
+ }
52
+ }
53
+
54
+ export function addAttention(db, { kind = 'topic', refId = null, label, note = null, weight = 0.5, expiresAt = null, ttlDays = null, blockPatterns = [] }) {
55
+ const clean = scrubIdentifiers(normalizeAttentionLabel(label));
56
+ note = note === null || note === undefined ? note : scrubIdentifiers(note);
57
+ if (!clean) return null;
58
+ if (kind === 'topic' && isBlocked(clean, blockPatterns)) return null;
59
+ const id = attentionIdFor(clean);
60
+ const existing = db.prepare('SELECT attention_id, weight FROM attention WHERE attention_id = ?').get(id);
61
+ const exp = expiresAt ?? (ttlDays ? now() + ttlDays * 86_400_000 : null);
62
+ if (existing) {
63
+ db.prepare('UPDATE attention SET weight = MAX(weight, ?), note = COALESCE(?, note), status = ? WHERE attention_id = ?').run(
64
+ weight,
65
+ note,
66
+ 'active',
67
+ id,
68
+ );
69
+ } else {
70
+ db.prepare(
71
+ `INSERT INTO attention(attention_id, kind, ref_id, label, note, weight, status, hits, created_at, expires_at)
72
+ VALUES (?,?,?,?,?,?,?,0,?,?)`,
73
+ ).run(id, kind, refId, clean, note, weight, 'active', now(), exp);
74
+ }
75
+ indexAttention(db, id, [clean, note].filter(Boolean).join(' '));
76
+ return id;
77
+ }
78
+
79
+ export function listAttention(db, { status = 'active', limit = 50 } = {}) {
80
+ return db
81
+ .prepare('SELECT attention_id AS id, kind, label, note, weight, status, hits, last_hit_at AS lastHitAt FROM attention WHERE status = ? ORDER BY weight DESC, created_at DESC LIMIT ?')
82
+ .all(status, limit);
83
+ }
84
+
85
+ export function retireAttention(db, id, reason = null) {
86
+ db.prepare("UPDATE attention SET status = 'retired', note = COALESCE(?, note) WHERE attention_id = ?").run(reason, id);
87
+ try {
88
+ db.prepare('DELETE FROM attention_fts WHERE attention_id = ?').run(id);
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ }
93
+
94
+ export function bumpAttentionHit(db, id) {
95
+ db.prepare('UPDATE attention SET hits = hits + 1, last_hit_at = ? WHERE attention_id = ?').run(now(), id);
96
+ }
97
+
98
+ /** 查询是否落在某个关注点上;返回命中项或 null。 */
99
+ export function matchAttention(db, text, { threshold = 2 } = {}) {
100
+ const clean = String(text ?? '').trim();
101
+ if (!clean) return null;
102
+ const grams = [...new Set(bigram(clean).split(' ').filter((g) => g.length >= 2))];
103
+ if (!grams.length) return null;
104
+ let rows = [];
105
+ try {
106
+ const expr = matchExpr(clean);
107
+ if (expr) {
108
+ rows = db
109
+ .prepare(
110
+ `SELECT a.attention_id AS id, a.label, a.note, a.weight, f.text
111
+ FROM attention_fts f JOIN attention a ON a.attention_id = f.attention_id
112
+ WHERE attention_fts MATCH ? AND a.status = 'active'
113
+ ORDER BY a.weight DESC LIMIT 10`,
114
+ )
115
+ .all(expr);
116
+ }
117
+ } catch {
118
+ rows = [];
119
+ }
120
+ if (!rows.length) {
121
+ rows = db
122
+ .prepare("SELECT attention_id AS id, label, note, weight, NULL AS text FROM attention WHERE status = 'active' ORDER BY weight DESC LIMIT 50")
123
+ .all();
124
+ }
125
+ let best = null;
126
+ for (const r of rows) {
127
+ const hay = String(r.text ?? '').replace(/ /g, '') + (r.label ?? '') + (r.note ?? '');
128
+ const hit = grams.filter((g) => hay.includes(g)).length;
129
+ if (hit >= threshold && (!best || hit > best.hit)) best = { id: r.id, label: r.label, note: r.note, weight: r.weight, hit };
130
+ }
131
+ return best;
132
+ }
133
+
134
+ /** 巡检:过期/低权重的关注点自动退休,保留最多 maxActive 条。 */
135
+ export function pruneAttention(db, { now: t = now(), maxActive = 80, expireDays = 30 } = {}) {
136
+ const expired = db
137
+ .prepare("SELECT attention_id AS id FROM attention WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < ?")
138
+ .all(t);
139
+ for (const r of expired) retireAttention(db, r.id, 'expired');
140
+
141
+ const stale = db
142
+ .prepare(
143
+ `SELECT attention_id AS id FROM attention
144
+ WHERE status = 'active' AND hits = 0 AND created_at < ?
145
+ ORDER BY created_at ASC LIMIT 50`,
146
+ )
147
+ .all(t - expireDays * 86_400_000);
148
+ for (const r of stale) retireAttention(db, r.id, 'stale');
149
+
150
+ const over = db
151
+ .prepare(
152
+ `SELECT attention_id AS id FROM attention WHERE status = 'active'
153
+ ORDER BY weight ASC, last_hit_at ASC NULLS FIRST LIMIT 500`,
154
+ )
155
+ .all();
156
+ let retired = 0;
157
+ const activeCount = db.prepare("SELECT count(*) c FROM attention WHERE status = 'active'").get().c;
158
+ for (const r of over) {
159
+ if (activeCount - retired <= maxActive) break;
160
+ retireAttention(db, r.id, 'over-capacity');
161
+ retired++;
162
+ }
163
+ return { expired: expired.length, stale: stale.length, overCapacity: retired };
164
+ }
165
+
166
+ /** 关注点向量化的文本:主题 + 备注 + 关键词。 */
167
+ export function attentionVectorText(row) {
168
+ return [row.label, row.note].filter(Boolean).join(' ');
169
+ }
170
+
171
+ /** attention 的隐式 rowid(vec_attention 用它对齐)。 */
172
+ export function attentionRowid(db, attentionId) {
173
+ try {
174
+ return db.prepare('SELECT rowid FROM attention WHERE attention_id = ?').get(attentionId)?.rowid ?? null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /** 还没有向量的活跃关注点(启动补算用)。 */
181
+ export function attentionNeedingVector(db, limit = 200) {
182
+ return db
183
+ .prepare(
184
+ `SELECT a.rowid AS rowid, a.attention_id AS id, a.label, a.note
185
+ FROM attention a
186
+ WHERE a.status = 'active'
187
+ AND NOT EXISTS (SELECT 1 FROM vec_attention v WHERE v.rowid = a.rowid)
188
+ LIMIT ?`,
189
+ )
190
+ .all(limit);
191
+ }
package/lib/budget.js ADDED
@@ -0,0 +1,37 @@
1
+ // 路由预算:按窗口累计花费,超限记 budget_skipped(与熔断分开)。
2
+ export const DEFAULT_WINDOW_MS = 5 * 60_000;
3
+
4
+ /** 读当前窗口已花费,不写入。 */
5
+ export function peekBudget(db, conversationKey, { windowMs = DEFAULT_WINDOW_MS, now = Date.now() } = {}) {
6
+ const windowStart = Math.floor(now / windowMs) * windowMs;
7
+ const row = db
8
+ .prepare('SELECT spent FROM route_budget WHERE conversation_key = ? AND window_start = ?')
9
+ .get(conversationKey, windowStart);
10
+ return { windowStart, spent: row?.spent ?? 0 };
11
+ }
12
+
13
+ /**
14
+ * 尝试花掉一次预算。
15
+ * @returns {{allowed:boolean, spent:number, remaining:number, windowStart:number}}
16
+ */
17
+ export function consumeBudget(db, conversationKey, { windowMs = DEFAULT_WINDOW_MS, budget = 1000, cost = 1, now = Date.now() } = {}) {
18
+ const windowStart = Math.floor(now / windowMs) * windowMs;
19
+ const row = db
20
+ .prepare('SELECT spent FROM route_budget WHERE conversation_key = ? AND window_start = ?')
21
+ .get(conversationKey, windowStart);
22
+ const spent = row?.spent ?? 0;
23
+ if (spent + cost > budget) return { allowed: false, spent, remaining: Math.max(0, budget - spent), windowStart };
24
+ if (row) {
25
+ db.prepare('UPDATE route_budget SET spent = spent + ? WHERE conversation_key = ? AND window_start = ?').run(cost, conversationKey, windowStart);
26
+ } else {
27
+ db.prepare('INSERT INTO route_budget(conversation_key, window_start, spent) VALUES (?,?,?)').run(conversationKey, windowStart, cost);
28
+ }
29
+ return { allowed: true, spent: spent + cost, remaining: Math.max(0, budget - spent - cost), windowStart };
30
+ }
31
+
32
+ /** 清理过期窗口(低频调用)。 */
33
+ export function pruneBudgets(db, { windowMs = DEFAULT_WINDOW_MS, now = Date.now(), keepWindows = 12 } = {}) {
34
+ const cutoff = Math.floor(now / windowMs) * windowMs - keepWindows * windowMs;
35
+ const res = db.prepare('DELETE FROM route_budget WHERE window_start < ?').run(cutoff);
36
+ return { pruned: res.changes };
37
+ }
package/lib/circuit.js ADDED
@@ -0,0 +1,42 @@
1
+ // 熔断:连续失败打开、冷却后半开、成功关闭。与预算跳过分开统计。
2
+ export function createCircuit({ threshold = 5, cooldownMs = 60_000, now = () => Date.now() } = {}) {
3
+ const state = new Map(); // name -> { failures, openedAt }
4
+
5
+ function get(name) {
6
+ let s = state.get(name);
7
+ if (!s) {
8
+ s = { failures: 0, openedAt: 0 };
9
+ state.set(name, s);
10
+ }
11
+ return s;
12
+ }
13
+
14
+ return {
15
+ isOpen(name) {
16
+ const s = get(name);
17
+ if (!s.openedAt) return false;
18
+ if (now() - s.openedAt >= cooldownMs) {
19
+ // 半开:放一个请求试探
20
+ s.openedAt = 0;
21
+ s.failures = 0;
22
+ return false;
23
+ }
24
+ return true;
25
+ },
26
+ recordFailure(name) {
27
+ const s = get(name);
28
+ s.failures += 1;
29
+ if (s.failures >= threshold && !s.openedAt) s.openedAt = now();
30
+ return s.failures >= threshold;
31
+ },
32
+ recordSuccess(name) {
33
+ const s = get(name);
34
+ s.failures = 0;
35
+ s.openedAt = 0;
36
+ },
37
+ snapshot(name) {
38
+ const s = get(name);
39
+ return { ...s, open: this.isOpen(name) };
40
+ },
41
+ };
42
+ }