@jjh32456/dsh-longlive 1.6.7 → 1.8.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/CHANGELOG.md +70 -1508
- package/LICENSE +1 -1
- package/QUICKSTART.md +34 -13
- package/README.en.md +18 -7
- package/README.md +34 -9
- package/SPEC.md +5 -5
- package/cordis.patch.yml +23 -9
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +168 -35
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +96 -28
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +4 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +47 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +760 -0
- package/src/activity/model.js +351 -0
- package/src/activity/store.js +644 -0
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +171 -47
- package/src/deps.js +6 -4
- package/src/features.js +182 -6
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/live.js +22 -2
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +105 -16
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/queue.mjs +1 -1
- package/src/router/service/service.mjs +27 -11
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -385
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -204
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -568
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1020
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -182
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1044
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -299
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -179
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- package/test/v4-hardening.test.mjs +0 -427
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// src/role/channels.js — 角色内核 · channel adapter(v2.8 §16.3 ②)
|
|
2
|
+
//
|
|
3
|
+
// 设计口径(§16.3):
|
|
4
|
+
// - 注册表 `.longlive/role/channels.json`;适配器统一接口
|
|
5
|
+
// `{id, type:qq|web|tool, send(text, attachments), enabled, lastSeenAt}`;
|
|
6
|
+
// - 1.8.0 只做**宿主内注册与只读状态**,不接真实 QQ 协议(QQ 侧由插件桥负责,1.8.1+);
|
|
7
|
+
// - 路由 #27 `/api/longlive/channels`(channels/v1)由 release-check 注册,本模块供
|
|
8
|
+
// `readChannels()` 数据读取器。
|
|
9
|
+
//
|
|
10
|
+
// 本文件补齐任务书要求的统一消息形态与身份:`normalizeInboundMessage()` 产出
|
|
11
|
+
// `{v, channel:{id,type}, actor, sessionId, text, at, meta, source}`;能力协商
|
|
12
|
+
// (capabilities 交集)与降级(渠道缺失/禁用/无能 力/发送抛错 → 结构化失败,不崩)。
|
|
13
|
+
//
|
|
14
|
+
// 子脑渠道:v2.8 的 type 枚举是 qq|web|tool,不新增第 4 类;宿主内把子脑注册为
|
|
15
|
+
// tool 渠道 `id:"tool:subagent"`(meta.channelKind="subagent" 可选),保持 schema 不变。
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
RoleError, ROLE_CODES, ensureRoleDir, iso, nowMs, readJsonSafe, rolePaths, tryRolePaths, writeJsonAtomic,
|
|
21
|
+
} from './store.js';
|
|
22
|
+
|
|
23
|
+
export const CHANNELS_SCHEMA = 'channels/v1';
|
|
24
|
+
export const CHANNEL_TYPES = Object.freeze(['qq', 'web', 'tool']);
|
|
25
|
+
export const CHANNEL_CAPABILITIES = Object.freeze(['send', 'attachments', 'stream', 'presence']);
|
|
26
|
+
export const CHANNEL_DEFAULTS = Object.freeze({
|
|
27
|
+
/** 内置宿主内渠道:web 与 tool 可注册;qq 只有外部桥接时才有适配器。 */
|
|
28
|
+
types: CHANNEL_TYPES,
|
|
29
|
+
});
|
|
30
|
+
export const CHANNEL_CODES = Object.freeze({
|
|
31
|
+
TYPE: 'E_CHANNEL_TYPE',
|
|
32
|
+
ID: 'E_CHANNEL_ID',
|
|
33
|
+
UNKNOWN: 'E_CHANNEL_UNKNOWN',
|
|
34
|
+
DISABLED: 'E_CHANNEL_DISABLED',
|
|
35
|
+
CAPABILITY: 'E_CHANNEL_CAPABILITY',
|
|
36
|
+
UNAVAILABLE: 'E_CHANNEL_UNAVAILABLE',
|
|
37
|
+
SEND_FAILED: 'E_CHANNEL_SEND_FAILED',
|
|
38
|
+
ACTOR: 'E_CHANNEL_ACTOR',
|
|
39
|
+
SESSION: 'E_CHANNEL_SESSION',
|
|
40
|
+
CONTEXT: 'E_CHANNEL_CONTEXT',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const CHANNEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/;
|
|
44
|
+
|
|
45
|
+
function channelError(code, message, details = undefined) {
|
|
46
|
+
const error = new RoleError(code, message, details);
|
|
47
|
+
error.status = 422;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isChannelType(value) {
|
|
52
|
+
return CHANNEL_TYPES.includes(String(value ?? ''));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeChannelCapabilities(value, fallback = ['send']) {
|
|
56
|
+
const input = Array.isArray(value) ? value : fallback;
|
|
57
|
+
const seen = [];
|
|
58
|
+
for (const item of input) {
|
|
59
|
+
const capability = String(item ?? '');
|
|
60
|
+
if (!CHANNEL_CAPABILITIES.includes(capability)) {
|
|
61
|
+
throw channelError(CHANNEL_CODES.CAPABILITY, `未知渠道能力:${JSON.stringify(item)}(允许 ${CHANNEL_CAPABILITIES.join('|')})`);
|
|
62
|
+
}
|
|
63
|
+
if (!seen.includes(capability)) seen.push(capability);
|
|
64
|
+
}
|
|
65
|
+
return seen;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeChannelId(value) {
|
|
69
|
+
const id = typeof value === 'string' ? value.trim() : '';
|
|
70
|
+
if (!CHANNEL_ID_RE.test(id)) {
|
|
71
|
+
throw channelError(CHANNEL_CODES.ID, `渠道 id 非法:${JSON.stringify(value)}(字母数字开头,允许 : . _ -)`);
|
|
72
|
+
}
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One persisted channel record (`channels.json` row). */
|
|
77
|
+
export function normalizeChannelRecord(raw) {
|
|
78
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
79
|
+
let id;
|
|
80
|
+
try { id = normalizeChannelId(raw.id); } catch { return null; }
|
|
81
|
+
if (!isChannelType(raw.type)) return null;
|
|
82
|
+
let capabilities = [];
|
|
83
|
+
try { capabilities = normalizeChannelCapabilities(raw.capabilities, []); } catch { capabilities = []; }
|
|
84
|
+
const lastSeenAt = typeof raw.lastSeenAt === 'string' && Number.isFinite(Date.parse(raw.lastSeenAt)) ? raw.lastSeenAt : null;
|
|
85
|
+
return {
|
|
86
|
+
id,
|
|
87
|
+
type: String(raw.type),
|
|
88
|
+
enabled: raw.enabled !== false,
|
|
89
|
+
lastSeenAt,
|
|
90
|
+
capabilities,
|
|
91
|
+
meta: raw.meta && typeof raw.meta === 'object' && !Array.isArray(raw.meta) ? { ...raw.meta } : {},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function emptyChannelsFile() {
|
|
96
|
+
return { v: 1, updatedAt: null, channels: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read-only channels projection; never throws (route #27 producer). */
|
|
100
|
+
export function readChannels({ memoryRoot, now = () => new Date(), caller = null, auditKey = null } = {}) {
|
|
101
|
+
const paths = tryRolePaths(memoryRoot);
|
|
102
|
+
if (paths === null) {
|
|
103
|
+
return {
|
|
104
|
+
schema: CHANNELS_SCHEMA,
|
|
105
|
+
version: 1,
|
|
106
|
+
present: false,
|
|
107
|
+
degraded: true,
|
|
108
|
+
updatedAt: null,
|
|
109
|
+
count: 0,
|
|
110
|
+
channels: [],
|
|
111
|
+
code: 'E_CHANNELS_ABSENT',
|
|
112
|
+
readAt: iso(nowMs(now())),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const read = readJsonSafe(paths.channels, { memoryRoot, auditKey, caller });
|
|
116
|
+
if (read.tampered === true || read.denied === true) {
|
|
117
|
+
return {
|
|
118
|
+
schema: CHANNELS_SCHEMA,
|
|
119
|
+
version: 1,
|
|
120
|
+
present: false,
|
|
121
|
+
degraded: true,
|
|
122
|
+
tampered: read.tampered === true,
|
|
123
|
+
denied: read.denied === true,
|
|
124
|
+
updatedAt: null,
|
|
125
|
+
count: 0,
|
|
126
|
+
channels: [],
|
|
127
|
+
code: read.code,
|
|
128
|
+
message: read.message ?? null,
|
|
129
|
+
readAt: iso(nowMs(now())),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!read.ok || !read.value || typeof read.value !== 'object') {
|
|
133
|
+
return {
|
|
134
|
+
schema: CHANNELS_SCHEMA,
|
|
135
|
+
version: 1,
|
|
136
|
+
present: read.present === true,
|
|
137
|
+
degraded: read.present === true,
|
|
138
|
+
updatedAt: null,
|
|
139
|
+
count: 0,
|
|
140
|
+
channels: [],
|
|
141
|
+
code: read.present === true ? 'E_CHANNELS_CORRUPT' : 'E_CHANNELS_ABSENT',
|
|
142
|
+
readAt: iso(nowMs(now())),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const rawList = Array.isArray(read.value.channels) ? read.value.channels : [];
|
|
146
|
+
const channels = [];
|
|
147
|
+
let bad = 0;
|
|
148
|
+
for (const raw of rawList) {
|
|
149
|
+
const record = normalizeChannelRecord(raw);
|
|
150
|
+
if (record === null) { bad += 1; continue; }
|
|
151
|
+
channels.push({
|
|
152
|
+
id: record.id,
|
|
153
|
+
type: record.type,
|
|
154
|
+
enabled: record.enabled,
|
|
155
|
+
lastSeenAt: record.lastSeenAt,
|
|
156
|
+
capabilities: record.capabilities,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
schema: CHANNELS_SCHEMA,
|
|
161
|
+
version: 1,
|
|
162
|
+
present: true,
|
|
163
|
+
degraded: bad > 0,
|
|
164
|
+
updatedAt: typeof read.value.updatedAt === 'string' ? read.value.updatedAt : null,
|
|
165
|
+
count: channels.length,
|
|
166
|
+
bad,
|
|
167
|
+
channels,
|
|
168
|
+
code: bad > 0 ? 'E_CHANNELS_PARTIAL' : 'OK',
|
|
169
|
+
readAt: iso(nowMs(now())),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Host-side registry: adapters live in process memory, their metadata is
|
|
175
|
+
* persisted so a panel/another session can see "which channels exist".
|
|
176
|
+
*/
|
|
177
|
+
export function createChannelRegistry({ memoryRoot, now = () => new Date(), auditKey = null, actorKind = 'host' } = {}) {
|
|
178
|
+
const paths = rolePaths(memoryRoot);
|
|
179
|
+
/** id -> {id,type,send,capabilities,enabled,meta} */
|
|
180
|
+
const adapters = new Map();
|
|
181
|
+
|
|
182
|
+
function readFile() {
|
|
183
|
+
const read = readJsonSafe(paths.channels, { memoryRoot, auditKey });
|
|
184
|
+
if (!read.ok || !read.value || typeof read.value !== 'object') return emptyChannelsFile();
|
|
185
|
+
const channels = [];
|
|
186
|
+
for (const raw of Array.isArray(read.value.channels) ? read.value.channels : []) {
|
|
187
|
+
const record = normalizeChannelRecord(raw);
|
|
188
|
+
if (record) channels.push(record);
|
|
189
|
+
}
|
|
190
|
+
return { v: 1, updatedAt: typeof read.value.updatedAt === 'string' ? read.value.updatedAt : null, channels };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeFile(channels) {
|
|
194
|
+
ensureRoleDir(memoryRoot);
|
|
195
|
+
return writeJsonAtomic(paths.channels, {
|
|
196
|
+
v: 1,
|
|
197
|
+
updatedAt: iso(nowMs(now())),
|
|
198
|
+
channels,
|
|
199
|
+
}, { memoryRoot, auditKey, actorKind });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function persist() {
|
|
203
|
+
const channels = [...adapters.values()].map((adapter) => ({
|
|
204
|
+
id: adapter.id,
|
|
205
|
+
type: adapter.type,
|
|
206
|
+
enabled: adapter.enabled !== false,
|
|
207
|
+
lastSeenAt: adapter.lastSeenAt ?? null,
|
|
208
|
+
capabilities: [...adapter.capabilities],
|
|
209
|
+
meta: { ...(adapter.meta ?? {}) },
|
|
210
|
+
})).sort((left, right) => left.id.localeCompare(right.id));
|
|
211
|
+
return writeFile(channels);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Register one adapter. `send` is optional: presence-only adapters can be
|
|
216
|
+
* registered for status/negotiation but a send attempt degrades cleanly.
|
|
217
|
+
*/
|
|
218
|
+
function registerAdapter({
|
|
219
|
+
id,
|
|
220
|
+
type,
|
|
221
|
+
send = undefined,
|
|
222
|
+
capabilities = ['send'],
|
|
223
|
+
enabled = true,
|
|
224
|
+
lastSeenAt = null,
|
|
225
|
+
meta = {},
|
|
226
|
+
} = {}) {
|
|
227
|
+
const channelId = normalizeChannelId(id);
|
|
228
|
+
if (!isChannelType(type)) {
|
|
229
|
+
throw channelError(CHANNEL_CODES.TYPE, `渠道 type 非法:${JSON.stringify(type)}(允许 ${CHANNEL_TYPES.join('|')})`);
|
|
230
|
+
}
|
|
231
|
+
const normalizedCaps = normalizeChannelCapabilities(capabilities, ['send']);
|
|
232
|
+
if (normalizedCaps.includes('send') && typeof send !== 'function') {
|
|
233
|
+
throw channelError(CHANNEL_CODES.CAPABILITY, `渠道 ${channelId} 声明了 send 能力但没有 send 函数`);
|
|
234
|
+
}
|
|
235
|
+
if (send !== undefined && typeof send !== 'function') {
|
|
236
|
+
throw channelError(CHANNEL_CODES.CAPABILITY, `渠道 ${channelId} 的 send 不是函数`);
|
|
237
|
+
}
|
|
238
|
+
const seenMs = lastSeenAt === null ? nowMs(now()) : nowMs(lastSeenAt);
|
|
239
|
+
if (!Number.isFinite(seenMs)) throw channelError(CHANNEL_CODES.CONTEXT, `渠道 ${channelId} 的 lastSeenAt 不可解析`);
|
|
240
|
+
const record = {
|
|
241
|
+
id: channelId,
|
|
242
|
+
type: String(type),
|
|
243
|
+
send: typeof send === 'function' ? send : undefined,
|
|
244
|
+
capabilities: Object.freeze(normalizedCaps),
|
|
245
|
+
enabled: enabled !== false,
|
|
246
|
+
lastSeenAt: iso(seenMs),
|
|
247
|
+
meta: meta && typeof meta === 'object' && !Array.isArray(meta) ? { ...meta } : {},
|
|
248
|
+
};
|
|
249
|
+
adapters.set(channelId, record);
|
|
250
|
+
persist();
|
|
251
|
+
return { ok: true, channel: describe(record) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function unregisterAdapter(id) {
|
|
255
|
+
const channelId = normalizeChannelId(id);
|
|
256
|
+
const removed = adapters.delete(channelId);
|
|
257
|
+
if (removed) persist();
|
|
258
|
+
return { ok: true, removed, channelId };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function describe(record) {
|
|
262
|
+
return {
|
|
263
|
+
id: record.id,
|
|
264
|
+
type: record.type,
|
|
265
|
+
enabled: record.enabled !== false,
|
|
266
|
+
lastSeenAt: record.lastSeenAt ?? null,
|
|
267
|
+
capabilities: [...(record.capabilities ?? [])],
|
|
268
|
+
hasSend: typeof record.send === 'function',
|
|
269
|
+
meta: { ...(record.meta ?? {}) },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function list() {
|
|
274
|
+
return [...adapters.values()].map(describe).sort((left, right) => left.id.localeCompare(right.id));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Capability negotiation: returns the intersection of required ∩ offered. */
|
|
278
|
+
function negotiate({ channelId, required = ['send'] } = {}) {
|
|
279
|
+
const id = normalizeChannelId(channelId);
|
|
280
|
+
const adapter = adapters.get(id) ?? null;
|
|
281
|
+
const wanted = normalizeChannelCapabilities(required, ['send']);
|
|
282
|
+
if (adapter === null) {
|
|
283
|
+
return { ok: false, channelId: id, code: CHANNEL_CODES.UNKNOWN, supported: [], missing: wanted, degraded: true };
|
|
284
|
+
}
|
|
285
|
+
const offered = adapter.capabilities;
|
|
286
|
+
const supported = wanted.filter((capability) => offered.includes(capability));
|
|
287
|
+
const missing = wanted.filter((capability) => !offered.includes(capability));
|
|
288
|
+
return {
|
|
289
|
+
ok: missing.length === 0 && adapter.enabled !== false,
|
|
290
|
+
channelId: id,
|
|
291
|
+
code: adapter.enabled !== false ? (missing.length === 0 ? 'OK' : CHANNEL_CODES.CAPABILITY) : CHANNEL_CODES.DISABLED,
|
|
292
|
+
supported,
|
|
293
|
+
missing,
|
|
294
|
+
degraded: missing.length > 0 || adapter.enabled === false,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Send one message; every failure mode is a structured, non-throwing result. */
|
|
299
|
+
async function send({ channelId, text, attachments = [] } = {}) {
|
|
300
|
+
let id;
|
|
301
|
+
try { id = normalizeChannelId(channelId); } catch (error) {
|
|
302
|
+
return { ok: false, channelId: String(channelId ?? ''), code: error?.code ?? CHANNEL_CODES.ID, degraded: true };
|
|
303
|
+
}
|
|
304
|
+
const adapter = adapters.get(id) ?? null;
|
|
305
|
+
if (adapter === null) return { ok: false, channelId: id, code: CHANNEL_CODES.UNKNOWN, degraded: true };
|
|
306
|
+
if (adapter.enabled === false) return { ok: false, channelId: id, code: CHANNEL_CODES.DISABLED, degraded: true };
|
|
307
|
+
if (!adapter.capabilities.includes('send') || typeof adapter.send !== 'function') {
|
|
308
|
+
return { ok: false, channelId: id, code: CHANNEL_CODES.CAPABILITY, degraded: true };
|
|
309
|
+
}
|
|
310
|
+
const payload = {
|
|
311
|
+
channelId: id,
|
|
312
|
+
text: typeof text === 'string' ? text : String(text ?? ''),
|
|
313
|
+
attachments: Array.isArray(attachments) ? attachments : [],
|
|
314
|
+
};
|
|
315
|
+
if (payload.attachments.length > 0 && !adapter.capabilities.includes('attachments')) {
|
|
316
|
+
return { ok: false, channelId: id, code: CHANNEL_CODES.CAPABILITY, degraded: true, missing: ['attachments'] };
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const result = await adapter.send(payload);
|
|
320
|
+
adapter.lastSeenAt = iso(nowMs(now()));
|
|
321
|
+
persist();
|
|
322
|
+
return { ok: true, channelId: id, delivered: result !== false, result: result ?? null, degraded: false };
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
channelId: id,
|
|
327
|
+
code: CHANNEL_CODES.SEND_FAILED,
|
|
328
|
+
degraded: true,
|
|
329
|
+
error: String(error?.message ?? error),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Update the lastSeenAt watermark (host event integration). */
|
|
335
|
+
function touch(channelId, { at = undefined } = {}) {
|
|
336
|
+
const id = normalizeChannelId(channelId);
|
|
337
|
+
const adapter = adapters.get(id);
|
|
338
|
+
if (!adapter) return { ok: false, channelId: id, code: CHANNEL_CODES.UNKNOWN, degraded: true };
|
|
339
|
+
const ms = at === undefined ? nowMs(now()) : nowMs(at);
|
|
340
|
+
if (!Number.isFinite(ms)) return { ok: false, channelId: id, code: CHANNEL_CODES.CONTEXT, degraded: true };
|
|
341
|
+
adapter.lastSeenAt = iso(ms);
|
|
342
|
+
persist();
|
|
343
|
+
return { ok: true, channelId: id, lastSeenAt: adapter.lastSeenAt };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Read-only view (same shape as `readChannels`, plus in-memory adapters). */
|
|
347
|
+
function view({ now: nowValue = undefined } = {}) {
|
|
348
|
+
const clock = typeof nowValue === 'function'
|
|
349
|
+
? nowValue
|
|
350
|
+
: (nowValue === undefined ? now : () => new Date(nowValue));
|
|
351
|
+
const view = readChannels({ memoryRoot, now: clock });
|
|
352
|
+
const merged = new Map(view.channels.map((channel) => [channel.id, channel]));
|
|
353
|
+
for (const adapter of list()) {
|
|
354
|
+
merged.set(adapter.id, {
|
|
355
|
+
id: adapter.id,
|
|
356
|
+
type: adapter.type,
|
|
357
|
+
enabled: adapter.enabled,
|
|
358
|
+
lastSeenAt: adapter.lastSeenAt,
|
|
359
|
+
capabilities: adapter.capabilities,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
const channels = [...merged.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
363
|
+
return {
|
|
364
|
+
schema: CHANNELS_SCHEMA,
|
|
365
|
+
present: view.present || adapterCount() > 0,
|
|
366
|
+
degraded: view.degraded,
|
|
367
|
+
updatedAt: view.updatedAt,
|
|
368
|
+
count: channels.length,
|
|
369
|
+
channels,
|
|
370
|
+
code: view.code,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function adapterCount() {
|
|
375
|
+
return adapters.size;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { registerAdapter, unregisterAdapter, list, negotiate, send, touch, view, persist, paths };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Unified inbound message identity (`QQ/Web/工具/子脑` all land in one shape).
|
|
383
|
+
* `actor` is who spoke (personId / 'user' / 'host' / 'subagent'); `sessionId`
|
|
384
|
+
* is the host session; `meta.channelKind` can mark a tool channel as subagent.
|
|
385
|
+
*/
|
|
386
|
+
export function normalizeInboundMessage({
|
|
387
|
+
channelId,
|
|
388
|
+
channelType = undefined,
|
|
389
|
+
actor,
|
|
390
|
+
sessionId,
|
|
391
|
+
text = '',
|
|
392
|
+
at = undefined,
|
|
393
|
+
meta = {},
|
|
394
|
+
now = () => new Date(),
|
|
395
|
+
} = {}) {
|
|
396
|
+
const id = normalizeChannelId(channelId);
|
|
397
|
+
const type = channelType === undefined ? undefined : String(channelType);
|
|
398
|
+
if (type !== undefined && !isChannelType(type)) {
|
|
399
|
+
throw channelError(CHANNEL_CODES.TYPE, `渠道 type 非法:${JSON.stringify(channelType)}(允许 ${CHANNEL_TYPES.join('|')})`);
|
|
400
|
+
}
|
|
401
|
+
if (typeof actor !== 'string' || actor.trim() === '') {
|
|
402
|
+
throw channelError(CHANNEL_CODES.ACTOR, '入站消息缺少 actor(谁说的)');
|
|
403
|
+
}
|
|
404
|
+
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
|
|
405
|
+
throw channelError(CHANNEL_CODES.SESSION, '入站消息缺少 sessionId');
|
|
406
|
+
}
|
|
407
|
+
const atMs = at === undefined ? nowMs(now()) : nowMs(at);
|
|
408
|
+
if (!Number.isFinite(atMs)) throw channelError(CHANNEL_CODES.CONTEXT, '入站消息 at 不可解析');
|
|
409
|
+
return {
|
|
410
|
+
v: 1,
|
|
411
|
+
channel: { id, type: type ?? (id.startsWith('tool:') ? 'tool' : id.split(':')[0] || 'tool') },
|
|
412
|
+
actor: actor.trim(),
|
|
413
|
+
sessionId: sessionId.trim(),
|
|
414
|
+
text: typeof text === 'string' ? text : String(text ?? ''),
|
|
415
|
+
at: iso(atMs),
|
|
416
|
+
meta: meta && typeof meta === 'object' && !Array.isArray(meta) ? { ...meta } : {},
|
|
417
|
+
source: 'role.channel',
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function newChannelNonce() {
|
|
422
|
+
return crypto.randomBytes(6).toString('hex');
|
|
423
|
+
}
|