dsh-deepmoyu 2.3.0 → 2.4.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.
- package/AGENTS.md +93 -153
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +143 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1404 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +1095 -285
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/update.mjs +265 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- package/tests/verify_pv_sample.py +0 -91
package/lib/asr.py
CHANGED
|
@@ -18,33 +18,42 @@ import argparse
|
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
20
|
import sys
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
# ⭐ Windows 上 Python 默认按 GBK 编/解码标准输出 —— 中文或 emoji 打进管道会直接炸
|
|
24
|
-
# (有用户反馈装依赖 / 跑转写时报 UnicodeDecodeError 或乱码)⇒ 这里钉死 UTF-8
|
|
25
|
-
try:
|
|
26
|
-
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
27
|
-
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
28
|
-
except Exception:
|
|
29
|
-
pass
|
|
21
|
+
import uuid
|
|
30
22
|
|
|
31
23
|
# ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
|
|
32
24
|
# yt-dlp 默认往 %TEMP% 写、会被直接拒掉(2026-10-14 在主人机器上实证:
|
|
33
25
|
# yt_dlp DownloadError: unable to open for writing: [Errno 13] Permission denied)
|
|
34
26
|
_HOME = os.path.dirname(os.path.abspath(__file__))
|
|
35
27
|
_TMP = os.path.join(_HOME, 'tmp', 'asr')
|
|
36
|
-
try:
|
|
37
|
-
os.makedirs(_TMP, exist_ok=True)
|
|
38
|
-
tempfile.tempdir = _TMP
|
|
39
|
-
except Exception:
|
|
40
|
-
pass
|
|
41
28
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
def prepare_runtime() -> None:
|
|
30
|
+
"""仅在执行命令时初始化环境,导入模块不改变进程的缓存或临时目录。"""
|
|
31
|
+
try:
|
|
32
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
33
|
+
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
# 必须在 import faster_whisper 前设置;保留用户明确指定的端点与缓存。
|
|
37
|
+
os.environ.setdefault('HF_ENDPOINT', 'https://hf-mirror.com')
|
|
38
|
+
os.environ.setdefault('HF_HOME', os.path.join(_HOME, '.hf-cache'))
|
|
39
|
+
os.environ.setdefault('HF_HUB_DISABLE_XET', '1')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cleanup_download(tmp: str, tag: str) -> None:
|
|
43
|
+
"""只回收本次下载的独占文件前缀,也处理失败时留下的分片。"""
|
|
44
|
+
try:
|
|
45
|
+
with os.scandir(tmp) as entries:
|
|
46
|
+
for entry in entries:
|
|
47
|
+
if not entry.name.startswith(tag + '.'):
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
# 不递归进入目录;即使出现符号链接也只删除链接本身。
|
|
51
|
+
if entry.is_file(follow_symlinks=False) or entry.is_symlink():
|
|
52
|
+
os.remove(entry.path)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
48
57
|
|
|
49
58
|
|
|
50
59
|
def to_url(target: str) -> str:
|
|
@@ -59,7 +68,7 @@ def fetch_audio(url: str, tmp: str, tag: str) -> str:
|
|
|
59
68
|
from yt_dlp import YoutubeDL
|
|
60
69
|
opts = {
|
|
61
70
|
'format': 'bestaudio/best',
|
|
62
|
-
'outtmpl': os.path.join(tmp, tag + '.%(ext)s'), #
|
|
71
|
+
'outtmpl': os.path.join(tmp, tag + '.%(ext)s'), # 每次调用的独占标签,允许同视频并行下载。
|
|
63
72
|
'paths': {'home': tmp, 'temp': tmp}, # 别让 yt-dlp 往 %TEMP% 写(沙箱会拒)
|
|
64
73
|
'cachedir': False, # 顺手别去动用户目录里的缓存
|
|
65
74
|
# ⭐ nopart:不写 .part 再改名,直接落最终文件。
|
|
@@ -89,17 +98,22 @@ def main() -> int:
|
|
|
89
98
|
help='Whisper 模型大小:tiny / base / small / medium(默认 small)')
|
|
90
99
|
ap.add_argument('--lang', default='zh', help='语言(默认 zh;不给就自动判断请传 auto)')
|
|
91
100
|
args = ap.parse_args()
|
|
101
|
+
prepare_runtime()
|
|
92
102
|
|
|
93
103
|
src = args.target
|
|
94
104
|
# ⚠️ 不用 mkdtemp:沙箱只允许写"子进程启动前就存在"的目录,新建的会被拒 ✗
|
|
95
|
-
#
|
|
105
|
+
# 这个目录由宿主(插件)预先建好;使用唯一前缀而不是新建子目录。
|
|
96
106
|
tmp = _TMP
|
|
97
|
-
#
|
|
98
|
-
# 上一轮转出来的其实是另一个视频的内容(2026-10-14 值班那轮亲踩 ✗)
|
|
107
|
+
# 同一个视频也可能同时转写;清理权只属于本次远程下载,不属于本地输入。
|
|
99
108
|
tag = os.path.basename(src).rsplit('.', 1)[0] or 'clip'
|
|
100
109
|
tag = re.sub(r'[^0-9A-Za-z_-]', '_', tag)[:40] or 'clip'
|
|
110
|
+
tag += '-' + uuid.uuid4().hex
|
|
111
|
+
downloaded = False
|
|
101
112
|
try:
|
|
102
|
-
|
|
113
|
+
# 已存在的本地文件优先,即使文件名恰好以 BV/http 开头。
|
|
114
|
+
if not os.path.exists(src) and (src.lower().startswith('http') or src.upper().startswith('BV')):
|
|
115
|
+
os.makedirs(tmp, exist_ok=True)
|
|
116
|
+
downloaded = True
|
|
103
117
|
print('[asr] 先下音频…', file=sys.stderr)
|
|
104
118
|
src = fetch_audio(to_url(src), tmp, tag)
|
|
105
119
|
if not os.path.exists(src):
|
|
@@ -117,16 +131,8 @@ def main() -> int:
|
|
|
117
131
|
)
|
|
118
132
|
text = ''.join(seg.text for seg in segments).strip()
|
|
119
133
|
finally:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
import glob
|
|
123
|
-
for f in glob.glob(os.path.join(tmp, tag + '.*')):
|
|
124
|
-
try:
|
|
125
|
-
os.remove(f)
|
|
126
|
-
except Exception:
|
|
127
|
-
pass
|
|
128
|
-
except Exception:
|
|
129
|
-
pass
|
|
134
|
+
if downloaded:
|
|
135
|
+
cleanup_download(tmp, tag)
|
|
130
136
|
|
|
131
137
|
if args.out:
|
|
132
138
|
with open(args.out, 'w', encoding='utf-8') as f:
|
|
@@ -1,48 +1,95 @@
|
|
|
1
1
|
/** Bounded background diagnostics. Never copy raw tool arguments, private inboxes or reasoning into the pet log. */
|
|
2
2
|
import { StringDecoder } from 'node:string_decoder'
|
|
3
|
-
import { redactRecordText } from './record-privacy.mjs'
|
|
4
3
|
|
|
5
4
|
const ansi = /\x1b\[[0-?]*[ -/]*[@-~]/g
|
|
5
|
+
const MAX_LINE = 16384
|
|
6
|
+
const TOOL_NAMES = new Set(('bili_search bili_video_info bili_subtitle bili_comments bili_reply bili_reply_opus bili_received_replies '
|
|
7
|
+
+ 'bili_dm_inbox bili_dm_list bili_dm_thread bili_dm_reply bili_send_message bili_send_dynamic bili_send_opus bili_moment '
|
|
8
|
+
+ 'bili_my_dynamics bili_my_notices bili_my_video_comments bili_user_info bili_user_videos bili_hot_videos bili_weekly_hot bili_rank '
|
|
9
|
+
+ 'bili_danmaku bili_login bili_login_poll bili_login_start bili_check_credential bili_whoami bili_config bili_duty_status '
|
|
10
|
+
+ 'bili_feiyu bili_guard_status bili_learn bili_pace bili_pv bili_records bili_run bili_say bili_seen bili_setup bili_soul bili_status bili_sticker bili_taste').split(' '))
|
|
11
|
+
const CODES = new Set(('NO_ADAPTER ENOENT EACCES EPERM ECONNREFUSED ECONNRESET ETIMEDOUT ERR_MODULE_NOT_FOUND MODULE_NOT_FOUND '
|
|
12
|
+
+ 'ACCOUNT_SIGN_IN_REQUIRED INVALID_ARGUMENT INVALID_ROLE INVALID_MODEL_ROUTE RELAY_ERROR RELAY_INVALID_URL RELAY_NO_CAPABILITY '
|
|
13
|
+
+ 'RELAY_ROUTE_MISMATCH RELAY_INVALID_MODEL_INFO RELAY_FRAME_LIMIT RELAY_STREAM_CLOSED RELAY_INVALID_ROLE RELAY_FORBIDDEN '
|
|
14
|
+
+ 'RELAY_EXPIRED RELAY_BODY_LIMIT RELAY_INVALID_JSON RELAY_BUSY RELAY_NO_LLM RELAY_INVALID_REQUEST RELAY_NOT_READY').split(' '))
|
|
15
|
+
|
|
16
|
+
/** Exception messages may echo arbitrary model/tool input. Emit allowlisted metadata only. */
|
|
17
|
+
function diagnosticSummary(text, isWarning, truncated) {
|
|
18
|
+
const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9_]+\b/g) || [])].filter(code => CODES.has(code)).slice(0, 6)
|
|
19
|
+
let summary = isWarning ? '后台运行时警告' : '后台进程报告错误'
|
|
20
|
+
if (/Cannot find module|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|failed to import/i.test(text)) summary = '后台依赖加载失败'
|
|
21
|
+
else if (/NO_ADAPTER|RELAY_NO_LLM|RELAY_NOT_READY/.test(text)) summary = '后台模型适配器不可用'
|
|
22
|
+
else if (/ACCOUNT_SIGN_IN_REQUIRED/.test(text)) summary = '后台账号需要重新登录'
|
|
23
|
+
else if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|连接失败/.test(text)) summary = '后台连接失败或超时'
|
|
24
|
+
else if (/启动失败/.test(text)) summary = '后台任务启动失败'
|
|
25
|
+
else if (/Ignoring extra certs/i.test(text)) summary = '后台证书配置警告'
|
|
26
|
+
else if (/ExperimentalWarning/i.test(text)) summary = '后台运行时实验性能力警告'
|
|
27
|
+
return { codes, text: summary + (codes.length ? `(${codes.join('、')})` : '') + (truncated ? ';长行已截断' : '') + ';原始输出已省略' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Shared by structured relay errors, which can echo the same private input as stderr. */
|
|
31
|
+
export function summarizeBackgroundDiagnostic(value) {
|
|
32
|
+
const text = typeof value === 'string' ? value : `${String(value?.code || '')} ${String(value?.message || value || '')}`
|
|
33
|
+
return diagnosticSummary(text.slice(0, MAX_LINE), false, text.length > MAX_LINE)
|
|
34
|
+
}
|
|
35
|
+
|
|
6
36
|
export function createBackgroundOutput({ onDiagnostic, onProgress } = {}) {
|
|
7
37
|
const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }
|
|
8
38
|
const pending = { stdout: '', stderr: '' }
|
|
39
|
+
const discarding = { stdout: false, stderr: false }
|
|
9
40
|
let lastError = '', lastProgress = '', ended = false
|
|
10
|
-
const notify = (fn, value) => {
|
|
11
|
-
|
|
12
|
-
|
|
41
|
+
const notify = (fn, value) => {
|
|
42
|
+
try { Promise.resolve(fn?.(value)).catch(() => {}) } catch { /* diagnostic failures cannot kill the host */ }
|
|
43
|
+
}
|
|
44
|
+
const line = (value, channel, truncated = false) => {
|
|
45
|
+
const text = String(value).replace(ansi, '').trim()
|
|
13
46
|
if (!text) return
|
|
14
47
|
// Print only task/diagnostic metadata; a tool invocation may contain somebody's private message.
|
|
15
|
-
const tools = [...new Set(text.match(/\bbili_[a-z_]+\b/g) || [])]
|
|
48
|
+
const tools = [...new Set(text.match(/\bbili_[a-z_]+\b/g) || [])].filter(name => TOOL_NAMES.has(name))
|
|
16
49
|
if (tools.length) {
|
|
17
50
|
const message = '工具:' + tools.slice(0, 8).join('、')
|
|
18
51
|
if (message !== lastProgress) { lastProgress = message; notify(onProgress, { stage: 'tool', text: message }) }
|
|
19
52
|
}
|
|
20
|
-
const isError = /NO_ADAPTER|\b(?:Error|ERROR|ENOENT|ECONNREFUSED|ETIMEDOUT|ACCOUNT_SIGN_IN_REQUIRED|INVALID_[A-Z_]+|RELAY_[A-Z_]+)\b|dsh:|failed to import|启动失败|连接失败/i.test(text)
|
|
53
|
+
const isError = /NO_ADAPTER|\b(?:Error|ERROR|ENOENT|EACCES|EPERM|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|ACCOUNT_SIGN_IN_REQUIRED|INVALID_[A-Z_]+|RELAY_[A-Z_]+)\b|dsh:|failed to import|启动失败|连接失败/i.test(text)
|
|
21
54
|
const isWarning = /^(?:\(node:\d+\)\s*)?(?:Warning:|warning:)|Ignoring extra certs|ExperimentalWarning/i.test(text)
|
|
22
55
|
if ((channel === 'stderr' && (isError || isWarning || /^\s*at\s/.test(text))) || isError) {
|
|
23
|
-
const
|
|
24
|
-
if (isError && !isWarning) lastError =
|
|
25
|
-
notify(onDiagnostic, { channel,
|
|
56
|
+
const summary = diagnosticSummary(text, isWarning, truncated)
|
|
57
|
+
if (isError && !isWarning) lastError = summary.text
|
|
58
|
+
notify(onDiagnostic, { channel, ...summary, error: isError && !isWarning })
|
|
26
59
|
}
|
|
27
60
|
}
|
|
28
|
-
const feed = (text, channel
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
61
|
+
const feed = (text, channel) => {
|
|
62
|
+
let offset = 0
|
|
63
|
+
while (offset < text.length) {
|
|
64
|
+
const newline = text.indexOf('\n', offset), end = newline < 0 ? text.length : newline
|
|
65
|
+
if (!discarding[channel]) {
|
|
66
|
+
const room = MAX_LINE - pending[channel].length
|
|
67
|
+
pending[channel] += text.slice(offset, Math.min(end, offset + room))
|
|
68
|
+
if (end - offset > room) {
|
|
69
|
+
line(pending[channel], channel, true)
|
|
70
|
+
pending[channel] = ''; discarding[channel] = true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (newline < 0) break
|
|
74
|
+
if (!discarding[channel]) line(pending[channel], channel)
|
|
75
|
+
pending[channel] = ''; discarding[channel] = false
|
|
76
|
+
offset = newline + 1
|
|
34
77
|
}
|
|
35
|
-
if (pending[channel].length > 16384 || flush) { line(pending[channel], channel); pending[channel] = '' }
|
|
36
78
|
}
|
|
37
79
|
return {
|
|
38
80
|
push(value, channel = 'stdout') {
|
|
39
81
|
if (ended) return
|
|
82
|
+
if (channel !== 'stdout' && channel !== 'stderr') return
|
|
40
83
|
try { feed(decoders[channel].write(Buffer.isBuffer(value) ? value : Buffer.from(String(value))), channel) } catch { /* invalid console bytes */ }
|
|
41
84
|
},
|
|
42
85
|
flush() {
|
|
43
86
|
if (ended) return
|
|
44
87
|
ended = true
|
|
45
|
-
for (const channel of ['stdout', 'stderr'])
|
|
88
|
+
for (const channel of ['stdout', 'stderr']) {
|
|
89
|
+
feed(decoders[channel].end(), channel)
|
|
90
|
+
if (!discarding[channel]) line(pending[channel], channel)
|
|
91
|
+
pending[channel] = ''; discarding[channel] = false
|
|
92
|
+
}
|
|
46
93
|
},
|
|
47
94
|
get lastError() { return lastError },
|
|
48
95
|
}
|
package/lib/background-tools.mjs
CHANGED
|
@@ -5,6 +5,7 @@ export function backgroundToolNames(cfg = {}) {
|
|
|
5
5
|
if (cfg.replyToComments) names.push('bili_received_replies')
|
|
6
6
|
if (cfg.autoMoment) names.push('bili_moment')
|
|
7
7
|
if (cfg.autoReplyDm) names.push('bili_dm_inbox', 'bili_dm_thread', 'bili_dm_reply')
|
|
8
|
+
if (['only', 'both'].includes(cfg.dmMode) && cfg.dmUid) names.push('bili_deliver')
|
|
8
9
|
return names
|
|
9
10
|
}
|
|
10
11
|
export function toolInputSchema(parameters = {}) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Presentation preferences never change browsing, account permissions or send gates. */
|
|
2
|
+
export const COMPANION_DEFAULTS = Object.freeze({
|
|
3
|
+
idleChatter: true,
|
|
4
|
+
chatterFrequency: 'normal', notificationLevel: 'normal', focusMode: false,
|
|
5
|
+
quietHoursEnabled: false, quietHoursStart: '23:00', quietHoursEnd: '08:00', petReducedMotion: false,
|
|
6
|
+
})
|
|
7
|
+
const choices = { chatterFrequency: ['rare', 'normal', 'lively'], notificationLevel: ['minimal', 'normal', 'all'] }
|
|
8
|
+
const clock = value => typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
|
|
9
|
+
export function clampCompanionSettings(patch = {}) {
|
|
10
|
+
const out = { ...patch }
|
|
11
|
+
for (const [key, list] of Object.entries(choices)) if (Object.hasOwn(out, key) && !list.includes(out[key])) out[key] = COMPANION_DEFAULTS[key]
|
|
12
|
+
for (const key of ['idleChatter', 'focusMode', 'quietHoursEnabled', 'petReducedMotion']) if (Object.hasOwn(out, key) && typeof out[key] !== 'boolean') out[key] = COMPANION_DEFAULTS[key]
|
|
13
|
+
for (const key of ['quietHoursStart', 'quietHoursEnd']) if (Object.hasOwn(out, key) && !clock(out[key])) out[key] = COMPANION_DEFAULTS[key]
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
export function companionPolicy(config = {}, now = new Date()) {
|
|
17
|
+
const cfg = { ...COMPANION_DEFAULTS, ...clampCompanionSettings(config) }
|
|
18
|
+
const minutes = text => Number(text.slice(0, 2)) * 60 + Number(text.slice(3))
|
|
19
|
+
const start = minutes(cfg.quietHoursStart), end = minutes(cfg.quietHoursEnd)
|
|
20
|
+
const at = now.getHours() * 60 + now.getMinutes()
|
|
21
|
+
const quiet = cfg.quietHoursEnabled && start !== end && (start < end ? at >= start && at < end : at >= start || at < end)
|
|
22
|
+
const focus = cfg.focusMode === true
|
|
23
|
+
const ranges = { rare: [240, 360], normal: [132, 210], lively: [45, 90] }
|
|
24
|
+
const range = ranges[cfg.chatterFrequency]
|
|
25
|
+
return {
|
|
26
|
+
quiet, focus, silenced: quiet || focus, reason: focus ? '专注模式' : quiet ? '夜间安静时段' : '',
|
|
27
|
+
chatterAllowed: cfg.idleChatter !== false && !quiet && !focus,
|
|
28
|
+
notificationLevel: cfg.notificationLevel, reducedMotion: cfg.petReducedMotion,
|
|
29
|
+
chatterMinMinutes: range[0], chatterMaxMinutes: range[1],
|
|
30
|
+
label: focus ? '专注陪伴' : quiet ? '夜间安静' : cfg.idleChatter === false ? '闲聊已关闭' : '自然陪伴',
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function nextChatterAt(config = {}, now = Date.now(), random = Math.random()) {
|
|
34
|
+
const policy = companionPolicy(config, new Date(now))
|
|
35
|
+
const fraction = Number.isFinite(random) ? Math.max(0, Math.min(1, random)) : 0.5
|
|
36
|
+
return now + (policy.chatterMinMinutes + fraction * (policy.chatterMaxMinutes - policy.chatterMinMinutes)) * 60_000
|
|
37
|
+
}
|
|
38
|
+
export function companionPrompt(config = {}, now = new Date()) {
|
|
39
|
+
const policy = companionPolicy(config, now)
|
|
40
|
+
return policy.silenced
|
|
41
|
+
? `【陪伴节奏】目前是${policy.reason},少闲聊、少追问,完成结果和需要本人决定的事情仍要清楚反馈。运行任务、发送权限与原有安全闸照旧。`
|
|
42
|
+
: '【陪伴节奏】只在有具体发现或需要接话时自然开口,不为显示在线编造观看、发言或心情经历。'
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Model-authored arguments are preferences, never evidence of human authorization. */
|
|
2
|
+
export const MODEL_CONFIG_KEYS = Object.freeze([
|
|
3
|
+
'persona', 'focus', 'keywords', 'reportLevel', 'perDay', 'momentStyle', 'interactionStyle',
|
|
4
|
+
'idleChatter', 'chatterFrequency', 'notificationLevel', 'focusMode',
|
|
5
|
+
'quietHoursEnabled', 'quietHoursStart', 'quietHoursEnd', 'petReducedMotion', 'petShow',
|
|
6
|
+
])
|
|
7
|
+
const permitted = new Set(MODEL_CONFIG_KEYS)
|
|
8
|
+
export function modelConfigDecision(patch) {
|
|
9
|
+
const keys = Object.keys(patch || {})
|
|
10
|
+
if (keys.includes('sendReview')) return { ok: false, code: 'SEND_REVIEW_MANUAL_ONLY', error: '发送预览开关只能由本人在工作台 → 互动与投递中修改。' }
|
|
11
|
+
if (keys.includes('stickerReview')) return { ok: false, code: 'STICKER_REVIEW_MANUAL_ONLY', error: '表情人工审核开关只能由本人在工作台 → 记忆与素材中修改。' }
|
|
12
|
+
const blockedKeys = keys.filter(key => !permitted.has(key))
|
|
13
|
+
return blockedKeys.length ? { ok: false, code: 'AUTHORIZATION_MANUAL_ONLY', blockedKeys,
|
|
14
|
+
error: '发送授权、收件人、防护、预算、自动任务和执行环境配置只能由本人在工作台修改;模型只能调整内容与陪伴偏好。' } : { ok: true }
|
|
15
|
+
}
|
|
16
|
+
export function modelForceDecision(args) {
|
|
17
|
+
return args && Object.hasOwn(args, 'force') && args.force !== false
|
|
18
|
+
? { ok: false, blocked: true, code: 'FORCE_NOT_AUTHORIZATION', error: '模型提供的 force 参数不是本人授权,不能绕过发送开关、审核、判重或节奏。请在工作台启用发送预览并由本人逐条批准,或手动调整发送权限。' }
|
|
19
|
+
: null
|
|
20
|
+
}
|
package/lib/favops.py
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
+
import re
|
|
11
12
|
import sys
|
|
12
13
|
|
|
13
14
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -49,6 +50,10 @@ async def folders():
|
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
async def add(bvid, fid):
|
|
53
|
+
if not isinstance(bvid, str) or not re.fullmatch(r'BV[0-9A-Za-z]{10}', bvid):
|
|
54
|
+
raise ValueError('视频 BV 号格式无效')
|
|
55
|
+
if isinstance(fid, bool) or not isinstance(fid, int) or fid <= 0:
|
|
56
|
+
raise ValueError('收藏夹 ID 必须为正整数')
|
|
52
57
|
# 2026-10-26:删掉了原来那句 `FavoriteList(aid, credential=cred).get_info()` ——
|
|
53
58
|
# 它把**视频 aid** 当收藏夹 media_id 传进去,查的是"编号恰好等于这个 aid 的收藏夹",
|
|
54
59
|
# 几乎必然不存在/报错;结果没被使用,却会把整个 add() 拖崩(真正的收藏动作到不了)。
|
|
@@ -73,9 +78,11 @@ def main():
|
|
|
73
78
|
if not args:
|
|
74
79
|
out({'ok': False, 'error': '没有子命令(folders / add)'})
|
|
75
80
|
try:
|
|
76
|
-
if args[0] == 'folders':
|
|
81
|
+
if args[0] == 'folders' and len(args) == 1:
|
|
77
82
|
asyncio.run(folders())
|
|
78
|
-
elif args[0] == 'add' and len(args)
|
|
83
|
+
elif args[0] == 'add' and len(args) == 3:
|
|
84
|
+
if not re.fullmatch(r'[0-9]+', args[2]):
|
|
85
|
+
raise ValueError('收藏夹 ID 必须为正整数')
|
|
79
86
|
asyncio.run(add(args[1], int(args[2])))
|
|
80
87
|
else:
|
|
81
88
|
out({'ok': False, 'error': '子命令不认识:' + ' '.join(args)})
|
package/lib/ids.mjs
CHANGED
|
@@ -33,3 +33,44 @@ export const replyTargetId = (primary, fallback) => {
|
|
|
33
33
|
const first = optionalId(primary)
|
|
34
34
|
return first !== '0' ? first : optionalId(fallback)
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/** Parse JSON without rounding decimal integer tokens. Strings/escapes are scanned, never rewritten.
|
|
38
|
+
* Ordinary JSON syntax validation remains JSON.parse's job; invalid leading zeros stay invalid.
|
|
39
|
+
* Safe integers, decimal fractions and exponent notation retain their standard JSON number types.
|
|
40
|
+
*/
|
|
41
|
+
export function parseJsonExactIntegers(source) {
|
|
42
|
+
if (typeof source !== 'string') throw new TypeError('JSON source must be a string')
|
|
43
|
+
// Validate original grammar first: quoting an illegal numeric object key must not repair it.
|
|
44
|
+
// Rounded values from this syntax pass are discarded whenever an unsafe token is present.
|
|
45
|
+
const ordinary = JSON.parse(source)
|
|
46
|
+
const parts = []
|
|
47
|
+
let cursor = 0, untouched = 0
|
|
48
|
+
const digit = index => source.charCodeAt(index) >= 48 && source.charCodeAt(index) <= 57
|
|
49
|
+
while (cursor < source.length) {
|
|
50
|
+
if (source[cursor] === '"') {
|
|
51
|
+
cursor++
|
|
52
|
+
while (cursor < source.length) {
|
|
53
|
+
if (source[cursor] === '\\') { cursor += 2; continue }
|
|
54
|
+
if (source[cursor++] === '"') break
|
|
55
|
+
}
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (source[cursor] !== '-' && !digit(cursor)) { cursor++; continue }
|
|
59
|
+
const start = cursor
|
|
60
|
+
if (source[cursor] === '-') cursor++
|
|
61
|
+
while (digit(cursor)) cursor++
|
|
62
|
+
let integer = true
|
|
63
|
+
if (source[cursor] === '.') { integer = false; cursor++; while (digit(cursor)) cursor++ }
|
|
64
|
+
if (source[cursor] === 'e' || source[cursor] === 'E') {
|
|
65
|
+
integer = false; cursor++
|
|
66
|
+
if (source[cursor] === '+' || source[cursor] === '-') cursor++
|
|
67
|
+
while (digit(cursor)) cursor++
|
|
68
|
+
}
|
|
69
|
+
const token = source.slice(start, cursor)
|
|
70
|
+
if (integer && /^-?(?:0|[1-9]\d*)$/.test(token) && !Number.isSafeInteger(Number(token))) {
|
|
71
|
+
parts.push(source.slice(untouched, start), JSON.stringify(token))
|
|
72
|
+
untouched = cursor
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return parts.length ? JSON.parse(parts.join('') + source.slice(untouched)) : ordinary
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** One explicit login generation owns credential writes; late polls cannot replace a newer account. */
|
|
2
|
+
export function createLoginSession({ start, poll, save, now = Date.now, ttlMs = 180_000 }) {
|
|
3
|
+
let generation = 0, active = null, disposed = false
|
|
4
|
+
const expired = () => ({ state: 'expired', message: '登录请求已失效,请使用最新二维码重新登录。' })
|
|
5
|
+
const current = attempt => !disposed && active === attempt && now() < attempt.expiresAt
|
|
6
|
+
return {
|
|
7
|
+
async start() {
|
|
8
|
+
if (disposed) throw new Error('登录服务已关闭')
|
|
9
|
+
const id = ++generation; active = null
|
|
10
|
+
const result = await start()
|
|
11
|
+
if (disposed || generation !== id) throw new Error('登录请求已被新的二维码替代')
|
|
12
|
+
if (typeof result?.key !== 'string' || !result.key || result.key.length > 512) throw new Error('二维码登录响应缺少有效 key')
|
|
13
|
+
active = Object.freeze({ ...result, generation: id, expiresAt: now() + ttlMs })
|
|
14
|
+
return active
|
|
15
|
+
},
|
|
16
|
+
current,
|
|
17
|
+
async poll(key) {
|
|
18
|
+
const attempt = active
|
|
19
|
+
if (typeof key !== 'string' || !key || key.length > 512 || !attempt || key !== attempt.key || !current(attempt)) return expired()
|
|
20
|
+
const result = await poll(key)
|
|
21
|
+
if (!current(attempt)) return expired()
|
|
22
|
+
if (result?.state === 'ok') {
|
|
23
|
+
const credential = result.credential
|
|
24
|
+
if (!credential || typeof credential.sessdata !== 'string' || !credential.sessdata || typeof credential.bili_jct !== 'string' || !credential.bili_jct) return { state: 'unknown', message: '登录凭证不完整,未替换本机账号。' }
|
|
25
|
+
const file = save(credential)
|
|
26
|
+
active = null
|
|
27
|
+
return { state: 'ok', file }
|
|
28
|
+
}
|
|
29
|
+
if (result?.state === 'expired') active = null
|
|
30
|
+
return { state: result?.state || 'unknown', code: result?.code, message: result?.message || '' }
|
|
31
|
+
},
|
|
32
|
+
dispose() { disposed = true; generation++; active = null },
|
|
33
|
+
}
|
|
34
|
+
}
|
package/lib/login.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { spawn } from 'node:child_process'
|
|
|
18
18
|
import { existsSync, mkdirSync } from 'node:fs'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { atomicWriteFileSync } from './record-storage.mjs'
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
21
22
|
|
|
22
23
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
|
|
23
24
|
const GEN = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
|
|
@@ -43,7 +44,7 @@ function pickCookies(list) {
|
|
|
43
44
|
async function ensureBuvid3(jar) {
|
|
44
45
|
if (jar.buvid3) return jar
|
|
45
46
|
try {
|
|
46
|
-
const r = await fetch(SPI, { headers: { 'User-Agent': UA } })
|
|
47
|
+
const r = await fetch(SPI, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15_000) })
|
|
47
48
|
const j = await r.json()
|
|
48
49
|
const v = j?.data?.b_3
|
|
49
50
|
if (v) jar.buvid3 = v
|
|
@@ -52,7 +53,7 @@ async function ensureBuvid3(jar) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export async function loginStart() {
|
|
55
|
-
const r = await fetch(GEN, { headers: { 'User-Agent': UA } })
|
|
56
|
+
const r = await fetch(GEN, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(20_000) })
|
|
56
57
|
const j = await r.json()
|
|
57
58
|
const data = j?.data
|
|
58
59
|
if (!data?.qrcode_key || !data?.url) throw new Error(`生成二维码失败:${j?.message || '返回里没有 qrcode_key'}`)
|
|
@@ -61,7 +62,7 @@ export async function loginStart() {
|
|
|
61
62
|
|
|
62
63
|
/** 轮询一次。state: waiting / scanned / ok / expired / unknown */
|
|
63
64
|
export async function loginPoll(key) {
|
|
64
|
-
const res = await fetch(pollUrl(key), { headers: { 'User-Agent': UA }, redirect: 'manual' })
|
|
65
|
+
const res = await fetch(pollUrl(key), { headers: { 'User-Agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(20_000) })
|
|
65
66
|
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
66
67
|
let body = {}
|
|
67
68
|
try { body = await res.json() } catch { /* poll 有时返回空体 */ }
|
|
@@ -95,7 +96,7 @@ export function saveCredential(stateDir, credential) {
|
|
|
95
96
|
* `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。 */
|
|
96
97
|
export function renderQrPng(pythonPath, stateDir, url, { timeoutMs = 20_000 } = {}) {
|
|
97
98
|
return new Promise((resolve) => {
|
|
98
|
-
const png = join(stateDir, `qrcode_login.${process.pid}.png`)
|
|
99
|
+
const png = join(stateDir, `qrcode_login.${process.pid}.${randomUUID()}.png`)
|
|
99
100
|
const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
|
|
100
101
|
let child
|
|
101
102
|
let settled = false
|
package/lib/mcp.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
13
|
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { parseJsonExactIntegers } from './ids.mjs'
|
|
14
15
|
|
|
15
16
|
// 报给对端的身份:版本号**从 package.json 读**,别手写(手写的那份永远追不上发版)
|
|
16
17
|
const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
|
|
@@ -96,7 +97,7 @@ export class McpClient {
|
|
|
96
97
|
const result = await this.#request('tools/call', { name, arguments: args }, timeoutMs)
|
|
97
98
|
const text = (result?.content ?? []).map((c) => c?.text ?? '').join('\n').trim()
|
|
98
99
|
if (result?.isError) throw new Error(text || `MCP 工具 ${name} 报错`)
|
|
99
|
-
try { return text ?
|
|
100
|
+
try { return text ? parseJsonExactIntegers(text) : {} } catch { return text }
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
#onData(chunk) {
|
|
@@ -107,7 +108,7 @@ export class McpClient {
|
|
|
107
108
|
this.buf = this.buf.slice(idx + 1)
|
|
108
109
|
if (!line) continue
|
|
109
110
|
let msg
|
|
110
|
-
try { msg =
|
|
111
|
+
try { msg = parseJsonExactIntegers(line) } catch { continue } // Ignore non-JSON log lines; protocol request IDs remain safe integers.
|
|
111
112
|
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
112
113
|
const p = this.pending.get(msg.id)
|
|
113
114
|
this.pending.delete(msg.id)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { asBook, learnList } from './learn.mjs'
|
|
3
|
+
import { SOUL_SECTIONS, soulSectionBody } from './persona.mjs'
|
|
4
|
+
import { redactRecordText } from './record-privacy.mjs'
|
|
5
|
+
|
|
6
|
+
export const MEMORY_SOURCES = ['all', 'learning', 'soul', 'voice', 'taste', 'seen', 'history']
|
|
7
|
+
export const memoryFingerprint = (value) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex')
|
|
8
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
9
|
+
const safe = (value, maxLength = 2000) => redactRecordText(String(value ?? ''), { maxLength })
|
|
10
|
+
const contentLines = (text) => {
|
|
11
|
+
const raw = String(text || '')
|
|
12
|
+
const visible = raw.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\r\n]/g, ' ')).split(/\r?\n/)
|
|
13
|
+
return raw.split(/\r?\n/).map((line, index) => ({ line, text: visible[index] || '', index }))
|
|
14
|
+
.filter((row) => row.text.trim() && !/^\s*#/.test(row.text))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A bounded read-only projection. It never creates memories or changes their authority. */
|
|
18
|
+
export function memoryCenterSnapshot(input = {}, options = {}) {
|
|
19
|
+
const source = MEMORY_SOURCES.includes(options.source) ? options.source : 'all'
|
|
20
|
+
const filter = String(options.filter || '').trim().slice(0, 160).toLocaleLowerCase()
|
|
21
|
+
const offset = Math.max(0, Math.min(10000, Math.floor(Number(options.offset) || 0)))
|
|
22
|
+
const limit = Math.max(1, Math.min(50, Math.floor(Number(options.limit) || 20)))
|
|
23
|
+
const rows = []
|
|
24
|
+
const add = (row) => {
|
|
25
|
+
const text = safe(row.text)
|
|
26
|
+
if (!text.trim()) return
|
|
27
|
+
rows.push({ ...row, text, from: safe(row.from, 300), label: safe(row.label, 80), at: String(row.at || ''), mutable: Boolean(row.mutable) })
|
|
28
|
+
}
|
|
29
|
+
const learning = []
|
|
30
|
+
for (let page = 0; page < 12; page++) {
|
|
31
|
+
const batch = learnList(asBook(input.learn), { state: 'all', limit: 100, offset: page * 100 })
|
|
32
|
+
learning.push(...batch.items)
|
|
33
|
+
if (!batch.more) break
|
|
34
|
+
}
|
|
35
|
+
for (const row of learning) add({ source: 'learning', id: String(row.id), label: row.label || row.kind || '学习',
|
|
36
|
+
state: row.state, text: row.text, from: row.from, at: row.decidedAt || row.at, example: safe(row.example, 500),
|
|
37
|
+
why: safe(row.why, 500), version: memoryFingerprint(object(asBook(input.learn).items)[row.id] || row), mutable: true,
|
|
38
|
+
actions: row.state === 'approved' ? ['revoke', 'forget'] : ['forget'] })
|
|
39
|
+
for (const section of SOUL_SECTIONS) {
|
|
40
|
+
const body = soulSectionBody(String(input.soulText || ''), section.key)
|
|
41
|
+
for (const row of contentLines(body)) add({ source: 'soul', id: `${section.key}:${row.index}`, section: section.key,
|
|
42
|
+
index: row.index, label: section.title, text: row.text, version: memoryFingerprint(row.line),
|
|
43
|
+
from: 'soul.md · 自己写下的心情', at: input.soulAt, mutable: true, actions: ['forget'] })
|
|
44
|
+
}
|
|
45
|
+
for (const row of contentLines(input.voiceText).slice(-500)) add({ source: 'voice', id: String(row.index), index: row.index,
|
|
46
|
+
label: '语气札记', text: row.text, from: 'voice.md · 自己整理的表达经验', at: input.voiceAt,
|
|
47
|
+
version: memoryFingerprint(row.line), mutable: true, actions: ['forget'] })
|
|
48
|
+
for (const [key, row] of Object.entries(object(input.taste)).slice(-300)) {
|
|
49
|
+
if (!/^\d{1,32}$/.test(key) || !row || typeof row !== 'object' || typeof row.text !== 'string') continue
|
|
50
|
+
add({ source: 'taste', id: key, label: '口味样本', text: row.text, from: row.bvid || '自己发出的评论',
|
|
51
|
+
at: row.at, likes: Number.isFinite(Number(row.likes)) ? Math.max(0, Math.floor(Number(row.likes))) : 0,
|
|
52
|
+
checked: Boolean(row.likesAt), version: memoryFingerprint(row), mutable: true, actions: ['forget'] })
|
|
53
|
+
}
|
|
54
|
+
for (const [key, value] of Object.entries(object(input.seen)).slice(-5000)) {
|
|
55
|
+
if (!/^BV[0-9A-Za-z]{10}$/.test(key)) continue
|
|
56
|
+
const row = typeof value === 'string' ? { note: value } : object(value)
|
|
57
|
+
add({ source: 'seen', id: key, label: '浏览历史', text: [key, row.title, row.note].filter(Boolean).join(' · '),
|
|
58
|
+
from: key, at: row.at, mutable: false, actions: [] })
|
|
59
|
+
}
|
|
60
|
+
for (const event of (Array.isArray(input.events) ? input.events : []).slice(0, 100)) {
|
|
61
|
+
if (!event || !['memory', 'learning'].includes(event.type)) continue
|
|
62
|
+
const data = object(event.data)
|
|
63
|
+
add({ source: 'history', id: String(event.eventId), label: event.type === 'memory' ? '记忆版本' : '审核审计',
|
|
64
|
+
text: event.type === 'memory' ? `${data.name || '记忆'} · ${data.operation || 'snapshot'}\n${data.text || ''}` : JSON.stringify(data),
|
|
65
|
+
from: event.source, at: event.at || event.recordedAt, mutable: false, actions: [] })
|
|
66
|
+
}
|
|
67
|
+
rows.sort((a, b) => String(b.at).localeCompare(String(a.at)) || a.source.localeCompare(b.source) || a.id.localeCompare(b.id))
|
|
68
|
+
const counts = Object.fromEntries(MEMORY_SOURCES.slice(1).map((key) => [key, rows.filter((row) => row.source === key).length]))
|
|
69
|
+
const matched = rows.filter((row) => (source === 'all' || row.source === source)
|
|
70
|
+
&& (!filter || [row.text, row.from, row.label, row.example, row.why, row.state].join(' ').toLocaleLowerCase().includes(filter)))
|
|
71
|
+
return { ok: true, source, filter, offset, limit, total: rows.length, matched: matched.length,
|
|
72
|
+
more: matched.length > offset + limit, counts, items: matched.slice(offset, offset + limit),
|
|
73
|
+
scope: 'current-snapshots-and-latest-100-audits', historyReadOnly: true }
|
|
74
|
+
}
|
package/lib/mode.mjs
CHANGED
|
@@ -6,21 +6,21 @@ const selection = (value, source) => {
|
|
|
6
6
|
if (value === undefined) return null
|
|
7
7
|
if (value === null || value === '') return { id: '', known: true, source }
|
|
8
8
|
if (typeof value === 'string') return { id: value, known: true, source }
|
|
9
|
-
return
|
|
9
|
+
return { id: '', known: true, source, malformed: true }
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function resolveSessionPreset(ctx, agent) {
|
|
13
13
|
if (!agent) return { id: '', known: false, source: 'none' }
|
|
14
14
|
try {
|
|
15
15
|
const service = ctx?.get?.('agentPresets')
|
|
16
|
-
if (typeof service?.composedPreset === 'function') {
|
|
16
|
+
if (agent.ctx && typeof service?.composedPreset === 'function') {
|
|
17
17
|
const found = selection(service.composedPreset(agent.ctx), 'host')
|
|
18
18
|
if (found) return found
|
|
19
19
|
}
|
|
20
20
|
} catch { /* Older hosts may not expose this service. */ }
|
|
21
21
|
try {
|
|
22
22
|
const service = ctx?.get?.('sessionProjections')
|
|
23
|
-
if (typeof service?.stateOf === 'function') {
|
|
23
|
+
if (agent.session && typeof service?.stateOf === 'function') {
|
|
24
24
|
const found = selection(service.stateOf(agent.session, 'agentPreset'), 'projection')
|
|
25
25
|
if (found) return found
|
|
26
26
|
}
|
|
@@ -38,8 +38,6 @@ export function resolveSessionPreset(ctx, agent) {
|
|
|
38
38
|
try {
|
|
39
39
|
const fromHeader = selection(agent.session?.header?.agentPreset, 'header')
|
|
40
40
|
if (fromHeader) return fromHeader
|
|
41
|
-
const fromAgent = selection(agent.agentPreset, 'agent')
|
|
42
|
-
if (fromAgent) return fromAgent
|
|
43
41
|
} catch { /* Unknown context must not inherit a global mode. */ }
|
|
44
42
|
return { id: '', known: false, source: 'none' }
|
|
45
43
|
}
|
|
@@ -52,8 +50,9 @@ export function isFeiyuGoal(goal) {
|
|
|
52
50
|
export function personaScope(preset, goal) {
|
|
53
51
|
const selected = preset?.id === DAFEIYU_PRESET_ID
|
|
54
52
|
const running = isFeiyuGoal(goal)
|
|
55
|
-
//
|
|
56
|
-
|
|
53
|
+
// A goal is work, never permission to change a conversation's identity.
|
|
54
|
+
// Unknown, Standard and None all fail closed instead of adopting stale goals.
|
|
55
|
+
return { enabled: selected, selected, running }
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
/** Defaults describe motives, not mandatory words or a repeated catchphrase recipe. */
|