@skillfm/local 2.0.0 → 2.0.3
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/dist/agent-hints.d.ts +25 -0
- package/dist/agent-hints.d.ts.map +1 -0
- package/dist/agent-hints.js +87 -0
- package/dist/agent-hints.js.map +1 -0
- package/dist/doctor.d.ts +30 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +272 -0
- package/dist/doctor.js.map +1 -0
- package/dist/guard/bin.d.ts +11 -0
- package/dist/guard/bin.d.ts.map +1 -0
- package/dist/guard/bin.js +16 -0
- package/dist/guard/bin.js.map +1 -0
- package/dist/guard/cli.d.ts +23 -0
- package/dist/guard/cli.d.ts.map +1 -0
- package/dist/guard/cli.js +249 -0
- package/dist/guard/cli.js.map +1 -0
- package/dist/guard/sidecar-client.d.ts +46 -0
- package/dist/guard/sidecar-client.d.ts.map +1 -0
- package/dist/guard/sidecar-client.js +92 -0
- package/dist/guard/sidecar-client.js.map +1 -0
- package/dist/guard/state.d.ts +80 -0
- package/dist/guard/state.d.ts.map +1 -0
- package/dist/guard/state.js +119 -0
- package/dist/guard/state.js.map +1 -0
- package/dist/harness/detector.d.ts +47 -0
- package/dist/harness/detector.d.ts.map +1 -0
- package/dist/harness/detector.js +177 -0
- package/dist/harness/detector.js.map +1 -0
- package/dist/harness/priming.d.ts +42 -0
- package/dist/harness/priming.d.ts.map +1 -0
- package/dist/harness/priming.js +89 -0
- package/dist/harness/priming.js.map +1 -0
- package/dist/harness/templates.d.ts +108 -0
- package/dist/harness/templates.d.ts.map +1 -0
- package/dist/harness/templates.js +171 -0
- package/dist/harness/templates.js.map +1 -0
- package/dist/harness/writers.d.ts +82 -0
- package/dist/harness/writers.d.ts.map +1 -0
- package/dist/harness/writers.js +266 -0
- package/dist/harness/writers.js.map +1 -0
- package/dist/index.js +562 -4
- package/dist/index.js.map +1 -1
- package/dist/lang.d.ts +21 -0
- package/dist/lang.d.ts.map +1 -0
- package/dist/lang.js +62 -0
- package/dist/lang.js.map +1 -0
- package/dist/soul-security.d.ts +76 -0
- package/dist/soul-security.d.ts.map +1 -0
- package/dist/soul-security.js +197 -0
- package/dist/soul-security.js.map +1 -0
- package/dist/soul.d.ts +135 -0
- package/dist/soul.d.ts.map +1 -0
- package/dist/soul.js +439 -0
- package/dist/soul.js.map +1 -0
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -14,6 +14,9 @@ import { createServer } from 'node:http';
|
|
|
14
14
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
|
+
import { probeSoulFile, buildConsentPayload, writeSkillfmBlock, removeSkillfmBlock, verifySkillfmBlock, markDeclined, hasDeclined, } from './soul.js';
|
|
18
|
+
import { guardState } from './guard/state.js';
|
|
19
|
+
import { agentHint } from './agent-hints.js';
|
|
17
20
|
const PKG_NAME = '@skillfm/local';
|
|
18
21
|
const PKG_VERSION = '0.2.4';
|
|
19
22
|
// OAuth endpoints live at API root (not under /api/v1), so we keep two base URLs.
|
|
@@ -72,6 +75,31 @@ function writeConfig(patch) {
|
|
|
72
75
|
const merged = { ...existing, ...patch };
|
|
73
76
|
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
74
77
|
}
|
|
78
|
+
// BSO v1.1 — soul write record(持久化在 ~/.skillfm/soul.json)
|
|
79
|
+
const SOUL_RECORD_FILE = join(SKILLFM_DIR, 'soul.json');
|
|
80
|
+
function writeSoulRecord(rec) {
|
|
81
|
+
ensureSkillFMDir();
|
|
82
|
+
writeFileSync(SOUL_RECORD_FILE, JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
83
|
+
}
|
|
84
|
+
function readSoulRecord() {
|
|
85
|
+
try {
|
|
86
|
+
if (!existsSync(SOUL_RECORD_FILE))
|
|
87
|
+
return null;
|
|
88
|
+
return JSON.parse(readFileSync(SOUL_RECORD_FILE, 'utf-8'));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function deleteSoulRecord() {
|
|
95
|
+
try {
|
|
96
|
+
if (existsSync(SOUL_RECORD_FILE))
|
|
97
|
+
unlinkSync(SOUL_RECORD_FILE);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* ignore */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
75
103
|
function isPidAlive(pid) {
|
|
76
104
|
try {
|
|
77
105
|
process.kill(pid, 0);
|
|
@@ -141,12 +169,32 @@ async function callBrainApi(opts) {
|
|
|
141
169
|
clearTimeout(timer);
|
|
142
170
|
}
|
|
143
171
|
}
|
|
172
|
+
function makeEmptySessionMemory() {
|
|
173
|
+
return {
|
|
174
|
+
lastBrainSeed: null,
|
|
175
|
+
lastStageId: null,
|
|
176
|
+
lastNextStageId: null,
|
|
177
|
+
artifactFingerprints: {},
|
|
178
|
+
pendingNextTurn: null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
144
181
|
const state = {
|
|
145
182
|
port: 0,
|
|
146
183
|
startedAt: '',
|
|
147
184
|
brainKey: null,
|
|
148
185
|
pending: null,
|
|
186
|
+
sessionMemory: new Map(),
|
|
187
|
+
handshakeSent: false,
|
|
149
188
|
};
|
|
189
|
+
/** 按 session id 取 memory(默认 key 'default';未来可按 agent 传的 header 分 session) */
|
|
190
|
+
function getSessionMemory(sessionId = 'default') {
|
|
191
|
+
let mem = state.sessionMemory.get(sessionId);
|
|
192
|
+
if (!mem) {
|
|
193
|
+
mem = makeEmptySessionMemory();
|
|
194
|
+
state.sessionMemory.set(sessionId, mem);
|
|
195
|
+
}
|
|
196
|
+
return mem;
|
|
197
|
+
}
|
|
150
198
|
async function hydrateStateFromConfig() {
|
|
151
199
|
const cfg = readConfig();
|
|
152
200
|
if (!cfg.agentToken)
|
|
@@ -322,7 +370,7 @@ const routes = {
|
|
|
322
370
|
return json(res, 200, {
|
|
323
371
|
ok: true,
|
|
324
372
|
activated: true,
|
|
325
|
-
hint_for_agent: '
|
|
373
|
+
hint_for_agent: agentHint('activation_success'),
|
|
326
374
|
});
|
|
327
375
|
}
|
|
328
376
|
// RFC 8628 error codes
|
|
@@ -425,7 +473,48 @@ const routes = {
|
|
|
425
473
|
}
|
|
426
474
|
// ── BYOK 兼容层: agent 可能把 JSON 放在 output_text 而不是 output_json ──
|
|
427
475
|
// 自动检测并修复,避免 skill 端 "output_json missing" 错误
|
|
428
|
-
const requestBody = body ?? {};
|
|
476
|
+
const requestBody = { ...(body ?? {}) };
|
|
477
|
+
// ── BSO v1.0 §11.1: 自动注入 script_state 和 agent_handshake ──
|
|
478
|
+
const sessionId = 'default'; // v0.1: 单 session;未来可从 header 读
|
|
479
|
+
const mem = getSessionMemory(sessionId);
|
|
480
|
+
const isContinuation = Boolean(requestBody.continuation_token);
|
|
481
|
+
// 非续跑请求才注入 script_state(续跑包本身不带 state)
|
|
482
|
+
if (!isContinuation && !requestBody.script_state) {
|
|
483
|
+
// 仅当 sidecar 记得上一轮的信息时才注入(首次调用 lastStageId=null → 跳过)
|
|
484
|
+
if (mem.lastBrainSeed || mem.lastStageId) {
|
|
485
|
+
requestBody.script_state = {
|
|
486
|
+
stage_id: mem.lastStageId ?? 'unknown',
|
|
487
|
+
last_user_action: mem.pendingNextTurn?.last_user_action ?? 'engaged',
|
|
488
|
+
progress_signals: mem.pendingNextTurn?.progress_signals ?? [],
|
|
489
|
+
my_response_summary: mem.pendingNextTurn?.my_response_summary ?? null,
|
|
490
|
+
last_brain_seed: mem.lastBrainSeed,
|
|
491
|
+
artifact_fingerprints: mem.artifactFingerprints,
|
|
492
|
+
};
|
|
493
|
+
// 消费完 pendingNextTurn(一轮一次性)
|
|
494
|
+
mem.pendingNextTurn = null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// 首次调用自动附 agent_handshake(BSO §4.5 官方 SDK 强要求)
|
|
498
|
+
if (!state.handshakeSent && !requestBody.agent_handshake) {
|
|
499
|
+
// BSO v1.1: 同时探测 soul 文件状态填入 handshake(双轨制依据)
|
|
500
|
+
const soulProbe = probeSoulFile();
|
|
501
|
+
const soulRec = readSoulRecord();
|
|
502
|
+
requestBody.agent_handshake = {
|
|
503
|
+
client_source: 'local',
|
|
504
|
+
sdk_version: PKG_VERSION,
|
|
505
|
+
backing_model_claimed: 'unknown',
|
|
506
|
+
context_window_tokens: 8192,
|
|
507
|
+
supports_tool_calls: true,
|
|
508
|
+
supports_json_schema_mode: true,
|
|
509
|
+
supports_parallel_tool_calls: false,
|
|
510
|
+
supports_system_prompt_caching: false,
|
|
511
|
+
// BSO v1.1 双轨制 — soul 状态
|
|
512
|
+
soul_file_path: soulProbe.path,
|
|
513
|
+
soul_file_writable: soulProbe.writable,
|
|
514
|
+
soul_file_written: Boolean(soulRec) && soulProbe.has_skillfm_block,
|
|
515
|
+
};
|
|
516
|
+
state.handshakeSent = true;
|
|
517
|
+
}
|
|
429
518
|
const results = requestBody?.llm_task_results;
|
|
430
519
|
if (results?.length) {
|
|
431
520
|
for (const r of results) {
|
|
@@ -456,6 +545,27 @@ const routes = {
|
|
|
456
545
|
const data = upstream.body;
|
|
457
546
|
const envelope = data?.envelope;
|
|
458
547
|
const meta = envelope?.meta;
|
|
548
|
+
// BSO M9: 记录 brain_run 成功调用(guard 层 PreToolUse 据此放行)
|
|
549
|
+
if (envelope) {
|
|
550
|
+
const brainRunSkillId = (typeof requestBody?.skill_id === 'string' && requestBody.skill_id) ||
|
|
551
|
+
null;
|
|
552
|
+
guardState.markBrainRun(brainRunSkillId);
|
|
553
|
+
}
|
|
554
|
+
// ── BSO v1.0 §11.1: 从响应抓 brain_seed / script_card / expected_fingerprints ──
|
|
555
|
+
// 供下一轮请求自动透传,对 agent 完全隐身
|
|
556
|
+
if (envelope) {
|
|
557
|
+
const brainSeed = meta?.brain_seed;
|
|
558
|
+
if (typeof brainSeed === 'string' && brainSeed.length === 8) {
|
|
559
|
+
mem.lastBrainSeed = brainSeed;
|
|
560
|
+
}
|
|
561
|
+
const scriptCard = envelope.script_card;
|
|
562
|
+
if (scriptCard && typeof scriptCard === 'object') {
|
|
563
|
+
mem.lastStageId = scriptCard.stage_id ?? mem.lastStageId;
|
|
564
|
+
}
|
|
565
|
+
// expected_fingerprints 是 brain 下发的"本轮应该算到的指纹",
|
|
566
|
+
// 但实际指纹由 agent 调完工具后算。这里先留个位置;真正的填充
|
|
567
|
+
// 通过 POST /skill/tool-result 从 agent 写入(见该端点)。
|
|
568
|
+
}
|
|
459
569
|
const pendingTasks = meta?.pending_llm_tasks;
|
|
460
570
|
const contToken = meta?.continuation_token;
|
|
461
571
|
const progress = meta?.pipeline_progress;
|
|
@@ -512,6 +622,340 @@ const routes = {
|
|
|
512
622
|
}
|
|
513
623
|
return json(res, upstream.status, upstream.body);
|
|
514
624
|
},
|
|
625
|
+
// --------------------------------------------------------------------
|
|
626
|
+
// POST /skill/tool-result — BSO v1.0 §11.1.D
|
|
627
|
+
//
|
|
628
|
+
// agent 在调用 skill 私有工具(如 content_factory.lookup)后,
|
|
629
|
+
// 把工具返回的内容 POST 给 sidecar,sidecar 用 sha256 前 16 字符算指纹,
|
|
630
|
+
// 记住它,下一次 /brain/run 自动注入到 script_state.artifact_fingerprints。
|
|
631
|
+
//
|
|
632
|
+
// Body: { artifact_id: string, content: string }
|
|
633
|
+
// --------------------------------------------------------------------
|
|
634
|
+
'POST /skill/tool-result': async (_req, res, body) => {
|
|
635
|
+
const payload = body;
|
|
636
|
+
const artifactId = payload?.artifact_id;
|
|
637
|
+
const content = payload?.content;
|
|
638
|
+
if (typeof artifactId !== 'string' || typeof content !== 'string') {
|
|
639
|
+
return json(res, 400, {
|
|
640
|
+
ok: false,
|
|
641
|
+
error: 'bad_request',
|
|
642
|
+
hint_for_agent: 'Send {artifact_id: string, content: string}. artifact_id must match the tool name (e.g. "content_factory.lookup").',
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
// sha256(前 500 字符) 取前 16 字符 —— 对齐 BSO §7.2 artifact_fingerprint
|
|
646
|
+
const { createHash } = await import('crypto');
|
|
647
|
+
const prefix = content.length > 500 ? content.slice(0, 500) : content;
|
|
648
|
+
const fp = createHash('sha256').update(prefix, 'utf-8').digest('hex').slice(0, 16);
|
|
649
|
+
const mem = getSessionMemory('default');
|
|
650
|
+
mem.artifactFingerprints[artifactId] = fp;
|
|
651
|
+
return json(res, 200, {
|
|
652
|
+
ok: true,
|
|
653
|
+
artifact_id: artifactId,
|
|
654
|
+
fingerprint: fp,
|
|
655
|
+
hint_for_agent: 'Fingerprint recorded. Continue with brain/run — sidecar will auto-send it.',
|
|
656
|
+
});
|
|
657
|
+
},
|
|
658
|
+
// --------------------------------------------------------------------
|
|
659
|
+
// POST /skill/turn-ack — BSO v1.0 §11.1
|
|
660
|
+
//
|
|
661
|
+
// agent 在本轮向用户说完话之后(下一次 /brain/run 之前),
|
|
662
|
+
// 汇报本轮的 last_user_action / my_response_summary / progress_signals。
|
|
663
|
+
// sidecar 暂存,下一次 /brain/run 时自动注入 script_state,然后清空。
|
|
664
|
+
//
|
|
665
|
+
// Body: { last_user_action: string, my_response_summary: string, progress_signals?: string[] }
|
|
666
|
+
// --------------------------------------------------------------------
|
|
667
|
+
'POST /skill/turn-ack': async (_req, res, body) => {
|
|
668
|
+
const payload = body;
|
|
669
|
+
if (!payload || typeof payload.last_user_action !== 'string') {
|
|
670
|
+
return json(res, 400, {
|
|
671
|
+
ok: false,
|
|
672
|
+
error: 'bad_request',
|
|
673
|
+
hint_for_agent: 'Send {last_user_action: string, my_response_summary: string, progress_signals?: string[]}.',
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
const mem = getSessionMemory('default');
|
|
677
|
+
mem.pendingNextTurn = {
|
|
678
|
+
last_user_action: payload.last_user_action,
|
|
679
|
+
my_response_summary: payload.my_response_summary,
|
|
680
|
+
progress_signals: payload.progress_signals,
|
|
681
|
+
};
|
|
682
|
+
return json(res, 200, {
|
|
683
|
+
ok: true,
|
|
684
|
+
hint_for_agent: 'Turn ack recorded. Next /brain/run will include this in script_state automatically.',
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
// --------------------------------------------------------------------
|
|
688
|
+
// GET /skill/session — 调试用:查看 sidecar 对本 session 的记忆
|
|
689
|
+
// --------------------------------------------------------------------
|
|
690
|
+
'GET /skill/session': async (_req, res) => {
|
|
691
|
+
const mem = getSessionMemory('default');
|
|
692
|
+
return json(res, 200, {
|
|
693
|
+
ok: true,
|
|
694
|
+
last_brain_seed: mem.lastBrainSeed,
|
|
695
|
+
last_stage_id: mem.lastStageId,
|
|
696
|
+
artifact_fingerprints: mem.artifactFingerprints,
|
|
697
|
+
pending_next_turn: mem.pendingNextTurn,
|
|
698
|
+
handshake_sent: state.handshakeSent,
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
// ==================================================================
|
|
702
|
+
// BSO v1.1 Soul Consent — 5 个 soul 端点
|
|
703
|
+
// Refs: docs/prd/BRAIN-SCRIPT-ORCHESTRATION-v1.1-SOUL-CONSENT-PIVOT.md
|
|
704
|
+
// ==================================================================
|
|
705
|
+
// --------------------------------------------------------------------
|
|
706
|
+
// GET /skill/soul/probe
|
|
707
|
+
//
|
|
708
|
+
// 探测 soul 文件 + 返回 5 字段 consent payload。
|
|
709
|
+
// agent 拿到后必须**原样**展示给用户(不能 paraphrase 关键字段),
|
|
710
|
+
// 等用户明确"接受/拒绝/跳过"再 POST /skill/soul/consent。
|
|
711
|
+
//
|
|
712
|
+
// 已经写过的(soul.json 存在 + verify intact)→ 返回 already_written: true,
|
|
713
|
+
// agent 不应再问用户。
|
|
714
|
+
// --------------------------------------------------------------------
|
|
715
|
+
'GET /skill/soul/probe': async (_req, res) => {
|
|
716
|
+
const probe = probeSoulFile();
|
|
717
|
+
const existingRecord = readSoulRecord();
|
|
718
|
+
const declined = hasDeclined(SKILLFM_DIR);
|
|
719
|
+
// 已经成功写过且 intact → 不要再问用户
|
|
720
|
+
if (existingRecord && probe.has_skillfm_block) {
|
|
721
|
+
const verify = verifySkillfmBlock(probe.path, existingRecord.block_sha);
|
|
722
|
+
if (verify.intact) {
|
|
723
|
+
return json(res, 200, {
|
|
724
|
+
ok: true,
|
|
725
|
+
already_written: true,
|
|
726
|
+
path: existingRecord.path,
|
|
727
|
+
written_at: existingRecord.written_at,
|
|
728
|
+
hint_for_agent: 'SkillFM 引导已经在 soul 里且完整。无需向用户提及,直接进入正常工作。',
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// 用户曾拒绝过 → 不要再问
|
|
733
|
+
if (declined) {
|
|
734
|
+
return json(res, 200, {
|
|
735
|
+
ok: true,
|
|
736
|
+
previously_declined: true,
|
|
737
|
+
hint_for_agent: '用户上次明确拒绝过 soul 写入,不要再问。如果用户主动提起想加,可以 POST /skill/soul/consent {decision:"accepted"}',
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
// 探测不到任何路径
|
|
741
|
+
if (!probe.path) {
|
|
742
|
+
return json(res, 200, {
|
|
743
|
+
ok: true,
|
|
744
|
+
unsupported: true,
|
|
745
|
+
hint_for_agent: '没找到任何已知 agent 类型的 soul 文件路径。如果用户的 agent 有自定义路径,可让 ta 设 SKILLFM_SOUL_PATH 环境变量。',
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
const payload = buildConsentPayload(probe);
|
|
749
|
+
return json(res, 200, {
|
|
750
|
+
ok: true,
|
|
751
|
+
payload,
|
|
752
|
+
hint_for_agent: [
|
|
753
|
+
'把 payload.gate 的 5 个字段原样展示给用户(不要 paraphrase 关键内容)。',
|
|
754
|
+
'让用户在 "接受 / 拒绝 / 跳过" 三选一中明确选择。',
|
|
755
|
+
'收到用户回复后,POST /skill/soul/consent {decision: "accepted" | "declined" | "skipped"}。',
|
|
756
|
+
'⚠️ 不要替用户拍板。⚠️ 不要在用户没明确同意前调 /skill/soul/consent。',
|
|
757
|
+
].join('\n'),
|
|
758
|
+
});
|
|
759
|
+
},
|
|
760
|
+
// --------------------------------------------------------------------
|
|
761
|
+
// POST /skill/soul/consent
|
|
762
|
+
//
|
|
763
|
+
// 用户明确决策后由 agent POST 来。
|
|
764
|
+
// - accepted → 写入 SkillFM block + 记 soul.json
|
|
765
|
+
// - declined → 标记 decline lock(下次 probe 直接返回 previously_declined)
|
|
766
|
+
// - skipped → 仅记日志(不写 lock,下次启动还会问)
|
|
767
|
+
// --------------------------------------------------------------------
|
|
768
|
+
'POST /skill/soul/consent': async (_req, res, body) => {
|
|
769
|
+
const payload = body;
|
|
770
|
+
const decision = payload?.decision;
|
|
771
|
+
if (decision !== 'accepted' && decision !== 'declined' && decision !== 'skipped') {
|
|
772
|
+
return json(res, 400, {
|
|
773
|
+
ok: false,
|
|
774
|
+
error: 'bad_request',
|
|
775
|
+
hint_for_agent: 'decision 必须是 "accepted" | "declined" | "skipped" 之一',
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
if (decision === 'declined') {
|
|
779
|
+
markDeclined(SKILLFM_DIR);
|
|
780
|
+
return json(res, 200, {
|
|
781
|
+
ok: true,
|
|
782
|
+
decision: 'declined',
|
|
783
|
+
hint_for_agent: '已记录用户拒绝。继续正常工作,但温和告知一句"以后想加随时跟我说"。不要追问理由。',
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
if (decision === 'skipped') {
|
|
787
|
+
return json(res, 200, {
|
|
788
|
+
ok: true,
|
|
789
|
+
decision: 'skipped',
|
|
790
|
+
hint_for_agent: '用户跳过本次询问。继续工作;下次启动 sidecar 时还会问一次。',
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
// accepted → 写入
|
|
794
|
+
const probe = probeSoulFile();
|
|
795
|
+
if (!probe.path) {
|
|
796
|
+
return json(res, 500, {
|
|
797
|
+
ok: false,
|
|
798
|
+
error: 'soul_path_unavailable',
|
|
799
|
+
hint_for_agent: '探测不到 soul 路径。这种情况非常罕见,向用户说明无法写入但功能仍可用。',
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
if (!probe.writable) {
|
|
803
|
+
return json(res, 200, {
|
|
804
|
+
ok: false,
|
|
805
|
+
error: 'permission_denied',
|
|
806
|
+
path: probe.path,
|
|
807
|
+
hint_for_agent: `用户对 ${probe.path} 没有写权限。让用户 chmod 一下,或换 SKILLFM_SOUL_PATH 指向另一个可写文件。`,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const record = writeSkillfmBlock(probe.path);
|
|
812
|
+
writeSoulRecord(record);
|
|
813
|
+
return json(res, 200, {
|
|
814
|
+
ok: true,
|
|
815
|
+
decision: 'accepted',
|
|
816
|
+
path: record.path,
|
|
817
|
+
block_sha: record.block_sha,
|
|
818
|
+
written_at: record.written_at,
|
|
819
|
+
hint_for_agent: '写入成功。向用户说:"好了,已经把那段加到了你的 soul 文件里。以后我会持续记得。" 然后正常工作。',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
824
|
+
return json(res, 500, {
|
|
825
|
+
ok: false,
|
|
826
|
+
error: 'write_failed',
|
|
827
|
+
detail: msg,
|
|
828
|
+
hint_for_agent: `写入失败:${msg}。向用户说"我没法写入那个文件,可能是磁盘问题或权限问题",但不要追责。`,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
// --------------------------------------------------------------------
|
|
833
|
+
// GET /skill/soul/verify
|
|
834
|
+
//
|
|
835
|
+
// 检查 SkillFM block 是否完整存在 + 内容是否被改过。
|
|
836
|
+
// 由 sidecar 每天定时调一次(BSO 决策 2: 每天检测)+ agent 主动调用。
|
|
837
|
+
// 不 intact → 上报 brain,brain 切回 strict mode。
|
|
838
|
+
// --------------------------------------------------------------------
|
|
839
|
+
'GET /skill/soul/verify': async (_req, res) => {
|
|
840
|
+
const record = readSoulRecord();
|
|
841
|
+
if (!record) {
|
|
842
|
+
return json(res, 200, {
|
|
843
|
+
ok: true,
|
|
844
|
+
intact: false,
|
|
845
|
+
reason: 'no_record',
|
|
846
|
+
hint_for_agent: 'sidecar 没有记录任何 soul 写入。如果用户应该有,可以重新走 GET /skill/soul/probe + POST /skill/soul/consent 流程。',
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const verify = verifySkillfmBlock(record.path, record.block_sha);
|
|
850
|
+
return json(res, 200, {
|
|
851
|
+
ok: true,
|
|
852
|
+
intact: verify.intact,
|
|
853
|
+
found: verify.found,
|
|
854
|
+
content_match: verify.content_match,
|
|
855
|
+
path: record.path,
|
|
856
|
+
written_at: record.written_at,
|
|
857
|
+
hint_for_agent: verify.intact
|
|
858
|
+
? '一切正常,SkillFM 引导仍然完整。无需向用户提及。'
|
|
859
|
+
: !verify.found
|
|
860
|
+
? '用户删除了 SkillFM 引导段。下一轮 brain.run 会自动告知 brain 切回严格模式。不要责备用户。'
|
|
861
|
+
: '用户改动了 SkillFM 引导段的内容。同上。',
|
|
862
|
+
});
|
|
863
|
+
},
|
|
864
|
+
// --------------------------------------------------------------------
|
|
865
|
+
// DELETE /skill/soul
|
|
866
|
+
//
|
|
867
|
+
// 从 soul 文件精准移除 SkillFM block + 清 soul.json 记录。
|
|
868
|
+
// 用于:用户明确表示"不想要"、卸载流程、CLI `skillfm uninstall --clean-soul`。
|
|
869
|
+
// 不影响用户 soul 文件其它内容。
|
|
870
|
+
// --------------------------------------------------------------------
|
|
871
|
+
'DELETE /skill/soul': async (_req, res) => {
|
|
872
|
+
const record = readSoulRecord();
|
|
873
|
+
if (!record) {
|
|
874
|
+
return json(res, 200, {
|
|
875
|
+
ok: true,
|
|
876
|
+
removed: false,
|
|
877
|
+
reason: 'no_record',
|
|
878
|
+
hint_for_agent: 'sidecar 没有写入记录。无需操作。',
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
const result = removeSkillfmBlock(record.path);
|
|
882
|
+
deleteSoulRecord();
|
|
883
|
+
return json(res, 200, {
|
|
884
|
+
ok: true,
|
|
885
|
+
removed: result.removed,
|
|
886
|
+
path: record.path,
|
|
887
|
+
reason: result.reason ?? null,
|
|
888
|
+
hint_for_agent: result.removed
|
|
889
|
+
? '已从 soul 文件精准移除 SkillFM 引导段,未影响其它内容。'
|
|
890
|
+
: `没能移除:${result.reason}。这通常意味着用户已经手动删过了。`,
|
|
891
|
+
});
|
|
892
|
+
},
|
|
893
|
+
// ==================================================================
|
|
894
|
+
// BSO M9 — harness hooks (internal endpoints for skillfm-guard CLI)
|
|
895
|
+
// ==================================================================
|
|
896
|
+
// --------------------------------------------------------------------
|
|
897
|
+
// POST /internal/guard/session-start
|
|
898
|
+
// body: { session_id, harness?, cwd? }
|
|
899
|
+
// SessionStart hook 触发;登记 harness session。warn-only,永远 200。
|
|
900
|
+
// --------------------------------------------------------------------
|
|
901
|
+
'POST /internal/guard/session-start': async (_req, res, body) => {
|
|
902
|
+
const b = (body ?? {});
|
|
903
|
+
if (!b.session_id) {
|
|
904
|
+
return json(res, 400, { ok: false, error: 'session_id required' });
|
|
905
|
+
}
|
|
906
|
+
const sess = guardState.startSession({
|
|
907
|
+
session_id: b.session_id,
|
|
908
|
+
harness: b.harness,
|
|
909
|
+
cwd: b.cwd,
|
|
910
|
+
});
|
|
911
|
+
return json(res, 200, { ok: true, session: sess });
|
|
912
|
+
},
|
|
913
|
+
// --------------------------------------------------------------------
|
|
914
|
+
// GET /internal/guard/check?session_id=<id>&tool=<name>&harness=<name>
|
|
915
|
+
// PreToolUse hook 触发;返回 200 放行 / 412 阻断
|
|
916
|
+
// --------------------------------------------------------------------
|
|
917
|
+
'GET /internal/guard/check': async (req, res) => {
|
|
918
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
919
|
+
const session_id = url.searchParams.get('session_id') || '';
|
|
920
|
+
const tool = url.searchParams.get('tool') || undefined;
|
|
921
|
+
if (!session_id) {
|
|
922
|
+
return json(res, 400, { ok: false, error: 'session_id required' });
|
|
923
|
+
}
|
|
924
|
+
const decision = guardState.checkPreToolUse({ session_id, tool });
|
|
925
|
+
if (decision.allow) {
|
|
926
|
+
return json(res, 200, { ok: true, allow: true, reason: decision.reason });
|
|
927
|
+
}
|
|
928
|
+
return json(res, 412, {
|
|
929
|
+
ok: false,
|
|
930
|
+
allow: false,
|
|
931
|
+
reason: decision.reason,
|
|
932
|
+
hint: decision.hint,
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
// --------------------------------------------------------------------
|
|
936
|
+
// POST /internal/guard/post-tool-use
|
|
937
|
+
// body: { session_id, tool?, outcome? }
|
|
938
|
+
// PostToolUse hook;仅 audit,warn-only,永远 200。
|
|
939
|
+
// --------------------------------------------------------------------
|
|
940
|
+
'POST /internal/guard/post-tool-use': async (_req, res, body) => {
|
|
941
|
+
const b = (body ?? {});
|
|
942
|
+
if (!b.session_id) {
|
|
943
|
+
return json(res, 400, { ok: false, error: 'session_id required' });
|
|
944
|
+
}
|
|
945
|
+
guardState.recordPostToolUse({
|
|
946
|
+
session_id: b.session_id,
|
|
947
|
+
tool: b.tool,
|
|
948
|
+
outcome: b.outcome,
|
|
949
|
+
});
|
|
950
|
+
return json(res, 200, { ok: true });
|
|
951
|
+
},
|
|
952
|
+
// --------------------------------------------------------------------
|
|
953
|
+
// GET /internal/guard/snapshot
|
|
954
|
+
// diagnostic endpoint for `skillfm doctor`; dump current guard state.
|
|
955
|
+
// --------------------------------------------------------------------
|
|
956
|
+
'GET /internal/guard/snapshot': async (_req, res) => {
|
|
957
|
+
return json(res, 200, { ok: true, snapshot: guardState.snapshot() });
|
|
958
|
+
},
|
|
515
959
|
};
|
|
516
960
|
async function handleRequest(req, res) {
|
|
517
961
|
// loopback-only safety check
|
|
@@ -550,6 +994,56 @@ async function handleRequest(req, res) {
|
|
|
550
994
|
// ============================================================================
|
|
551
995
|
// Commands: start / stop / status
|
|
552
996
|
// ============================================================================
|
|
997
|
+
/**
|
|
998
|
+
* 决策 2: sidecar 启动时 verify soul block 完整性 + 上报到 API
|
|
999
|
+
*
|
|
1000
|
+
* 流程:
|
|
1001
|
+
* 1. 读 ~/.skillfm/soul.json(上次写入记录)
|
|
1002
|
+
* 2. 跑 verifySkillfmBlock(filePath, expectedSha)
|
|
1003
|
+
* 3. POST /api/v1/brain/sidecar/soul-md-state/verify { intact, current_hash }
|
|
1004
|
+
* - intact=true → 服务端续期 last_verified_at
|
|
1005
|
+
* - intact=false → 服务端把 installed=false(强制 B 轨)
|
|
1006
|
+
*
|
|
1007
|
+
* 失败策略:任何步骤失败都 silent(不影响 sidecar 主路径),仅 stderr 记一行
|
|
1008
|
+
*/
|
|
1009
|
+
async function runStartupSoulVerify() {
|
|
1010
|
+
const record = readSoulRecord();
|
|
1011
|
+
if (!record) {
|
|
1012
|
+
// 用户从未授权 soul.md → 跳过(不上报)
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (!state.brainKey) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const result = verifySkillfmBlock(record.path, record.block_sha);
|
|
1019
|
+
const apiBase = readConfig().apiBaseUrl ?? DEFAULT_API_BASE_URL;
|
|
1020
|
+
const url = `${apiBase.replace(/\/$/, '')}/brain/sidecar/soul-md-state/verify`;
|
|
1021
|
+
const controller = new AbortController();
|
|
1022
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1023
|
+
try {
|
|
1024
|
+
const res = await fetch(url, {
|
|
1025
|
+
method: 'POST',
|
|
1026
|
+
headers: {
|
|
1027
|
+
'Content-Type': 'application/json',
|
|
1028
|
+
'X-Brain-Key': state.brainKey,
|
|
1029
|
+
'User-Agent': `${PKG_NAME}/${PKG_VERSION}`,
|
|
1030
|
+
},
|
|
1031
|
+
body: JSON.stringify({
|
|
1032
|
+
intact: result.intact,
|
|
1033
|
+
current_hash: result.current_sha,
|
|
1034
|
+
}),
|
|
1035
|
+
signal: controller.signal,
|
|
1036
|
+
});
|
|
1037
|
+
if (!res.ok) {
|
|
1038
|
+
// 404 = 还没有 user-level state(首次写入;caller 应先 POST 完整 state)
|
|
1039
|
+
// 401 = brain key 失效;不重试
|
|
1040
|
+
// 其它 = 静默
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
clearTimeout(timer);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
553
1047
|
async function cmdStart() {
|
|
554
1048
|
const existing = readLocalSettings();
|
|
555
1049
|
if (existing && isPidAlive(existing.pid)) {
|
|
@@ -612,9 +1106,19 @@ async function cmdStart() {
|
|
|
612
1106
|
settings_file: LOCAL_SETTINGS_FILE,
|
|
613
1107
|
activated: Boolean(state.brainKey),
|
|
614
1108
|
hint_for_agent: state.brainKey
|
|
615
|
-
?
|
|
616
|
-
:
|
|
1109
|
+
? agentHint('running_activated', { url })
|
|
1110
|
+
: agentHint('running_not_activated', { url }),
|
|
617
1111
|
}));
|
|
1112
|
+
// 决策 2: sidecar 启动 verify hook — 用户关机重启不会错过 7 天窗口
|
|
1113
|
+
// fire-and-forget;失败不阻塞 sidecar 主路径
|
|
1114
|
+
if (state.brainKey) {
|
|
1115
|
+
runStartupSoulVerify().catch((err) => {
|
|
1116
|
+
console.error(JSON.stringify({
|
|
1117
|
+
ok: false,
|
|
1118
|
+
startup_verify_failed: err.message,
|
|
1119
|
+
}));
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
618
1122
|
const shutdown = () => {
|
|
619
1123
|
deleteLocalSettings();
|
|
620
1124
|
server.close(() => process.exit(0));
|
|
@@ -698,6 +1202,12 @@ const main = async () => {
|
|
|
698
1202
|
case 'status':
|
|
699
1203
|
await cmdStatus();
|
|
700
1204
|
break;
|
|
1205
|
+
case 'init':
|
|
1206
|
+
await cmdInit();
|
|
1207
|
+
break;
|
|
1208
|
+
case 'doctor':
|
|
1209
|
+
await cmdDoctor();
|
|
1210
|
+
break;
|
|
701
1211
|
case 'help':
|
|
702
1212
|
case '--help':
|
|
703
1213
|
case '-h':
|
|
@@ -709,6 +1219,54 @@ const main = async () => {
|
|
|
709
1219
|
process.exit(1);
|
|
710
1220
|
}
|
|
711
1221
|
};
|
|
1222
|
+
// ============================================================================
|
|
1223
|
+
// BSO M9 — init / doctor subcommands
|
|
1224
|
+
// ============================================================================
|
|
1225
|
+
async function cmdInit() {
|
|
1226
|
+
const { detectHarness } = await import('./harness/detector.js');
|
|
1227
|
+
const { initHarness } = await import('./harness/writers.js');
|
|
1228
|
+
const args = process.argv.slice(3);
|
|
1229
|
+
const flags = {};
|
|
1230
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1231
|
+
const t = args[i];
|
|
1232
|
+
if (t.startsWith('--')) {
|
|
1233
|
+
const eq = t.indexOf('=');
|
|
1234
|
+
if (eq > 0)
|
|
1235
|
+
flags[t.slice(2, eq)] = t.slice(eq + 1);
|
|
1236
|
+
else {
|
|
1237
|
+
const nxt = args[i + 1];
|
|
1238
|
+
if (nxt !== undefined && !nxt.startsWith('--')) {
|
|
1239
|
+
flags[t.slice(2)] = nxt;
|
|
1240
|
+
i += 1;
|
|
1241
|
+
}
|
|
1242
|
+
else
|
|
1243
|
+
flags[t.slice(2)] = true;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const detected = detectHarness({});
|
|
1248
|
+
const harness = (typeof flags.harness === 'string' && flags.harness) || detected.harness;
|
|
1249
|
+
const lang = (typeof flags.lang === 'string' && flags.lang) || 'auto';
|
|
1250
|
+
const report = initHarness({
|
|
1251
|
+
harness,
|
|
1252
|
+
cwd: process.cwd(),
|
|
1253
|
+
lang,
|
|
1254
|
+
llmOnly: flags['llm-only'] === true,
|
|
1255
|
+
});
|
|
1256
|
+
console.log(JSON.stringify({ ok: true, detect: detected, report }, null, 2));
|
|
1257
|
+
}
|
|
1258
|
+
async function cmdDoctor() {
|
|
1259
|
+
const { runDoctor, renderDoctorReport } = await import('./doctor.js');
|
|
1260
|
+
const report = await runDoctor(process.cwd());
|
|
1261
|
+
if (process.argv.includes('--json')) {
|
|
1262
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
console.log(renderDoctorReport(report));
|
|
1266
|
+
}
|
|
1267
|
+
const hasFail = report.items.some((i) => i.status === 'fail');
|
|
1268
|
+
process.exit(hasFail ? 1 : 0);
|
|
1269
|
+
}
|
|
712
1270
|
main().catch((e) => {
|
|
713
1271
|
console.error(JSON.stringify({ ok: false, error: e?.message || String(e) }));
|
|
714
1272
|
process.exit(1);
|