metame-cli 1.4.18 → 1.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -29
- package/index.js +1 -1
- package/package.json +2 -2
- package/scripts/daemon-agent-commands.js +20 -1
- package/scripts/daemon-claude-engine.js +6 -3
- package/scripts/daemon-command-router.js +18 -11
- package/scripts/daemon-session-store.js +176 -41
- package/scripts/daemon-user-acl.js +399 -0
- package/scripts/daemon.js +5 -0
package/README.md
CHANGED
|
@@ -14,25 +14,31 @@
|
|
|
14
14
|
<a href="./README.md">English</a> | <a href="./README中文版.md">中文</a>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
> **
|
|
17
|
+
> **Your digital twin. Lives on your Mac.**
|
|
18
18
|
|
|
19
|
-
MetaMe
|
|
19
|
+
MetaMe is an AI that lives on your machine — remembers how you think, stays online 24/7, and takes commands from your phone via Telegram or Feishu. Not in the cloud. In your computer.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
No cloud. Your machine, your data.
|
|
22
22
|
|
|
23
|
+
**macOS / Linux / Windows WSL — same command:**
|
|
24
|
+
```bash
|
|
25
|
+
curl -fsSL https://raw.githubusercontent.com/Yaron9/MetaMe/main/install.sh | bash
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Already have Node.js ≥ 18:**
|
|
23
29
|
```bash
|
|
24
30
|
npm install -g metame-cli && metame
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
---
|
|
28
34
|
|
|
29
|
-
> ### 🚀 v1.4.
|
|
35
|
+
> ### 🚀 v1.4.18 — Multi-User ACL + Session Context Preview
|
|
30
36
|
>
|
|
31
|
-
>
|
|
32
|
-
> - **
|
|
33
|
-
> - **
|
|
34
|
-
> - **
|
|
35
|
-
> - **Unix Socket IPC
|
|
37
|
+
> - **Multi-user permission system**: role-based ACL (admin / member / stranger) — share your bots with teammates without giving them full access. Manage users with `/user` commands.
|
|
38
|
+
> - **Session context preview**: `/resume` and `/sessions` now show the last message snippet so you know exactly what to pick up.
|
|
39
|
+
> - **Team Task protocol**: multi-agent task board for cross-agent collaboration. Agents can dispatch and track tasks across workspaces.
|
|
40
|
+
> - **Layered Memory Architecture**: three-layer memory (long-term facts, session summaries, session index) — all automatic.
|
|
41
|
+
> - **Unix Socket IPC**: dispatch latency <100ms.
|
|
36
42
|
>
|
|
37
43
|
> Zero configuration. It just works.
|
|
38
44
|
|
|
@@ -151,7 +157,21 @@ Chain skills into multi-step workflows — research → write → publish — fu
|
|
|
151
157
|
prompt: "Publish it"
|
|
152
158
|
```
|
|
153
159
|
|
|
154
|
-
Task options
|
|
160
|
+
**Task options:**
|
|
161
|
+
|
|
162
|
+
| Option | Description |
|
|
163
|
+
|--------|-------------|
|
|
164
|
+
| `at` | Fixed-time trigger, e.g. `"09:30"` (local time) |
|
|
165
|
+
| `days` | Day filter, e.g. `"weekdays"`, `[mon, wed, fri]` |
|
|
166
|
+
| `interval` | Interval trigger, e.g. `"4h"`, `"30m"` |
|
|
167
|
+
| `require_idle` | Skip if you're active; retry on next heartbeat tick |
|
|
168
|
+
| `precondition` | Shell guard — skip task if command returns non-zero (zero tokens consumed) |
|
|
169
|
+
| `notify` | Push result to phone when done |
|
|
170
|
+
| `model` | Override model, e.g. `"sonnet"`, `"haiku"` |
|
|
171
|
+
| `cwd` | Working directory for the task |
|
|
172
|
+
| `timeout` | Max run time |
|
|
173
|
+
|
|
174
|
+
> **Scheduled tasks require system registration.** Run `metame daemon install-launchd` and tasks fire on schedule even with the screen locked or the lid closed — as long as the Mac is on.
|
|
155
175
|
|
|
156
176
|
### 5. Skills That Evolve Themselves
|
|
157
177
|
|
|
@@ -173,6 +193,13 @@ Task fails → skill-scout finds a skill → installs → retries → succeeds
|
|
|
173
193
|
|
|
174
194
|
## Quick Start
|
|
175
195
|
|
|
196
|
+
**macOS / Linux / Windows WSL:**
|
|
197
|
+
```bash
|
|
198
|
+
curl -fsSL https://raw.githubusercontent.com/Yaron9/MetaMe/main/install.sh | bash
|
|
199
|
+
```
|
|
200
|
+
> Same command everywhere. The script detects your OS and uses Homebrew (macOS) or apt/dnf/pacman (Linux/WSL) to install Node.js automatically.
|
|
201
|
+
|
|
202
|
+
**Already have Node.js ≥ 18:**
|
|
176
203
|
```bash
|
|
177
204
|
npm install -g metame-cli && metame
|
|
178
205
|
```
|
|
@@ -184,7 +211,35 @@ npm install -g metame-cli && metame
|
|
|
184
211
|
| 1. Install & profile | `metame` | First run: cognitive interview → builds `~/.claude_profile.yaml` |
|
|
185
212
|
| 2. Connect phone | Follow the setup wizard | Bot token + app credentials → `~/.metame/daemon.yaml` |
|
|
186
213
|
| 3. Start daemon | `metame start` | Background daemon launches, bot goes online |
|
|
187
|
-
| 4.
|
|
214
|
+
| 4. Register with system | macOS: `metame daemon install-launchd` · WSL/Linux: see below | Always-on, crash recovery |
|
|
215
|
+
|
|
216
|
+
> **What does system registration mean?**
|
|
217
|
+
> Once registered, MetaMe runs in the background automatically — screen locked, lid closed, woken from sleep — as long as the machine is on. Scheduled tasks fire on time. No terminal window needed.
|
|
218
|
+
|
|
219
|
+
**WSL2 / Linux — register with systemd:**
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
cat > ~/.config/systemd/user/metame.service << 'EOF'
|
|
223
|
+
[Unit]
|
|
224
|
+
Description=MetaMe Daemon
|
|
225
|
+
After=network.target
|
|
226
|
+
|
|
227
|
+
[Service]
|
|
228
|
+
ExecStart=/usr/bin/env metame start
|
|
229
|
+
Restart=on-failure
|
|
230
|
+
RestartSec=5
|
|
231
|
+
|
|
232
|
+
[Install]
|
|
233
|
+
WantedBy=default.target
|
|
234
|
+
EOF
|
|
235
|
+
|
|
236
|
+
systemctl --user enable metame
|
|
237
|
+
systemctl --user start metame
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
> WSL2 requires systemd enabled first: add `[boot]\nsystemd=true` to `/etc/wsl.conf`, then restart WSL.
|
|
241
|
+
|
|
242
|
+
> **WSL limitation:** `/mac` commands (macOS AppleScript/JXA automation) are not available.
|
|
188
243
|
|
|
189
244
|
**Create your first Agent:**
|
|
190
245
|
|
|
@@ -209,15 +264,46 @@ npm install -g metame-cli && metame
|
|
|
209
264
|
| **Browser Automation** | Built-in Playwright MCP. Browser control out of the box for every user. |
|
|
210
265
|
| **Provider Relay** | Route through any Anthropic-compatible API. Use GPT-4, DeepSeek, Gemini — zero config file mutation. |
|
|
211
266
|
| **Metacognition** | Detects behavioral patterns (decision style, comfort zones, goal drift) and injects mirror observations. Zero extra API cost. |
|
|
267
|
+
| **Multi-User ACL** | Role-based permission system (admin / member / stranger). Share bots with teammates safely. Dynamic user management via `/user` commands with hot-reload config. |
|
|
268
|
+
| **Team Task** | Multi-agent task board for cross-agent collaboration. Agents can create, assign, and track tasks across workspaces. N-agent session scoping for parallel team workflows. |
|
|
212
269
|
| **Emergency Tools** | `/doctor` diagnostics, `/mac` macOS control helpers, `/sh` raw shell, `/fix` config restore, `/undo` git-based rollback. |
|
|
213
270
|
|
|
214
271
|
## Defining Your Agents
|
|
215
272
|
|
|
216
|
-
|
|
273
|
+
MetaMe's design philosophy: **one folder = one agent.**
|
|
274
|
+
|
|
275
|
+
Give an agent a directory, drop a `CLAUDE.md` inside describing its role, and you're done. The folder is the agent — it can be a code project, a blog repo, any workspace you already have.
|
|
276
|
+
|
|
277
|
+
### Option 1: Just say it (fastest)
|
|
278
|
+
|
|
279
|
+
No commands needed. Tell the bot what you want in plain language — MetaMe understands intent and acts:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
You: Create an agent for this group, directory ~/projects/assistant
|
|
283
|
+
Bot: ✅ Agent created and bound
|
|
284
|
+
Name: assistant
|
|
285
|
+
Dir: ~/projects/assistant
|
|
286
|
+
|
|
287
|
+
You: Change this agent's role to: a writing and content creation assistant
|
|
288
|
+
Bot: ✅ Role definition updated in CLAUDE.md
|
|
289
|
+
|
|
290
|
+
You: Bind an agent to ~/AGI/MyProject
|
|
291
|
+
Bot: ✅ Agent bound
|
|
292
|
+
Name: MyProject
|
|
293
|
+
Dir: ~/AGI/MyProject
|
|
294
|
+
|
|
295
|
+
You: List all agents
|
|
296
|
+
Bot: 📋 Agent list
|
|
297
|
+
🤖 assistant ◀ current
|
|
298
|
+
Dir: ~/projects/assistant
|
|
299
|
+
...
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Supported intents: create, bind, unbind, edit role, list — just say it naturally.
|
|
217
303
|
|
|
218
|
-
###
|
|
304
|
+
### Option 2: Wizard commands
|
|
219
305
|
|
|
220
|
-
|
|
306
|
+
Use `/agent` commands in any Telegram/Feishu group:
|
|
221
307
|
|
|
222
308
|
| Command | What it does |
|
|
223
309
|
|---------|-------------|
|
|
@@ -227,21 +313,7 @@ The easiest way. Open any Telegram/Feishu group and use the `/agent` wizard:
|
|
|
227
313
|
| `/agent edit` | Update the current agent's role description (rewrites its `CLAUDE.md` section). |
|
|
228
314
|
| `/agent reset` | Remove the current agent's role section. |
|
|
229
315
|
|
|
230
|
-
|
|
231
|
-
```
|
|
232
|
-
You: /agent new
|
|
233
|
-
Bot: Please select a working directory:
|
|
234
|
-
📁 ~/AGI 📁 ~/projects 📁 ~/Desktop
|
|
235
|
-
You: ~/AGI/MyProject/NewDir
|
|
236
|
-
Bot: ✅ 已新建目录:~/AGI/MyProject/NewDir
|
|
237
|
-
What should we name this agent?
|
|
238
|
-
You: 小美
|
|
239
|
-
Bot: Describe 小美's role and responsibilities:
|
|
240
|
-
You: Personal assistant. Manages my calendar, drafts messages, and tracks todos.
|
|
241
|
-
Bot: ✅ Agent「小美」created. CLAUDE.md updated with role definition.
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
You can tap a button to pick an existing directory, or type any path directly in chat. If the path doesn't exist, it's created automatically. All entry points (`/agent new` wizard and `/agent bind`) validate that the directory is real before saving.
|
|
316
|
+
You can tap a button to pick an existing directory, or type any path directly in chat. If the path doesn't exist, it's created automatically.
|
|
245
317
|
|
|
246
318
|
### From config file (for power users)
|
|
247
319
|
|
|
@@ -301,6 +373,15 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
|
|
|
301
373
|
| `/memory` | Memory stats: fact count, session tags, DB size |
|
|
302
374
|
| `/memory <keyword>` | Search long-term facts by keyword |
|
|
303
375
|
| `/doctor` | Interactive diagnostics |
|
|
376
|
+
| `/user add <open_id>` | Add a user (admin only) |
|
|
377
|
+
| `/user role <open_id> <admin\|member>` | Set user role |
|
|
378
|
+
| `/user list` | List all configured users |
|
|
379
|
+
| `/user remove <open_id>` | Remove a user |
|
|
380
|
+
| `/sessions` | Browse recent sessions with last message preview |
|
|
381
|
+
| `/teamtask create <agent> <goal>` | Create a cross-agent collaboration task |
|
|
382
|
+
| `/teamtask` | List recent TeamTasks (last 10) |
|
|
383
|
+
| `/teamtask <task_id>` | View task detail |
|
|
384
|
+
| `/teamtask resume <task_id>` | Resume a task |
|
|
304
385
|
|
|
305
386
|
## How It Works
|
|
306
387
|
|
package/index.js
CHANGED
|
@@ -30,7 +30,7 @@ if (!fs.existsSync(METAME_DIR)) {
|
|
|
30
30
|
// Auto-deploy bundled scripts to ~/.metame/
|
|
31
31
|
// IMPORTANT: daemon.yaml is USER CONFIG — never overwrite it. Only daemon-default.yaml (template) is synced.
|
|
32
32
|
const scriptsDir = path.join(__dirname, 'scripts');
|
|
33
|
-
const BUNDLED_BASE_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js', 'resolve-yaml.js', 'utils.js', 'skill-evolution.js', 'memory.js', 'memory-extract.js', 'memory-search.js', 'qmd-client.js', 'session-summarize.js', 'check-macos-control-capabilities.sh'];
|
|
33
|
+
const BUNDLED_BASE_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js', 'resolve-yaml.js', 'utils.js', 'skill-evolution.js', 'memory.js', 'memory-extract.js', 'memory-search.js', 'qmd-client.js', 'session-summarize.js', 'check-macos-control-capabilities.sh', 'usage-classifier.js', 'task-board.js'];
|
|
34
34
|
const DAEMON_MODULE_SCRIPTS = (() => {
|
|
35
35
|
try {
|
|
36
36
|
return fs.readdirSync(scriptsDir).filter((f) => /^daemon-[\w-]+\.js$/.test(f));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metame-cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.19",
|
|
4
4
|
"description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"test": "node --test scripts/*.test.js",
|
|
15
15
|
"start": "node index.js",
|
|
16
|
-
"sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/daemon-agent-commands.js scripts/daemon-session-commands.js scripts/daemon-admin-commands.js scripts/daemon-exec-commands.js scripts/daemon-ops-commands.js scripts/daemon-session-store.js scripts/daemon-checkpoints.js scripts/daemon-bridges.js scripts/daemon-file-browser.js scripts/daemon-runtime-lifecycle.js scripts/daemon-notify.js scripts/daemon-claude-engine.js scripts/daemon-command-router.js scripts/daemon-agent-tools.js scripts/daemon-task-scheduler.js scripts/daemon-task-envelope.js scripts/task-board.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js scripts/utils.js scripts/usage-classifier.js scripts/resolve-yaml.js scripts/memory.js scripts/memory-extract.js scripts/qmd-client.js scripts/session-summarize.js scripts/session-analytics.js scripts/skill-evolution.js scripts/check-macos-control-capabilities.sh plugin/scripts/ && echo '✅ Plugin scripts synced'",
|
|
16
|
+
"sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/daemon-agent-commands.js scripts/daemon-session-commands.js scripts/daemon-admin-commands.js scripts/daemon-exec-commands.js scripts/daemon-ops-commands.js scripts/daemon-session-store.js scripts/daemon-checkpoints.js scripts/daemon-bridges.js scripts/daemon-file-browser.js scripts/daemon-runtime-lifecycle.js scripts/daemon-notify.js scripts/daemon-claude-engine.js scripts/daemon-command-router.js scripts/daemon-user-acl.js scripts/daemon-agent-tools.js scripts/daemon-task-scheduler.js scripts/daemon-task-envelope.js scripts/task-board.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js scripts/utils.js scripts/usage-classifier.js scripts/resolve-yaml.js scripts/memory.js scripts/memory-extract.js scripts/qmd-client.js scripts/session-summarize.js scripts/session-analytics.js scripts/skill-evolution.js scripts/check-macos-control-capabilities.sh plugin/scripts/ && echo '✅ Plugin scripts synced'",
|
|
17
17
|
"restart:daemon": "node index.js stop 2>/dev/null; sleep 1; node index.js start 2>/dev/null || echo '⚠️ Daemon not running or restart failed'",
|
|
18
18
|
"precommit": "npm run sync:plugin && npm run restart:daemon"
|
|
19
19
|
},
|
|
@@ -18,6 +18,7 @@ function createAgentCommandHandler(deps) {
|
|
|
18
18
|
sessionLabel,
|
|
19
19
|
loadSessionTags,
|
|
20
20
|
sessionRichLabel,
|
|
21
|
+
getSessionRecentContext,
|
|
21
22
|
pendingBinds,
|
|
22
23
|
pendingAgentFlows,
|
|
23
24
|
doBindAgent,
|
|
@@ -249,7 +250,25 @@ function createAgentCommandHandler(deps) {
|
|
|
249
250
|
saveState(state2);
|
|
250
251
|
const name = fullMatch.customTitle;
|
|
251
252
|
const label = name || (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) || sessionId.slice(0, 8);
|
|
252
|
-
|
|
253
|
+
|
|
254
|
+
// 读取最近对话片段,帮助确认是否切换到正确的 session
|
|
255
|
+
const recentCtx = getSessionRecentContext ? getSessionRecentContext(sessionId) : null;
|
|
256
|
+
let msg = `✅ 已切换: **${label}**\n📁 ${path.basename(cwd)}`;
|
|
257
|
+
if (recentCtx) {
|
|
258
|
+
if (recentCtx.lastUser) {
|
|
259
|
+
const snippet = recentCtx.lastUser.replace(/\n/g, ' ').slice(0, 80);
|
|
260
|
+
msg += `\n\n💬 上次你说: _${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}_`;
|
|
261
|
+
}
|
|
262
|
+
if (recentCtx.lastAssistant) {
|
|
263
|
+
const snippet = recentCtx.lastAssistant.replace(/\n/g, ' ').slice(0, 80);
|
|
264
|
+
msg += `\n🤖 上次回复: ${snippet}${recentCtx.lastAssistant.length > 80 ? '…' : ''}`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (bot.sendMarkdown) {
|
|
268
|
+
await bot.sendMarkdown(chatId, msg);
|
|
269
|
+
} else {
|
|
270
|
+
await bot.sendMessage(chatId, msg.replace(/[_*`]/g, ''));
|
|
271
|
+
}
|
|
253
272
|
return true;
|
|
254
273
|
}
|
|
255
274
|
|
|
@@ -700,8 +700,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
700
700
|
|
|
701
701
|
// Inject daemon hints only on first message of a session
|
|
702
702
|
const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
|
|
703
|
-
1.
|
|
704
|
-
2.
|
|
703
|
+
1. Language: ALWAYS respond in Simplified Chinese (简体中文). NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.
|
|
704
|
+
2. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
705
|
+
3. File sending: User is on MOBILE. When they ask to see/download a file:
|
|
705
706
|
- Just FIND the file path (use Glob/ls if needed)
|
|
706
707
|
- Do NOT read or summarize the file content (wastes tokens)
|
|
707
708
|
- Add at END of response: [[FILE:/absolute/path/to/file]]
|
|
@@ -741,7 +742,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
|
|
|
741
742
|
} catch { /* non-critical */ }
|
|
742
743
|
}
|
|
743
744
|
|
|
744
|
-
|
|
745
|
+
// Always append a compact language guard to prevent accidental Korean/Japanese responses
|
|
746
|
+
const langGuard = '\n\n[Respond in Simplified Chinese (简体中文) only.]';
|
|
747
|
+
const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint + langGuard;
|
|
745
748
|
|
|
746
749
|
// Git checkpoint before Claude modifies files (for /undo)
|
|
747
750
|
// Pass the user prompt as label so checkpoint list is human-readable
|
|
@@ -604,19 +604,26 @@ function createCommandRouter(deps) {
|
|
|
604
604
|
}, 5000);
|
|
605
605
|
return;
|
|
606
606
|
}
|
|
607
|
+
// Strict mode: chats with a fixed agent in chat_agent_map must not cross-dispatch
|
|
608
|
+
const _strictChatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
|
|
609
|
+
const _isStrictChat = !!(_strictChatAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
|
|
610
|
+
|
|
607
611
|
// Nickname-only switch: bypass cooldown + budget (no Claude call)
|
|
608
|
-
|
|
609
|
-
if (
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
612
|
+
// Skipped for strict chats (fixed-agent groups)
|
|
613
|
+
if (!_isStrictChat) {
|
|
614
|
+
const quickAgent = routeAgent(text, config);
|
|
615
|
+
if (quickAgent && !quickAgent.rest) {
|
|
616
|
+
const { key, proj } = quickAgent;
|
|
617
|
+
const projCwd = normalizeCwd(proj.cwd);
|
|
618
|
+
attachOrCreateSession(chatId, projCwd, proj.name || key);
|
|
619
|
+
log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
|
|
620
|
+
await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
617
623
|
|
|
618
|
-
|
|
619
|
-
|
|
624
|
+
if (await tryHandleAgentIntent(bot, chatId, text, config)) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
620
627
|
}
|
|
621
628
|
|
|
622
629
|
const daemonCfg = (config && config.daemon) || {};
|
|
@@ -106,6 +106,59 @@ function createSessionStore(deps) {
|
|
|
106
106
|
|
|
107
107
|
function invalidateSessionCache() { _sessionCache = null; }
|
|
108
108
|
|
|
109
|
+
// 监听 ~/.claude/projects 目录,手机端新建 session 后桌面端无需重启即可感知
|
|
110
|
+
let _watcher = null;
|
|
111
|
+
let _invalidateDebounce = null;
|
|
112
|
+
|
|
113
|
+
function _debouncedInvalidate() {
|
|
114
|
+
if (_invalidateDebounce) return;
|
|
115
|
+
_invalidateDebounce = setTimeout(() => {
|
|
116
|
+
_sessionCache = null;
|
|
117
|
+
_invalidateDebounce = null;
|
|
118
|
+
}, 500);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function watchSessionFiles() {
|
|
122
|
+
// 先关闭旧 watcher,防止热重载时叠加
|
|
123
|
+
if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
|
|
124
|
+
if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return;
|
|
125
|
+
try {
|
|
126
|
+
_watcher = fs.watch(CLAUDE_PROJECTS_DIR, { recursive: true }, (evt, filename) => {
|
|
127
|
+
if (filename && filename.endsWith('.jsonl')) _debouncedInvalidate();
|
|
128
|
+
});
|
|
129
|
+
_watcher.on('error', (e) => {
|
|
130
|
+
log('WARN', '[session-store] fs.watch error: ' + e.message);
|
|
131
|
+
_watcher = null;
|
|
132
|
+
});
|
|
133
|
+
log('INFO', '[session-store] fs.watch active on ' + CLAUDE_PROJECTS_DIR);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log('WARN', '[session-store] fs.watch failed, fallback to TTL cache: ' + e.message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stopWatchingSessionFiles() {
|
|
140
|
+
if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// [M3] 共享辅助:从 reversed JSONL 行数组中提取最后一条外部用户消息(统一规则)
|
|
144
|
+
function extractLastUserFromLines(lines) {
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
if (!line) continue;
|
|
147
|
+
try {
|
|
148
|
+
const d = JSON.parse(line);
|
|
149
|
+
if (d.type === 'user' && d.message && d.userType !== 'internal') {
|
|
150
|
+
const content = d.message.content;
|
|
151
|
+
let raw = typeof content === 'string' ? content
|
|
152
|
+
: Array.isArray(content) ? (content.find(c => c.type === 'text') || {}).text || '' : '';
|
|
153
|
+
raw = raw.replace(/\[System hints[\s\S]*/i, '')
|
|
154
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
155
|
+
if (raw.length > 2) return raw.slice(0, 80);
|
|
156
|
+
}
|
|
157
|
+
} catch { /* skip */ }
|
|
158
|
+
}
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
|
|
109
162
|
function scanAllSessions() {
|
|
110
163
|
if (_sessionCache && (Date.now() - _sessionCacheTime < SESSION_CACHE_TTL)) return _sessionCache;
|
|
111
164
|
try {
|
|
@@ -162,49 +215,58 @@ function createSessionStore(deps) {
|
|
|
162
215
|
const ENRICH_LIMIT = 20;
|
|
163
216
|
for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
|
|
164
217
|
const s = all[i];
|
|
165
|
-
|
|
218
|
+
// [M1] 用 _enriched 标志替代三字段联合判断
|
|
219
|
+
// customTitle 是可选的,无命名 session 合法值为 undefined,不能作为 skip 条件
|
|
220
|
+
if (s._enriched) continue;
|
|
166
221
|
try {
|
|
167
222
|
const sessionFile = findSessionFile(s.sessionId);
|
|
168
223
|
if (!sessionFile) continue;
|
|
169
224
|
const fd = fs.openSync(sessionFile, 'r');
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
225
|
+
try {
|
|
226
|
+
if (!s.firstPrompt) {
|
|
227
|
+
const headBuf = Buffer.alloc(8192);
|
|
228
|
+
const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
|
|
229
|
+
const headStr = headBuf.toString('utf8', 0, headBytes);
|
|
230
|
+
for (const line of headStr.split('\n')) {
|
|
231
|
+
if (!line) continue;
|
|
232
|
+
try {
|
|
233
|
+
const d = JSON.parse(line);
|
|
234
|
+
if (d.type === 'user' && d.message && d.userType !== 'internal') {
|
|
235
|
+
const content = d.message.content;
|
|
236
|
+
let raw = '';
|
|
237
|
+
if (typeof content === 'string') raw = content;
|
|
238
|
+
else if (Array.isArray(content)) {
|
|
239
|
+
const txt = content.find(c => c.type === 'text');
|
|
240
|
+
if (txt) raw = txt.text;
|
|
241
|
+
}
|
|
242
|
+
raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
243
|
+
if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
|
|
185
244
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
} catch { /* skip line */ }
|
|
245
|
+
} catch { /* skip line */ }
|
|
246
|
+
}
|
|
190
247
|
}
|
|
191
|
-
|
|
192
|
-
if (!s.customTitle) {
|
|
248
|
+
// 从尾部读取:customTitle + lastUser(256KB 覆盖 tool-heavy session)
|
|
193
249
|
const stat = fs.fstatSync(fd);
|
|
194
|
-
const tailSize = Math.min(
|
|
250
|
+
const tailSize = Math.min(262144, stat.size);
|
|
195
251
|
const tailBuf = Buffer.alloc(tailSize);
|
|
196
252
|
fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
253
|
+
const tailLines = tailBuf.toString('utf8').split('\n').reverse();
|
|
254
|
+
if (!s.customTitle) {
|
|
255
|
+
for (const line of tailLines) {
|
|
256
|
+
if (!line) continue;
|
|
257
|
+
try {
|
|
258
|
+
const d = JSON.parse(line);
|
|
259
|
+
if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
|
|
260
|
+
} catch { /* skip */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!s.lastUser) {
|
|
264
|
+
s.lastUser = extractLastUserFromLines(tailLines);
|
|
205
265
|
}
|
|
266
|
+
} finally {
|
|
267
|
+
fs.closeSync(fd);
|
|
206
268
|
}
|
|
207
|
-
|
|
269
|
+
s._enriched = true; // [M1] 标记已完成富化,下次跳过
|
|
208
270
|
} catch { /* non-fatal */ }
|
|
209
271
|
}
|
|
210
272
|
|
|
@@ -265,20 +327,34 @@ function createSessionStore(deps) {
|
|
|
265
327
|
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
|
|
266
328
|
.replace(/\s+/g, ' ')
|
|
267
329
|
.trim();
|
|
330
|
+
|
|
331
|
+
// 优先级:name > summary > tags > firstPrompt > sessionId 前缀
|
|
268
332
|
if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
|
|
333
|
+
|
|
334
|
+
if (s.summary) {
|
|
335
|
+
const t = sanitize(s.summary);
|
|
336
|
+
if (t.length > 2) return t.slice(0, maxLen);
|
|
337
|
+
}
|
|
338
|
+
|
|
269
339
|
const tagEntry = sessionTags && sessionTags[s.sessionId];
|
|
270
340
|
if (tagEntry && tagEntry.name) return sanitize(tagEntry.name).slice(0, maxLen);
|
|
341
|
+
|
|
271
342
|
if (s.firstPrompt) {
|
|
272
343
|
const clean = s.firstPrompt
|
|
273
344
|
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
274
|
-
.replace(
|
|
275
|
-
.replace(
|
|
276
|
-
|
|
345
|
+
.replace(/\[System hints[\s\S]*/i, '')
|
|
346
|
+
.replace(/^[\s\S]*?<\/[^>]+>\s*/s, '') // 剥离 XML 头部标签
|
|
347
|
+
.trim();
|
|
348
|
+
// 取第一行非空、有实际内容(非纯符号/空格)的行
|
|
349
|
+
const firstLine = clean.split('\n')
|
|
350
|
+
.map(l => l.trim())
|
|
351
|
+
.find(l => l.length > 4 && /\p{L}/u.test(l)) || '';
|
|
277
352
|
const sanitized = sanitize(firstLine);
|
|
278
353
|
if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
|
|
279
354
|
}
|
|
280
|
-
|
|
281
|
-
|
|
355
|
+
|
|
356
|
+
// 最终兜底:显示 session ID 前缀而非空白
|
|
357
|
+
return s.sessionId ? s.sessionId.slice(0, 8) : '';
|
|
282
358
|
}
|
|
283
359
|
|
|
284
360
|
function sessionRichLabel(s, index, sessionTags) {
|
|
@@ -291,11 +367,17 @@ function createSessionStore(deps) {
|
|
|
291
367
|
const shortId = s.sessionId.slice(0, 8);
|
|
292
368
|
const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
|
|
293
369
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
370
|
+
// [M2] 转义 markdown 特殊字符,防止用户历史消息破坏渲染
|
|
371
|
+
const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
|
|
372
|
+
// fallback to firstPrompt when lastUser not found in tail
|
|
373
|
+
const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
|
|
374
|
+
let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`; // [M4] title 已有 sessionId 兜底,不会为空
|
|
297
375
|
if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
|
|
298
376
|
line += `\n 📁${proj} · ${ago}`;
|
|
377
|
+
if (snippetRaw && snippetRaw.length > 2) {
|
|
378
|
+
const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
|
|
379
|
+
line += `\n 💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
|
|
380
|
+
}
|
|
299
381
|
line += `\n /resume ${shortId}`;
|
|
300
382
|
return line;
|
|
301
383
|
}
|
|
@@ -312,8 +394,15 @@ function createSessionStore(deps) {
|
|
|
312
394
|
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
313
395
|
const shortId = s.sessionId.slice(0, 6);
|
|
314
396
|
const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
|
|
315
|
-
|
|
397
|
+
// [M2] 转义 markdown 特殊字符;[M4] title 已有 sessionId 兜底
|
|
398
|
+
const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
|
|
399
|
+
const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
|
|
400
|
+
let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago}`;
|
|
316
401
|
if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
|
|
402
|
+
if (snippetRaw && snippetRaw.length > 2) {
|
|
403
|
+
const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
|
|
404
|
+
desc += `\n💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
|
|
405
|
+
}
|
|
317
406
|
elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
|
|
318
407
|
elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
|
|
319
408
|
});
|
|
@@ -393,6 +482,49 @@ function createSessionStore(deps) {
|
|
|
393
482
|
}
|
|
394
483
|
}
|
|
395
484
|
|
|
485
|
+
/**
|
|
486
|
+
* 读取 session 最近一条用户消息 + 最近一条 AI 回复
|
|
487
|
+
* 用于 /resume 后帮助确认切换到正确 session
|
|
488
|
+
*/
|
|
489
|
+
function getSessionRecentContext(sessionId) {
|
|
490
|
+
try {
|
|
491
|
+
const sessionFile = findSessionFile(sessionId);
|
|
492
|
+
if (!sessionFile) return null;
|
|
493
|
+
const stat = fs.statSync(sessionFile);
|
|
494
|
+
const tailSize = Math.min(262144, stat.size); // 256KB for better coverage of tool-heavy sessions
|
|
495
|
+
const buf = Buffer.alloc(tailSize);
|
|
496
|
+
const fd = fs.openSync(sessionFile, 'r');
|
|
497
|
+
try {
|
|
498
|
+
fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
|
|
499
|
+
} finally {
|
|
500
|
+
fs.closeSync(fd);
|
|
501
|
+
}
|
|
502
|
+
const lines = buf.toString('utf8').split('\n').reverse();
|
|
503
|
+
// [M3] 复用共享函数,统一截取逻辑
|
|
504
|
+
const lastUser = extractLastUserFromLines(lines);
|
|
505
|
+
let lastAssistant = '';
|
|
506
|
+
for (const line of lines) {
|
|
507
|
+
if (!line.trim()) continue;
|
|
508
|
+
try {
|
|
509
|
+
const d = JSON.parse(line);
|
|
510
|
+
if (!lastAssistant && d.type === 'assistant' && d.message) {
|
|
511
|
+
const content = d.message.content;
|
|
512
|
+
if (Array.isArray(content)) {
|
|
513
|
+
for (const c of content) {
|
|
514
|
+
if (c.type === 'text' && c.text && c.text.trim().length > 2) {
|
|
515
|
+
lastAssistant = c.text.trim().slice(0, 80);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (lastAssistant) break;
|
|
522
|
+
} catch { /* skip bad line */ }
|
|
523
|
+
}
|
|
524
|
+
return (lastUser || lastAssistant) ? { lastUser, lastAssistant } : null;
|
|
525
|
+
} catch { return null; }
|
|
526
|
+
}
|
|
527
|
+
|
|
396
528
|
function markSessionStarted(chatId) {
|
|
397
529
|
const state = loadState();
|
|
398
530
|
if (state.sessions[chatId]) {
|
|
@@ -405,6 +537,8 @@ function createSessionStore(deps) {
|
|
|
405
537
|
findSessionFile,
|
|
406
538
|
clearSessionFileCache,
|
|
407
539
|
truncateSessionToCheckpoint,
|
|
540
|
+
watchSessionFiles,
|
|
541
|
+
stopWatchingSessionFiles,
|
|
408
542
|
listRecentSessions,
|
|
409
543
|
loadSessionTags,
|
|
410
544
|
getSessionFileMtime,
|
|
@@ -417,6 +551,7 @@ function createSessionStore(deps) {
|
|
|
417
551
|
getSessionName,
|
|
418
552
|
writeSessionName,
|
|
419
553
|
markSessionStarted,
|
|
554
|
+
getSessionRecentContext,
|
|
420
555
|
};
|
|
421
556
|
}
|
|
422
557
|
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-user-acl.js — MetaMe 多用户权限管理模块
|
|
3
|
+
*
|
|
4
|
+
* 角色体系:
|
|
5
|
+
* admin — 全部权限(王总)
|
|
6
|
+
* member — 可配置白名单操作
|
|
7
|
+
* stranger — 仅基础问答,无系统操作
|
|
8
|
+
*
|
|
9
|
+
* 配置文件:~/.metame/users.yaml(热更新,独立于 daemon.yaml)
|
|
10
|
+
* 格式:
|
|
11
|
+
* users:
|
|
12
|
+
* ou_abc123: { role: admin, name: 王总 }
|
|
13
|
+
* ou_def456: { role: member, name: 老马, allowed_actions: [feedback, status, query] }
|
|
14
|
+
* default_role: stranger
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
const USERS_FILE = path.join(os.homedir(), '.metame', 'users.yaml');
|
|
24
|
+
|
|
25
|
+
// ─── YAML 轻量解析(无依赖) ─────────────────────────────────────────────────
|
|
26
|
+
// 只解析本文件需要的简单结构,不引入 js-yaml 依赖
|
|
27
|
+
function parseSimpleYaml(content) {
|
|
28
|
+
const result = {};
|
|
29
|
+
const lines = content.split('\n');
|
|
30
|
+
let currentSection = null;
|
|
31
|
+
let currentUserId = null;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
const line = lines[i];
|
|
35
|
+
const trimmed = line.trimEnd();
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
37
|
+
|
|
38
|
+
const indent = line.length - line.trimStart().length;
|
|
39
|
+
const stripped = trimmed.trim();
|
|
40
|
+
|
|
41
|
+
if (indent === 0) {
|
|
42
|
+
const m = stripped.match(/^(\w[\w_]*):\s*(.*)$/);
|
|
43
|
+
if (m) {
|
|
44
|
+
currentSection = m[1];
|
|
45
|
+
if (m[2]) result[currentSection] = m[2];
|
|
46
|
+
else result[currentSection] = {};
|
|
47
|
+
currentUserId = null;
|
|
48
|
+
}
|
|
49
|
+
} else if (indent === 2 && currentSection === 'users') {
|
|
50
|
+
const m = stripped.match(/^([\w_-]+):\s*\{?(.*)\}?$/);
|
|
51
|
+
if (m) {
|
|
52
|
+
currentUserId = m[1];
|
|
53
|
+
result.users = result.users || {};
|
|
54
|
+
result.users[currentUserId] = parseInlineObj(m[2]);
|
|
55
|
+
}
|
|
56
|
+
} else if (indent === 4 && currentSection === 'users' && currentUserId) {
|
|
57
|
+
const m = stripped.match(/^([\w_]+):\s*(.+)$/);
|
|
58
|
+
if (m) {
|
|
59
|
+
result.users[currentUserId][m[1]] = parseYamlValue(m[2]);
|
|
60
|
+
}
|
|
61
|
+
} else if (indent === 2 && currentSection !== 'users') {
|
|
62
|
+
const m = stripped.match(/^([\w_]+):\s*(.+)$/);
|
|
63
|
+
if (m && typeof result[currentSection] === 'object') {
|
|
64
|
+
result[currentSection][m[1]] = parseYamlValue(m[2]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseInlineObj(str) {
|
|
72
|
+
const obj = {};
|
|
73
|
+
str = str.replace(/^\{|\}$/g, '').trim();
|
|
74
|
+
if (!str) return obj;
|
|
75
|
+
const parts = str.split(',').map(s => s.trim());
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
const m = part.match(/^([\w_]+):\s*(.+)$/);
|
|
78
|
+
if (m) obj[m[1]] = parseYamlValue(m[2]);
|
|
79
|
+
}
|
|
80
|
+
return obj;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseYamlValue(val) {
|
|
84
|
+
val = val.trim();
|
|
85
|
+
if (val === 'true') return true;
|
|
86
|
+
if (val === 'false') return false;
|
|
87
|
+
if (/^\d+$/.test(val)) return parseInt(val, 10);
|
|
88
|
+
// Array like [a, b, c]
|
|
89
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
90
|
+
return val.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
// Quoted string
|
|
93
|
+
return val.replace(/^['"]|['"]$/g, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── 序列化为 YAML ────────────────────────────────────────────────────────────
|
|
97
|
+
// name 字段转义:去除可能破坏行内 YAML 的特殊字符
|
|
98
|
+
function sanitizeYamlScalar(str) {
|
|
99
|
+
return String(str || '').replace(/[,:{}\[\]\r\n#]/g, '_').slice(0, 40);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function serializeUsers(data) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
// 始终写出 default_role,确保 reload 时语义明确
|
|
105
|
+
lines.push(`default_role: ${data.default_role || 'stranger'}`);
|
|
106
|
+
lines.push('users:');
|
|
107
|
+
for (const [uid, info] of Object.entries(data.users || {})) {
|
|
108
|
+
const actions = info.allowed_actions
|
|
109
|
+
? `, allowed_actions: [${info.allowed_actions.join(', ')}]`
|
|
110
|
+
: '';
|
|
111
|
+
const safeName = info.name ? `, name: ${sanitizeYamlScalar(info.name)}` : '';
|
|
112
|
+
lines.push(` ${uid}: { role: ${info.role}${safeName}${actions} }`);
|
|
113
|
+
}
|
|
114
|
+
return lines.join('\n') + '\n';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 飞书 open_id 格式校验(字母数字下划线,10-64 位)
|
|
118
|
+
function isValidOpenId(uid) {
|
|
119
|
+
return typeof uid === 'string' && /^[a-zA-Z0-9_-]{10,64}$/.test(uid);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── 加载用户配置 ─────────────────────────────────────────────────────────────
|
|
123
|
+
let _cachedUsers = null;
|
|
124
|
+
let _cachedMtime = 0;
|
|
125
|
+
|
|
126
|
+
function loadUsers() {
|
|
127
|
+
try {
|
|
128
|
+
const stat = fs.statSync(USERS_FILE);
|
|
129
|
+
if (stat.mtimeMs !== _cachedMtime) {
|
|
130
|
+
const content = fs.readFileSync(USERS_FILE, 'utf8');
|
|
131
|
+
_cachedUsers = parseSimpleYaml(content);
|
|
132
|
+
_cachedMtime = stat.mtimeMs;
|
|
133
|
+
}
|
|
134
|
+
return _cachedUsers || { users: {}, default_role: 'stranger' };
|
|
135
|
+
} catch {
|
|
136
|
+
return { users: {}, default_role: 'stranger' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function saveUsers(data) {
|
|
141
|
+
const dir = path.dirname(USERS_FILE);
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
143
|
+
// [M3] 原子写入:先写临时文件再 rename,防止崩溃时损坏 users.yaml
|
|
144
|
+
const tmp = USERS_FILE + '.tmp';
|
|
145
|
+
fs.writeFileSync(tmp, serializeUsers(data), 'utf8');
|
|
146
|
+
fs.renameSync(tmp, USERS_FILE);
|
|
147
|
+
_cachedMtime = 0; // 强制下次重新加载
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── 权限动作定义 ─────────────────────────────────────────────────────────────
|
|
151
|
+
// 每个 action 代表一类权限门控点
|
|
152
|
+
const ACTION_GROUPS = {
|
|
153
|
+
// member 可开放的安全操作
|
|
154
|
+
feedback: { desc: '提交反馈' },
|
|
155
|
+
status: { desc: '查询系统状态 (/status, /tasks)' },
|
|
156
|
+
query: { desc: '自然语言问答(只读)' },
|
|
157
|
+
file_read: { desc: '查看文件' },
|
|
158
|
+
|
|
159
|
+
// admin 专属(不可赋予 member)
|
|
160
|
+
system: { desc: '系统操作 (/sh, /mac, /fix)' },
|
|
161
|
+
agent: { desc: 'Agent 调度与管理' },
|
|
162
|
+
config: { desc: '配置修改 (/reload, /model)' },
|
|
163
|
+
admin_acl: { desc: '用户权限管理 (/user *)' },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const ROLE_DEFAULT_ACTIONS = {
|
|
167
|
+
admin: Object.keys(ACTION_GROUPS), // 全部权限
|
|
168
|
+
member: ['query'], // 默认只能问答
|
|
169
|
+
stranger: [], // 无系统权限,但允许基础问答由 askClaude readOnly 处理
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// 不可赋予 member 的 admin 专属 action
|
|
173
|
+
const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
|
|
174
|
+
|
|
175
|
+
// ─── 核心 API ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 根据 senderId 解析用户上下文
|
|
179
|
+
* @param {string|null} senderId 飞书 open_id
|
|
180
|
+
* @param {object} config daemon 配置(兼容旧 operator_ids)
|
|
181
|
+
* @returns {object} userCtx { senderId, role, name, allowedActions, can(action) }
|
|
182
|
+
*/
|
|
183
|
+
function resolveUserCtx(senderId, config) {
|
|
184
|
+
const userData = loadUsers();
|
|
185
|
+
|
|
186
|
+
let role, name, allowedActions;
|
|
187
|
+
|
|
188
|
+
if (!senderId) {
|
|
189
|
+
// 无 ID(Telegram 等)— 兼容旧逻辑,视为 admin
|
|
190
|
+
role = 'admin';
|
|
191
|
+
name = 'unknown';
|
|
192
|
+
allowedActions = ROLE_DEFAULT_ACTIONS.admin;
|
|
193
|
+
} else {
|
|
194
|
+
const userInfo = (userData.users || {})[senderId];
|
|
195
|
+
|
|
196
|
+
if (userInfo) {
|
|
197
|
+
role = userInfo.role || 'member';
|
|
198
|
+
name = userInfo.name || senderId.slice(-6);
|
|
199
|
+
if (role === 'admin') {
|
|
200
|
+
allowedActions = ROLE_DEFAULT_ACTIONS.admin;
|
|
201
|
+
} else if (role === 'member') {
|
|
202
|
+
// member 的 allowed_actions = 默认 member 权限 ∪ 配置的扩展权限(过滤 admin-only)
|
|
203
|
+
const extra = (userInfo.allowed_actions || []).filter(a => !ADMIN_ONLY_ACTIONS.has(a));
|
|
204
|
+
allowedActions = [...new Set([...ROLE_DEFAULT_ACTIONS.member, ...extra])];
|
|
205
|
+
} else {
|
|
206
|
+
allowedActions = [];
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// 兼容旧 operator_ids:若 senderId 在 operator_ids 中,视为 admin
|
|
210
|
+
const operatorIds = (config && config.feishu && config.feishu.operator_ids) || [];
|
|
211
|
+
if (operatorIds.includes(senderId)) {
|
|
212
|
+
role = 'admin';
|
|
213
|
+
name = senderId.slice(-6);
|
|
214
|
+
allowedActions = ROLE_DEFAULT_ACTIONS.admin;
|
|
215
|
+
} else {
|
|
216
|
+
role = userData.default_role || 'stranger';
|
|
217
|
+
name = senderId.slice(-6);
|
|
218
|
+
allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
senderId,
|
|
225
|
+
role,
|
|
226
|
+
name,
|
|
227
|
+
allowedActions,
|
|
228
|
+
isAdmin: role === 'admin',
|
|
229
|
+
isMember: role === 'member',
|
|
230
|
+
isStranger: role === 'stranger',
|
|
231
|
+
can(action) { return allowedActions.includes(action); },
|
|
232
|
+
readOnly: role !== 'admin',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 所有人(包括 stranger)均可使用的公开命令,集中维护
|
|
237
|
+
const PUBLIC_COMMANDS = ['/myid', '/chatid', '/user whoami'];
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 判断命令文本对应的 action 类型
|
|
241
|
+
*/
|
|
242
|
+
function classifyCommandAction(text) {
|
|
243
|
+
if (!text) return 'query';
|
|
244
|
+
const t = text.trim().toLowerCase();
|
|
245
|
+
if (t.startsWith('/sh ') || t.startsWith('/mac ') || t.startsWith('/fix') || t.startsWith('/reset') || t.startsWith('/doctor')) return 'system';
|
|
246
|
+
// /run 执行任务,副作用等级与 system 相同,不应归入 status
|
|
247
|
+
if (t.startsWith('/run ') || t.startsWith('/run\n')) return 'system';
|
|
248
|
+
if (t.startsWith('/agent ') || t.startsWith('/dispatch')) return 'agent';
|
|
249
|
+
if (t.startsWith('/model') || t.startsWith('/reload') || t.startsWith('/budget')) return 'config';
|
|
250
|
+
if (t.startsWith('/user ')) return 'admin_acl';
|
|
251
|
+
if (t.startsWith('/status') || t.startsWith('/tasks')) return 'status';
|
|
252
|
+
if (t.startsWith('/')) return 'system'; // 未知 slash 命令默认需要 system 权限
|
|
253
|
+
return 'query';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── /user 管理命令处理 ───────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 处理 /user 系列命令(仅 admin 可调用)
|
|
260
|
+
* 返回 { handled: boolean, reply: string }
|
|
261
|
+
*/
|
|
262
|
+
function handleUserCommand(text, userCtx) {
|
|
263
|
+
if (!text || !text.startsWith('/user')) return { handled: false };
|
|
264
|
+
|
|
265
|
+
const args = text.trim().split(/\s+/);
|
|
266
|
+
// args[0] = '/user', args[1] = subcommand
|
|
267
|
+
|
|
268
|
+
const sub = args[1];
|
|
269
|
+
|
|
270
|
+
if (!sub || sub === 'help') {
|
|
271
|
+
return {
|
|
272
|
+
handled: true,
|
|
273
|
+
reply: `**用户权限管理**\n\n` +
|
|
274
|
+
`/user list — 列出所有用户\n` +
|
|
275
|
+
`/user add <open_id> <role> [name] — 添加用户 (role: admin/member)\n` +
|
|
276
|
+
`/user role <open_id> <role> — 修改角色\n` +
|
|
277
|
+
`/user grant <open_id> <action> — 赋予 member 额外权限\n` +
|
|
278
|
+
`/user revoke <open_id> <action> — 撤销 member 权限\n` +
|
|
279
|
+
`/user remove <open_id> — 移除用户\n` +
|
|
280
|
+
`/user actions — 列出可用 action\n` +
|
|
281
|
+
`/user whoami — 查看当前身份`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (sub === 'whoami') {
|
|
286
|
+
return {
|
|
287
|
+
handled: true,
|
|
288
|
+
reply: `**你的身份**\n\nID: \`${userCtx.senderId || 'N/A'}\`\n角色: ${userCtx.role}\n名称: ${userCtx.name}\n权限: ${userCtx.allowedActions.join(', ') || '无'}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (sub === 'actions') {
|
|
293
|
+
const lines = Object.entries(ACTION_GROUPS).map(([k, v]) => `- \`${k}\` — ${v.desc}`);
|
|
294
|
+
return { handled: true, reply: `**可用 Actions**\n\n${lines.join('\n')}` };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (sub === 'list') {
|
|
298
|
+
const data = loadUsers();
|
|
299
|
+
const users = Object.entries(data.users || {});
|
|
300
|
+
if (!users.length) return { handled: true, reply: '暂无用户配置(仅依赖 operator_ids)' };
|
|
301
|
+
const lines = users.map(([uid, info]) => {
|
|
302
|
+
const actions = info.allowed_actions ? ` | ${info.allowed_actions.join(',')}` : '';
|
|
303
|
+
return `- \`${uid}\` [${info.role}] ${info.name || ''}${actions}`;
|
|
304
|
+
});
|
|
305
|
+
return { handled: true, reply: `**用户列表** (default: ${data.default_role || 'stranger'})\n\n${lines.join('\n')}` };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// [S1] 破坏性子命令内部守卫(防止外部绕过调用方校验)
|
|
309
|
+
const ADMIN_REQUIRED_SUBS = new Set(['add', 'role', 'grant', 'revoke', 'remove']);
|
|
310
|
+
if (ADMIN_REQUIRED_SUBS.has(sub) && !userCtx.isAdmin) {
|
|
311
|
+
return { handled: true, reply: '⚠️ 此操作需要 admin 权限,请联系管理员。' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (sub === 'add') {
|
|
315
|
+
// /user add <open_id> <role> [name...]
|
|
316
|
+
const [, , , uid, role, ...nameParts] = args;
|
|
317
|
+
if (!uid || !role) return { handled: true, reply: '用法: /user add <open_id> <role> [name]' };
|
|
318
|
+
// [S2] open_id 格式校验
|
|
319
|
+
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法(应为 10-64 位字母数字下划线)' };
|
|
320
|
+
if (!['admin', 'member', 'stranger'].includes(role)) {
|
|
321
|
+
return { handled: true, reply: '角色必须是 admin / member / stranger' };
|
|
322
|
+
}
|
|
323
|
+
const data = loadUsers();
|
|
324
|
+
data.users = data.users || {};
|
|
325
|
+
data.users[uid] = { role, name: nameParts.join(' ') || uid.slice(-6) };
|
|
326
|
+
saveUsers(data);
|
|
327
|
+
return { handled: true, reply: `✅ 已添加用户 \`${uid}\` → ${role}` };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (sub === 'role') {
|
|
331
|
+
const [, , , uid, role] = args;
|
|
332
|
+
if (!uid || !role) return { handled: true, reply: '用法: /user role <open_id> <role>' };
|
|
333
|
+
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
334
|
+
if (!['admin', 'member', 'stranger'].includes(role)) {
|
|
335
|
+
return { handled: true, reply: '角色必须是 admin / member / stranger' };
|
|
336
|
+
}
|
|
337
|
+
const data = loadUsers();
|
|
338
|
+
data.users = data.users || {};
|
|
339
|
+
if (!data.users[uid]) data.users[uid] = {};
|
|
340
|
+
data.users[uid].role = role;
|
|
341
|
+
saveUsers(data);
|
|
342
|
+
return { handled: true, reply: `✅ 用户 \`${uid}\` 角色已更新为 ${role}` };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (sub === 'grant') {
|
|
346
|
+
const [, , , uid, action] = args;
|
|
347
|
+
if (!uid || !action) return { handled: true, reply: '用法: /user grant <open_id> <action>' };
|
|
348
|
+
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
349
|
+
if (ADMIN_ONLY_ACTIONS.has(action)) {
|
|
350
|
+
return { handled: true, reply: `❌ \`${action}\` 是 admin 专属权限,不可赋予 member` };
|
|
351
|
+
}
|
|
352
|
+
if (!ACTION_GROUPS[action]) {
|
|
353
|
+
return { handled: true, reply: `❌ 未知 action: ${action},用 /user actions 查看可用列表` };
|
|
354
|
+
}
|
|
355
|
+
const data = loadUsers();
|
|
356
|
+
data.users = data.users || {};
|
|
357
|
+
if (!data.users[uid]) data.users[uid] = { role: 'member' };
|
|
358
|
+
const existing = data.users[uid].allowed_actions || [];
|
|
359
|
+
if (!existing.includes(action)) {
|
|
360
|
+
data.users[uid].allowed_actions = [...existing, action];
|
|
361
|
+
saveUsers(data);
|
|
362
|
+
}
|
|
363
|
+
return { handled: true, reply: `✅ 已授权 \`${uid}\` → ${action}` };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (sub === 'revoke') {
|
|
367
|
+
const [, , , uid, action] = args;
|
|
368
|
+
if (!uid || !action) return { handled: true, reply: '用法: /user revoke <open_id> <action>' };
|
|
369
|
+
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
370
|
+
const data = loadUsers();
|
|
371
|
+
const userInfo = (data.users || {})[uid];
|
|
372
|
+
if (!userInfo) return { handled: true, reply: `❌ 未找到用户 \`${uid}\`` };
|
|
373
|
+
userInfo.allowed_actions = (userInfo.allowed_actions || []).filter(a => a !== action);
|
|
374
|
+
saveUsers(data);
|
|
375
|
+
return { handled: true, reply: `✅ 已撤销 \`${uid}\` 的 ${action} 权限` };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (sub === 'remove') {
|
|
379
|
+
const [, , , uid] = args;
|
|
380
|
+
if (!uid) return { handled: true, reply: '用法: /user remove <open_id>' };
|
|
381
|
+
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
382
|
+
const data = loadUsers();
|
|
383
|
+
delete (data.users || {})[uid];
|
|
384
|
+
saveUsers(data);
|
|
385
|
+
return { handled: true, reply: `✅ 已移除用户 \`${uid}\`` };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { handled: true, reply: `未知子命令: ${sub},用 /user help 查看帮助` };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
resolveUserCtx,
|
|
393
|
+
classifyCommandAction,
|
|
394
|
+
handleUserCommand,
|
|
395
|
+
loadUsers,
|
|
396
|
+
saveUsers,
|
|
397
|
+
ACTION_GROUPS,
|
|
398
|
+
PUBLIC_COMMANDS,
|
|
399
|
+
};
|
package/scripts/daemon.js
CHANGED
|
@@ -1218,6 +1218,7 @@ const {
|
|
|
1218
1218
|
getSessionFileMtime,
|
|
1219
1219
|
sessionLabel,
|
|
1220
1220
|
sessionRichLabel,
|
|
1221
|
+
getSessionRecentContext,
|
|
1221
1222
|
buildSessionCardElements,
|
|
1222
1223
|
listProjectDirs,
|
|
1223
1224
|
getSession,
|
|
@@ -1225,6 +1226,7 @@ const {
|
|
|
1225
1226
|
getSessionName,
|
|
1226
1227
|
writeSessionName,
|
|
1227
1228
|
markSessionStarted,
|
|
1229
|
+
watchSessionFiles,
|
|
1228
1230
|
} = createSessionStore({
|
|
1229
1231
|
fs,
|
|
1230
1232
|
path,
|
|
@@ -1236,6 +1238,8 @@ const {
|
|
|
1236
1238
|
cpExtractTimestamp,
|
|
1237
1239
|
});
|
|
1238
1240
|
|
|
1241
|
+
watchSessionFiles(); // 热加载:手机端新建 session 后桌面无需重启
|
|
1242
|
+
|
|
1239
1243
|
// Active Claude processes per chat (for /stop)
|
|
1240
1244
|
const activeProcesses = new Map(); // chatId -> { child, aborted }
|
|
1241
1245
|
|
|
@@ -1485,6 +1489,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
|
|
|
1485
1489
|
sessionLabel,
|
|
1486
1490
|
loadSessionTags,
|
|
1487
1491
|
sessionRichLabel,
|
|
1492
|
+
getSessionRecentContext,
|
|
1488
1493
|
pendingBinds,
|
|
1489
1494
|
pendingAgentFlows,
|
|
1490
1495
|
doBindAgent,
|