pi-shepherd 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.
- package/README.en.md +136 -0
- package/README.md +136 -0
- package/index.ts +229 -0
- package/node_modules/@pi-atelier/shared-utils/README.en.md +182 -0
- package/node_modules/@pi-atelier/shared-utils/README.md +182 -0
- package/node_modules/@pi-atelier/shared-utils/package.json +51 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +120 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +100 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +152 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +187 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +170 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +126 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +138 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +257 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +189 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +204 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +269 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +267 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +520 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +116 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +119 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +145 -0
- package/node_modules/@pi-atelier/shared-utils/src/agents.ts +39 -0
- package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +42 -0
- package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +62 -0
- package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +100 -0
- package/node_modules/@pi-atelier/shared-utils/src/index.ts +71 -0
- package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +96 -0
- package/node_modules/@pi-atelier/shared-utils/src/paths.ts +23 -0
- package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +241 -0
- package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +191 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +73 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +104 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +75 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +172 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings.ts +104 -0
- package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +149 -0
- package/node_modules/@pi-atelier/shared-utils/tsconfig.json +9 -0
- package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +24 -0
- package/package.json +49 -0
- package/rules.json +516 -0
- package/shepherd/ephemeral-shared.ts +14 -0
- package/shepherd/ephemeral.ts +52 -0
- package/shepherd/index.ts +39 -0
- package/shepherd/line-count.ts +86 -0
- package/shepherd/rules-editor.ts +135 -0
- package/shepherd/rules-tool.ts +99 -0
- package/shepherd/rules-validate.ts +44 -0
- package/shepherd/rules.ts +283 -0
- package/shepherd/state-tracker.ts +119 -0
- package/shepherd/tool-event-types.ts +31 -0
- package/shepherd/tool-hooks.ts +176 -0
- package/shepherd/worktree-check.ts +130 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.ts 写入操作测试(patchSettingsSection, setSettingsValue)
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
+
|
|
6
|
+
const store = vi.hoisted(() => {
|
|
7
|
+
let content = "";
|
|
8
|
+
return { get: () => content, set: (v: string) => { content = v; } };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const mockOs = vi.hoisted(() => ({
|
|
12
|
+
homedir: vi.fn().mockReturnValue("/fake-home"),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("node:os", () => mockOs);
|
|
15
|
+
|
|
16
|
+
const mockFs = vi.hoisted(() => ({
|
|
17
|
+
existsSync: vi.fn(),
|
|
18
|
+
readFileSync: vi.fn(() => store.get()),
|
|
19
|
+
writeFileSync: vi.fn((_path: string, data: string) => store.set(data)),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("node:fs", () => mockFs);
|
|
22
|
+
|
|
23
|
+
import { patchSettingsSection, setSettingsValue } from "../settings";
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
store.set("");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function mockSettings(content: Record<string, unknown>): void {
|
|
31
|
+
store.set(JSON.stringify(content));
|
|
32
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodedStorage(): Record<string, unknown> {
|
|
36
|
+
return JSON.parse(store.get());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── patchSettingsSection ────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("patchSettingsSection", () => {
|
|
42
|
+
it("updates specified fields and persists", () => {
|
|
43
|
+
mockSettings({ context: { threshold: 5000 } });
|
|
44
|
+
const result = patchSettingsSection("context", { threshold: 8000 }, { threshold: 5000, active: true });
|
|
45
|
+
expect(result).toEqual({ threshold: 8000, active: true });
|
|
46
|
+
expect(decodedStorage().context.threshold).toBe(8000);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("preserves unspecified fields", () => {
|
|
50
|
+
mockSettings({ context: { threshold: 5000, active: false } });
|
|
51
|
+
const result = patchSettingsSection("context", { threshold: 10000 }, { threshold: 5000, active: false });
|
|
52
|
+
expect(result).toEqual({ threshold: 10000, active: false });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("skips patch entries with undefined value", () => {
|
|
56
|
+
mockSettings({ context: { threshold: 5000 } });
|
|
57
|
+
const result = patchSettingsSection(
|
|
58
|
+
"context",
|
|
59
|
+
{ threshold: 8000, active: undefined },
|
|
60
|
+
{ threshold: 5000, active: true },
|
|
61
|
+
);
|
|
62
|
+
expect(result).toEqual({ threshold: 8000, active: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("creates new section when not present", () => {
|
|
66
|
+
mockSettings({});
|
|
67
|
+
const result = patchSettingsSection("newSection", { key: "val" }, { key: "" });
|
|
68
|
+
expect(result).toEqual({ key: "val" });
|
|
69
|
+
expect(decodedStorage().newSection.key).toBe("val");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("appends trailing newline in written JSON", () => {
|
|
73
|
+
mockSettings({ context: { threshold: 5000 } });
|
|
74
|
+
patchSettingsSection("context", { threshold: 9999 }, { threshold: 5000 });
|
|
75
|
+
expect(store.get().endsWith("\n")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── setSettingsValue ────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("setSettingsValue", () => {
|
|
82
|
+
it("sets value at simple path", () => {
|
|
83
|
+
mockSettings({});
|
|
84
|
+
setSettingsValue("recording.enabled", true);
|
|
85
|
+
expect(decodedStorage().recording.enabled).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("overwrites existing value", () => {
|
|
89
|
+
mockSettings({ recording: { enabled: false } });
|
|
90
|
+
setSettingsValue("recording.enabled", true);
|
|
91
|
+
expect(decodedStorage().recording.enabled).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("creates intermediate objects", () => {
|
|
95
|
+
mockSettings({});
|
|
96
|
+
setSettingsValue("deeply.nested.path", 42);
|
|
97
|
+
expect(decodedStorage().deeply.nested.path).toBe(42);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("replaces non-object with object at intermediate path", () => {
|
|
101
|
+
mockSettings({ a: "string" });
|
|
102
|
+
setSettingsValue("a.b.c", 1);
|
|
103
|
+
expect(decodedStorage().a.b.c).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("appends trailing newline in written JSON", () => {
|
|
107
|
+
mockSettings({});
|
|
108
|
+
setSettingsValue("key", "val");
|
|
109
|
+
expect(store.get().endsWith("\n")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("preserves other sections when setting a value", () => {
|
|
113
|
+
mockSettings({ other: { keep: true } });
|
|
114
|
+
setSettingsValue("new.key", "value");
|
|
115
|
+
const data = decodedStorage();
|
|
116
|
+
expect(data.other.keep).toBe(true);
|
|
117
|
+
expect(data.new.key).toBe("value");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { mockTruncateHead, mockFormatSize, mockCreateHashObj } = vi.hoisted(() => ({
|
|
4
|
+
mockTruncateHead: vi.fn(),
|
|
5
|
+
mockFormatSize: vi.fn((bytes: number) => `${bytes}B`),
|
|
6
|
+
mockCreateHashObj: {
|
|
7
|
+
update: vi.fn().mockReturnThis(),
|
|
8
|
+
digest: vi.fn(() => "a1b2c3d4e5f6g7h8i9j0k1l2"),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
13
|
+
DEFAULT_MAX_LINES: 2000,
|
|
14
|
+
DEFAULT_MAX_BYTES: 51_200,
|
|
15
|
+
formatSize: mockFormatSize,
|
|
16
|
+
truncateHead: mockTruncateHead,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("node:crypto", () => ({
|
|
20
|
+
createHash: vi.fn(() => mockCreateHashObj),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { truncateToolOutput, truncatedResult } from "../tool-output";
|
|
24
|
+
|
|
25
|
+
const TRUNC = {
|
|
26
|
+
content: "head",
|
|
27
|
+
truncated: true,
|
|
28
|
+
truncatedBy: "lines" as const,
|
|
29
|
+
totalLines: 200,
|
|
30
|
+
totalBytes: 100_000,
|
|
31
|
+
outputLines: 50,
|
|
32
|
+
outputBytes: 25_000,
|
|
33
|
+
lastLinePartial: false,
|
|
34
|
+
firstLineExceedsLimit: false,
|
|
35
|
+
maxLines: 2000,
|
|
36
|
+
maxBytes: 51_200,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const UNTRUNC = {
|
|
40
|
+
content: "full",
|
|
41
|
+
truncated: false,
|
|
42
|
+
truncatedBy: null,
|
|
43
|
+
totalLines: 5,
|
|
44
|
+
totalBytes: 200,
|
|
45
|
+
outputLines: 5,
|
|
46
|
+
outputBytes: 200,
|
|
47
|
+
lastLinePartial: false,
|
|
48
|
+
firstLineExceedsLimit: false,
|
|
49
|
+
maxLines: 2000,
|
|
50
|
+
maxBytes: 51_200,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
beforeEach(() => vi.clearAllMocks());
|
|
54
|
+
|
|
55
|
+
describe("truncateToolOutput", () => {
|
|
56
|
+
it("returns untruncated result when content fits limits", () => {
|
|
57
|
+
mockTruncateHead.mockReturnValue(UNTRUNC);
|
|
58
|
+
const r = truncateToolOutput("full content");
|
|
59
|
+
expect(r.truncated).toBe(false);
|
|
60
|
+
expect(r.content).toBe("full content");
|
|
61
|
+
expect(r.totalLines).toBe(5);
|
|
62
|
+
expect(r.totalBytes).toBe(200);
|
|
63
|
+
expect(r.outputLines).toBe(5);
|
|
64
|
+
expect(r.outputBytes).toBe(200);
|
|
65
|
+
expect(r.savedPath).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("truncates and writes temp file when content exceeds limits", () => {
|
|
69
|
+
mockTruncateHead.mockReturnValue(TRUNC);
|
|
70
|
+
const r = truncateToolOutput("head content only", {
|
|
71
|
+
toolName: "my-tool",
|
|
72
|
+
label: "MyTool",
|
|
73
|
+
});
|
|
74
|
+
expect(r.truncated).toBe(true);
|
|
75
|
+
expect(r.content).toContain("[输出已截断: MyTool]");
|
|
76
|
+
expect(r.content).toContain("提示: 用 read 工具的 offset/limit");
|
|
77
|
+
expect(r.totalLines).toBe(200);
|
|
78
|
+
expect(r.totalBytes).toBe(100_000);
|
|
79
|
+
expect(r.outputLines).toBe(50);
|
|
80
|
+
expect(r.outputBytes).toBe(25_000);
|
|
81
|
+
expect(r.savedPath).toBeDefined();
|
|
82
|
+
expect(r.savedPath).toContain("/pi-tool-output/my-tool-a1b2c3d4e5f6.txt");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("accepts custom maxLines and maxBytes", () => {
|
|
86
|
+
mockTruncateHead.mockReturnValue({ ...TRUNC, maxLines: 100, maxBytes: 10_000 });
|
|
87
|
+
truncateToolOutput("text", { maxLines: 100, maxBytes: 10_000 });
|
|
88
|
+
expect(mockTruncateHead).toHaveBeenCalledWith("text", {
|
|
89
|
+
maxLines: 100,
|
|
90
|
+
maxBytes: 10_000,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("uses default options when none provided", () => {
|
|
95
|
+
mockTruncateHead.mockReturnValue(UNTRUNC);
|
|
96
|
+
truncateToolOutput("text");
|
|
97
|
+
expect(mockTruncateHead).toHaveBeenCalledWith("text", {
|
|
98
|
+
maxLines: 2000,
|
|
99
|
+
maxBytes: 51_200,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to toolName when label is not provided", () => {
|
|
104
|
+
mockTruncateHead.mockReturnValue(TRUNC);
|
|
105
|
+
const r = truncateToolOutput("text", { toolName: "search" });
|
|
106
|
+
expect(r.content).toContain("[输出已截断: search]");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("truncatedResult", () => {
|
|
111
|
+
it("returns pi tool format without truncation", () => {
|
|
112
|
+
mockTruncateHead.mockReturnValue(UNTRUNC);
|
|
113
|
+
const r = truncatedResult("hello world");
|
|
114
|
+
expect(r.content).toEqual([{ type: "text", text: "hello world" }]);
|
|
115
|
+
expect(r.details).toEqual({ truncation: null });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("adds truncation details when truncated", () => {
|
|
119
|
+
mockTruncateHead.mockReturnValue(TRUNC);
|
|
120
|
+
const r = truncatedResult("head content only", { toolName: "test" });
|
|
121
|
+
expect(r.content[0].text).toContain("[输出已截断: test]");
|
|
122
|
+
expect(r.details.truncation).toEqual({
|
|
123
|
+
truncated: true,
|
|
124
|
+
totalLines: 200,
|
|
125
|
+
totalBytes: 100_000,
|
|
126
|
+
outputLines: 50,
|
|
127
|
+
outputBytes: 25_000,
|
|
128
|
+
savedPath: expect.any(String),
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("merges existingDetails with truncation info", () => {
|
|
133
|
+
mockTruncateHead.mockReturnValue(TRUNC);
|
|
134
|
+
const r = truncatedResult("text", {}, { source: "session_search", extra: 42 });
|
|
135
|
+
expect(r.details.source).toBe("session_search");
|
|
136
|
+
expect(r.details.extra).toBe(42);
|
|
137
|
+
expect(r.details.truncation).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("omits truncation when not truncated", () => {
|
|
141
|
+
mockTruncateHead.mockReturnValue(UNTRUNC);
|
|
142
|
+
const r = truncatedResult("hello");
|
|
143
|
+
expect(r.details.truncation).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent 发现 — 扫描 ~/.pi/agent/agents/*.md 获取可用子代理列表
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { AGENTS_DIR } from "./paths";
|
|
8
|
+
|
|
9
|
+
/** 扫描所有可用的 agent 名称 */
|
|
10
|
+
export function discoverAgents(): string[] {
|
|
11
|
+
if (!fs.existsSync(AGENTS_DIR)) return [];
|
|
12
|
+
return fs
|
|
13
|
+
.readdirSync(AGENTS_DIR)
|
|
14
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
15
|
+
.map((f) => f.replace(/\.md$/, ""));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 获取 agent 的简短描述(从 frontmatter 的 description 字段) */
|
|
19
|
+
export function getAgentDescription(name: string): string {
|
|
20
|
+
const filePath = path.join(AGENTS_DIR, `${name}.md`);
|
|
21
|
+
try {
|
|
22
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
23
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const descLine = match[1].split("\n").find((l) => l.startsWith("description:"));
|
|
26
|
+
if (descLine) return descLine.replace(/^description:\s*/, "").trim();
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore */
|
|
30
|
+
}
|
|
31
|
+
return "read, grep, find, ls";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 格式化为 description 中的子代理列表文本 */
|
|
35
|
+
export function formatAgentsList(): string {
|
|
36
|
+
const agents = discoverAgents();
|
|
37
|
+
if (agents.length === 0) return "(无可用子代理)";
|
|
38
|
+
return agents.map((a) => `- **${a}**: ${getAgentDescription(a)}`).join("\n");
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 临时提示注入机制(单扩展内使用)
|
|
3
|
+
*
|
|
4
|
+
* 提供提示缓冲区(pushHint/drainHints/peekHints)。
|
|
5
|
+
*
|
|
6
|
+
* 跨扩展传递提示请用 pi.events.emit("ephemeral:hint"),
|
|
7
|
+
* 不要依赖本模块的内存变量(jiti 多实例问题)。
|
|
8
|
+
*
|
|
9
|
+
* payload 持久化/录制已移到 shepherd 扩展内联处理。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const _hints: string[] = [];
|
|
13
|
+
const _labels: string[] = [];
|
|
14
|
+
|
|
15
|
+
/** 推入一条临时提示 */
|
|
16
|
+
export function pushHint(hint: string, label?: string): void {
|
|
17
|
+
_hints.push(hint);
|
|
18
|
+
if (label) _labels.push(label);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 查看待发送的标签(不消费) */
|
|
22
|
+
export function peekLabels(): string[] {
|
|
23
|
+
return [..._labels];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 缓冲区是否有待发送的提示 */
|
|
27
|
+
export function hasHints(): boolean {
|
|
28
|
+
return _hints.length > 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 查看待发送的提示内容(不消费) */
|
|
32
|
+
export function peekHints(): string | null {
|
|
33
|
+
if (_hints.length === 0) return null;
|
|
34
|
+
return _hints.join("\n\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 取出所有待发送的提示,拼接为单条 user message(同时清空 labels) */
|
|
38
|
+
export function drainHints(): string | null {
|
|
39
|
+
if (_hints.length === 0) return null;
|
|
40
|
+
_labels.splice(0);
|
|
41
|
+
return _hints.splice(0).join("\n\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件锁(与 pi 的 proper-lockfile 兼容)
|
|
3
|
+
*
|
|
4
|
+
* proper-lockfile 用 mkdir 创建 .lock 目录做互斥,我们复用同一机制。
|
|
5
|
+
* 锁文件路径 = `${filePath}.lock`(目录),过时判断 = mtime > 10s。
|
|
6
|
+
*
|
|
7
|
+
* 这样 shared-utils 的写入和 pi 的 SettingsManager 不会竞态覆盖。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
const LOCK_STALE_MS = 10_000;
|
|
13
|
+
const LOCK_MAX_ATTEMPTS = 50;
|
|
14
|
+
const LOCK_RETRY_DELAY_MS = 20;
|
|
15
|
+
|
|
16
|
+
export function acquireLock(filePath: string): void {
|
|
17
|
+
const lockDir = `${filePath}.lock`;
|
|
18
|
+
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(lockDir);
|
|
21
|
+
return; // 获锁成功
|
|
22
|
+
} catch (err: unknown) {
|
|
23
|
+
if (!(err instanceof Error) || (err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
24
|
+
// 锁被占 — 检查是否过时
|
|
25
|
+
try {
|
|
26
|
+
const stat = statSync(lockDir);
|
|
27
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
28
|
+
// 过时锁,强制删除后重试
|
|
29
|
+
rmSync(lockDir, { recursive: true });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// 锁刚好被释放,重试
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// 锁仍有效,等待后重试
|
|
37
|
+
const start = Date.now();
|
|
38
|
+
while (Date.now() - start < LOCK_RETRY_DELAY_MS) {
|
|
39
|
+
// 同步等待(和 pi 的 SettingsManager 一致)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`无法获取文件锁: ${lockDir}(尝试 ${LOCK_MAX_ATTEMPTS} 次后超时)`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function releaseLock(filePath: string): void {
|
|
47
|
+
const lockDir = `${filePath}.lock`;
|
|
48
|
+
try {
|
|
49
|
+
rmSync(lockDir, { recursive: true });
|
|
50
|
+
} catch {
|
|
51
|
+
// 忽略 — 可能已被其他进程清理
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function withFileLock<T>(filePath: string, fn: () => T): T {
|
|
56
|
+
acquireLock(filePath);
|
|
57
|
+
try {
|
|
58
|
+
return fn();
|
|
59
|
+
} finally {
|
|
60
|
+
releaseLock(filePath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 过滤匹配工具 — toolName 前缀/通配符/多值 + file 路径匹配
|
|
3
|
+
*
|
|
4
|
+
* 供 session-analyzer 和 context-manager 的 entries/messages 过滤使用。
|
|
5
|
+
* 零外部依赖,纯逻辑函数。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── globToRegex ──────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** 将简易 glob(只含 *)转为 RegExp。仅处理 *,不处理 ? 和 [] */
|
|
11
|
+
function globToRegex(pattern: string): RegExp {
|
|
12
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
const starReplaced = escaped.replace(/\*/g, ".*");
|
|
14
|
+
return new RegExp(`^${starReplaced}$`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── matchToolName ────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 工具名匹配,按优先级:
|
|
21
|
+
* 1. 空/undefined → 不过滤(true)
|
|
22
|
+
* 2. 含 `|` → 多值 OR,递归每个子串
|
|
23
|
+
* 3. 含 `*` → glob 匹配
|
|
24
|
+
* 4. 精确匹配 ===
|
|
25
|
+
* 5. 前缀匹配 startsWith
|
|
26
|
+
*/
|
|
27
|
+
export function matchToolName(input: string | undefined, toolName: string): boolean {
|
|
28
|
+
if (!input) return true;
|
|
29
|
+
|
|
30
|
+
// 多值 OR
|
|
31
|
+
if (input.includes("|")) {
|
|
32
|
+
return input.split("|").some((part) => matchToolName(part.trim(), toolName));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// glob 匹配
|
|
36
|
+
if (input.includes("*")) {
|
|
37
|
+
return globToRegex(input).test(toolName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 精确匹配
|
|
41
|
+
if (input === toolName) return true;
|
|
42
|
+
|
|
43
|
+
// 不做裸前缀匹配 — 用户需显式用通配符
|
|
44
|
+
// 例如 "code_graph*" 而不是 "code_graph"
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── extractStringValues ──────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** 从嵌套 JSON 值中递归提取所有 string */
|
|
51
|
+
export function extractStringValues(value: unknown): string[] {
|
|
52
|
+
if (typeof value === "string") return [value];
|
|
53
|
+
if (value == null || typeof value !== "object") return [];
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
const result: string[] = [];
|
|
57
|
+
for (const item of value) {
|
|
58
|
+
result.push(...extractStringValues(item));
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 普通对象
|
|
64
|
+
const result: string[] = [];
|
|
65
|
+
for (const val of Object.values(value as Record<string, unknown>)) {
|
|
66
|
+
result.push(...extractStringValues(val));
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── matchFile ────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 文件路径匹配,候选路径列表中任一命中即可:
|
|
75
|
+
* 1. 空/undefined → 不过滤(true)
|
|
76
|
+
* 2. 含 `|` → 多值 OR
|
|
77
|
+
* 3. 含 `*` → glob 匹配
|
|
78
|
+
* 4. 子串匹配
|
|
79
|
+
*/
|
|
80
|
+
export function matchFile(input: string | undefined, filePaths: string[]): boolean {
|
|
81
|
+
if (!input) return true;
|
|
82
|
+
if (filePaths.length === 0) return false;
|
|
83
|
+
|
|
84
|
+
// 多值 OR
|
|
85
|
+
if (input.includes("|")) {
|
|
86
|
+
return input.split("|").some((part) => matchFile(part.trim(), filePaths));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 每条候选路径逐一检查
|
|
90
|
+
for (const fp of filePaths) {
|
|
91
|
+
// glob
|
|
92
|
+
if (input.includes("*")) {
|
|
93
|
+
if (globToRegex(input).test(fp)) return true;
|
|
94
|
+
} else {
|
|
95
|
+
// 子串匹配
|
|
96
|
+
if (fp.includes(input)) return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-atelier/shared-utils — barrel export
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
discoverAgents,
|
|
7
|
+
formatAgentsList,
|
|
8
|
+
getAgentDescription,
|
|
9
|
+
} from "./agents";
|
|
10
|
+
export {
|
|
11
|
+
drainHints,
|
|
12
|
+
hasHints,
|
|
13
|
+
peekHints,
|
|
14
|
+
peekLabels,
|
|
15
|
+
pushHint,
|
|
16
|
+
} from "./ephemeral";
|
|
17
|
+
export {
|
|
18
|
+
buildFileName,
|
|
19
|
+
type MemoryEntry,
|
|
20
|
+
parseFileName,
|
|
21
|
+
scanMemoryDir,
|
|
22
|
+
} from "./memory-parser";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
AGENT_DIR,
|
|
26
|
+
AGENTS_DIR,
|
|
27
|
+
GLOBAL_RULES_PATH,
|
|
28
|
+
MCP_CACHE_PATH,
|
|
29
|
+
MCP_CONFIG_PATH,
|
|
30
|
+
MEMORY_DIR,
|
|
31
|
+
MEMORY_MD_PATH,
|
|
32
|
+
MODELS_CONFIG_PATH,
|
|
33
|
+
SETTINGS_PATH,
|
|
34
|
+
} from "./paths";
|
|
35
|
+
export {
|
|
36
|
+
type ConfigConflict,
|
|
37
|
+
clearProjectSettingsCache,
|
|
38
|
+
detectConfigConflicts,
|
|
39
|
+
type EffectiveConfigResult,
|
|
40
|
+
getEffectiveConfig,
|
|
41
|
+
type MergeOptions,
|
|
42
|
+
type SchemaError,
|
|
43
|
+
validateConfigSchema,
|
|
44
|
+
} from "./project-config";
|
|
45
|
+
export {
|
|
46
|
+
getDisabledMcpServers,
|
|
47
|
+
getDisabledTools,
|
|
48
|
+
getEnabledTools,
|
|
49
|
+
isToolDisabled,
|
|
50
|
+
type ToolFilter,
|
|
51
|
+
} from "./project-tools";
|
|
52
|
+
export {
|
|
53
|
+
getSettingsSection,
|
|
54
|
+
getSettingsValue,
|
|
55
|
+
patchSettingsSection,
|
|
56
|
+
setSettingsValue,
|
|
57
|
+
} from "./settings";
|
|
58
|
+
export {
|
|
59
|
+
listBackups,
|
|
60
|
+
patchSettingsSectionWithBackup,
|
|
61
|
+
rollbackSettings,
|
|
62
|
+
type BackupEntry,
|
|
63
|
+
type PatchBackupOptions,
|
|
64
|
+
type PatchBackupResult,
|
|
65
|
+
type RollbackOptions,
|
|
66
|
+
} from "./settings-backup";
|
|
67
|
+
export {
|
|
68
|
+
matchToolName,
|
|
69
|
+
matchFile,
|
|
70
|
+
extractStringValues,
|
|
71
|
+
} from "./filter-match";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Parser — 共享的记忆文件名解析与目录扫描
|
|
3
|
+
*
|
|
4
|
+
* 文件名格式:topic--kw1,kw2,kw3,kw4,kw5.md
|
|
5
|
+
* -- 分隔 topic 和关键词
|
|
6
|
+
* , 分隔关键词
|
|
7
|
+
*
|
|
8
|
+
* 被 memory 扩展和 smart-context 扩展共同使用。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
// ── 类型定义 ─────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface MemoryEntry {
|
|
17
|
+
name: string;
|
|
18
|
+
file: string;
|
|
19
|
+
description: string;
|
|
20
|
+
lines: number;
|
|
21
|
+
scope: "L1" | "L2";
|
|
22
|
+
topic: string;
|
|
23
|
+
keywords: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── 文件名解析 ───────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** 从文件名解析 topic 和 keywords
|
|
29
|
+
*
|
|
30
|
+
* 格式:topic--kw1,kw2,kw3.md
|
|
31
|
+
* 无 -- 时 topic = basename(兼容旧格式)
|
|
32
|
+
*/
|
|
33
|
+
export function parseFileName(fileName: string): { topic: string; keywords: string[] } {
|
|
34
|
+
if (fileName == null) return { topic: "", keywords: [] };
|
|
35
|
+
const base = fileName.replace(/\.md$/, "");
|
|
36
|
+
const idx = base.indexOf("--");
|
|
37
|
+
if (idx === -1) {
|
|
38
|
+
return { topic: base, keywords: [] };
|
|
39
|
+
}
|
|
40
|
+
const topic = base.substring(0, idx);
|
|
41
|
+
const kwStr = base.substring(idx + 2);
|
|
42
|
+
const keywords = kwStr ? kwStr.split(",") : [];
|
|
43
|
+
return { topic, keywords };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 生成文件名:topic--kw1,kw2,...md */
|
|
47
|
+
export function buildFileName(topic: string, keywords: string[]): string {
|
|
48
|
+
const kwPart = keywords.length > 0 ? keywords.join(",") : "";
|
|
49
|
+
const base = kwPart ? `${topic}--${kwPart}` : topic;
|
|
50
|
+
return `${base}.md`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── 目录扫描 ─────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** 扫描目录下的记忆文件,从文件名解析 topic + keywords */
|
|
56
|
+
export function scanMemoryDir(dir: string, scope: "L1" | "L2"): MemoryEntry[] {
|
|
57
|
+
const entries: MemoryEntry[] = [];
|
|
58
|
+
if (!fs.existsSync(dir)) return entries;
|
|
59
|
+
|
|
60
|
+
const files = fs
|
|
61
|
+
.readdirSync(dir)
|
|
62
|
+
.filter((f) => f.endsWith(".md") && f !== "MEMORY.md")
|
|
63
|
+
.sort();
|
|
64
|
+
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const filePath = path.join(dir, file);
|
|
67
|
+
try {
|
|
68
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
69
|
+
const lines = content.split("\n").length;
|
|
70
|
+
const titleLine = content.split("\n").find((l) => l.startsWith("# "));
|
|
71
|
+
const description = titleLine?.replace(/^#\s+/, "") || "";
|
|
72
|
+
const parsed = parseFileName(file);
|
|
73
|
+
entries.push({
|
|
74
|
+
name: file.replace(".md", ""),
|
|
75
|
+
file,
|
|
76
|
+
description,
|
|
77
|
+
lines,
|
|
78
|
+
scope,
|
|
79
|
+
topic: parsed.topic,
|
|
80
|
+
keywords: parsed.keywords,
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
entries.push({
|
|
84
|
+
name: file.replace(".md", ""),
|
|
85
|
+
file,
|
|
86
|
+
description: "(读取失败)",
|
|
87
|
+
lines: 0,
|
|
88
|
+
scope,
|
|
89
|
+
topic: parseFileName(file).topic,
|
|
90
|
+
keywords: parseFileName(file).keywords,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享路径常量
|
|
3
|
+
*
|
|
4
|
+
* 统一管理 ~/.pi/agent 下所有路径,支持 PI_AGENT_DIR 环境变量覆盖。
|
|
5
|
+
* 用 `||` 确保空字符串也会回退到默认值。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** 统一的 agent 目录路径 */
|
|
12
|
+
export const AGENT_DIR = process.env.PI_AGENT_DIR || join(homedir(), ".pi/agent");
|
|
13
|
+
|
|
14
|
+
// ── 常用路径常量 ──────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export const SETTINGS_PATH = join(AGENT_DIR, "settings.json");
|
|
17
|
+
export const MODELS_CONFIG_PATH = join(AGENT_DIR, "models.json");
|
|
18
|
+
export const MEMORY_MD_PATH = join(AGENT_DIR, "MEMORY.md");
|
|
19
|
+
export const MEMORY_DIR = join(AGENT_DIR, "memory");
|
|
20
|
+
export const MCP_CONFIG_PATH = join(AGENT_DIR, "mcp.json");
|
|
21
|
+
export const MCP_CACHE_PATH = join(AGENT_DIR, "mcp-cache.json");
|
|
22
|
+
export const AGENTS_DIR = join(AGENT_DIR, "agents");
|
|
23
|
+
export const GLOBAL_RULES_PATH = join(AGENT_DIR, "extensions/shepherd/rules.json");
|