metame-cli 1.4.10 → 1.4.12
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 +19 -30
- package/index.js +13 -15
- package/package.json +2 -2
- package/scripts/daemon.js +216 -16
- package/scripts/distill.js +19 -7
- package/scripts/memory-extract.js +5 -4
- package/scripts/memory.js +92 -6
- package/scripts/pending-traits.js +15 -2
- package/scripts/self-reflect.js +169 -0
- package/scripts/skill-evolution.js +56 -2
- package/scripts/utils.js +57 -0
package/README.md
CHANGED
|
@@ -172,40 +172,26 @@ Task fails → skill-scout finds a skill → installs → retries → succeeds
|
|
|
172
172
|
|
|
173
173
|
## Quick Start
|
|
174
174
|
|
|
175
|
-
### Install
|
|
176
|
-
|
|
177
175
|
```bash
|
|
178
|
-
|
|
179
|
-
curl -fsSL https://raw.githubusercontent.com/Yaron9/MetaMe/main/install.sh | bash
|
|
180
|
-
|
|
181
|
-
# Or if you already have Claude Code
|
|
182
|
-
npm install -g metame-cli
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
### First Run
|
|
186
|
-
|
|
187
|
-
```bash
|
|
188
|
-
metame
|
|
176
|
+
npm install -g metame-cli && metame
|
|
189
177
|
```
|
|
190
178
|
|
|
191
|
-
|
|
179
|
+
**3 minutes to full setup:**
|
|
192
180
|
|
|
193
|
-
|
|
181
|
+
| Step | Command | What happens |
|
|
182
|
+
|------|---------|-------------|
|
|
183
|
+
| 1. Install & profile | `metame` | First run: cognitive interview → builds `~/.claude_profile.yaml` |
|
|
184
|
+
| 2. Connect phone | Follow the setup wizard | Bot token + app credentials → `~/.metame/daemon.yaml` |
|
|
185
|
+
| 3. Start daemon | `metame start` | Background daemon launches, bot goes online |
|
|
186
|
+
| 4. Auto-start | `metame daemon install-launchd` | Survives reboot + crash recovery |
|
|
194
187
|
|
|
195
|
-
|
|
196
|
-
metame daemon init # Creates config with setup guide
|
|
197
|
-
metame start # Launches background daemon
|
|
198
|
-
```
|
|
188
|
+
**Create your first Agent:**
|
|
199
189
|
|
|
200
|
-
|
|
190
|
+
1. Create a group chat in Telegram/Feishu, add your bot
|
|
191
|
+
2. Send `/agent bind <name>` in the group (e.g. `/agent bind personal`)
|
|
192
|
+
3. Pick a working directory from the buttons, or type a path directly — non-existent directories are created automatically → done
|
|
201
193
|
|
|
202
|
-
|
|
203
|
-
metame daemon install-launchd # Auto-start on boot + crash recovery
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
Done. Open Telegram, message your bot.
|
|
207
|
-
|
|
208
|
-
> **First message?** New chats aren't whitelisted yet. The bot will reply with a one-step setup command — just send `/agent bind personal ~/` and you're in.
|
|
194
|
+
> Want more Agents? Repeat: new group → add bot → `/agent bind <name>`. Each group = independent AI workspace.
|
|
209
195
|
|
|
210
196
|
---
|
|
211
197
|
|
|
@@ -234,7 +220,7 @@ The easiest way. Open any Telegram/Feishu group and use the `/agent` wizard:
|
|
|
234
220
|
|
|
235
221
|
| Command | What it does |
|
|
236
222
|
|---------|-------------|
|
|
237
|
-
| `/agent new` | Step-by-step wizard: pick a directory → name the agent → describe its role. MetaMe writes the role into `CLAUDE.md` automatically. |
|
|
223
|
+
| `/agent new` | Step-by-step wizard: pick a directory → name the agent → describe its role. MetaMe writes the role into `CLAUDE.md` automatically. You can also type a path directly in chat — if it doesn't exist, MetaMe creates it for you. |
|
|
238
224
|
| `/agent bind <name> [dir]` | Quick bind: register this group as a named agent, optionally set working directory. |
|
|
239
225
|
| `/agent list` | Show all configured agents. |
|
|
240
226
|
| `/agent edit` | Update the current agent's role description (rewrites its `CLAUDE.md` section). |
|
|
@@ -245,14 +231,17 @@ Example flow:
|
|
|
245
231
|
You: /agent new
|
|
246
232
|
Bot: Please select a working directory:
|
|
247
233
|
📁 ~/AGI 📁 ~/projects 📁 ~/Desktop
|
|
248
|
-
You: ~/AGI/MyProject
|
|
249
|
-
Bot:
|
|
234
|
+
You: ~/AGI/MyProject/NewDir
|
|
235
|
+
Bot: ✅ 已新建目录:~/AGI/MyProject/NewDir
|
|
236
|
+
What should we name this agent?
|
|
250
237
|
You: 小美
|
|
251
238
|
Bot: Describe 小美's role and responsibilities:
|
|
252
239
|
You: Personal assistant. Manages my calendar, drafts messages, and tracks todos.
|
|
253
240
|
Bot: ✅ Agent「小美」created. CLAUDE.md updated with role definition.
|
|
254
241
|
```
|
|
255
242
|
|
|
243
|
+
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.
|
|
244
|
+
|
|
256
245
|
### From config file (for power users)
|
|
257
246
|
|
|
258
247
|
```yaml
|
package/index.js
CHANGED
|
@@ -390,10 +390,14 @@ You are entering **Calibration Mode**. You are not a chatbot; you are a Psycholo
|
|
|
390
390
|
- Announce: "Link Established. Profile calibrated."
|
|
391
391
|
- Then proceed to **Phase 2** below.
|
|
392
392
|
|
|
393
|
-
**3. SETUP WIZARD (Phase 2 —
|
|
393
|
+
**3. SETUP WIZARD (Phase 2 — Mobile Access):**
|
|
394
394
|
|
|
395
395
|
After writing the profile, ask: *"Want to set up mobile access so you can reach me from your phone? (Telegram / Feishu / Skip)"*
|
|
396
396
|
|
|
397
|
+
**Step A: Create Bot & Connect Private Chat (必做)**
|
|
398
|
+
|
|
399
|
+
This step connects the bot to the user's PRIVATE chat — this is the admin channel.
|
|
400
|
+
|
|
397
401
|
- If **Telegram:**
|
|
398
402
|
1. Tell user to open Telegram, search @BotFather, send /newbot, create a bot, copy the token.
|
|
399
403
|
2. Ask user to paste the bot token.
|
|
@@ -408,7 +412,7 @@ After writing the profile, ask: *"Want to set up mobile access so you can reach
|
|
|
408
412
|
**⚠️ 重要:** 在「事件订阅」页面,必须开启「接收消息 im.message.receive_v1」事件。然后在该事件的配置中,勾选「获取群组中所有消息」(否则 bot 在群聊中只能收到 @它 的消息,无法接收普通群消息)。
|
|
409
413
|
2. Ask user to paste App ID and App Secret.
|
|
410
414
|
3. Write \`app_id\` and \`app_secret\` into \`~/.metame/daemon.yaml\` under \`feishu:\` section, set \`enabled: true\`.
|
|
411
|
-
4. Tell user: "Now open Feishu and send any message to your new bot, then tell me you're done."
|
|
415
|
+
4. Tell user: "Now open Feishu and send any message to your new bot (private chat), then tell me you're done."
|
|
412
416
|
5. After user confirms, auto-fetch the chat ID:
|
|
413
417
|
\`\`\`bash
|
|
414
418
|
TOKEN=$(curl -s -X POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal -H "Content-Type: application/json" -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>"}' | jq -r '.tenant_access_token')
|
|
@@ -419,22 +423,16 @@ After writing the profile, ask: *"Want to set up mobile access so you can reach
|
|
|
419
423
|
|
|
420
424
|
- If **Skip:** Say "No problem. You can run \`metame daemon init\` anytime to set this up later." Then begin normal work.
|
|
421
425
|
|
|
422
|
-
**
|
|
423
|
-
|
|
424
|
-
Tell the user: *"You now have multiple AI agents that can collaborate. Here's how to send messages between them:"*
|
|
425
|
-
|
|
426
|
-
- **From mobile (Telegram/Feishu):** Each chat group is bound to a specific agent (project). To send a message to another agent, use:
|
|
427
|
-
\`/dispatch <project_key> <message>\`
|
|
428
|
-
Example: \`/dispatch desktop 帮我检查一下桌面端的日志\`
|
|
429
|
-
|
|
430
|
-
- **From Claude Code terminal:** Use the dispatch command:
|
|
431
|
-
\`~/.metame/bin/dispatch_to <project_key> "message"\`
|
|
426
|
+
**Step B: Create Your First Agent (引导用户建立第一个 Agent)**
|
|
432
427
|
|
|
433
|
-
|
|
428
|
+
After bot is connected, explain the Agent concept and guide the user to create their first one:
|
|
434
429
|
|
|
435
|
-
|
|
430
|
+
Tell the user: *"Now let's create your first AI Agent. Each Agent is an independent AI workspace bound to a specific project folder and chat group."*
|
|
436
431
|
|
|
437
|
-
|
|
432
|
+
1. Tell user to create a new group chat in Telegram/Feishu, add the bot to the group, and name it (e.g. "Personal Assistant" or "My Project").
|
|
433
|
+
2. Tell user to send \`/agent bind <name>\` in that group (e.g. \`/agent bind personal\`). This will show a directory picker — user taps to select the working directory.
|
|
434
|
+
3. Once bound, that group becomes a dedicated Agent channel — messages there go to that Agent's Claude session.
|
|
435
|
+
4. Tell user: *"Want to create more Agents? Just repeat: create a group → add bot → send /agent bind <name>. Each group becomes an independent Agent."*
|
|
438
436
|
|
|
439
437
|
**4. EVOLUTION MECHANISM (Manual Sync):**
|
|
440
438
|
* **PHILOSOPHY:** You respect the User's flow. You do NOT interrupt.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metame-cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.12",
|
|
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": {
|
|
@@ -33,4 +33,4 @@
|
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=22.5"
|
|
35
35
|
}
|
|
36
|
-
}
|
|
36
|
+
}
|
package/scripts/daemon.js
CHANGED
|
@@ -28,6 +28,7 @@ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
|
|
|
28
28
|
const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
|
|
29
29
|
const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
|
|
30
30
|
const SOCK_PATH = path.join(METAME_DIR, 'daemon.sock');
|
|
31
|
+
const DISPATCH_SECRET_FILE = path.join(METAME_DIR, '.dispatch_secret');
|
|
31
32
|
|
|
32
33
|
// Resolve claude binary path (daemon may not inherit user's full PATH)
|
|
33
34
|
const CLAUDE_BIN = (() => {
|
|
@@ -78,7 +79,7 @@ function routeAgent(prompt, config) {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
const yaml = require('./resolve-yaml');
|
|
81
|
-
const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
|
|
82
|
+
const { parseInterval, formatRelativeTime, createPathMap, writeBrainFileSafe } = require('./utils');
|
|
82
83
|
if (!yaml) {
|
|
83
84
|
console.error('Cannot find js-yaml module. Ensure metame-cli is installed.');
|
|
84
85
|
process.exit(1);
|
|
@@ -149,6 +150,21 @@ function backupConfig() {
|
|
|
149
150
|
try { fs.copyFileSync(CONFIG_FILE, bak); } catch { }
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Atomically write cfg object to CONFIG_FILE.
|
|
155
|
+
* Writes to a .tmp file first, then fs.renameSync (POSIX atomic).
|
|
156
|
+
* Process crash leaves .tmp on disk, never a partially-written CONFIG_FILE.
|
|
157
|
+
*/
|
|
158
|
+
function writeConfigSafe(cfg) {
|
|
159
|
+
const tmp = CONFIG_FILE + '.tmp.' + process.pid;
|
|
160
|
+
try {
|
|
161
|
+
fs.writeFileSync(tmp, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
|
|
162
|
+
fs.renameSync(tmp, CONFIG_FILE);
|
|
163
|
+
} finally {
|
|
164
|
+
try { fs.unlinkSync(tmp); } catch { } // no-op if rename succeeded
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
function restoreConfig() {
|
|
153
169
|
const bak = CONFIG_FILE + '.bak';
|
|
154
170
|
if (!fs.existsSync(bak)) return false;
|
|
@@ -171,7 +187,7 @@ function restoreConfig() {
|
|
|
171
187
|
);
|
|
172
188
|
}
|
|
173
189
|
}
|
|
174
|
-
|
|
190
|
+
writeConfigSafe(bakCfg);
|
|
175
191
|
config = loadConfig();
|
|
176
192
|
return true;
|
|
177
193
|
} catch {
|
|
@@ -797,11 +813,59 @@ function spawnSessionSummaries() {
|
|
|
797
813
|
* Physiological heartbeat: zero-token awareness check.
|
|
798
814
|
* Runs every tick unconditionally.
|
|
799
815
|
*/
|
|
816
|
+
/**
|
|
817
|
+
* Load or generate the dispatch HMAC secret.
|
|
818
|
+
*/
|
|
819
|
+
function getDispatchSecret() {
|
|
820
|
+
try {
|
|
821
|
+
if (fs.existsSync(DISPATCH_SECRET_FILE)) {
|
|
822
|
+
return fs.readFileSync(DISPATCH_SECRET_FILE, 'utf8').trim();
|
|
823
|
+
}
|
|
824
|
+
} catch { /* fall through to generate */ }
|
|
825
|
+
// Auto-generate and persist
|
|
826
|
+
const secret = require('crypto').randomBytes(32).toString('hex');
|
|
827
|
+
try {
|
|
828
|
+
fs.writeFileSync(DISPATCH_SECRET_FILE, secret, { mode: 0o600 });
|
|
829
|
+
} catch (e) {
|
|
830
|
+
log('WARN', `[dispatch] Could not write secret file: ${e.message}`);
|
|
831
|
+
}
|
|
832
|
+
return secret;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Compute HMAC-SHA256 signature for a dispatch item.
|
|
837
|
+
*/
|
|
838
|
+
function signDispatch(target, prompt, ts) {
|
|
839
|
+
const secret = getDispatchSecret();
|
|
840
|
+
const payload = JSON.stringify({ target, prompt, ts });
|
|
841
|
+
return require('crypto').createHmac('sha256', secret).update(payload).digest('hex');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Verify dispatch signature. Returns true if valid or if sig is absent and
|
|
846
|
+
* legacy mode (no secret file exists yet). Logs + returns false on mismatch.
|
|
847
|
+
*/
|
|
848
|
+
function verifyDispatchSig(item) {
|
|
849
|
+
// If no secret file exists, accept unsigned messages (migration grace period)
|
|
850
|
+
if (!fs.existsSync(DISPATCH_SECRET_FILE)) return true;
|
|
851
|
+
if (!item.sig || !item.ts) {
|
|
852
|
+
log('WARN', `[dispatch] Unsigned message from "${item.from}" — dropped (sig/ts missing)`);
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
const expected = signDispatch(item.target, item.prompt, item.ts);
|
|
856
|
+
if (item.sig !== expected) {
|
|
857
|
+
log('WARN', `[dispatch] Signature mismatch from "${item.from}" → "${item.target}" — dropped`);
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
|
|
800
863
|
/**
|
|
801
864
|
* Handle a single dispatch message (from socket or pending.jsonl fallback).
|
|
802
865
|
*/
|
|
803
866
|
function handleDispatchItem(item, config) {
|
|
804
867
|
if (!item.target || !item.prompt) return;
|
|
868
|
+
if (!verifyDispatchSig(item)) return;
|
|
805
869
|
if (!(config && config.projects && config.projects[item.target])) {
|
|
806
870
|
log('WARN', `dispatch: unknown target "${item.target}"`);
|
|
807
871
|
return;
|
|
@@ -918,6 +982,31 @@ function physiologicalHeartbeat(config) {
|
|
|
918
982
|
log('WARN', `Pending dispatch drain failed: ${e.message}`);
|
|
919
983
|
}
|
|
920
984
|
|
|
985
|
+
// 2a. Check distill budget alerts — notify user once then clear
|
|
986
|
+
const DISTILL_ALERTS = path.join(METAME_DIR, 'distill_alerts.jsonl');
|
|
987
|
+
try {
|
|
988
|
+
if (fs.existsSync(DISTILL_ALERTS)) {
|
|
989
|
+
const raw = fs.readFileSync(DISTILL_ALERTS, 'utf8').trim();
|
|
990
|
+
if (raw) {
|
|
991
|
+
const alerts = raw.split('\n').filter(Boolean)
|
|
992
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
993
|
+
const budgetAlerts = alerts.filter(a => a.type === 'budget_exceeded');
|
|
994
|
+
if (budgetAlerts.length > 0) {
|
|
995
|
+
const latest = budgetAlerts[budgetAlerts.length - 1];
|
|
996
|
+
const msg = `⚠️ 认知系统告警:Profile 大小超出预算 (${latest.tokens} tokens > ${latest.budget}),本次更新已被丢弃。建议 /quiet 或手动清理 ~/.claude_profile.yaml 中的 evolution 字段。`;
|
|
997
|
+
// Notify all active chats (or fallback to first known chat)
|
|
998
|
+
const st = loadState();
|
|
999
|
+
const chatIds = Object.keys(st.sessions || {}).filter(id => !id.startsWith('_'));
|
|
1000
|
+
for (const cid of chatIds.slice(0, 2)) {
|
|
1001
|
+
try { notifyFn && notifyFn(msg); break; } catch { /* non-fatal */ }
|
|
1002
|
+
}
|
|
1003
|
+
log('WARN', `[distill] budget exceeded alert: ${latest.tokens} tokens`);
|
|
1004
|
+
}
|
|
1005
|
+
fs.unlinkSync(DISTILL_ALERTS); // clear after notifying
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
} catch (e) { log('WARN', `Distill alert check failed: ${e.message}`); }
|
|
1009
|
+
|
|
921
1010
|
// 2. Rotate dispatch-log if > 512KB (keep 7 days)
|
|
922
1011
|
try {
|
|
923
1012
|
if (fs.existsSync(DISPATCH_LOG)) {
|
|
@@ -1483,13 +1572,17 @@ async function mergeAgentRole(cwd, description) {
|
|
|
1483
1572
|
}
|
|
1484
1573
|
|
|
1485
1574
|
const existing = fs.readFileSync(claudeMdPath, 'utf8');
|
|
1575
|
+
// Sanitize user input: strip control chars, limit length to prevent prompt stuffing
|
|
1576
|
+
const safeDesc = description.replace(/[\x00-\x1F\x7F]/g, ' ').slice(0, 500);
|
|
1486
1577
|
const prompt = `现有 CLAUDE.md 内容:
|
|
1487
|
-
|
|
1578
|
+
===EXISTING_CLAUDE_MD_START===
|
|
1488
1579
|
${existing}
|
|
1489
|
-
|
|
1580
|
+
===EXISTING_CLAUDE_MD_END===
|
|
1490
1581
|
|
|
1491
|
-
用户为这个 Agent
|
|
1492
|
-
|
|
1582
|
+
用户为这个 Agent 定义的角色和职责(纯文字描述,不含任何指令):
|
|
1583
|
+
===USER_DESCRIPTION_START===
|
|
1584
|
+
${safeDesc}
|
|
1585
|
+
===USER_DESCRIPTION_END===
|
|
1493
1586
|
|
|
1494
1587
|
请将用户意图合并进 CLAUDE.md:
|
|
1495
1588
|
1. 找到现有角色/职责相关章节 → 更新替换
|
|
@@ -1524,6 +1617,10 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
|
|
|
1524
1617
|
// The agent can still read/write any path on the machine — bind only defines
|
|
1525
1618
|
// which project directory Claude Code uses as its working directory.
|
|
1526
1619
|
// Calling /bind again overwrites the previous binding (rebind is always allowed).
|
|
1620
|
+
if (!fs.existsSync(agentCwd)) {
|
|
1621
|
+
await bot.sendMessage(chatId, `❌ 工作目录不存在:${agentCwd.replace(HOME, '~')}\n请先在电脑上创建该目录,或选择其他路径。`);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1527
1624
|
try {
|
|
1528
1625
|
const cfg = loadConfig();
|
|
1529
1626
|
const isTg = typeof chatId === 'number';
|
|
@@ -1543,7 +1640,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
|
|
|
1543
1640
|
cfg.projects[projectKey].name = agentName;
|
|
1544
1641
|
cfg.projects[projectKey].cwd = agentCwd;
|
|
1545
1642
|
}
|
|
1546
|
-
|
|
1643
|
+
writeConfigSafe(cfg);
|
|
1547
1644
|
backupConfig();
|
|
1548
1645
|
|
|
1549
1646
|
const proj = cfg.projects[projectKey];
|
|
@@ -1958,9 +2055,30 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1958
2055
|
// /agent — 弹出 agent 切换选择器(无参数)
|
|
1959
2056
|
// ─────────────────────────────────────────────────────────────────────
|
|
1960
2057
|
|
|
1961
|
-
// 处理 /agent new 多步向导状态机中的文本输入(name/desc 步骤)
|
|
2058
|
+
// 处理 /agent new 多步向导状态机中的文本输入(dir/name/desc 步骤)
|
|
1962
2059
|
{
|
|
1963
2060
|
const flow = pendingAgentFlows.get(String(chatId));
|
|
2061
|
+
if (flow && flow.step === 'dir' && text && !text.startsWith('/')) {
|
|
2062
|
+
// 步骤1:用户直接输入了目录路径(对话式新建)
|
|
2063
|
+
const typedPath = expandPath(text.trim());
|
|
2064
|
+
let created = false;
|
|
2065
|
+
if (!fs.existsSync(typedPath)) {
|
|
2066
|
+
try {
|
|
2067
|
+
fs.mkdirSync(typedPath, { recursive: true });
|
|
2068
|
+
created = true;
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
await bot.sendMessage(chatId, `❌ 无法创建目录:${typedPath.replace(HOME, '~')}\n${e.message}`);
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
flow.dir = typedPath;
|
|
2075
|
+
flow.step = 'name';
|
|
2076
|
+
pendingAgentFlows.set(String(chatId), flow);
|
|
2077
|
+
const displayPath = typedPath.replace(HOME, '~');
|
|
2078
|
+
const label = created ? `✅ 已新建目录:${displayPath}` : `✓ 已选择目录:${displayPath}`;
|
|
2079
|
+
await bot.sendMessage(chatId, `${label}\n\n步骤2/3:给这个 Agent 起个名字?`);
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
1964
2082
|
if (flow && flow.step === 'name' && text && !text.startsWith('/')) {
|
|
1965
2083
|
// 步骤2: 用户回复了 Agent 名称
|
|
1966
2084
|
flow.name = text.trim();
|
|
@@ -2034,7 +2152,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2034
2152
|
await sendDirPicker(bot, chatId, 'bind', `为「${bindName}」选择工作目录:`);
|
|
2035
2153
|
return;
|
|
2036
2154
|
}
|
|
2037
|
-
|
|
2155
|
+
const resolvedBindCwd = expandPath(bindCwd);
|
|
2156
|
+
if (!fs.existsSync(resolvedBindCwd)) {
|
|
2157
|
+
await bot.sendMessage(chatId, `❌ 目录不存在:${resolvedBindCwd.replace(HOME, '~')}\n请检查路径或选择其他目录。`);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
await doBindAgent(bot, chatId, bindName, resolvedBindCwd);
|
|
2038
2161
|
return;
|
|
2039
2162
|
}
|
|
2040
2163
|
|
|
@@ -2167,11 +2290,23 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2167
2290
|
await bot.sendMessage(chatId, '❌ 没有待完成的 /agent new,请重新发送 /agent new');
|
|
2168
2291
|
return;
|
|
2169
2292
|
}
|
|
2293
|
+
let dirCreated = false;
|
|
2294
|
+
if (!fs.existsSync(dirPath)) {
|
|
2295
|
+
try {
|
|
2296
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
2297
|
+
dirCreated = true;
|
|
2298
|
+
} catch (e) {
|
|
2299
|
+
await bot.sendMessage(chatId, `❌ 无法创建目录:${dirPath.replace(HOME, '~')}\n${e.message}`);
|
|
2300
|
+
await sendBrowse(bot, chatId, 'agent-new', HOME, '步骤1/3:选择这个 Agent 的工作目录');
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2170
2304
|
flow.dir = dirPath;
|
|
2171
2305
|
flow.step = 'name';
|
|
2172
2306
|
pendingAgentFlows.set(String(chatId), flow);
|
|
2173
2307
|
const displayPath = dirPath.replace(HOME, '~');
|
|
2174
|
-
|
|
2308
|
+
const dirLabel = dirCreated ? `✅ 已新建目录:${displayPath}` : `✓ 已选择目录:${displayPath}`;
|
|
2309
|
+
await bot.sendMessage(chatId, `${dirLabel}\n\n步骤2/3:给这个 Agent 起个名字?`);
|
|
2175
2310
|
return;
|
|
2176
2311
|
}
|
|
2177
2312
|
|
|
@@ -2919,7 +3054,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2919
3054
|
const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
|
|
2920
3055
|
if (!doc.growth) doc.growth = {};
|
|
2921
3056
|
doc.growth.quiet_until = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString();
|
|
2922
|
-
|
|
3057
|
+
await writeBrainFileSafe(yaml.dump(doc, { lineWidth: -1 }), BRAIN_FILE);
|
|
2923
3058
|
await bot.sendMessage(chatId, 'Mirror & reflections silenced for 48h.');
|
|
2924
3059
|
} catch (e) { await bot.sendMessage(chatId, `Error: ${e.message}`); }
|
|
2925
3060
|
return;
|
|
@@ -2954,7 +3089,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2954
3089
|
const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
2955
3090
|
if (!cfg.daemon) cfg.daemon = {};
|
|
2956
3091
|
cfg.daemon.model = 'opus';
|
|
2957
|
-
|
|
3092
|
+
writeConfigSafe(cfg);
|
|
2958
3093
|
config = loadConfig();
|
|
2959
3094
|
await bot.sendMessage(chatId, '✅ 模型已重置为 opus');
|
|
2960
3095
|
} catch (e) {
|
|
@@ -3053,7 +3188,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
3053
3188
|
const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
3054
3189
|
if (!cfg.daemon) cfg.daemon = {};
|
|
3055
3190
|
cfg.daemon.model = modelName;
|
|
3056
|
-
|
|
3191
|
+
writeConfigSafe(cfg);
|
|
3057
3192
|
config = loadConfig();
|
|
3058
3193
|
await bot.sendMessage(chatId, `✅ 模型已切换: ${currentModel} → ${modelName}`);
|
|
3059
3194
|
} catch (e) {
|
|
@@ -4491,7 +4626,7 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
|
|
|
4491
4626
|
const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
4492
4627
|
if (!cfg.daemon) cfg.daemon = {};
|
|
4493
4628
|
cfg.daemon.model = 'opus';
|
|
4494
|
-
|
|
4629
|
+
writeConfigSafe(cfg);
|
|
4495
4630
|
config = loadConfig();
|
|
4496
4631
|
await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
|
|
4497
4632
|
} catch (fbErr) {
|
|
@@ -4587,7 +4722,7 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
|
|
|
4587
4722
|
const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
4588
4723
|
if (!cfg.daemon) cfg.daemon = {};
|
|
4589
4724
|
cfg.daemon.model = 'opus';
|
|
4590
|
-
|
|
4725
|
+
writeConfigSafe(cfg);
|
|
4591
4726
|
config = loadConfig();
|
|
4592
4727
|
await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
|
|
4593
4728
|
} catch (fallbackErr) {
|
|
@@ -4626,7 +4761,7 @@ async function startFeishuBridge(config, executeTaskByName) {
|
|
|
4626
4761
|
const isBindCmd = trimmedText && (trimmedText.startsWith('/bind') || trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
|
|
4627
4762
|
if (!allowedIds.includes(chatId) && !isBindCmd) {
|
|
4628
4763
|
log('WARN', `Feishu: rejected message from ${chatId}`);
|
|
4629
|
-
(bot.sendMarkdown ? bot.sendMarkdown(chatId, `⚠️
|
|
4764
|
+
(bot.sendMarkdown ? bot.sendMarkdown(chatId, `⚠️ 此会话未授权\n\n复制发送以下命令注册:\n\n/agent bind personal`) : bot.sendMessage(chatId, `⚠️ 此会话未授权\n\n复制发送以下命令注册:\n\n/agent bind personal`)).catch(() => {});
|
|
4630
4765
|
return;
|
|
4631
4766
|
}
|
|
4632
4767
|
|
|
@@ -4885,6 +5020,64 @@ async function main() {
|
|
|
4885
5020
|
// Start heartbeat scheduler
|
|
4886
5021
|
let heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
4887
5022
|
|
|
5023
|
+
// Adapter modules that support require.cache invalidation on file change
|
|
5024
|
+
const ADAPTER_MODULES = ['feishu-adapter', 'telegram-adapter', 'providers', 'skill-evolution'];
|
|
5025
|
+
// Track last known mtime for each adapter file (populated after bridge startup)
|
|
5026
|
+
const _adapterMtimes = new Map();
|
|
5027
|
+
function _readAdapterMtimes() {
|
|
5028
|
+
for (const mod of ADAPTER_MODULES) {
|
|
5029
|
+
const fullPath = path.join(__dirname, `${mod}.js`);
|
|
5030
|
+
try { _adapterMtimes.set(fullPath, fs.statSync(fullPath).mtimeMs); } catch { /* file may not exist */ }
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
5033
|
+
|
|
5034
|
+
// Reload adapters whose files changed on disk (stop→clear cache→restart bridges)
|
|
5035
|
+
async function reloadAdapters() {
|
|
5036
|
+
const changed = [];
|
|
5037
|
+
for (const mod of ADAPTER_MODULES) {
|
|
5038
|
+
const fullPath = path.join(__dirname, `${mod}.js`);
|
|
5039
|
+
try {
|
|
5040
|
+
const mtime = fs.statSync(fullPath).mtimeMs;
|
|
5041
|
+
if (_adapterMtimes.get(fullPath) !== mtime) {
|
|
5042
|
+
changed.push({ mod, fullPath });
|
|
5043
|
+
_adapterMtimes.set(fullPath, mtime);
|
|
5044
|
+
}
|
|
5045
|
+
} catch { /* ignore */ }
|
|
5046
|
+
}
|
|
5047
|
+
if (changed.length === 0) return { reloaded: [] };
|
|
5048
|
+
log('INFO', `[reloadAdapters] changed files: ${changed.map(c => c.mod).join(', ')}`);
|
|
5049
|
+
const bridgeAdapters = new Set(changed.map(c => c.mod).filter(m => m === 'feishu-adapter' || m === 'telegram-adapter'));
|
|
5050
|
+
// Stop bridges before clearing cache to avoid duplicate listeners
|
|
5051
|
+
if (bridgeAdapters.has('feishu-adapter') && feishuBridge) {
|
|
5052
|
+
try { feishuBridge.stop(); } catch { /* ignore */ }
|
|
5053
|
+
feishuBridge = null;
|
|
5054
|
+
_dispatchBridgeRef = null;
|
|
5055
|
+
}
|
|
5056
|
+
if (bridgeAdapters.has('telegram-adapter') && telegramBridge) {
|
|
5057
|
+
try { telegramBridge.stop(); } catch { /* ignore */ }
|
|
5058
|
+
telegramBridge = null;
|
|
5059
|
+
}
|
|
5060
|
+
// Clear require.cache for changed modules
|
|
5061
|
+
for (const { fullPath } of changed) {
|
|
5062
|
+
try { delete require.cache[require.resolve(fullPath)]; } catch { /* ignore */ }
|
|
5063
|
+
}
|
|
5064
|
+
// Restart bridges
|
|
5065
|
+
if (bridgeAdapters.has('telegram-adapter')) {
|
|
5066
|
+
telegramBridge = await startTelegramBridge(config, executeTaskByName).catch(e => {
|
|
5067
|
+
log('ERROR', `[reloadAdapters] Telegram restart failed: ${e.message}`);
|
|
5068
|
+
return null;
|
|
5069
|
+
});
|
|
5070
|
+
}
|
|
5071
|
+
if (bridgeAdapters.has('feishu-adapter')) {
|
|
5072
|
+
feishuBridge = await startFeishuBridge(config, executeTaskByName).catch(e => {
|
|
5073
|
+
log('ERROR', `[reloadAdapters] Feishu restart failed: ${e.message}`);
|
|
5074
|
+
return null;
|
|
5075
|
+
});
|
|
5076
|
+
if (feishuBridge) _dispatchBridgeRef = feishuBridge;
|
|
5077
|
+
}
|
|
5078
|
+
return { reloaded: changed.map(c => c.mod) };
|
|
5079
|
+
}
|
|
5080
|
+
|
|
4888
5081
|
// Hot reload: re-read config and restart heartbeat scheduler
|
|
4889
5082
|
function reloadConfig() {
|
|
4890
5083
|
const newConfig = loadConfig();
|
|
@@ -4895,6 +5088,12 @@ async function main() {
|
|
|
4895
5088
|
heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
4896
5089
|
const { general, project } = getAllTasks(config);
|
|
4897
5090
|
const totalCount = general.length + project.length;
|
|
5091
|
+
// Also check for adapter file changes (non-daemon.js modules)
|
|
5092
|
+
reloadAdapters().then(r => {
|
|
5093
|
+
if (r.reloaded.length > 0) {
|
|
5094
|
+
log('INFO', `[reloadAdapters] Reloaded: ${r.reloaded.join(', ')}`);
|
|
5095
|
+
}
|
|
5096
|
+
}).catch(e => log('WARN', `[reloadAdapters] Error: ${e.message}`));
|
|
4898
5097
|
log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
|
|
4899
5098
|
return { success: true, tasks: totalCount };
|
|
4900
5099
|
}
|
|
@@ -4956,6 +5155,7 @@ async function main() {
|
|
|
4956
5155
|
telegramBridge = await startTelegramBridge(config, executeTaskByName);
|
|
4957
5156
|
feishuBridge = await startFeishuBridge(config, executeTaskByName);
|
|
4958
5157
|
if (feishuBridge) _dispatchBridgeRef = feishuBridge; // store bridge, not bot, so .bot stays live after reconnects
|
|
5158
|
+
_readAdapterMtimes(); // Baseline mtimes for hot-reload detection
|
|
4959
5159
|
|
|
4960
5160
|
// Notify once on startup (single message, no duplicates)
|
|
4961
5161
|
await sleep(1500); // Let polling settle
|
package/scripts/distill.js
CHANGED
|
@@ -20,13 +20,16 @@ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
|
|
|
20
20
|
const LOCK_FILE = path.join(HOME, '.metame', 'distill.lock');
|
|
21
21
|
|
|
22
22
|
const { hasKey, isLocked, getTier, getWritableKeysForPrompt, estimateTokens, TOKEN_BUDGET } = require('./schema');
|
|
23
|
-
const { loadPending, savePending, upsertPending, getPromotable, removePromoted } = require('./pending-traits');
|
|
23
|
+
const { loadPending, savePending, upsertPending, getPromotable, removePromoted, expireStale } = require('./pending-traits');
|
|
24
|
+
const { writeBrainFileSafe } = require('./utils');
|
|
24
25
|
|
|
25
26
|
// Session analytics — local skeleton extraction (zero API cost)
|
|
26
27
|
let sessionAnalytics = null;
|
|
27
28
|
try {
|
|
28
29
|
sessionAnalytics = require('./session-analytics');
|
|
29
|
-
} catch {
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.log(`[distill] session-analytics unavailable: ${e.message} — behavior extraction disabled`);
|
|
32
|
+
}
|
|
30
33
|
|
|
31
34
|
// Provider env for distillation (cheap relay for background tasks)
|
|
32
35
|
let distillEnv = {};
|
|
@@ -115,7 +118,9 @@ async function distill() {
|
|
|
115
118
|
// For long sessions, extract pivot points
|
|
116
119
|
sessionSummary = sessionAnalytics.summarizeSession(skeleton, latest.path);
|
|
117
120
|
}
|
|
118
|
-
} catch {
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.log(`[distill] session context extraction failed: ${e.message}`);
|
|
123
|
+
}
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
// 4. Read current profile
|
|
@@ -169,9 +174,9 @@ async function distill() {
|
|
|
169
174
|
budgetForMessages = availableForContent;
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
// Format signals: tag metacognitive signals so Haiku treats them differently
|
|
177
|
+
// Format signals: tag metacognitive and correction signals so Haiku treats them differently
|
|
173
178
|
const formatSignal = (s, i) => {
|
|
174
|
-
const tag = s.type === 'metacognitive' ? ' [META]' : '';
|
|
179
|
+
const tag = s.type === 'metacognitive' ? ' [META]' : s.type === 'correction' ? ' [CORRECTION]' : '';
|
|
175
180
|
return `${i + 1}. "${s.text}"${tag}`;
|
|
176
181
|
};
|
|
177
182
|
|
|
@@ -215,6 +220,7 @@ BIAS PREVENTION:
|
|
|
215
220
|
- Single observation = STATE, not TRAIT. T3 cognition needs 3+ observations.
|
|
216
221
|
- L1 Surface → needs 5+, L2 Behavior → needs 3, L3 Self-declaration → direct write.
|
|
217
222
|
- Contradiction with existing value → do NOT output (needs accumulation).
|
|
223
|
+
- EXCEPTION: [CORRECTION] signals are explicit user corrections — they CAN and SHOULD overwrite contradicting existing values directly, with _confidence: high. The user is teaching us we were wrong.
|
|
218
224
|
|
|
219
225
|
BEHAVIORAL ANALYSIS — _behavior block (always output, use null if insufficient signal):
|
|
220
226
|
decision_pattern: premature_closure | exploratory | iterative | null
|
|
@@ -302,6 +308,7 @@ Do NOT repeat existing unchanged values.`;
|
|
|
302
308
|
|
|
303
309
|
// Strategic merge: tier-aware upsert with pending traits
|
|
304
310
|
const pendingTraits = loadPending();
|
|
311
|
+
expireStale(pendingTraits); // remove observations not seen for >30 days
|
|
305
312
|
const confidenceMap = updates._confidence || {};
|
|
306
313
|
const sourceMap = updates._source || {};
|
|
307
314
|
const merged = strategicMerge(profile, filtered, lockedKeys, pendingTraits, confidenceMap, sourceMap);
|
|
@@ -343,12 +350,17 @@ Do NOT repeat existing unchanged values.`;
|
|
|
343
350
|
tokens = estimateTokens(restored);
|
|
344
351
|
}
|
|
345
352
|
if (tokens > TOKEN_BUDGET) {
|
|
346
|
-
// Step 3: Reject write entirely
|
|
353
|
+
// Step 3: Reject write entirely — emit alert so daemon can notify user
|
|
354
|
+
const alertFile = path.join(HOME, '.metame', 'distill_alerts.jsonl');
|
|
355
|
+
try {
|
|
356
|
+
const alert = { ts: new Date().toISOString(), type: 'budget_exceeded', tokens, budget: TOKEN_BUDGET };
|
|
357
|
+
fs.appendFileSync(alertFile, JSON.stringify(alert) + '\n', 'utf8');
|
|
358
|
+
} catch { /* non-fatal */ }
|
|
347
359
|
cleanup();
|
|
348
360
|
return { updated: false, behavior, signalCount: signals.length, summary: `Profile too large (${tokens} tokens > ${TOKEN_BUDGET}). Write rejected to prevent bloat.` };
|
|
349
361
|
}
|
|
350
362
|
|
|
351
|
-
|
|
363
|
+
await writeBrainFileSafe(restored, BRAIN_FILE);
|
|
352
364
|
|
|
353
365
|
// Mark session as analyzed after successful distill
|
|
354
366
|
if (skeleton && sessionAnalytics) {
|
|
@@ -181,8 +181,8 @@ async function run() {
|
|
|
181
181
|
let sessionAnalytics;
|
|
182
182
|
try {
|
|
183
183
|
sessionAnalytics = require('./session-analytics');
|
|
184
|
-
} catch {
|
|
185
|
-
console.log(
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.log(`[memory-extract] session-analytics unavailable: ${e.message} — memory extraction disabled`);
|
|
186
186
|
return { sessionsProcessed: 0, factsSaved: 0, factsSkipped: 0 };
|
|
187
187
|
}
|
|
188
188
|
|
|
@@ -222,14 +222,15 @@ async function run() {
|
|
|
222
222
|
const { facts, session_name } = await extractFacts(skeleton, null, distillEnv);
|
|
223
223
|
|
|
224
224
|
if (facts.length > 0) {
|
|
225
|
-
const { saved, skipped } = memory.saveFacts(
|
|
225
|
+
const { saved, skipped, superseded } = memory.saveFacts(
|
|
226
226
|
skeleton.session_id,
|
|
227
227
|
skeleton.project || 'unknown',
|
|
228
228
|
facts
|
|
229
229
|
);
|
|
230
230
|
totalSaved += saved;
|
|
231
231
|
totalSkipped += skipped;
|
|
232
|
-
|
|
232
|
+
const superMsg = superseded > 0 ? `, ${superseded} superseded` : '';
|
|
233
|
+
console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped${superMsg}`);
|
|
233
234
|
} else {
|
|
234
235
|
console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)} (${session_name}): no facts extracted`);
|
|
235
236
|
}
|
package/scripts/memory.js
CHANGED
|
@@ -144,6 +144,15 @@ function getDb() {
|
|
|
144
144
|
try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity)'); } catch {}
|
|
145
145
|
try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project)'); } catch {}
|
|
146
146
|
|
|
147
|
+
// Search frequency tracking: counts how many times a fact appeared in search results.
|
|
148
|
+
// This is a RELEVANCE PROXY, not a usefulness score — "searched" ≠ "actually helpful".
|
|
149
|
+
// Renamed from recall_count (was ambiguous). Migration copies existing data forward.
|
|
150
|
+
try { _db.exec('ALTER TABLE facts ADD COLUMN recall_count INTEGER DEFAULT 0'); } catch {}
|
|
151
|
+
try { _db.exec('ALTER TABLE facts ADD COLUMN search_count INTEGER DEFAULT 0'); } catch {}
|
|
152
|
+
try { _db.exec('ALTER TABLE facts ADD COLUMN last_searched_at TEXT'); } catch {}
|
|
153
|
+
// One-time migration: copy recall_count → search_count for existing rows
|
|
154
|
+
try { _db.exec('UPDATE facts SET search_count = recall_count WHERE recall_count > 0 AND search_count = 0'); } catch {}
|
|
155
|
+
|
|
147
156
|
return _db;
|
|
148
157
|
}
|
|
149
158
|
|
|
@@ -177,16 +186,20 @@ function saveSession({ sessionId, project, summary, keywords = '', mood = '', to
|
|
|
177
186
|
return { ok: true, id: sessionId };
|
|
178
187
|
}
|
|
179
188
|
|
|
189
|
+
// Relations with "current state" semantics: new value replaces old.
|
|
190
|
+
// Historical relations (tech_decision, bug_lesson, arch_convention, project_milestone) keep all versions.
|
|
191
|
+
const STATEFUL_RELATIONS = new Set(['user_pref', 'config_fact', 'config_change', 'workflow_rule']);
|
|
192
|
+
|
|
180
193
|
/**
|
|
181
194
|
* Save atomic facts extracted from a session.
|
|
182
195
|
*
|
|
183
196
|
* @param {string} sessionId - Source session ID
|
|
184
197
|
* @param {string} project - Project key ('metame', 'desktop', '*' for global)
|
|
185
198
|
* @param {Array} facts - Array of { entity, relation, value, confidence, tags }
|
|
186
|
-
* @returns {{ saved: number, skipped: number }}
|
|
199
|
+
* @returns {{ saved: number, skipped: number, superseded: number }}
|
|
187
200
|
*/
|
|
188
201
|
function saveFacts(sessionId, project, facts) {
|
|
189
|
-
if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0 };
|
|
202
|
+
if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0, superseded: 0 };
|
|
190
203
|
const db = getDb();
|
|
191
204
|
|
|
192
205
|
// Load existing facts for dedup check
|
|
@@ -200,8 +213,14 @@ function saveFacts(sessionId, project, facts) {
|
|
|
200
213
|
ON CONFLICT(id) DO NOTHING
|
|
201
214
|
`);
|
|
202
215
|
|
|
216
|
+
const supersede = db.prepare(`
|
|
217
|
+
UPDATE facts SET superseded_by = ?, updated_at = datetime('now')
|
|
218
|
+
WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
|
|
219
|
+
`);
|
|
220
|
+
|
|
203
221
|
let saved = 0;
|
|
204
222
|
let skipped = 0;
|
|
223
|
+
let superseded = 0;
|
|
205
224
|
const savedFacts = [];
|
|
206
225
|
|
|
207
226
|
for (const f of facts) {
|
|
@@ -225,6 +244,24 @@ function saveFacts(sessionId, project, facts) {
|
|
|
225
244
|
savedFacts.push({ id, entity: f.entity, relation: f.relation, value: f.value,
|
|
226
245
|
project: project === '*' ? '*' : project, tags: f.tags || [], created_at: new Date().toISOString() });
|
|
227
246
|
saved++;
|
|
247
|
+
|
|
248
|
+
// For stateful relations, mark older active facts with same entity::relation as superseded
|
|
249
|
+
if (STATEFUL_RELATIONS.has(f.relation)) {
|
|
250
|
+
// Fetch the IDs being superseded before running the update (for audit log)
|
|
251
|
+
const db2 = getDb();
|
|
252
|
+
const toSupersede = db2.prepare(
|
|
253
|
+
'SELECT id, value FROM facts WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL'
|
|
254
|
+
).all(f.entity, f.relation, id);
|
|
255
|
+
|
|
256
|
+
const result = supersede.run(id, f.entity, f.relation, id);
|
|
257
|
+
const changes = result.changes || 0;
|
|
258
|
+
superseded += changes;
|
|
259
|
+
|
|
260
|
+
// Audit log: append to ~/.metame/memory_supersede_log.jsonl (never mutates, only appends)
|
|
261
|
+
if (changes > 0) {
|
|
262
|
+
_logSupersede(toSupersede, id, f.entity, f.relation, f.value, sessionId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
228
265
|
} catch { skipped++; }
|
|
229
266
|
}
|
|
230
267
|
|
|
@@ -235,7 +272,48 @@ function saveFacts(sessionId, project, facts) {
|
|
|
235
272
|
if (qmdClient) qmdClient.upsertFacts(savedFacts);
|
|
236
273
|
}
|
|
237
274
|
|
|
238
|
-
return { saved, skipped };
|
|
275
|
+
return { saved, skipped, superseded };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Increment search_count and last_searched_at for a list of fact IDs.
|
|
280
|
+
* Semantics: "this fact appeared in search results" — NOT "this fact was useful".
|
|
281
|
+
* High search_count = frequently retrieved. Low/zero = candidate for pruning.
|
|
282
|
+
* Non-fatal; called after each successful search.
|
|
283
|
+
*/
|
|
284
|
+
function _trackSearch(ids) {
|
|
285
|
+
if (!ids || ids.length === 0) return;
|
|
286
|
+
try {
|
|
287
|
+
const db = getDb();
|
|
288
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
289
|
+
db.prepare(
|
|
290
|
+
`UPDATE facts SET search_count = search_count + 1, last_searched_at = datetime('now')
|
|
291
|
+
WHERE id IN (${placeholders})`
|
|
292
|
+
).run(...ids);
|
|
293
|
+
} catch { /* non-fatal */ }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const SUPERSEDE_LOG = path.join(os.homedir(), '.metame', 'memory_supersede_log.jsonl');
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Append supersede operations to audit log (append-only, never mutated).
|
|
300
|
+
* Each line: { ts, new_id, new_value_prefix, entity, relation, superseded: [{id, value_prefix}], session_id }
|
|
301
|
+
* Use this to investigate accidental overwrites or replay if needed.
|
|
302
|
+
*/
|
|
303
|
+
function _logSupersede(oldFacts, newId, entity, relation, newValue, sessionId) {
|
|
304
|
+
if (!oldFacts || oldFacts.length === 0) return;
|
|
305
|
+
try {
|
|
306
|
+
const entry = {
|
|
307
|
+
ts: new Date().toISOString(),
|
|
308
|
+
entity,
|
|
309
|
+
relation,
|
|
310
|
+
new_id: newId,
|
|
311
|
+
new_value: newValue.slice(0, 80),
|
|
312
|
+
session_id: sessionId,
|
|
313
|
+
superseded: oldFacts.map(f => ({ id: f.id, value: f.value.slice(0, 80) })),
|
|
314
|
+
};
|
|
315
|
+
fs.appendFileSync(SUPERSEDE_LOG, JSON.stringify(entry) + '\n', 'utf8');
|
|
316
|
+
} catch { /* non-fatal */ }
|
|
239
317
|
}
|
|
240
318
|
|
|
241
319
|
/**
|
|
@@ -272,7 +350,10 @@ async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
|
|
|
272
350
|
const idOrder = new Map(ids.map((id, i) => [id, i]));
|
|
273
351
|
rows.sort((a, b) => (idOrder.get(a.id) ?? 999) - (idOrder.get(b.id) ?? 999));
|
|
274
352
|
|
|
275
|
-
if (rows.length > 0)
|
|
353
|
+
if (rows.length > 0) {
|
|
354
|
+
_trackSearch(rows.map(r => r.id));
|
|
355
|
+
return rows.slice(0, limit);
|
|
356
|
+
}
|
|
276
357
|
}
|
|
277
358
|
} catch { /* QMD failed, fall through to FTS5 */ }
|
|
278
359
|
}
|
|
@@ -317,7 +398,10 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
|
|
|
317
398
|
params = [sanitized, limit];
|
|
318
399
|
}
|
|
319
400
|
const ftsResults = db.prepare(sql).all(...params);
|
|
320
|
-
if (ftsResults.length > 0)
|
|
401
|
+
if (ftsResults.length > 0) {
|
|
402
|
+
_trackSearch(ftsResults.map(r => r.id));
|
|
403
|
+
return ftsResults;
|
|
404
|
+
}
|
|
321
405
|
} catch { /* FTS error, fall through */ }
|
|
322
406
|
|
|
323
407
|
// LIKE fallback
|
|
@@ -331,9 +415,11 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
|
|
|
331
415
|
FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
|
|
332
416
|
AND superseded_by IS NULL
|
|
333
417
|
ORDER BY created_at DESC LIMIT ?`;
|
|
334
|
-
|
|
418
|
+
const likeResults = project
|
|
335
419
|
? db.prepare(likeSql).all(like, like, like, project, limit)
|
|
336
420
|
: db.prepare(likeSql).all(like, like, like, limit);
|
|
421
|
+
if (likeResults.length > 0) _trackSearch(likeResults.map(r => r.id));
|
|
422
|
+
return likeResults;
|
|
337
423
|
}
|
|
338
424
|
|
|
339
425
|
/**
|
|
@@ -90,13 +90,26 @@ function upsertPending(pending, key, value, confidence, sourceQuote) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Get traits ready for promotion
|
|
93
|
+
* Get traits ready for promotion.
|
|
94
|
+
* high confidence → always promote immediately.
|
|
95
|
+
* normal confidence → time-weighted count must reach threshold.
|
|
96
|
+
* effective_count = count * max(0.3, 1 - age_days/60)
|
|
97
|
+
* So recent observations count fully; 30-day-old observations count 50%;
|
|
98
|
+
* 60-day-old observations count at 30% floor (not zero — prevents complete stall).
|
|
94
99
|
* Returns array of { key, value, source_quote }
|
|
95
100
|
*/
|
|
96
101
|
function getPromotable(pending) {
|
|
97
102
|
const ready = [];
|
|
103
|
+
const now = Date.now();
|
|
98
104
|
for (const [key, meta] of Object.entries(pending)) {
|
|
99
|
-
if (meta.
|
|
105
|
+
if (meta.confidence === 'high') {
|
|
106
|
+
ready.push({ key, value: meta.value, source_quote: meta.source_quote });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const ageDays = Math.floor((now - new Date(meta.last_seen).getTime()) / (1000 * 60 * 60 * 24));
|
|
110
|
+
const ageWeight = Math.max(0.3, 1 - ageDays / 60);
|
|
111
|
+
const effectiveCount = meta.count * ageWeight;
|
|
112
|
+
if (effectiveCount >= PROMOTION_THRESHOLD) {
|
|
100
113
|
ready.push({ key, value: meta.value, source_quote: meta.source_quote });
|
|
101
114
|
}
|
|
102
115
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* self-reflect.js — Weekly Self-Reflection Task
|
|
5
|
+
*
|
|
6
|
+
* Scans correction/metacognitive signals from the past 7 days,
|
|
7
|
+
* aggregates "where did the AI get it wrong", and writes a brief
|
|
8
|
+
* self-critique pattern into growth.patterns in ~/.claude_profile.yaml.
|
|
9
|
+
*
|
|
10
|
+
* Heartbeat: weekly, require_idle, non-blocking.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { callHaiku, buildDistillEnv } = require('./providers');
|
|
19
|
+
|
|
20
|
+
const HOME = os.homedir();
|
|
21
|
+
const SIGNAL_FILE = path.join(HOME, '.metame', 'raw_signals.jsonl');
|
|
22
|
+
const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
|
|
23
|
+
const LOCK_FILE = path.join(HOME, '.metame', 'self-reflect.lock');
|
|
24
|
+
const WINDOW_DAYS = 7;
|
|
25
|
+
|
|
26
|
+
async function run() {
|
|
27
|
+
// Atomic lock
|
|
28
|
+
let lockFd;
|
|
29
|
+
try {
|
|
30
|
+
lockFd = fs.openSync(LOCK_FILE, 'wx');
|
|
31
|
+
fs.writeSync(lockFd, process.pid.toString());
|
|
32
|
+
fs.closeSync(lockFd);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (e.code === 'EEXIST') {
|
|
35
|
+
const age = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
|
|
36
|
+
if (age < 300000) { console.log('[self-reflect] Already running.'); return; }
|
|
37
|
+
fs.unlinkSync(LOCK_FILE);
|
|
38
|
+
lockFd = fs.openSync(LOCK_FILE, 'wx');
|
|
39
|
+
fs.writeSync(lockFd, process.pid.toString());
|
|
40
|
+
fs.closeSync(lockFd);
|
|
41
|
+
} else throw e;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Read signals from last WINDOW_DAYS days
|
|
46
|
+
if (!fs.existsSync(SIGNAL_FILE)) {
|
|
47
|
+
console.log('[self-reflect] No signal file, skipping.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cutoff = Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
52
|
+
const lines = fs.readFileSync(SIGNAL_FILE, 'utf8').trim().split('\n').filter(Boolean);
|
|
53
|
+
const recentSignals = lines
|
|
54
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
55
|
+
.filter(s => s && s.ts && new Date(s.ts).getTime() > cutoff);
|
|
56
|
+
|
|
57
|
+
// Filter to correction + metacognitive signals only
|
|
58
|
+
const correctionSignals = recentSignals.filter(s =>
|
|
59
|
+
s.type === 'correction' || s.type === 'metacognitive'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (correctionSignals.length < 2) {
|
|
63
|
+
console.log(`[self-reflect] Only ${correctionSignals.length} correction signals this week, skipping.`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Read current profile for context
|
|
68
|
+
let currentPatterns = '';
|
|
69
|
+
try {
|
|
70
|
+
const yaml = require('js-yaml');
|
|
71
|
+
const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
|
|
72
|
+
const existing = (profile.growth && profile.growth.patterns) || [];
|
|
73
|
+
if (existing.length > 0) {
|
|
74
|
+
currentPatterns = `Current growth.patterns (avoid repeating):\n${existing.map(p => `- ${p}`).join('\n')}\n\n`;
|
|
75
|
+
}
|
|
76
|
+
} catch { /* non-fatal */ }
|
|
77
|
+
|
|
78
|
+
const signalText = correctionSignals
|
|
79
|
+
.slice(-20) // cap at 20 signals
|
|
80
|
+
.map((s, i) => `${i + 1}. [${s.type}] "${s.prompt}"`)
|
|
81
|
+
.join('\n');
|
|
82
|
+
|
|
83
|
+
const prompt = `你是一个AI自我审视引擎。分析以下用户纠正/元认知信号,找出AI(即你)**系统性**犯错的模式。
|
|
84
|
+
|
|
85
|
+
${currentPatterns}用户纠正信号(最近7天):
|
|
86
|
+
${signalText}
|
|
87
|
+
|
|
88
|
+
任务:找出1-2条AI的系统性问题(不是偶发错误),例如:
|
|
89
|
+
- "经常过度简化用户的技术问题,忽略背景细节"
|
|
90
|
+
- "倾向于在用户还没说完就开始行动,导致方向偏差"
|
|
91
|
+
- "在不确定时倾向于肯定用户,而非直接说不知道"
|
|
92
|
+
|
|
93
|
+
输出格式(JSON数组,最多2条,每条≤40字中文):
|
|
94
|
+
["模式1描述", "模式2描述"]
|
|
95
|
+
|
|
96
|
+
注意:
|
|
97
|
+
- 只输出有充分证据支持的系统性模式
|
|
98
|
+
- 如果证据不足,输出 []
|
|
99
|
+
- 只输出JSON,不要解释`;
|
|
100
|
+
|
|
101
|
+
let distillEnv = {};
|
|
102
|
+
try { distillEnv = buildDistillEnv(); } catch {}
|
|
103
|
+
|
|
104
|
+
let result;
|
|
105
|
+
try {
|
|
106
|
+
result = await Promise.race([
|
|
107
|
+
callHaiku(prompt, distillEnv, 60000),
|
|
108
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 65000)),
|
|
109
|
+
]);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.log(`[self-reflect] Haiku call failed: ${e.message}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse result
|
|
116
|
+
let patterns = [];
|
|
117
|
+
try {
|
|
118
|
+
const cleaned = result.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
119
|
+
const parsed = JSON.parse(cleaned);
|
|
120
|
+
if (Array.isArray(parsed)) {
|
|
121
|
+
patterns = parsed.filter(p => typeof p === 'string' && p.length > 5 && p.length <= 80);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
console.log('[self-reflect] Failed to parse Haiku output.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (patterns.length === 0) {
|
|
129
|
+
console.log('[self-reflect] No patterns found this week.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Merge into growth.patterns (cap at 3, keep newest)
|
|
134
|
+
try {
|
|
135
|
+
const yaml = require('js-yaml');
|
|
136
|
+
const raw = fs.readFileSync(BRAIN_FILE, 'utf8');
|
|
137
|
+
const profile = yaml.load(raw) || {};
|
|
138
|
+
if (!profile.growth) profile.growth = {};
|
|
139
|
+
const existing = Array.isArray(profile.growth.patterns) ? profile.growth.patterns : [];
|
|
140
|
+
// Add new patterns, deduplicate, cap at 3 newest
|
|
141
|
+
const merged = [...existing, ...patterns]
|
|
142
|
+
.filter((p, i, arr) => arr.indexOf(p) === i)
|
|
143
|
+
.slice(-3);
|
|
144
|
+
profile.growth.patterns = merged;
|
|
145
|
+
profile.growth.last_reflection = new Date().toISOString().slice(0, 10);
|
|
146
|
+
|
|
147
|
+
// Preserve locked lines (simple approach: only update growth section)
|
|
148
|
+
const dumped = yaml.dump(profile, { lineWidth: -1 });
|
|
149
|
+
fs.writeFileSync(BRAIN_FILE, dumped, 'utf8');
|
|
150
|
+
console.log(`[self-reflect] ${patterns.length} pattern(s) written to growth.patterns: ${patterns.join(' | ')}`);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.log(`[self-reflect] Failed to write profile: ${e.message}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
} finally {
|
|
156
|
+
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (require.main === module) {
|
|
161
|
+
run().then(() => {
|
|
162
|
+
console.log('✅ self-reflect complete');
|
|
163
|
+
}).catch(e => {
|
|
164
|
+
console.error(`[self-reflect] Fatal: ${e.message}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { run };
|
|
@@ -286,6 +286,49 @@ function checkHotEvolution(signal) {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
saveEvolutionQueue(yaml, queue);
|
|
289
|
+
|
|
290
|
+
// Rule 4: Track insight outcomes (success/failure per skill)
|
|
291
|
+
if (signal.skills_invoked && signal.skills_invoked.length > 0) {
|
|
292
|
+
const isSuccess = signal.outcome === 'success' && !signal.error && !signal.has_tool_failure;
|
|
293
|
+
const isFail = !isSuccess && (signal.error || signal.has_tool_failure ||
|
|
294
|
+
(signal.prompt && complaintRe.test(signal.prompt)));
|
|
295
|
+
if (isSuccess || isFail) {
|
|
296
|
+
for (const sk of signal.skills_invoked) {
|
|
297
|
+
const skillDir = findSkillDir(sk);
|
|
298
|
+
if (skillDir) trackInsightOutcome(skillDir, isSuccess);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Update insight outcome stats in evolution.json for a skill.
|
|
306
|
+
* Tracks success_count, fail_count, last_applied_at per insight text.
|
|
307
|
+
*/
|
|
308
|
+
function trackInsightOutcome(skillDir, isSuccess) {
|
|
309
|
+
const evoPath = path.join(skillDir, 'evolution.json');
|
|
310
|
+
let data = {};
|
|
311
|
+
try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
|
|
312
|
+
|
|
313
|
+
if (!data.insights_stats) data.insights_stats = {};
|
|
314
|
+
const now = new Date().toISOString();
|
|
315
|
+
const allInsights = [
|
|
316
|
+
...(data.preferences || []),
|
|
317
|
+
...(data.fixes || []),
|
|
318
|
+
...(data.contexts || []),
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
for (const insight of allInsights) {
|
|
322
|
+
if (!data.insights_stats[insight]) {
|
|
323
|
+
data.insights_stats[insight] = { success_count: 0, fail_count: 0, last_applied_at: null };
|
|
324
|
+
}
|
|
325
|
+
const stat = data.insights_stats[insight];
|
|
326
|
+
if (isSuccess) stat.success_count++;
|
|
327
|
+
else stat.fail_count++;
|
|
328
|
+
stat.last_applied_at = now;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try { fs.writeFileSync(evoPath, JSON.stringify(data, null, 2), 'utf8'); } catch {}
|
|
289
332
|
}
|
|
290
333
|
|
|
291
334
|
// ─────────────────────────────────────────────
|
|
@@ -687,14 +730,24 @@ function smartStitch(skillDir) {
|
|
|
687
730
|
sections.push('\n\n## User-Learned Best Practices & Constraints');
|
|
688
731
|
sections.push('\n> **Auto-Generated Section**: Maintained by skill-evolution-manager. Do not edit manually.');
|
|
689
732
|
|
|
733
|
+
// Helper: get quality indicator for an insight based on stats
|
|
734
|
+
const getQualityTag = (insight) => {
|
|
735
|
+
const stat = data.insights_stats?.[insight];
|
|
736
|
+
if (!stat || stat.success_count + stat.fail_count < 3) return ''; // insufficient data
|
|
737
|
+
const total = stat.success_count + stat.fail_count;
|
|
738
|
+
const failRate = stat.fail_count / total;
|
|
739
|
+
if (failRate > 0.6) return ' ⚠️'; // >60% fail rate
|
|
740
|
+
return '';
|
|
741
|
+
};
|
|
742
|
+
|
|
690
743
|
if (data.preferences?.length) {
|
|
691
744
|
sections.push('\n### User Preferences');
|
|
692
|
-
for (const item of data.preferences) sections.push(`- ${item}`);
|
|
745
|
+
for (const item of data.preferences) sections.push(`- ${item}${getQualityTag(item)}`);
|
|
693
746
|
}
|
|
694
747
|
|
|
695
748
|
if (data.fixes?.length) {
|
|
696
749
|
sections.push('\n### Known Fixes & Workarounds');
|
|
697
|
-
for (const item of data.fixes) sections.push(`- ${item}`);
|
|
750
|
+
for (const item of data.fixes) sections.push(`- ${item}${getQualityTag(item)}`);
|
|
698
751
|
}
|
|
699
752
|
|
|
700
753
|
if (data.custom_prompts) {
|
|
@@ -776,6 +829,7 @@ module.exports = {
|
|
|
776
829
|
resolveQueueItem,
|
|
777
830
|
mergeEvolution,
|
|
778
831
|
smartStitch,
|
|
832
|
+
trackInsightOutcome,
|
|
779
833
|
listInstalledSkills,
|
|
780
834
|
};
|
|
781
835
|
|
package/scripts/utils.js
CHANGED
|
@@ -1,9 +1,64 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* utils.js — Pure utility functions extracted for testability.
|
|
5
9
|
*/
|
|
6
10
|
|
|
11
|
+
// ---------------------------------------------------------
|
|
12
|
+
// BRAIN FILE SAFE WRITE
|
|
13
|
+
// Atomic write with exclusive file lock.
|
|
14
|
+
// Prevents race condition between distill.js and daemon.js /quiet command.
|
|
15
|
+
// ---------------------------------------------------------
|
|
16
|
+
const BRAIN_LOCK_FILE = path.join(os.homedir(), '.metame', 'brain.lock');
|
|
17
|
+
const BRAIN_FILE_DEFAULT = path.join(os.homedir(), '.claude_profile.yaml');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write content to the brain profile file atomically and exclusively.
|
|
21
|
+
* Uses a .lock file to prevent concurrent writes, and write-then-rename
|
|
22
|
+
* for atomicity (process crash leaves .tmp, not a partial BRAIN_FILE).
|
|
23
|
+
*
|
|
24
|
+
* @param {string} content - YAML string to write
|
|
25
|
+
* @param {string} [brainFile] - Target path (defaults to ~/.claude_profile.yaml)
|
|
26
|
+
* @returns {Promise<void>}
|
|
27
|
+
*/
|
|
28
|
+
async function writeBrainFileSafe(content, brainFile = BRAIN_FILE_DEFAULT) {
|
|
29
|
+
const maxRetries = 10;
|
|
30
|
+
const retryDelay = 150; // ms between retries
|
|
31
|
+
const staleTimeout = 30000; // 30s: lock older than this is stale
|
|
32
|
+
|
|
33
|
+
let acquired = false;
|
|
34
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
35
|
+
try {
|
|
36
|
+
const fd = fs.openSync(BRAIN_LOCK_FILE, 'wx');
|
|
37
|
+
fs.writeSync(fd, process.pid.toString());
|
|
38
|
+
fs.closeSync(fd);
|
|
39
|
+
acquired = true;
|
|
40
|
+
break;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.code !== 'EEXIST') throw e;
|
|
43
|
+
// Check for stale lock
|
|
44
|
+
try {
|
|
45
|
+
const age = Date.now() - fs.statSync(BRAIN_LOCK_FILE).mtimeMs;
|
|
46
|
+
if (age > staleTimeout) { fs.unlinkSync(BRAIN_LOCK_FILE); continue; }
|
|
47
|
+
} catch { /* lock removed by another process */ }
|
|
48
|
+
await new Promise(r => setTimeout(r, retryDelay));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tmp = brainFile + '.tmp.' + process.pid;
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
55
|
+
fs.renameSync(tmp, brainFile); // atomic on POSIX
|
|
56
|
+
} finally {
|
|
57
|
+
try { fs.unlinkSync(tmp); } catch { } // clean up tmp if rename failed
|
|
58
|
+
if (acquired) try { fs.unlinkSync(BRAIN_LOCK_FILE); } catch { }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
7
62
|
// ---------------------------------------------------------
|
|
8
63
|
// INTERVAL PARSING
|
|
9
64
|
// ---------------------------------------------------------
|
|
@@ -67,4 +122,6 @@ module.exports = {
|
|
|
67
122
|
parseInterval,
|
|
68
123
|
formatRelativeTime,
|
|
69
124
|
createPathMap,
|
|
125
|
+
writeBrainFileSafe,
|
|
126
|
+
BRAIN_LOCK_FILE,
|
|
70
127
|
};
|