luckytri 1.0.0
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/.github/SECURITY.md +71 -0
- package/LICENSE +21 -0
- package/README.en.md +250 -0
- package/README.md +250 -0
- package/bin/luckytri.js +107 -0
- package/docs/brand/banner.png +0 -0
- package/docs/brand/palette.png +0 -0
- package/docs/brand/portrait.png +0 -0
- package/docs/en/CHANGELOG.md +113 -0
- package/docs/en/architecture.md +152 -0
- package/docs/en/chat-style.md +62 -0
- package/docs/en/connect.md +63 -0
- package/docs/en/guide.md +216 -0
- package/docs/en/her-life.md +189 -0
- package/docs/en/npm-release.md +25 -0
- package/docs/en/plugins.md +82 -0
- package/docs/en/recovery.md +28 -0
- package/docs/en/time.md +71 -0
- package/docs/en/webui.md +73 -0
- package/docs/zh/CHANGELOG.md +113 -0
- package/docs/zh/architecture.md +152 -0
- package/docs/zh/chat-style.md +62 -0
- package/docs/zh/connect.md +63 -0
- package/docs/zh/guide.md +216 -0
- package/docs/zh/her-life.md +189 -0
- package/docs/zh/npm-release.md +33 -0
- package/docs/zh/plugins.md +82 -0
- package/docs/zh/recovery.md +28 -0
- package/docs/zh/time.md +71 -0
- package/docs/zh/webui.md +73 -0
- package/package.json +108 -0
- package/plugins/sdk/index.d.ts +155 -0
- package/plugins/sdk/testing.js +58 -0
- package/plugins/weather/index.js +82 -0
- package/plugins/weather/luckytri-plugin.json +29 -0
- package/public/app/assets/ChatsPage-C-FcRapR.js +3 -0
- package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
- package/public/app/assets/Empty-CpP5kQHH.js +2 -0
- package/public/app/assets/Empty-DmUB2-Bk.css +1 -0
- package/public/app/assets/Field-D4gY-INU.js +1 -0
- package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
- package/public/app/assets/HeartPage-CPy_thQd.js +1 -0
- package/public/app/assets/LifePage-BUi5euBF.css +1 -0
- package/public/app/assets/LifePage-CxWvwmSf.js +1 -0
- package/public/app/assets/MemoryPage-DC4rhQ68.js +1 -0
- package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
- package/public/app/assets/NaturePage-CTXHCE2D.css +1 -0
- package/public/app/assets/NaturePage-IvYw04Zq.js +3 -0
- package/public/app/assets/PeoplePage-BmUxkrOU.css +1 -0
- package/public/app/assets/PeoplePage-Cqefm9-q.js +1 -0
- package/public/app/assets/PluginsPage-DKSaKvXb.js +2 -0
- package/public/app/assets/PluginsPage-HyBe5D8w.css +1 -0
- package/public/app/assets/SystemPage-CAMPoYqM.css +1 -0
- package/public/app/assets/SystemPage-CJmtJkrK.js +1 -0
- package/public/app/assets/TimePage-BOms_n4I.css +1 -0
- package/public/app/assets/TimePage-Cw44inM4.js +3 -0
- package/public/app/assets/formatters-ZmW_xFHw.js +1 -0
- package/public/app/assets/index-BheRW-1t.js +4 -0
- package/public/app/assets/index-YDz1ree1.css +1 -0
- package/public/app/assets/knowledge-DG0e0uKw.js +1 -0
- package/public/app/assets/liquid-garden-IMoHBwzc.png +0 -0
- package/public/app/assets/luckytri-home-95QKeMtc.png +0 -0
- package/public/app/assets/media-BzPDBm7e.js +1 -0
- package/public/app/index.html +27 -0
- package/public/guide.en.html +1053 -0
- package/public/guide.html +893 -0
- package/public/plugin-kit/v1/bridge.js +30 -0
- package/public/plugin-kit/v1/kit.css +14 -0
- package/public/tutorial.en.md +214 -0
- package/public/tutorial.md +214 -0
- package/scripts/backup.js +311 -0
- package/scripts/browser.js +41 -0
- package/scripts/launch.js +94 -0
- package/scripts/plugin.js +150 -0
- package/scripts/proxy-config.js +167 -0
- package/scripts/recover.js +26 -0
- package/scripts/runtime.js +25 -0
- package/scripts/service.js +47 -0
- package/scripts/setup.js +50 -0
- package/scripts/start-server.js +41 -0
- package/scripts/stop.js +36 -0
- package/server/app.js +96 -0
- package/server/channels/adapters.js +27 -0
- package/server/channels/contract.js +57 -0
- package/server/channels/hub.js +165 -0
- package/server/channels/index.js +25 -0
- package/server/channels/onebot/adapter.js +288 -0
- package/server/channels/onebot/index.js +336 -0
- package/server/channels/onebot/token.js +3 -0
- package/server/channels/qqbot/adapter.js +253 -0
- package/server/channels/qqbot/api.js +199 -0
- package/server/channels/qqbot/credentials.js +11 -0
- package/server/channels/qqbot/gateway.js +274 -0
- package/server/channels/qqbot/index.js +389 -0
- package/server/channels/segments.js +40 -0
- package/server/channels/session-key.js +153 -0
- package/server/channels/state.js +42 -0
- package/server/core/api.js +564 -0
- package/server/core/capabilities.js +208 -0
- package/server/core/context-builder.js +288 -0
- package/server/core/context-compactor.js +454 -0
- package/server/core/conversation-cues.js +325 -0
- package/server/core/conversation-manager.js +87 -0
- package/server/core/events.js +60 -0
- package/server/core/feedback.js +8 -0
- package/server/core/group-style.js +72 -0
- package/server/core/image-preprocessor.js +208 -0
- package/server/core/initiative-context.js +66 -0
- package/server/core/input-budget.js +32 -0
- package/server/core/local-demo.js +98 -0
- package/server/core/message-manager.js +118 -0
- package/server/core/message-scheduler.js +78 -0
- package/server/core/model-json.js +190 -0
- package/server/core/model-manager.js +1213 -0
- package/server/core/model-presets.js +900 -0
- package/server/core/name-call.js +29 -0
- package/server/core/net-guard.js +49 -0
- package/server/core/network.js +157 -0
- package/server/core/orchestrator.js +569 -0
- package/server/core/perceivers.js +91 -0
- package/server/core/person-name.js +22 -0
- package/server/core/persona-manager.js +243 -0
- package/server/core/reply-delivery.js +393 -0
- package/server/core/reply-target-resolver.js +50 -0
- package/server/core/repository.js +293 -0
- package/server/core/response-generator.js +50 -0
- package/server/core/response-validator.js +508 -0
- package/server/core/retired-prompts.js +51 -0
- package/server/core/sessions.js +109 -0
- package/server/core/speaker-names.js +92 -0
- package/server/core/topic-tracker.js +65 -0
- package/server/core/turn-processor.js +630 -0
- package/server/core/turn.js +236 -0
- package/server/core/vision-manager.js +633 -0
- package/server/http.js +18 -0
- package/server/i18n/decisions.js +206 -0
- package/server/i18n/errors.js +497 -0
- package/server/i18n/index.js +144 -0
- package/server/i18n/labels.js +653 -0
- package/server/i18n/models.js +111 -0
- package/server/index.js +102 -0
- package/server/knowledge/api.js +342 -0
- package/server/knowledge/embedding-profile.js +114 -0
- package/server/knowledge/index.js +13 -0
- package/server/knowledge/manager.js +386 -0
- package/server/knowledge/retrieval.js +93 -0
- package/server/knowledge/schema.js +154 -0
- package/server/knowledge/vector-index.js +90 -0
- package/server/mind/affect.js +265 -0
- package/server/mind/anticipations.js +507 -0
- package/server/mind/api.js +703 -0
- package/server/mind/attention.js +200 -0
- package/server/mind/bonds.js +558 -0
- package/server/mind/budget.js +152 -0
- package/server/mind/clock.js +83 -0
- package/server/mind/context-selection.js +61 -0
- package/server/mind/continuity.js +562 -0
- package/server/mind/conversation-origin.js +175 -0
- package/server/mind/day-planner.js +324 -0
- package/server/mind/days.js +210 -0
- package/server/mind/evidence-read.js +45 -0
- package/server/mind/evidence.js +148 -0
- package/server/mind/faces.js +138 -0
- package/server/mind/guard.js +60 -0
- package/server/mind/index.js +445 -0
- package/server/mind/initiative.js +333 -0
- package/server/mind/life/activities.js +21 -0
- package/server/mind/life/context.js +309 -0
- package/server/mind/life/diary.js +786 -0
- package/server/mind/life/growth.js +252 -0
- package/server/mind/life/index.js +1049 -0
- package/server/mind/life/presence.js +305 -0
- package/server/mind/meetings.js +1069 -0
- package/server/mind/memory-policy.js +48 -0
- package/server/mind/memory.js +888 -0
- package/server/mind/nature.js +262 -0
- package/server/mind/novelty.js +19 -0
- package/server/mind/observations.js +84 -0
- package/server/mind/own-day.js +324 -0
- package/server/mind/own-voice.js +318 -0
- package/server/mind/periods.js +118 -0
- package/server/mind/reading.js +120 -0
- package/server/mind/salience.js +201 -0
- package/server/mind/schema.js +562 -0
- package/server/mind/self.js +484 -0
- package/server/mind/senses.js +53 -0
- package/server/mind/thoughts.js +274 -0
- package/server/mind/time/activity-clock.js +196 -0
- package/server/mind/time/agenda.js +242 -0
- package/server/mind/time/api.js +200 -0
- package/server/mind/time/attention.js +34 -0
- package/server/mind/time/availability.js +69 -0
- package/server/mind/time/commitment-review.js +198 -0
- package/server/mind/time/delivery-links.js +130 -0
- package/server/mind/time/executor.js +512 -0
- package/server/mind/time/experiences.js +128 -0
- package/server/mind/time/games.js +562 -0
- package/server/mind/time/index.js +739 -0
- package/server/mind/time/intent.js +166 -0
- package/server/mind/time/kinds.js +171 -0
- package/server/mind/time/model-material.js +77 -0
- package/server/mind/time/plugin-activity.js +207 -0
- package/server/mind/time/presentation.js +51 -0
- package/server/mind/time/schedule.js +83 -0
- package/server/mind/time/schema.js +84 -0
- package/server/mind/time/search.js +383 -0
- package/server/mind/time/sharing.js +472 -0
- package/server/mind/time/task-links.js +311 -0
- package/server/mind/time/tasks.js +587 -0
- package/server/mind/time/works.js +432 -0
- package/server/mind/traits.js +191 -0
- package/server/mind/util.js +110 -0
- package/server/mind/view.js +273 -0
- package/server/paths.js +102 -0
- package/server/plugins/api.js +204 -0
- package/server/plugins/host.js +967 -0
- package/server/plugins/install.js +295 -0
- package/server/plugins/manifest.js +201 -0
- package/server/plugins/permissions.js +42 -0
- package/server/plugins/runner/guard.mjs +36 -0
- package/server/plugins/runner/worker.mjs +250 -0
- package/server/plugins/schema.js +91 -0
- package/server/plugins/zip.js +112 -0
- package/server/startup-lock.js +118 -0
- package/server/storage/archive.js +241 -0
- package/server/storage/backup-cleanup-worker.js +8 -0
- package/server/storage/backup-cleanup.js +414 -0
- package/server/storage/backup-lock.js +76 -0
- package/server/storage/backup-scheduler.js +131 -0
- package/server/storage/store.js +199 -0
- package/server/studio/display-names.js +138 -0
- package/server/studio/management.js +102 -0
- package/server/studio/readiness.js +190 -0
- package/server/studio/settings.js +236 -0
- package/server/version.js +10 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Repository } from "./repository.js";
|
|
3
|
+
import {
|
|
4
|
+
ModelManager,
|
|
5
|
+
backupModels,
|
|
6
|
+
isTransientModelFailure,
|
|
7
|
+
storedModels,
|
|
8
|
+
withFallback,
|
|
9
|
+
} from "./model-manager.js";
|
|
10
|
+
import { KnowledgeManager } from "../knowledge/manager.js";
|
|
11
|
+
import { ConversationManager } from "./conversation-manager.js";
|
|
12
|
+
import { persistIncoming, messageEnvelope } from "./message-manager.js";
|
|
13
|
+
import { prompts } from "./persona-manager.js";
|
|
14
|
+
import {
|
|
15
|
+
ContextCompactor,
|
|
16
|
+
contextKeep,
|
|
17
|
+
DEFAULT_CONTEXT_MESSAGES,
|
|
18
|
+
} from "./context-compactor.js";
|
|
19
|
+
import { loadVisionImages } from "./vision-manager.js";
|
|
20
|
+
import { deliver } from "./message-scheduler.js";
|
|
21
|
+
import { TopicTracker } from "./topic-tracker.js";
|
|
22
|
+
import { invalidateSpeakerNames } from "./speaker-names.js";
|
|
23
|
+
import { demoReply } from "./local-demo.js";
|
|
24
|
+
import { Mind } from "../mind/index.js";
|
|
25
|
+
import { attend, interestTerms } from "../mind/attention.js";
|
|
26
|
+
import { crisisSignal } from "../mind/guard.js";
|
|
27
|
+
const TRACE_PRUNE_INTERVAL_MS = 3600000;
|
|
28
|
+
const BATCH_LIMIT = 30;
|
|
29
|
+
const BACKLOG_RECHECK_MS = 10 * 60000;
|
|
30
|
+
import { TurnProcessor } from "./turn-processor.js";
|
|
31
|
+
import { ReplyDelivery } from "./reply-delivery.js";
|
|
32
|
+
import { Capabilities } from "./capabilities.js";
|
|
33
|
+
import { Perceivers } from "./perceivers.js";
|
|
34
|
+
|
|
35
|
+
export class ChatSystem {
|
|
36
|
+
constructor(
|
|
37
|
+
store,
|
|
38
|
+
send,
|
|
39
|
+
{
|
|
40
|
+
models,
|
|
41
|
+
knowledge,
|
|
42
|
+
localDemo = demoReply,
|
|
43
|
+
fetchQuoted,
|
|
44
|
+
fetchImage,
|
|
45
|
+
loadVisionImages: loadImages = loadVisionImages,
|
|
46
|
+
now = Date.now,
|
|
47
|
+
} = {},
|
|
48
|
+
) {
|
|
49
|
+
this.now = now;
|
|
50
|
+
this.store = store;
|
|
51
|
+
this.repo = new Repository(store);
|
|
52
|
+
this.repo.db
|
|
53
|
+
.prepare(
|
|
54
|
+
"UPDATE core_jobs SET status='interrupted' WHERE status IN ('pending','running')",
|
|
55
|
+
)
|
|
56
|
+
.run();
|
|
57
|
+
this.models = models || new ModelManager(this.repo);
|
|
58
|
+
this.mind = new Mind(this.repo, { models: this.models });
|
|
59
|
+
this.memory = this.mind.memory;
|
|
60
|
+
this.knowledge = knowledge || new KnowledgeManager(this.repo, this.models);
|
|
61
|
+
this.compactor = new ContextCompactor(this.repo);
|
|
62
|
+
this.topics = new TopicTracker(this.repo);
|
|
63
|
+
this.capabilities = new Capabilities(this.repo, { now });
|
|
64
|
+
this.perceivers = new Perceivers(this.repo.db);
|
|
65
|
+
this.send = send;
|
|
66
|
+
this.localDemo = localDemo;
|
|
67
|
+
this.fetchQuoted = fetchQuoted;
|
|
68
|
+
this.fetchImage = fetchImage;
|
|
69
|
+
this.loadVisionImages = loadImages;
|
|
70
|
+
this.clearEpoch = new Map();
|
|
71
|
+
// A model service that was out of reach for a moment should not leave
|
|
72
|
+
// someone who spoke to her directly without an answer until they write
|
|
73
|
+
// again: a few later tries, then it is left as it is.
|
|
74
|
+
this.retryDelays = [30000, 120000];
|
|
75
|
+
this.retried = new Map();
|
|
76
|
+
// Batches already put back once because the person kept talking.
|
|
77
|
+
this.settled = new Set();
|
|
78
|
+
this.tracePruneDue = 0;
|
|
79
|
+
this.backlogChecked = new Map();
|
|
80
|
+
this.queue = new ConversationManager((id, batch) =>
|
|
81
|
+
this.process(id, batch),
|
|
82
|
+
);
|
|
83
|
+
this.turnProcessor = new TurnProcessor(this);
|
|
84
|
+
this.replyDelivery = new ReplyDelivery(this);
|
|
85
|
+
}
|
|
86
|
+
policy(session) {
|
|
87
|
+
const {
|
|
88
|
+
topicBoost: _topicBoost,
|
|
89
|
+
comfortOnDistress: _comfort,
|
|
90
|
+
persona: _persona,
|
|
91
|
+
...saved
|
|
92
|
+
} = this.repo.config("session:" + session, {});
|
|
93
|
+
const policy = {
|
|
94
|
+
timeZone: this.mind.timeZone(),
|
|
95
|
+
aggregateMs: 1200,
|
|
96
|
+
maxWaitMs: 4000,
|
|
97
|
+
contextMessages: DEFAULT_CONTEXT_MESSAGES,
|
|
98
|
+
maxReply: 180,
|
|
99
|
+
memory: true,
|
|
100
|
+
deepCheck: true,
|
|
101
|
+
selectiveVision: false,
|
|
102
|
+
compaction: true,
|
|
103
|
+
...saved,
|
|
104
|
+
};
|
|
105
|
+
// Older saves used 0 for "fill the whole model window".
|
|
106
|
+
policy.contextMessages = contextKeep(policy);
|
|
107
|
+
return policy;
|
|
108
|
+
}
|
|
109
|
+
// What a reply was generated against. Her own inner changes, other
|
|
110
|
+
// sessions and background jobs do not discard a reply; configuration does.
|
|
111
|
+
sessionState(session) {
|
|
112
|
+
const settings = this.store.settings();
|
|
113
|
+
return JSON.stringify([
|
|
114
|
+
this.repo.config("session:" + session, null),
|
|
115
|
+
this.mind.nature.version(),
|
|
116
|
+
this.repo.config("prompts", null),
|
|
117
|
+
storedModels(this.repo).map(({ apiKey: _apiKey, ...model }) => model),
|
|
118
|
+
[settings.name, settings.aliases, settings.enabled, settings.demo],
|
|
119
|
+
this.clearEpoch.get(session) || 0,
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
// While the room was being read (waiting in the queue, describing images),
|
|
123
|
+
// the person she is about to answer may have kept talking. Spending a whole
|
|
124
|
+
// turn on the older words only to throw the answer away is waste, and it
|
|
125
|
+
// makes them wait for it: put the batch back, once, and answer everything
|
|
126
|
+
// together. Never for a crisis, and only if there is a place to put it back.
|
|
127
|
+
batchMovedOn(session, batch, snapshot, privateChat) {
|
|
128
|
+
if (!batch.length) return false;
|
|
129
|
+
if (batch.some((m) => m.role !== "assistant" && crisisSignal(m.text)))
|
|
130
|
+
return false;
|
|
131
|
+
const key = `${session}:${batch[0].seq}`;
|
|
132
|
+
if (this.settled.has(key)) return false;
|
|
133
|
+
const speaker = (m) => String(m.speaker ?? m.userId);
|
|
134
|
+
const asked = snapshot.batch.filter((m) => m.relation === "direct");
|
|
135
|
+
const focus = new Set(
|
|
136
|
+
(asked.length ? asked : snapshot.batch.slice(-1)).map(speaker),
|
|
137
|
+
);
|
|
138
|
+
const moved = this.repo
|
|
139
|
+
.eventsAfter(session, batch.at(-1).seq, { simulated: false })
|
|
140
|
+
.some(
|
|
141
|
+
(m) =>
|
|
142
|
+
m.role === "user" &&
|
|
143
|
+
(privateChat ||
|
|
144
|
+
focus.has(String(m.userId)) ||
|
|
145
|
+
(m.replyId &&
|
|
146
|
+
snapshot.batch.some((b) => b.platformId === m.replyId))),
|
|
147
|
+
);
|
|
148
|
+
if (!moved || !this.queue.retain(session, batch)) return false;
|
|
149
|
+
if (this.settled.size > 500) this.settled.clear();
|
|
150
|
+
this.settled.add(key);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
// Ask again later for a batch that was spoken to her directly, when the model
|
|
154
|
+
// service was only briefly unavailable. Not for a missing balance or a
|
|
155
|
+
// rejected request, and not when she has already answered since.
|
|
156
|
+
retryAfterOutage(session, batch, error, trace) {
|
|
157
|
+
if (!batch.length || !isTransientModelFailure(error)) return false;
|
|
158
|
+
const now = this.now();
|
|
159
|
+
for (const [key, entry] of this.retried)
|
|
160
|
+
if (now - entry.at > 60 * 60000) this.retried.delete(key);
|
|
161
|
+
const key = `${session}:${batch[0].seq}`;
|
|
162
|
+
const tried = this.retried.get(key)?.count ?? 0;
|
|
163
|
+
if (tried >= this.retryDelays.length) return false;
|
|
164
|
+
this.retried.set(key, { count: tried + 1, at: now });
|
|
165
|
+
const delay = this.retryDelays[tried];
|
|
166
|
+
const last = batch.at(-1).seq;
|
|
167
|
+
trace.steps.push(
|
|
168
|
+
`模型服务暂时不可用,${Math.round(delay / 1000)} 秒后再试(第 ${tried + 1} 次)`,
|
|
169
|
+
);
|
|
170
|
+
this.queue.retryLater(
|
|
171
|
+
session,
|
|
172
|
+
batch,
|
|
173
|
+
delay,
|
|
174
|
+
() =>
|
|
175
|
+
this.enabled(session, { simulated: false }) &&
|
|
176
|
+
!this.repo
|
|
177
|
+
.eventsAfter(session, last, { simulated: false })
|
|
178
|
+
.some((m) => m.role === "assistant"),
|
|
179
|
+
);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
fallbackFor(_policy, primary, trace) {
|
|
183
|
+
const chain = [];
|
|
184
|
+
for (const model of backupModels(storedModels(this.repo), primary?.id)) {
|
|
185
|
+
try {
|
|
186
|
+
const profile = this.models.profile(model.id);
|
|
187
|
+
if (profile && profile.id !== primary?.id) chain.push(profile);
|
|
188
|
+
} catch {
|
|
189
|
+
trace?.steps?.push("有一个备用模型档案不存在,已跳过");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return chain;
|
|
193
|
+
}
|
|
194
|
+
receive(m) {
|
|
195
|
+
const event = persistIncoming(this.repo, m);
|
|
196
|
+
if (event) {
|
|
197
|
+
this.repo.db
|
|
198
|
+
.prepare("INSERT INTO core_jobs VALUES (?,?,'pending',NULL,?)")
|
|
199
|
+
.run(event.seq, event.sessionId, this.now());
|
|
200
|
+
if (
|
|
201
|
+
!m.simulated &&
|
|
202
|
+
this.enabled(event.sessionId, { simulated: false }) &&
|
|
203
|
+
this.policy(event.sessionId).memory &&
|
|
204
|
+
this.store.settings().memoryEnabled !== false &&
|
|
205
|
+
this.store.settings().memoryCandidates !== false
|
|
206
|
+
)
|
|
207
|
+
this.mind.memory.remember({ ...m, ...event });
|
|
208
|
+
if (m.simulated)
|
|
209
|
+
return this.process(event.sessionId, [event], { simulated: true });
|
|
210
|
+
this.queue.enqueue(event, this.policy(event.sessionId));
|
|
211
|
+
}
|
|
212
|
+
return { queued: !!event };
|
|
213
|
+
}
|
|
214
|
+
enabled(session, { simulated = false } = {}) {
|
|
215
|
+
const settings = this.store.settings();
|
|
216
|
+
const row = this.repo.db
|
|
217
|
+
.prepare("SELECT enabled,archived FROM sessions WHERE id=?")
|
|
218
|
+
.get(session);
|
|
219
|
+
if (!settings.enabled || !row?.enabled || row.archived) return false;
|
|
220
|
+
if (settings.demo) return !!simulated;
|
|
221
|
+
return !simulated;
|
|
222
|
+
}
|
|
223
|
+
cancelSession(session) {
|
|
224
|
+
this.clearEpoch.set(session, (this.clearEpoch.get(session) || 0) + 1);
|
|
225
|
+
this.queue.clear(session);
|
|
226
|
+
}
|
|
227
|
+
clearContext(session) {
|
|
228
|
+
if (
|
|
229
|
+
!this.repo.db.prepare("SELECT id FROM sessions WHERE id=?").get(session)
|
|
230
|
+
)
|
|
231
|
+
throw Error("会话不存在");
|
|
232
|
+
this.cancelSession(session);
|
|
233
|
+
const count = (table) =>
|
|
234
|
+
this.repo.db
|
|
235
|
+
.prepare(`SELECT COUNT(*) n FROM ${table} WHERE session_id=?`)
|
|
236
|
+
.get(session).n;
|
|
237
|
+
const removed = {
|
|
238
|
+
events: count("core_events"),
|
|
239
|
+
messages: count("messages"),
|
|
240
|
+
references: count("core_references"),
|
|
241
|
+
stages: count("core_stages"),
|
|
242
|
+
summaries: count("core_context_summaries"),
|
|
243
|
+
jobs: count("core_jobs"),
|
|
244
|
+
outbox: count("core_outbox"),
|
|
245
|
+
};
|
|
246
|
+
this.repo.db.exec("BEGIN IMMEDIATE");
|
|
247
|
+
try {
|
|
248
|
+
for (const table of [
|
|
249
|
+
"core_events",
|
|
250
|
+
"core_references",
|
|
251
|
+
"core_stages",
|
|
252
|
+
"core_context_summaries",
|
|
253
|
+
"core_jobs",
|
|
254
|
+
"core_outbox",
|
|
255
|
+
"messages",
|
|
256
|
+
"core_vision_cache",
|
|
257
|
+
"core_perceptions",
|
|
258
|
+
"core_cursors",
|
|
259
|
+
"mind_attention",
|
|
260
|
+
])
|
|
261
|
+
this.repo.db
|
|
262
|
+
.prepare(`DELETE FROM ${table} WHERE session_id=?`)
|
|
263
|
+
.run(session);
|
|
264
|
+
this.repo.db.exec("COMMIT");
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.repo.db.exec("ROLLBACK");
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
this.compactor.clear(session);
|
|
270
|
+
invalidateSpeakerNames(this.repo.db, session);
|
|
271
|
+
this.store.revision++;
|
|
272
|
+
return removed;
|
|
273
|
+
}
|
|
274
|
+
// Whether she reads this batch now. Deterministic, and cheap: no model call.
|
|
275
|
+
gate(session, batch, resolved, now, nature) {
|
|
276
|
+
const bySeq = new Map(resolved.map((m) => [m.seq, m]));
|
|
277
|
+
const unread = this.mind.unread(session, { now });
|
|
278
|
+
const seqs = [
|
|
279
|
+
...new Set([...unread.map((m) => m.seq), ...batch.map((m) => m.seq)]),
|
|
280
|
+
]
|
|
281
|
+
.sort((a, b) => a - b)
|
|
282
|
+
.slice(-BATCH_LIMIT);
|
|
283
|
+
const rows = seqs.map((seq) => bySeq.get(seq)).filter(Boolean);
|
|
284
|
+
const affect = this.mind.affect.state(now, { nature, room: session });
|
|
285
|
+
const interests = interestTerms(nature.interests || []);
|
|
286
|
+
const here = (thread) => this.mind.meetings.stays(thread, session);
|
|
287
|
+
const livingThread = this.mind.self.living({
|
|
288
|
+
before: now,
|
|
289
|
+
now,
|
|
290
|
+
room: session,
|
|
291
|
+
});
|
|
292
|
+
const living = interestTerms(livingThread ? [livingThread.content] : []);
|
|
293
|
+
const wishRows = this.mind.meetings.ownWishes(session, now);
|
|
294
|
+
const wishes = wishRows.map((row) => interestTerms([row.content]));
|
|
295
|
+
const curiosities = interestTerms(
|
|
296
|
+
this.mind.self
|
|
297
|
+
.active({ before: now, limit: 12 })
|
|
298
|
+
.filter(
|
|
299
|
+
(t) =>
|
|
300
|
+
["interest", "curiosity", "care", "intention"].includes(t.kind) &&
|
|
301
|
+
here(t),
|
|
302
|
+
)
|
|
303
|
+
.map((t) => t.content),
|
|
304
|
+
);
|
|
305
|
+
const closeness = new Map(
|
|
306
|
+
rows.map((m) => [
|
|
307
|
+
String(m.userId),
|
|
308
|
+
this.mind.bonds.person(m.userId, now)?.closeness || 0,
|
|
309
|
+
]),
|
|
310
|
+
);
|
|
311
|
+
const speakers = rows
|
|
312
|
+
.filter((m) => m.role !== "assistant")
|
|
313
|
+
.map((m) => m.userId);
|
|
314
|
+
const held = new Set();
|
|
315
|
+
for (const row of wishRows)
|
|
316
|
+
for (const id of this.mind.meetings.touchedPeople(
|
|
317
|
+
session,
|
|
318
|
+
speakers,
|
|
319
|
+
now,
|
|
320
|
+
row.thread,
|
|
321
|
+
))
|
|
322
|
+
held.add(id);
|
|
323
|
+
const decision = attend({
|
|
324
|
+
batch: rows,
|
|
325
|
+
unread: seqs.length,
|
|
326
|
+
lastLookAt: this.mind.attention(session).looked_at,
|
|
327
|
+
lastSpokeAt:
|
|
328
|
+
resolved
|
|
329
|
+
.filter((m) => m.role === "assistant" && !m.referenceOnly)
|
|
330
|
+
.at(-1)?.time || null,
|
|
331
|
+
belongs: (() => {
|
|
332
|
+
const spoke = resolved
|
|
333
|
+
.filter((m) => m.role === "assistant" && !m.referenceOnly)
|
|
334
|
+
.at(-1)?.time;
|
|
335
|
+
return (
|
|
336
|
+
!!(spoke && now - spoke < 6 * 60 * 60000) ||
|
|
337
|
+
!!this.mind.faces.current(session, now)
|
|
338
|
+
);
|
|
339
|
+
})(),
|
|
340
|
+
phase: affect.phase,
|
|
341
|
+
energy: affect.energy,
|
|
342
|
+
interests,
|
|
343
|
+
curiosities,
|
|
344
|
+
living,
|
|
345
|
+
wishes,
|
|
346
|
+
held,
|
|
347
|
+
closeness,
|
|
348
|
+
pressure: this.mind.budget.pressure("conversation", now),
|
|
349
|
+
initiative: nature.initiative,
|
|
350
|
+
now,
|
|
351
|
+
});
|
|
352
|
+
return { ...decision, rows };
|
|
353
|
+
}
|
|
354
|
+
async restoreQuotes(session, batch, trace) {
|
|
355
|
+
if (!this.fetchQuoted) return;
|
|
356
|
+
for (const message of batch.filter((m) => m.replyId)) {
|
|
357
|
+
const found = this.repo.db
|
|
358
|
+
.prepare(
|
|
359
|
+
"SELECT seq FROM core_events WHERE session_id=? AND platform_id=? AND account_id=?",
|
|
360
|
+
)
|
|
361
|
+
.get(session, message.replyId, message.accountId);
|
|
362
|
+
if (found) continue;
|
|
363
|
+
try {
|
|
364
|
+
const quoted = await this.fetchQuoted(message);
|
|
365
|
+
if (!quoted) continue;
|
|
366
|
+
this.repo.db
|
|
367
|
+
.prepare(
|
|
368
|
+
"INSERT OR IGNORE INTO core_references(session_id,platform_id,account_id,payload) VALUES (?,?,?,?)",
|
|
369
|
+
)
|
|
370
|
+
.run(
|
|
371
|
+
session,
|
|
372
|
+
message.replyId,
|
|
373
|
+
message.accountId,
|
|
374
|
+
JSON.stringify(messageEnvelope(quoted)),
|
|
375
|
+
);
|
|
376
|
+
} catch {
|
|
377
|
+
trace.steps.push("引用消息未能恢复,保留 unknown,不认领对象");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async process(...args) {
|
|
383
|
+
const options = args[2] || {};
|
|
384
|
+
if (
|
|
385
|
+
options.replay ||
|
|
386
|
+
options.preview ||
|
|
387
|
+
options.simulated ||
|
|
388
|
+
args[1]?.some((m) => m.simulated)
|
|
389
|
+
)
|
|
390
|
+
return this.turnProcessor.process(...args);
|
|
391
|
+
return this.mind.time.attention.chat(() =>
|
|
392
|
+
this.turnProcessor.process(...args),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async speak(...args) {
|
|
397
|
+
return this.replyDelivery.speak(...args);
|
|
398
|
+
}
|
|
399
|
+
async offline(session, batch, trace, finish, context, preview, state) {
|
|
400
|
+
const local = this.localDemo(this.store.settings(), {
|
|
401
|
+
message: batch.at(-1),
|
|
402
|
+
direct: true,
|
|
403
|
+
context,
|
|
404
|
+
});
|
|
405
|
+
if (!local.speak) return finish("silent", local.reason);
|
|
406
|
+
trace.response = { bubbles: [local.reply || "嗯"], reason: local.reason };
|
|
407
|
+
if (preview) return finish("previewed", local.reason);
|
|
408
|
+
const isCurrent = () =>
|
|
409
|
+
!this.queue.closed &&
|
|
410
|
+
this.enabled(session, { simulated: true }) &&
|
|
411
|
+
(!state || this.sessionState(session) === state);
|
|
412
|
+
if (!isCurrent()) return finish("stale", "生成期间语境已更新,旧稿未发送");
|
|
413
|
+
trace.sent = await deliver(
|
|
414
|
+
this.repo,
|
|
415
|
+
batch.at(-1),
|
|
416
|
+
trace.response.bubbles,
|
|
417
|
+
trace,
|
|
418
|
+
this.send,
|
|
419
|
+
isCurrent,
|
|
420
|
+
{ now: this.now },
|
|
421
|
+
);
|
|
422
|
+
return finish(trace.sent.length ? "sent" : "cancelled", local.reason);
|
|
423
|
+
}
|
|
424
|
+
// A dry run through the real pipeline: reads her mind as it is now (or a
|
|
425
|
+
// draft nature), writes nothing to it and sends nothing.
|
|
426
|
+
async preview({ text, history = [], nature = null, viewSession = "" }) {
|
|
427
|
+
const session = `preview:${randomUUID()}`;
|
|
428
|
+
const base = this.now() - (history.length + 1) * 1000;
|
|
429
|
+
const append = (row, i) =>
|
|
430
|
+
this.repo.append({
|
|
431
|
+
eventId: `${session}:${i}`,
|
|
432
|
+
sessionId: session,
|
|
433
|
+
kind: "private",
|
|
434
|
+
userId: row.role === "assistant" ? "bot" : "preview",
|
|
435
|
+
name:
|
|
436
|
+
row.role === "assistant"
|
|
437
|
+
? this.mind.nature.current().name
|
|
438
|
+
: "试聊的人",
|
|
439
|
+
text: row.text,
|
|
440
|
+
role: row.role,
|
|
441
|
+
time: base + i * 1000,
|
|
442
|
+
platformId: String(i + 1),
|
|
443
|
+
accountId: "preview",
|
|
444
|
+
mentions: [],
|
|
445
|
+
attachments: [],
|
|
446
|
+
simulated: true,
|
|
447
|
+
});
|
|
448
|
+
history.forEach(append);
|
|
449
|
+
const seq = append({ role: "user", text }, history.length);
|
|
450
|
+
try {
|
|
451
|
+
const event = this.repo.eventsAfter(session, seq - 1).at(-1);
|
|
452
|
+
return await this.process(session, [event], {
|
|
453
|
+
preview: { nature, viewSession: viewSession || null },
|
|
454
|
+
});
|
|
455
|
+
} finally {
|
|
456
|
+
for (const table of [
|
|
457
|
+
"core_events",
|
|
458
|
+
"core_jobs",
|
|
459
|
+
"core_outbox",
|
|
460
|
+
"core_topics",
|
|
461
|
+
])
|
|
462
|
+
this.repo.db
|
|
463
|
+
.prepare(`DELETE FROM ${table} WHERE session_id=?`)
|
|
464
|
+
.run(session);
|
|
465
|
+
invalidateSpeakerNames(this.repo.db, session);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// A turn she starts herself: waking up to messages, or deciding to say
|
|
469
|
+
// something to someone she has been thinking about.
|
|
470
|
+
async initiate(session, occasion, batch = []) {
|
|
471
|
+
if (!this.enabled(session, { simulated: false })) return null;
|
|
472
|
+
if (this.queue.lanes.has(session)) return null;
|
|
473
|
+
const anchor =
|
|
474
|
+
batch.at(-1) ||
|
|
475
|
+
this.repo
|
|
476
|
+
.recentEvents(session, 1, { simulated: false, role: "user" })
|
|
477
|
+
.at(-1);
|
|
478
|
+
if (!anchor) return null;
|
|
479
|
+
return this.process(session, batch, { occasion, anchor });
|
|
480
|
+
}
|
|
481
|
+
// Memory consolidation and context compaction after a live turn. Both run
|
|
482
|
+
// detached so they never hold the conversational lane.
|
|
483
|
+
backgroundWork(session, policy, now = this.now()) {
|
|
484
|
+
if (!policy.memory && policy.compaction === false) return;
|
|
485
|
+
if (!this.enabled(session, { simulated: false })) return;
|
|
486
|
+
if (!this.mind.budget.allows("upkeep")) return;
|
|
487
|
+
let profile;
|
|
488
|
+
try {
|
|
489
|
+
profile = this.models.profile();
|
|
490
|
+
} catch {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const models = withFallback(this.models, this.fallbackFor(policy, profile));
|
|
494
|
+
const memory = this.mind.memory;
|
|
495
|
+
if (
|
|
496
|
+
policy.memory &&
|
|
497
|
+
this.store.settings().memoryEnabled !== false &&
|
|
498
|
+
memory.due(session, now).due &&
|
|
499
|
+
!memory.busy.has(session) &&
|
|
500
|
+
now - (memory.lastAttempt.get(session) || 0) >= 60000
|
|
501
|
+
) {
|
|
502
|
+
const t = this.repo.trace(session, "memory");
|
|
503
|
+
memory
|
|
504
|
+
.consolidate(session, profile, prompts(this.repo).memory, t, {
|
|
505
|
+
models,
|
|
506
|
+
now,
|
|
507
|
+
})
|
|
508
|
+
.then(() => this.finishQuietly(t, "complete"))
|
|
509
|
+
.catch((e) => {
|
|
510
|
+
t.error = e.message;
|
|
511
|
+
this.finishQuietly(t, "error");
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (policy.compaction !== false)
|
|
515
|
+
this.scheduleCompaction(session, policy, profile, models);
|
|
516
|
+
}
|
|
517
|
+
scheduleCompaction(session, policy, profile, models) {
|
|
518
|
+
const prompt = prompts(this.repo);
|
|
519
|
+
return this.compactor.schedule(session, {
|
|
520
|
+
models,
|
|
521
|
+
profile,
|
|
522
|
+
keep: contextKeep(policy),
|
|
523
|
+
timeZone: policy.timeZone,
|
|
524
|
+
self: this.mind.nature.current().name,
|
|
525
|
+
system: prompt.system + "\n" + prompt.summary,
|
|
526
|
+
mergeSystem: prompt.system + "\n" + prompt.summaryMerge,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// Runs on the server maintenance timer: finishes interrupted compaction,
|
|
530
|
+
// trims old traces hourly, and lets her glance back at conversations that
|
|
531
|
+
// went on while she was not looking.
|
|
532
|
+
maintain(now = this.now()) {
|
|
533
|
+
if (this.queue.closed) return;
|
|
534
|
+
if (now >= this.tracePruneDue) {
|
|
535
|
+
const { more } = this.repo.pruneTraces(now);
|
|
536
|
+
this.tracePruneDue = more ? now : now + TRACE_PRUNE_INTERVAL_MS;
|
|
537
|
+
}
|
|
538
|
+
for (const { id } of this.repo.db
|
|
539
|
+
.prepare("SELECT id FROM sessions WHERE enabled=1 AND archived=0")
|
|
540
|
+
.all()) {
|
|
541
|
+
if (!this.enabled(id, { simulated: false })) continue;
|
|
542
|
+
const policy = this.policy(id);
|
|
543
|
+
this.backgroundWork(id, policy, now);
|
|
544
|
+
this.checkBacklog(id, now);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
checkBacklog(session, now = this.now()) {
|
|
548
|
+
if (this.queue.lanes.has(session)) return null;
|
|
549
|
+
if (now - (this.backlogChecked.get(session) || 0) < BACKLOG_RECHECK_MS)
|
|
550
|
+
return null;
|
|
551
|
+
const unread = this.mind.unread(session, { now });
|
|
552
|
+
// Still flowing: the next batch will be read with them.
|
|
553
|
+
if (!unread.length || now - unread.at(-1).time < 3 * 60000) return null;
|
|
554
|
+
this.backlogChecked.set(session, now);
|
|
555
|
+
return this.process(session, unread, { backlog: true }).catch(() => null);
|
|
556
|
+
}
|
|
557
|
+
finishQuietly(trace, status) {
|
|
558
|
+
try {
|
|
559
|
+
this.repo.finish(trace, status);
|
|
560
|
+
} catch {
|
|
561
|
+
// The database may already be closed during shutdown.
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
close() {
|
|
565
|
+
this.mind.time.attention.close();
|
|
566
|
+
this.queue.close();
|
|
567
|
+
this.compactor.close();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { text } from "../mind/util.js";
|
|
3
|
+
|
|
4
|
+
// A plugin reads one attachment she is actually looking at (a voice message,
|
|
5
|
+
// a file) and hands back words. The words are cached, so a replay never asks
|
|
6
|
+
// again, and they stay data: never an instruction.
|
|
7
|
+
export class Perceivers {
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.handlers = new Map();
|
|
11
|
+
}
|
|
12
|
+
register(type, pluginId, run) {
|
|
13
|
+
const key = String(type || "").slice(0, 40);
|
|
14
|
+
if (!key || typeof run !== "function") throw Error("感知类型无效");
|
|
15
|
+
const list = this.handlers.get(key) || [];
|
|
16
|
+
list.push({ pluginId, run });
|
|
17
|
+
this.handlers.set(key, list);
|
|
18
|
+
}
|
|
19
|
+
unregister(pluginId) {
|
|
20
|
+
for (const [type, list] of this.handlers) {
|
|
21
|
+
const next = list.filter((item) => item.pluginId !== pluginId);
|
|
22
|
+
if (next.length) this.handlers.set(type, next);
|
|
23
|
+
else this.handlers.delete(type);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async apply(snapshot, { replay = false, now = Date.now() } = {}) {
|
|
27
|
+
if (!this.handlers.size) return;
|
|
28
|
+
const batch = new Set(snapshot.batchIds || []);
|
|
29
|
+
for (const message of snapshot.messages || []) {
|
|
30
|
+
if (!batch.has(message.id)) continue;
|
|
31
|
+
const notes = [];
|
|
32
|
+
for (const attachment of message.attachments || []) {
|
|
33
|
+
const list = this.handlers.get(attachment?.type) || [];
|
|
34
|
+
for (const handler of list) {
|
|
35
|
+
const key = createHash("sha256")
|
|
36
|
+
.update(
|
|
37
|
+
JSON.stringify([
|
|
38
|
+
handler.pluginId,
|
|
39
|
+
attachment.type,
|
|
40
|
+
attachment.file || attachment.url || "",
|
|
41
|
+
message.id,
|
|
42
|
+
]),
|
|
43
|
+
)
|
|
44
|
+
.digest("hex");
|
|
45
|
+
const cached = this.db
|
|
46
|
+
.prepare("SELECT text FROM core_perceptions WHERE cache_key=?")
|
|
47
|
+
.get(key);
|
|
48
|
+
if (cached) {
|
|
49
|
+
notes.push(cached.text);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (replay) continue;
|
|
53
|
+
let result = null;
|
|
54
|
+
try {
|
|
55
|
+
result = await Promise.race([
|
|
56
|
+
Promise.resolve().then(() =>
|
|
57
|
+
handler.run({
|
|
58
|
+
attachment,
|
|
59
|
+
messageId: message.id,
|
|
60
|
+
sessionId: snapshot.sessionId,
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
new Promise((_, reject) =>
|
|
64
|
+
setTimeout(() => reject(Error("感知超时")), 15000),
|
|
65
|
+
),
|
|
66
|
+
]);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const line = text(result?.text, 400);
|
|
71
|
+
if (!line) continue;
|
|
72
|
+
this.db
|
|
73
|
+
.prepare(
|
|
74
|
+
"INSERT OR IGNORE INTO core_perceptions(cache_key,session_id,message_id,plugin_id,type,text,created) VALUES (?,?,?,?,?,?,?)",
|
|
75
|
+
)
|
|
76
|
+
.run(
|
|
77
|
+
key,
|
|
78
|
+
snapshot.sessionId,
|
|
79
|
+
message.id,
|
|
80
|
+
handler.pluginId,
|
|
81
|
+
attachment.type,
|
|
82
|
+
line,
|
|
83
|
+
now,
|
|
84
|
+
);
|
|
85
|
+
notes.push(line);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (notes.length) message.perceived = notes.slice(0, 4);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readableName } from "./speaker-names.js";
|
|
2
|
+
|
|
3
|
+
// Only a person's own explicit request supplies a preferred form of address.
|
|
4
|
+
export function requestedName(value) {
|
|
5
|
+
const words = String(value || "").trim();
|
|
6
|
+
if (/不要记|别记|不许记|不要叫我|别叫我|忘掉/.test(words)) return "";
|
|
7
|
+
const match = words.match(
|
|
8
|
+
/(?:^|[,,。!!;;\s])(?:以后|今后|请(?:你)?|麻烦|你(?:就|可以)?|记住[,,::\s]*)*(?:叫我|称呼我(?:为)?|(?:记住[,,::\s]*)?我的(?:名字|昵称)(?:是|叫)|记住[,,::\s]*我叫)[\s::]*[“"「『]?([^,,。!!??;;\n”"」』]{1,32})/u,
|
|
9
|
+
);
|
|
10
|
+
if (!match) return "";
|
|
11
|
+
const name = match[1]
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/(?:就好|就行|即可|好吗|吧|哦|呀|啦)$/, "")
|
|
14
|
+
.trim();
|
|
15
|
+
if (
|
|
16
|
+
/^(?:group|private|onebot):|^(?:群聊|私聊)\s*\d|^(?:去|来|帮|把|别|先|等|做|打开|关掉|回答|发消息)|叫你|不要|记住/.test(
|
|
17
|
+
name,
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
return "";
|
|
21
|
+
return readableName(name, "");
|
|
22
|
+
}
|