pi-pier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 档2 Week1:role manifest 类型 + 校验器(D76 v2;2026-08-18)。
3
+ *
4
+ * 契约镜像 `schemas/role-manifest.schema.json`(JSON Schema 文件是合同文档,
5
+ * 运行时用零依赖手写校验——不引 ajv,worker 侧不背包袱)。
6
+ *
7
+ * 严格契约:未知顶层键报 issue(防 "rulez" 类 typo 静默失效);
8
+ * 错误收集式(一次报全,便于修档案)。
9
+ */
10
+ export type PermissionAction = 'allow' | 'ask' | 'deny';
11
+ export type TodosMode = 'serial' | 'parallel';
12
+ /** D82:未知工具姿态——可见性=信任关系(用户→master 凭 install;master→被委派凭镜像)。 */
13
+ export type UnknownToolStance = 'allow' | 'deny';
14
+
15
+ export interface RoleManifest {
16
+ role: string;
17
+ /** 语义版本 x.y.z(档案演化追溯;P2)。 */
18
+ version: string;
19
+ /**
20
+ * WS-D10:按角色路由模型——`provider/model` 形态(advisor = 高级别第三方判断)。
21
+ * 可省略 = 跟随进程默认(master/worker 不设)。spawn 时注入 `--provider/--model`。
22
+ */
23
+ model?: string;
24
+ description?: string;
25
+ manifest: {
26
+ /** 基线工具(非空,必含 todo_write + ask_user_question——协调工具)。 */
27
+ tools: string[];
28
+ /** 三态权限;`*` 通配符默认;可省略 = `{"*":"allow"}`。 */
29
+ rules?: Record<string, PermissionAction>;
30
+ /**
31
+ * D82 unknownTools:清单外工具的姿态(缺省 deny = 安全默认)。
32
+ * master/worker = allow(用户安装的扩展默认可见可用——install 即授予);
33
+ * 自定义角色缺省 deny。与 rules 的 `*` 通配分属两轴:可见性 vs 强制力。
34
+ * 排除族(如 worker 的 subagent/terminal)须显式 deny 才能在 allow 姿态下持禁令。
35
+ */
36
+ unknownTools?: UnknownToolStance;
37
+ };
38
+ services?: {
39
+ todos?: {
40
+ mode?: TodosMode;
41
+ reminderLimit?: number;
42
+ };
43
+ };
44
+ }
45
+
46
+ export type ValidateResult =
47
+ | { ok: true; value: RoleManifest }
48
+ | { ok: false; code: 'INVALID_ROLE_CONFIG'; issues: string[] };
49
+
50
+ /** 所有角色必需的协调工具(C7 边界约束 1)。 */
51
+ export const COORDINATION_TOOLS = ['todo_write', 'ask_user_question'] as const;
52
+
53
+ const ROLE_NAME_RE = /^[a-z0-9-]+$/;
54
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
55
+ const TOOL_KEY_RE = /^[a-z0-9_*-]+$/;
56
+ const ACTIONS: readonly PermissionAction[] = ['allow', 'ask', 'deny'];
57
+
58
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
59
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
60
+ }
61
+
62
+ function isPosInt(v: unknown): boolean {
63
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
64
+ }
65
+
66
+ export function validateRoleManifest(input: unknown): ValidateResult {
67
+ const issues: string[] = [];
68
+ if (!isPlainObject(input)) {
69
+ return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
70
+ }
71
+
72
+ /* ── 顶层键 ── */
73
+ const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'manifest', 'services']);
74
+ for (const k of Object.keys(input)) {
75
+ if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
76
+ }
77
+
78
+ /* ── role 名 ── */
79
+ if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
80
+ issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
81
+ }
82
+
83
+ /* ── version(P2 semver) ── */
84
+ if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
85
+ issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
86
+ }
87
+
88
+ /* ── model(WS-D10:provider/model 按角色路由;可选) ── */
89
+ if (input.model !== undefined) {
90
+ if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
91
+ issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
92
+ }
93
+ }
94
+
95
+ /* ── description 可选 ── */
96
+ if (input.description !== undefined && typeof input.description !== 'string') {
97
+ issues.push('description 必须是字符串');
98
+ }
99
+
100
+ /* ── manifest ── */
101
+ const m = input.manifest;
102
+ if (!isPlainObject(m)) {
103
+ issues.push('manifest 必须是对象');
104
+ } else {
105
+ const KNOWN_MANIFEST = new Set(['tools', 'rules', 'unknownTools']);
106
+ for (const k of Object.keys(m)) {
107
+ if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
108
+ }
109
+
110
+ // D82 unknownTools:enum 校验,缺省 = deny(安全默认)
111
+ if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
112
+ issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
113
+ }
114
+
115
+ // tools:字符串数组、无重复、非空、含协调工具(空数组也要报缺协调工具)
116
+ const tools = m.tools;
117
+ if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
118
+ issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
119
+ } else {
120
+ const seen = new Set<string>();
121
+ for (const t of tools) {
122
+ if (seen.has(t)) issues.push(`manifest.tools 重复项 "${t}"`);
123
+ seen.add(t);
124
+ }
125
+ for (const need of COORDINATION_TOOLS) {
126
+ if (!seen.has(need)) issues.push(`基线非空约束:manifest.tools 必须包含 "${need}"(协调工具)`);
127
+ }
128
+ }
129
+
130
+ // rules:三态值;键 = 工具名或 *
131
+ if (m.rules !== undefined) {
132
+ if (!isPlainObject(m.rules)) {
133
+ issues.push('manifest.rules 必须是对象');
134
+ } else {
135
+ for (const [k, v] of Object.entries(m.rules)) {
136
+ if (!TOOL_KEY_RE.test(k)) issues.push(`rules 键 "${k}" 不是合法工具名([a-z0-9_-] 或 *)`);
137
+ if (!ACTIONS.includes(v as PermissionAction)) {
138
+ issues.push(`rules["${k}"] 必须是 ${ACTIONS.join('/')} 之一,收到 ${JSON.stringify(v)}`);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ /* ── services ── */
146
+ if (input.services !== undefined) {
147
+ if (!isPlainObject(input.services)) {
148
+ issues.push('services 必须是对象');
149
+ } else if (input.services.todos !== undefined) {
150
+ const t = input.services.todos;
151
+ if (!isPlainObject(t)) {
152
+ issues.push('services.todos 必须是对象');
153
+ } else {
154
+ if (t.mode !== undefined && t.mode !== 'serial' && t.mode !== 'parallel') {
155
+ issues.push(`services.todos.mode 必须是 serial/parallel,收到 ${JSON.stringify(t.mode)}`);
156
+ }
157
+ if (t.reminderLimit !== undefined && !isPosInt(t.reminderLimit)) {
158
+ issues.push(`services.todos.reminderLimit 必须是正整数,收到 ${JSON.stringify(t.reminderLimit)}`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ if (issues.length > 0) return { ok: false, code: 'INVALID_ROLE_CONFIG', issues };
165
+ return { ok: true, value: input as unknown as RoleManifest };
166
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "role": "master",
3
+ "version": "1.3.0",
4
+ "description": "主控角色(WS-D7 自应用;D83 镜像源):自研核心全量(todos/协调/subagent 族/终端族)+ pi 内建检视执行 + web_search;D82 unknownTools=allow——用户安装的任意扩展默认可见可用(install 即授予)",
5
+ "manifest": {
6
+ "unknownTools": "allow",
7
+ "tools": [
8
+ "todo_write", "ask_user_question",
9
+ "subagent", "resume_subagent", "list_agents", "send_message", "interrupt_agent",
10
+ "terminal_open", "terminal_send", "terminal_read", "terminal_signal", "terminal_close", "terminal_list",
11
+ "read", "grep", "glob", "bash", "pwsh", "edit", "write",
12
+ "web_search"
13
+ ],
14
+ "rules": {
15
+ "*": "allow"
16
+ }
17
+ },
18
+ "services": {
19
+ "todos": { "mode": "parallel", "reminderLimit": 5 }
20
+ }
21
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "role": "worker-default",
3
+ "version": "1.3.0",
4
+ "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial/3",
5
+ "manifest": {
6
+ "unknownTools": "allow",
7
+ "tools": [
8
+ "todo_write", "ask_user_question",
9
+ "read", "grep", "glob", "bash", "pwsh", "edit", "write",
10
+ "web_search"
11
+ ],
12
+ "rules": {
13
+ "subagent": "deny",
14
+ "resume_subagent": "deny",
15
+ "list_agents": "deny",
16
+ "send_message": "deny",
17
+ "interrupt_agent": "deny",
18
+ "terminal_open": "deny",
19
+ "terminal_send": "deny",
20
+ "terminal_read": "deny",
21
+ "terminal_signal": "deny",
22
+ "terminal_close": "deny",
23
+ "terminal_list": "deny",
24
+ "*": "allow"
25
+ }
26
+ },
27
+ "services": {
28
+ "todos": { "mode": "serial", "reminderLimit": 3 }
29
+ }
30
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * pi 会话 JSONL 尾部解析(纯函数库,v1.1 结果通道)。
3
+ *
4
+ * v1.1(DESIGN.md §12)起,子代理的结果不再从 pane 文本解析,而是读子代理的
5
+ * 会话文件(pi 会话 JSONL,权威、机器精确、无回声污染/滚动盲区)。
6
+ * 本模块只依赖 pi 会话格式(session-format 文档实测形状):
7
+ * 行 = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
8
+ */
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+
12
+ export interface SessionMessageLike {
13
+ role?: string;
14
+ content?: Array<{ type?: string; text?: string } | unknown> | unknown;
15
+ timestamp?: number;
16
+ stopReason?: string;
17
+ }
18
+
19
+ export interface SessionEntryLike {
20
+ type?: string;
21
+ message?: SessionMessageLike | unknown;
22
+ [k: string]: unknown;
23
+ }
24
+
25
+ /** 容忍式逐行解析(损坏行/非 JSON 行跳过)。 */
26
+ export function parseSessionEntries(text: string): SessionEntryLike[] {
27
+ const entries: SessionEntryLike[] = [];
28
+ for (const line of (text ?? '').split(/\r?\n/)) {
29
+ const t = line.trim();
30
+ if (!t) continue;
31
+ try {
32
+ const obj = JSON.parse(t);
33
+ if (obj && typeof obj === 'object') entries.push(obj as SessionEntryLike);
34
+ } catch {
35
+ /* 截断/损坏行跳过 */
36
+ }
37
+ }
38
+ return entries;
39
+ }
40
+
41
+ function messageOf(entry: SessionEntryLike): SessionMessageLike | null {
42
+ if (entry.type !== 'message') return null;
43
+ const m = entry.message;
44
+ if (typeof m !== 'object' || m === null) return null;
45
+ return m as SessionMessageLike;
46
+ }
47
+
48
+ function textOf(m: SessionMessageLike): string {
49
+ const content = m.content;
50
+ if (!Array.isArray(content)) return '';
51
+ return content
52
+ .map((c) => {
53
+ if (typeof c === 'object' && c !== null && (c as { type?: string }).type === 'text') {
54
+ return (c as { text?: string }).text ?? '';
55
+ }
56
+ return '';
57
+ })
58
+ .join('\n')
59
+ .trim();
60
+ }
61
+
62
+ /**
63
+ * 取最后一次"已定稿"的 assistant 文本(stopReason === 'stop',排除 toolUse 中间态)。
64
+ * sinceTs 用于过滤注入时间点之前的旧消息。
65
+ */
66
+ export function lastAssistantText(
67
+ entries: readonly SessionEntryLike[],
68
+ opts: { sinceTs?: number } = {},
69
+ ): { text: string; timestamp: number } | null {
70
+ let best: { text: string; timestamp: number } | null = null;
71
+ for (const entry of entries) {
72
+ const m = messageOf(entry);
73
+ if (!m || m.role !== 'assistant') continue;
74
+ if (m.stopReason !== 'stop') continue;
75
+ const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
76
+ if (opts.sinceTs !== undefined && ts < opts.sinceTs) continue;
77
+ if (!best || ts >= best.timestamp) best = { text: textOf(m), timestamp: ts };
78
+ }
79
+ return best;
80
+ }
81
+
82
+ /** 会话是否出现注入时间点之后的 assistant 消息(任意 stopReason,探测活跃)。 */
83
+ export function hasAssistantAfter(
84
+ entries: readonly SessionEntryLike[],
85
+ sinceTs: number,
86
+ ): boolean {
87
+ return entries.some((e) => {
88
+ const m = messageOf(e);
89
+ return !!m && m.role === 'assistant' && (typeof m.timestamp === 'number' ? m.timestamp : 0) >= sinceTs;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * 注入后是否存在"已发起但尚无结果"的 toolCall(工具执行中,如 ask_user_question
95
+ * 等人类输入)→ 未真正结算(v1.3 M8 结算竞态修复)。并行调用按深度计数。
96
+ */
97
+ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs: number): boolean {
98
+ let depth = 0;
99
+ for (const entry of entries) {
100
+ const m = messageOf(entry);
101
+ if (!m) continue;
102
+ const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
103
+ if (ts < sinceTs) continue;
104
+ if (m.role === 'assistant') {
105
+ const content = m.content;
106
+ if (Array.isArray(content)) {
107
+ depth += content.filter((c) => typeof c === 'object' && c !== null && (c as { type?: string }).type === 'toolCall').length;
108
+ }
109
+ }
110
+ if (m.role === 'toolResult') depth -= 1;
111
+ }
112
+ return depth > 0;
113
+ }
114
+
115
+ /** pi 会话目录名:cwd → `--F--herdr-pi--` 形态(实测命名约定)。 */
116
+ export function sessionDirName(cwd: string): string {
117
+ const flat = cwd.replace(/[\\/]/g, '-').replace(/:/g, '-');
118
+ return `--${flat}--`;
119
+ }
120
+
121
+ /** cwd 会话目录下按 mtime 倒序取最新 limit 个会话文件(v1.3 M7 候选定位)。 */
122
+ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
123
+ const dir = path.join(agentDir, sessionDirName(cwd));
124
+ let names: string[];
125
+ try {
126
+ names = fs.readdirSync(dir);
127
+ } catch {
128
+ return [];
129
+ }
130
+ const files: Array<{ file: string; mtime: number }> = [];
131
+ for (const name of names) {
132
+ if (!name.endsWith('.jsonl')) continue;
133
+ const full = path.join(dir, name);
134
+ try {
135
+ files.push({ file: full, mtime: fs.statSync(full).mtimeMs });
136
+ } catch {
137
+ /* 跳过消失文件 */
138
+ }
139
+ }
140
+ files.sort((a, b) => b.mtime - a.mtime);
141
+ return files.slice(0, limit).map((f) => f.file);
142
+ }
143
+
144
+ /**
145
+ * 按 session id 定位会话文件(文件名形如 `<ts>_<id>.jsonl`,pi 实测命名)。
146
+ * 官方 herdr 集成上报 agent_session 为 kind 'id' 时用它还原路径。
147
+ */
148
+ export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
149
+ const dir = path.join(agentDir, sessionDirName(cwd));
150
+ const suffix = `_${id}.jsonl`;
151
+ let names: string[];
152
+ try {
153
+ names = fs.readdirSync(dir);
154
+ } catch {
155
+ return null;
156
+ }
157
+ for (const name of names) {
158
+ if (name.endsWith(suffix)) return path.join(dir, name);
159
+ }
160
+ return null;
161
+ }
162
+
163
+ export function readSessionFile(file: string): SessionEntryLike[] | null {
164
+ try {
165
+ return parseSessionEntries(fs.readFileSync(file, 'utf8'));
166
+ } catch {
167
+ return null;
168
+ }
169
+ }