personal-bot 0.2.2 → 0.2.4

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.
Files changed (45) hide show
  1. package/CONTEXT.md +4 -0
  2. package/README.md +39 -6
  3. package/README.zh-CN.md +39 -6
  4. package/bin/pbot.mjs +3 -0
  5. package/docs/README.md +2 -0
  6. package/docs/file-storage.md +7 -2
  7. package/docs/history.md +40 -0
  8. package/docs/installation-design.md +224 -0
  9. package/docs/release.md +2 -2
  10. package/docs/technical-design.md +7 -2
  11. package/docs/validation.md +18 -2
  12. package/lib/common/package-source.js +16 -0
  13. package/lib/extension/setup-cli.js +136 -0
  14. package/lib/extension/setup-guard.js +11 -0
  15. package/lib/integrations/installation/pi-bridge.js +131 -0
  16. package/lib/integrations/installation/pi-setup.js +193 -0
  17. package/lib/integrations/installation/processes.js +155 -0
  18. package/lib/integrations/installation/workspace.js +21 -0
  19. package/lib/services/installation/setup-service.js +121 -0
  20. package/lib/ui/setup.js +52 -0
  21. package/npm-shrinkwrap.json +29 -4
  22. package/package.json +26 -4
  23. package/resources/agents/coordinator.md +1 -0
  24. package/resources/installer/plugins.json +16 -0
  25. package/resources/prompts/history-summary.md +7 -0
  26. package/scripts/README.md +4 -0
  27. package/src/common/package-source.ts +16 -0
  28. package/src/extension/command.ts +5 -3
  29. package/src/extension/history.ts +71 -0
  30. package/src/extension/lifecycle.ts +4 -2
  31. package/src/extension/setup-cli.ts +109 -0
  32. package/src/extension/setup-guard.ts +12 -0
  33. package/src/integrations/installation/pi-bridge.ts +107 -0
  34. package/src/integrations/installation/pi-setup.ts +162 -0
  35. package/src/integrations/installation/processes.ts +124 -0
  36. package/src/integrations/installation/workspace.ts +18 -0
  37. package/src/integrations/pi/history.ts +107 -0
  38. package/src/model/history.ts +31 -0
  39. package/src/pbot.ts +15 -2
  40. package/src/services/history/history-service.ts +145 -0
  41. package/src/services/installation/setup-service.ts +145 -0
  42. package/src/storage/history-files.ts +25 -0
  43. package/src/tools/history/tool.ts +42 -0
  44. package/src/tools/register.ts +5 -3
  45. package/src/ui/setup.ts +40 -0
@@ -0,0 +1,107 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parseSessionEntries, SessionManager, type ExtensionContext, type SessionEntry, type SessionMessageEntry } from "@earendil-works/pi-coding-agent";
4
+ import type { HistoryExchange, HistoryLocation, HistorySession, HistorySource, HistorySummarizer } from "../../model/history.js";
5
+ import { requireThat } from "../../common/validation.js";
6
+ import { resourceRoot } from "../../common/paths.js";
7
+
8
+ function text(content: unknown): string {
9
+ if (typeof content === "string") return content;
10
+ if (!Array.isArray(content)) return "";
11
+ return content.flatMap(block => block.type === "text" ? [String(block.text)] : block.type === "image" ? ["[图片]"] : []).join("\n");
12
+ }
13
+ function exchanges(branch: SessionEntry[]): HistoryExchange[] {
14
+ const result: HistoryExchange[] = [];
15
+ let users: SessionMessageEntry[] = [], partial: SessionMessageEntry | undefined;
16
+ const finish = (answer?: SessionMessageEntry, completed = false) => {
17
+ if (!users.length) return;
18
+ const entries = [...users, ...(answer ? [answer] : [])];
19
+ result.push({ entryIds: entries.map(entry => entry.id), from: users[0]!.timestamp, to: entries.at(-1)!.timestamp, completed,
20
+ text: users.map(entry => "用户:\n" + text("content" in entry.message ? entry.message.content : "")).join("\n\n") +
21
+ (answer && "content" in answer.message ? "\n\n助手:\n" + text(answer.message.content) : "") });
22
+ users = []; partial = undefined;
23
+ };
24
+ for (const entry of branch) {
25
+ if (entry.type !== "message") continue;
26
+ const message = entry.message;
27
+ if (message.role === "user") {
28
+ if (partial?.message.role === "assistant" && ["aborted", "error"].includes(partial.message.stopReason)) finish(partial);
29
+ users.push(entry);
30
+ } else if (message.role === "assistant" && users.length) {
31
+ partial = entry;
32
+ if (message.stopReason === "stop" && !message.content.some(block => block.type === "toolCall")) finish(entry, true);
33
+ }
34
+ }
35
+ finish(partial);
36
+ return result;
37
+ }
38
+
39
+ /** Parse in memory; never open a writable SessionManager or migrate native JSONL. */
40
+ export function projectHistory(content: string): HistorySession {
41
+ const parsed = parseSessionEntries(content), header = parsed[0];
42
+ requireThat(header?.type === "session", "缺少 Pi 会话头。");
43
+ const manager = SessionManager.inMemory(header.cwd, undefined, parsed), entries = manager.getEntries();
44
+ const parents = new Set(entries.map(entry => entry.parentId));
45
+ const tips = entries.filter(entry => !parents.has(entry.id));
46
+ return { sessionId: header.id, name: manager.getSessionName() ?? "", branches: tips.map(tip => exchanges(manager.getBranch(tip.id))).filter(branch => branch.length) };
47
+ }
48
+
49
+ export const historyLocation = (ctx: ExtensionContext): HistoryLocation => {
50
+ const file = ctx.sessionManager.getSessionFile();
51
+ return { directory: ctx.sessionManager.getSessionDir(), ...(file ? { file } : {}) };
52
+ };
53
+ export class PiHistory implements HistorySource {
54
+ constructor(private readonly root: string, private readonly relatedFiles: () => string[] = () => []) {}
55
+ async scan(location: HistoryLocation, signal?: AbortSignal) {
56
+ const lists = await Promise.all([SessionManager.list(this.root, undefined, undefined, signal),
57
+ SessionManager.list(this.root, location.directory, undefined, signal)]);
58
+ const files = new Set([...lists.flat().map(session => session.path), ...this.relatedFiles(), ...(location.file ? [location.file] : [])]);
59
+ const sessions: HistorySession[] = [], warnings: string[] = [];
60
+ for (const file of files) {
61
+ signal?.throwIfAborted();
62
+ try {
63
+ const session = projectHistory(await readFile(file, { encoding: "utf8", signal }));
64
+ if (session.branches.length) {
65
+ session.name ||= session.branches[0]![0]!.text.replace(/\s+/g, " ").slice(0, 80);
66
+ sessions.push(session);
67
+ }
68
+ } catch (error) {
69
+ signal?.throwIfAborted();
70
+ // Pi reserves a filename before the first message; copied archives may also outlive their native files.
71
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") warnings.push(`${file}: ${String(error)}`);
72
+ }
73
+ }
74
+ return { sessions, warnings };
75
+ }
76
+ }
77
+
78
+ export async function historySummarizer(ctx: ExtensionContext): Promise<HistorySummarizer> {
79
+ const model = ctx.model;
80
+ requireThat(model, "请先选择 Pi 模型。");
81
+ const systemPrompt = await readFile(join(resourceRoot, "prompts/history-summary.md"), "utf8");
82
+ const budget = Math.max(2000, Math.min(24000, Math.floor(model.contextWindow / 2)));
83
+ const ask = async (content: string, signal: AbortSignal) => {
84
+ signal.throwIfAborted();
85
+ const response = await ctx.modelRegistry.streamSimple(model, { systemPrompt,
86
+ messages: [{ role: "user", content, timestamp: Date.now() }] }, { signal, reasoning: "low", maxTokens: 2048, transport: "sse" }).result();
87
+ signal.throwIfAborted();
88
+ requireThat(response.stopReason === "stop", response.errorMessage ?? "历史摘要未完成:" + response.stopReason);
89
+ const answer = response.content.filter(part => part.type === "text").map(part => part.text).join("\n").trim();
90
+ requireThat(answer, "历史摘要为空。");
91
+ return answer;
92
+ };
93
+ return async (text, signal) => {
94
+ while (text.length > budget) {
95
+ const parts: string[] = [];
96
+ for (let offset = 0; offset < text.length;) {
97
+ let end = Math.min(text.length, offset + budget);
98
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]!)) end--;
99
+ parts.push(await ask(text.slice(offset, end), signal)); offset = end;
100
+ }
101
+ const smaller = parts.join("\n\n");
102
+ requireThat(smaller.length < text.length, "历史摘要未缩短,稍后重试。");
103
+ text = smaller;
104
+ }
105
+ return ask(text, signal);
106
+ };
107
+ }
@@ -0,0 +1,31 @@
1
+ export interface HistoryExchange {
2
+ entryIds: string[];
3
+ text: string;
4
+ from: string;
5
+ to: string;
6
+ completed: boolean;
7
+ }
8
+ export interface HistorySession {
9
+ sessionId: string;
10
+ name: string;
11
+ branches: HistoryExchange[][];
12
+ }
13
+ export interface HistoryNode extends HistoryExchange {
14
+ id: string;
15
+ kind: "session" | "exchange" | "summary";
16
+ level: number;
17
+ children: string[];
18
+ parents: string[];
19
+ ready: boolean;
20
+ }
21
+ export interface HistoryRecord {
22
+ id: string;
23
+ sessionId: string;
24
+ name: string;
25
+ nodes: HistoryNode[];
26
+ }
27
+ export interface HistoryLocation { directory: string; file?: string }
28
+ export interface HistorySource {
29
+ scan(location: HistoryLocation, signal?: AbortSignal): Promise<{ sessions: HistorySession[]; warnings: string[] }>;
30
+ }
31
+ export type HistorySummarizer = (text: string, signal: AbortSignal) => Promise<string>;
package/src/pbot.ts CHANGED
@@ -9,6 +9,13 @@ import { Lifecycle, WorkspaceInstance } from "./extension/lifecycle.js";
9
9
  import { registerCommand } from "./extension/command.js";
10
10
  import { registerEvents } from "./extension/events.js";
11
11
  import { registerAgentEvents } from "./extension/agent-events.js";
12
+ import { HistoryService } from "./services/history/history-service.js";
13
+ import { HistoryFiles } from "./storage/history-files.js";
14
+ import { PiHistory } from "./integrations/pi/history.js";
15
+ import { sessionRef } from "./storage/agent-files.js";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+ import { join } from "node:path";
18
+ import { registerHistoryEvents } from "./extension/history.js";
12
19
 
13
20
  export default function pbot(pi: ExtensionAPI) {
14
21
  // The explicitly loaded child entry owns the child; normal discovery never grants main privileges.
@@ -16,9 +23,15 @@ export default function pbot(pi: ExtensionAPI) {
16
23
  const confirmations = new Confirmations(), registry = new ToolRegistry(pi);
17
24
  const lifecycle = new Lifecycle(root => {
18
25
  const agents = new AgentService(root, agentDirectory(), new PiAgents());
19
- return new WorkspaceInstance(root, new PythonEnvironmentService(root), agents, new TeamService(root, agents.files, agents.teamFiles));
20
- }, instance => registry.register(mainTools(instance.agents, instance.teams, instance)), () => registry.hide());
26
+ const history = new HistoryService(root, new HistoryFiles(join(root, ".pi/pbot/history")), new PiHistory(root, () =>
27
+ agents.files.list().filter(agent => agent.workspace === root).flatMap(agent => {
28
+ const file = agents.files.optional(agent.agentId, "session-ref.json", sessionRef)?.file;
29
+ return file ? [file] : [];
30
+ })), new HistoryFiles(join(getAgentDir(), "pbot/history")));
31
+ return new WorkspaceInstance(root, new PythonEnvironmentService(root), agents, new TeamService(root, agents.files, agents.teamFiles), history);
32
+ }, instance => registry.register(mainTools(instance.agents, instance.teams, instance.history, instance)), () => registry.hide());
21
33
  registerCommand(pi, lifecycle, confirmations);
22
34
  registerEvents(pi, lifecycle, confirmations);
23
35
  registerAgentEvents(pi, lifecycle, confirmations);
36
+ registerHistoryEvents(pi, lifecycle);
24
37
  }
@@ -0,0 +1,145 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { HistoryExchange, HistoryLocation, HistoryNode, HistoryRecord, HistorySession, HistorySource, HistorySummarizer } from "../../model/history.js";
3
+ import type { HistoryFiles } from "../../storage/history-files.js";
4
+ import { requireThat } from "../../common/validation.js";
5
+
6
+ /** Shared prefixes reuse nodes; only consecutive exchanges on one native branch can merge. */
7
+ export function indexHistory(session: HistorySession, previous?: HistoryRecord): HistoryRecord {
8
+ const id = previous?.id ?? randomUUID(), nodes = new Map<string, HistoryNode>();
9
+ const old = new Map(previous?.nodes.map(node => [node.id, node])), changed = new Set<string>();
10
+ const add = (value: HistoryExchange, level: number, children: string[] = []): HistoryNode => {
11
+ const key = `${id}:${level}:${value.entryIds.at(-1)}`, found = nodes.get(key);
12
+ if (found) return found;
13
+ const before = old.get(key), same = before && JSON.stringify(before.children) === JSON.stringify(children) &&
14
+ (level ? children.every(child => !changed.has(child)) : before.text === value.text && before.completed === value.completed);
15
+ const node: HistoryNode = { ...value, id: key, kind: level ? "summary" : "exchange", level, children, parents: [],
16
+ text: level ? same ? before.text : "" : value.text, ready: level ? Boolean(same && before.ready) : true };
17
+ if (!same) changed.add(key);
18
+ nodes.set(key, node); return node;
19
+ };
20
+ const roots = new Set<string>();
21
+ for (const branch of session.branches) {
22
+ let layer = branch.map(exchange => add(exchange, 0)), level = 1;
23
+ while (layer.length >= 4) {
24
+ const count = Math.floor(layer.length / 4) * 4, next: HistoryNode[] = [];
25
+ layer.slice(count).forEach(node => roots.add(node.id));
26
+ for (let i = 0; i < count; i += 4) {
27
+ const children = layer.slice(i, i + 4), first = children[0]!, last = children.at(-1)!;
28
+ next.push(add({ entryIds: [first.entryIds[0]!, last.entryIds.at(-1)!], text: "", from: first.from, to: last.to,
29
+ completed: children.every(node => node.completed) }, level, children.map(node => node.id)));
30
+ }
31
+ layer = next; level++;
32
+ }
33
+ layer.forEach(node => roots.add(node.id));
34
+ }
35
+ const dates = [...nodes.values()].flatMap(node => [node.from, node.to]).sort();
36
+ nodes.set(id, { id, kind: "session", level: -1, entryIds: [], text: session.name, ready: true, completed: [...nodes.values()].every(node => node.completed),
37
+ from: dates[0] ?? "", to: dates.at(-1) ?? "", parents: [],
38
+ children: [...roots].sort((a, b) => nodes.get(a)!.from.localeCompare(nodes.get(b)!.from)) });
39
+ for (const node of nodes.values()) for (const child of node.children) nodes.get(child)!.parents.push(node.id);
40
+ return { id, sessionId: session.sessionId, name: session.name, nodes: [...nodes.values()] };
41
+ }
42
+
43
+ export interface HistoryQuery { query?: string; sessionId?: string; from?: string; to?: string; offset?: number; limit?: number }
44
+ const page = <T>(values: T[], offset: number, limit: number) => ({ values: values.slice(offset, offset + limit),
45
+ nextOffset: offset + limit < values.length ? offset + limit : null });
46
+ const brief = (record: HistoryRecord, node: HistoryNode) => ({ id: node.id, kind: node.kind, sessionId: record.sessionId,
47
+ name: record.name, preview: node.text.replace(/\s+/g, " ").slice(0, 100), from: node.from, to: node.to, ready: node.ready, completed: node.completed });
48
+
49
+ export class HistoryService {
50
+ #records: HistoryRecord[] = [];
51
+ #closed = false;
52
+ #refresh: Promise<void> | undefined;
53
+ #work: Promise<void> | undefined;
54
+ warnings: string[] = [];
55
+ constructor(readonly root: string, private readonly files: HistoryFiles, private readonly source: HistorySource,
56
+ private previousFiles?: HistoryFiles) {}
57
+ check() { requireThat(!this.#closed, "历史服务已关闭。"); }
58
+ refresh(location: HistoryLocation, signal?: AbortSignal): Promise<void> {
59
+ this.check();
60
+ if (this.#refresh) return this.#refresh;
61
+ this.#refresh = this.load(location, signal).finally(() => { this.#refresh = undefined; });
62
+ return this.#refresh;
63
+ }
64
+ private async load(location: HistoryLocation, signal?: AbortSignal) {
65
+ const previous = this.files.list();
66
+ for (const value of previous) if ("file" in value) {
67
+ const { id, sessionId, name, nodes } = value;
68
+ this.files.save({ id, sessionId, name, nodes });
69
+ }
70
+ for (const { id, sessionId, name, nodes } of this.previousFiles?.list(this.root) ?? []) {
71
+ if (previous.some(record => record.sessionId === sessionId)) continue;
72
+ const value = { id, sessionId, name, nodes };
73
+ this.files.save(value); previous.push(value);
74
+ }
75
+ this.previousFiles = undefined;
76
+ const { sessions, warnings } = await this.source.scan(location, signal);
77
+ this.check(); signal?.throwIfAborted();
78
+ const known = new Map(this.files.list().map(record => [record.sessionId, record]));
79
+ for (const session of sessions) {
80
+ const before = known.get(session.sessionId), value = indexHistory(session, before);
81
+ if (JSON.stringify(before) !== JSON.stringify(value)) this.files.save(value);
82
+ known.set(session.sessionId, value);
83
+ }
84
+ this.#records = [...known.values()];
85
+ this.warnings = warnings;
86
+ }
87
+ search({ query = "", sessionId, from, to, offset = 0, limit = 20 }: HistoryQuery = {}) {
88
+ this.check();
89
+ const needle = query.trim().toLowerCase(), after = from === undefined ? -Infinity : Date.parse(from), before = to === undefined ? Infinity : Date.parse(to);
90
+ requireThat(!Number.isNaN(after) && !Number.isNaN(before) && after <= before, "时间范围无效,请使用 ISO 日期或时间。");
91
+ const matches = this.#records.filter(record => !sessionId || record.sessionId === sessionId).flatMap(record => record.nodes
92
+ .filter(node => (needle ? node.kind !== "session" && node.ready && node.text.toLowerCase().includes(needle) : node.kind === "session") &&
93
+ Date.parse(node.to) >= after && Date.parse(node.from) <= before)
94
+ .map(node => {
95
+ const start = Math.max(0, node.text.toLowerCase().indexOf(needle) - 80);
96
+ return { ...brief(record, node), parents: node.parents, snippet: node.text.slice(start, start + 400) };
97
+ })).sort((a, b) => b.to.localeCompare(a.to) || a.id.localeCompare(b.id));
98
+ const result = page(matches, offset, limit);
99
+ return { matches: result.values, total: matches.length, nextOffset: result.nextOffset, warnings: this.warnings };
100
+ }
101
+ private find(id: string) {
102
+ const record = this.#records.find(record => record.nodes.some(node => node.id === id));
103
+ const node = record?.nodes.find(node => node.id === id);
104
+ requireThat(record && node, "历史节点不存在,请重新搜索。");
105
+ return { record, node };
106
+ }
107
+ read(id: string, offset = 0, limit = 12000, childrenOffset = 0) {
108
+ this.check();
109
+ const { record, node } = this.find(id), lookup = new Map(record.nodes.map(value => [value.id, value]));
110
+ requireThat(offset <= node.text.length, "读取位置超出正文。");
111
+ if (offset && /[\uDC00-\uDFFF]/.test(node.text[offset] ?? "")) offset--;
112
+ let end = Math.min(node.text.length, offset + limit);
113
+ if (end < node.text.length && /[\uD800-\uDBFF]/.test(node.text[end - 1] ?? "")) end += end - offset === 1 ? 1 : -1;
114
+ const children = page(node.children, childrenOffset, 20);
115
+ return { ...brief(record, node), text: node.text.slice(offset, end), totalLength: node.text.length,
116
+ nextOffset: end < node.text.length ? end : null,
117
+ parents: node.parents.map(id => brief(record, lookup.get(id)!)), children: children.values.map(id => brief(record, lookup.get(id)!)),
118
+ nextChildrenOffset: children.nextOffset, source: { sessionId: record.sessionId, entryIds: node.entryIds }, warnings: this.warnings };
119
+ }
120
+ /** One worker per workspace instance. Failed or cancelled nodes remain pending for the next idle period. */
121
+ organize(summarize: HistorySummarizer, signal: AbortSignal): Promise<void> {
122
+ this.check();
123
+ const run = async () => {
124
+ for (const record of this.#records) {
125
+ const lookup = new Map(record.nodes.map(node => [node.id, node]));
126
+ for (const node of [...record.nodes].sort((a, b) => a.level - b.level)) {
127
+ this.check(); signal.throwIfAborted();
128
+ if (node.kind !== "summary" || node.ready) continue;
129
+ const children = node.children.map(id => lookup.get(id)!);
130
+ if (children.some(child => !child.ready)) continue;
131
+ const text = await summarize(children.map(child => `[${child.from} — ${child.to}${child.completed ? "" : ";未完成"}]\n${child.text}`).join("\n\n"), signal);
132
+ this.check(); signal.throwIfAborted();
133
+ // A query may have refreshed the source while this model request was running.
134
+ if (!this.#records.includes(record)) return;
135
+ requireThat(text.trim(), "历史摘要为空。");
136
+ node.text = text.trim(); node.ready = true; this.files.save(record);
137
+ }
138
+ }
139
+ };
140
+ const work = (this.#work ?? Promise.resolve()).catch(() => {}).then(run);
141
+ this.#work = work;
142
+ return work.finally(() => { if (this.#work === work) this.#work = undefined; });
143
+ }
144
+ async close() { this.#closed = true; await Promise.allSettled([this.#work, this.#refresh]); }
145
+ }
@@ -0,0 +1,145 @@
1
+ import { npmName, ordinaryNpm } from "../../common/package-source.js";
2
+
3
+ export interface PluginSpec {
4
+ id: string;
5
+ label: string;
6
+ description: string;
7
+ source: string;
8
+ defaultSelected: boolean;
9
+ }
10
+ export interface InstalledPackage {
11
+ source: string;
12
+ scope: "user" | "project";
13
+ name?: string;
14
+ version?: string;
15
+ installedPath?: string;
16
+ filtered: boolean;
17
+ entry: unknown;
18
+ }
19
+ export interface PackageChoice {
20
+ plugin: PluginSpec;
21
+ required: boolean;
22
+ existing: InstalledPackage[];
23
+ selected: boolean;
24
+ keep: boolean;
25
+ target?: string;
26
+ error?: string;
27
+ }
28
+ export interface PiInstallation { command: string; root: string; version: string }
29
+ export interface SystemCommand { command: string; args: string[] }
30
+ export interface SetupPlan {
31
+ workspace: string;
32
+ pi?: PiInstallation;
33
+ piVersion: string;
34
+ tmux?: SystemCommand;
35
+ packages: PackageChoice[];
36
+ }
37
+ export interface SetupResult { name: string; state: "done" | "kept" | "skipped" | "failed"; detail: string }
38
+ export interface SetupOperations {
39
+ installTmux(command: SystemCommand): Promise<void>;
40
+ ensurePi(existing: PiInstallation | undefined, version: string): Promise<PiInstallation>;
41
+ installPackage(pi: PiInstallation, workspace: string, choice: PackageChoice): Promise<string>;
42
+ verifyKeptPackage(pi: PiInstallation, workspace: string, choice: PackageChoice): Promise<string>;
43
+ }
44
+
45
+ export const pbotPackage: PluginSpec = {
46
+ id: "pbot", label: "Personal Bot", description: "工作区个人助手", source: "npm:personal-bot", defaultSelected: true,
47
+ };
48
+ export function parseCatalog(value: unknown): PluginSpec[] {
49
+ if (!Array.isArray(value)) throw new Error("插件清单必须是数组。");
50
+ const ids = new Set(["pbot"]), names = new Set(["personal-bot"]);
51
+ return value.map((item: unknown) => {
52
+ if (!item || typeof item !== "object") throw new Error("无效插件清单项。");
53
+ const entry = item as Record<string, unknown>;
54
+ for (const field of ["id", "label", "description", "source"]) {
55
+ if (typeof entry[field] !== "string" || !entry[field].trim() || /[\x00-\x1f\x7f]/.test(entry[field])) {
56
+ throw new Error(`无效插件字段:${field}`);
57
+ }
58
+ }
59
+ const plugin = entry as unknown as PluginSpec;
60
+ const name = npmName(plugin.source);
61
+ if (!/^[a-z0-9-]+$/.test(plugin.id) || !name || !ordinaryNpm(plugin.source) ||
62
+ typeof plugin.defaultSelected !== "boolean" || ids.has(plugin.id) || names.has(name)) {
63
+ throw new Error(`无效或重复的插件:${plugin.id}`);
64
+ }
65
+ ids.add(plugin.id); names.add(name);
66
+ return { id: plugin.id, label: plugin.label, description: plugin.description,
67
+ source: `npm:${name}`, defaultSelected: plugin.defaultSelected };
68
+ });
69
+ }
70
+ export function packageChoices(catalog: PluginSpec[], installed: InstalledPackage[]): PackageChoice[] {
71
+ const first = !installed.some(pkg => pkg.name === "personal-bot");
72
+ return [pbotPackage, ...catalog].map(plugin => {
73
+ const matches = installed.filter(pkg => pkg.name === npmName(plugin.source));
74
+ // A project npm entry overrides the same npm identity in user settings. Different
75
+ // git/local identities still need an explicit decision before changing sources.
76
+ const localNpm = matches.some(pkg => pkg.scope === "project" && npmName(pkg.source));
77
+ const existing = matches.filter(pkg => !(localNpm && pkg.scope === "user" && npmName(pkg.source)));
78
+ const custom = existing.some(pkg => !ordinaryNpm(pkg.source));
79
+ return { plugin, required: plugin.id === "pbot", existing,
80
+ selected: plugin.id === "pbot" || (first ? plugin.defaultSelected : existing.length > 0), keep: custom };
81
+ });
82
+ }
83
+ export function parseSetupArgs(args: string[]): { action: "help" | "version" | "setup"; workspace?: string } {
84
+ if (!args.length || args.length === 1 && ["--help", "-h"].includes(args[0]!)) return { action: "help" };
85
+ if (args.length === 1 && ["--version", "-v"].includes(args[0]!)) return { action: "version" };
86
+ if (args[0] !== "setup") throw new Error("用法:pbot setup [--workspace <目录>]");
87
+ let workspace: string | undefined;
88
+ for (let index = 1; index < args.length; index++) {
89
+ const arg = args[index]!;
90
+ if ((arg === "--help" || arg === "-h") && args.length === 2) return { action: "help" };
91
+ if (arg !== "--workspace" || workspace !== undefined) throw new Error(`不支持的参数:${arg}`);
92
+ const value = args[++index];
93
+ if (!value || value.startsWith("--") || /[\x00-\x1f\x7f]/.test(value)) throw new Error("--workspace 需要目录。");
94
+ workspace = value;
95
+ }
96
+ return workspace === undefined ? { action: "setup" } : { action: "setup", workspace };
97
+ }
98
+
99
+ export async function executeSetup(plan: SetupPlan, operations: SetupOperations,
100
+ report: (result: SetupResult) => void, signal: AbortSignal): Promise<{ pi?: PiInstallation; results: SetupResult[] }> {
101
+ const results: SetupResult[] = [];
102
+ const add = (result: SetupResult) => { results.push(result); report(result); };
103
+ let pi: PiInstallation;
104
+ try {
105
+ signal.throwIfAborted();
106
+ if (plan.tmux) { await operations.installTmux(plan.tmux); add({ name: "tmux", state: "done", detail: "已安装" }); }
107
+ signal.throwIfAborted();
108
+ pi = await operations.ensurePi(plan.pi, plan.piVersion);
109
+ add({ name: "Pi", state: "done", detail: pi.version });
110
+ } catch (error) {
111
+ signal.throwIfAborted();
112
+ add({ name: "运行环境", state: "failed", detail: String(error) });
113
+ return { results };
114
+ }
115
+ for (const choice of plan.packages) {
116
+ signal.throwIfAborted();
117
+ const name = choice.plugin.label;
118
+ if (!choice.selected) { add({ name, state: "skipped", detail: "本次不处理" }); continue; }
119
+ if (choice.error) {
120
+ add({ name, state: "failed", detail: choice.error });
121
+ if (choice.required) return { results };
122
+ continue;
123
+ }
124
+ if (choice.keep) {
125
+ try {
126
+ const source = await operations.verifyKeptPackage(pi, plan.workspace, choice);
127
+ add({ name, state: "kept", detail: source });
128
+ } catch (error) {
129
+ signal.throwIfAborted();
130
+ add({ name, state: "failed", detail: String(error) });
131
+ if (choice.required) return { results };
132
+ }
133
+ continue;
134
+ }
135
+ try {
136
+ const version = await operations.installPackage(pi, plan.workspace, choice);
137
+ add({ name, state: "done", detail: version });
138
+ } catch (error) {
139
+ signal.throwIfAborted();
140
+ add({ name, state: "failed", detail: String(error) });
141
+ if (choice.required) return { results };
142
+ }
143
+ }
144
+ return { pi, results };
145
+ }
@@ -0,0 +1,25 @@
1
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import type { HistoryRecord } from "../model/history.js";
3
+ import { object } from "../common/json.js";
4
+ import { requireThat } from "../common/validation.js";
5
+ import { readJsonFile, writeJsonFile } from "./atomic-json.js";
6
+ import { fileId } from "./file-access.js";
7
+
8
+ function record(value: unknown): HistoryRecord {
9
+ requireThat(object(value) && typeof value.id === "string" && typeof value.sessionId === "string" &&
10
+ typeof value.name === "string" && Array.isArray(value.nodes), "历史索引无效。");
11
+ return value as unknown as HistoryRecord;
12
+ }
13
+ export class HistoryFiles {
14
+ constructor(readonly directory: string) {}
15
+ list(workspace?: string) {
16
+ if (!existsSync(this.directory)) return [];
17
+ return readdirSync(this.directory).filter(name => name.endsWith(".json"))
18
+ .map(name => readJsonFile(this.directory, name, record))
19
+ .filter(value => workspace === undefined || ("workspace" in value && value.workspace === workspace));
20
+ }
21
+ save(value: HistoryRecord) {
22
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 });
23
+ writeJsonFile(this.directory, fileId(value.id) + ".json", value, record);
24
+ }
25
+ }
@@ -0,0 +1,42 @@
1
+ import { Type } from "typebox";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import type { HistoryService } from "../../services/history/history-service.js";
4
+ import type { CallAccess } from "../access.js";
5
+ import { historyLocation } from "../../integrations/pi/history.js";
6
+ import { requireThat } from "../../common/validation.js";
7
+ import { markdownResult } from "../result.js";
8
+
9
+ const nullableOffset = Type.Union([Type.Integer(), Type.Null()]);
10
+ const node = Type.Object({ id: Type.String(), kind: Type.Union([Type.Literal("session"), Type.Literal("exchange"), Type.Literal("summary")]),
11
+ sessionId: Type.String(), name: Type.String(), preview: Type.String(), from: Type.String(), to: Type.String(), ready: Type.Boolean(), completed: Type.Boolean() });
12
+ const resultSchema = Type.Object({
13
+ matches: Type.Optional(Type.Array(Type.Object({ ...node.properties, snippet: Type.String(), parents: Type.Array(Type.String()) }))),
14
+ total: Type.Optional(Type.Integer()), nextOffset: Type.Optional(nullableOffset),
15
+ node: Type.Optional(Type.Object({ ...node.properties, text: Type.String(), totalLength: Type.Integer(), nextOffset: nullableOffset,
16
+ parents: Type.Array(node), children: Type.Array(node), nextChildrenOffset: nullableOffset,
17
+ source: Type.Object({ sessionId: Type.String({ description: "原始 Pi 会话 ID;来源不保存文件路径。" }), entryIds: Type.Array(Type.String()) }), warnings: Type.Array(Type.String()) })),
18
+ warnings: Type.Array(Type.String()),
19
+ });
20
+ export function historyTool(service: HistoryService, access: CallAccess) {
21
+ return defineTool({ name: "pbot_history", label: "历史对话",
22
+ description: "查询当前工作区的 Pi 历史。search 对问答原文和各层摘要做不区分大小写的连续文本匹配;省略 query 列出会话。忘记原词时换几组词查询。read 读取命中节点并返回父子节点;沿 parents/children 的 id 展开,nextOffset 续读正文,nextChildrenOffset 续读子节点。摘要是检索线索,重要结论回到 exchange 原文核对。共享前缀可能有多个父节点。查询不调用模型,不恢复或重放旧任务。",
23
+ parameters: Type.Object({ action: Type.Union([Type.Literal("search"), Type.Literal("read")]),
24
+ query: Type.Optional(Type.String()), sessionId: Type.Optional(Type.String()),
25
+ from: Type.Optional(Type.String({ description: "search:ISO 日期或时间,下界。" })),
26
+ to: Type.Optional(Type.String({ description: "search:ISO 日期或时间,上界。" })),
27
+ id: Type.Optional(Type.String({ description: "read 必填:返回的节点 id。" })),
28
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
29
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 24000, description: "search:最多 50 条,默认 20;read:字符数,默认 12000。" })),
30
+ childrenOffset: Type.Optional(Type.Integer({ minimum: 0 })),
31
+ }, { additionalProperties: false }), outputSchema: resultSchema,
32
+ async execute(_id, args, signal, _update, ctx) {
33
+ access.check(); signal?.throwIfAborted();
34
+ requireThat(args.action !== "read" || args.id, "read 需要节点 id。");
35
+ requireThat(args.action !== "search" || (args.limit ?? 20) <= 50, "search limit 最多 50。");
36
+ await service.refresh(historyLocation(ctx), signal);
37
+ access.check();
38
+ return args.action === "search" ? markdownResult(service.search(args), "历史搜索")
39
+ : markdownResult({ node: service.read(args.id!, args.offset, args.limit, args.childrenOffset), warnings: service.warnings }, "历史正文");
40
+ },
41
+ });
42
+ }
@@ -8,9 +8,11 @@ import { agentTool } from "./agents/tool.js";
8
8
  import type { TeamService } from "../services/agents/team-service.js";
9
9
  import { teamTool, teamMessageTool } from "./teams/tool.js";
10
10
  import { agentOwner } from "../integrations/pi/agent-context.js";
11
+ import type { HistoryService } from "../services/history/history-service.js";
12
+ import { historyTool } from "./history/tool.js";
11
13
 
12
- export function mainTools(agents: AgentService, teams: TeamService, access: CallAccess): ToolDefinition[] {
13
- return [agentTool(agents, access), teamTool(teams, agents, access), teamMessageTool(teams, access, ctx => ({ kind: "coordinator", owner: agentOwner(ctx) }))];
14
+ export function mainTools(agents: AgentService, teams: TeamService, history: HistoryService, access: CallAccess): ToolDefinition[] {
15
+ return [agentTool(agents, access), teamTool(teams, agents, access), teamMessageTool(teams, access, ctx => ({ kind: "coordinator", owner: agentOwner(ctx) })), historyTool(history, access)];
14
16
  }
15
17
  export class ToolRegistry {
16
18
  readonly #registered = new Map<string, ToolDefinition>();
@@ -19,7 +21,7 @@ export class ToolRegistry {
19
21
  const promptGuidelines = readFileSync(join(resourceRoot, "prompts/tool-guidelines.md"), "utf8")
20
22
  .trim().split(/\r?\n\s*\r?\n/);
21
23
  for (const definition of tools) {
22
- const tool = { ...definition, namespace: { name: "pbot", description: "Personal Bot 独立子会话与团队协作工具。" },
24
+ const tool = { ...definition, namespace: { name: "pbot", description: "Personal Bot 历史查询、独立子会话与团队协作工具。" },
23
25
  promptGuidelines };
24
26
  this.#registered.set(tool.name, tool); this.pi.registerTool(tool);
25
27
  }
@@ -0,0 +1,40 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export class SetupUI {
4
+ constructor(private readonly controller: AbortController) {}
5
+ text(value: string) { console.log(value); }
6
+ async ask(label: string, fallback = ""): Promise<string> {
7
+ this.controller.signal.throwIfAborted();
8
+ const reader = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
9
+ reader.on("SIGINT", () => this.controller.abort(new Error("安装已取消。")));
10
+ reader.on("close", () => { if (!answered) this.controller.abort(new Error("输入已关闭。")); });
11
+ let answered = false;
12
+ try {
13
+ const value = await reader.question(label + (fallback ? " [" + fallback + "]" : "") + ": ", { signal: this.controller.signal });
14
+ answered = true;
15
+ return value.trim() || fallback;
16
+ } finally { answered = true; reader.close(); }
17
+ }
18
+ async confirm(label: string, fallback = true): Promise<boolean> {
19
+ while (true) {
20
+ const answer = (await this.ask(label + (fallback ? " [Y/n]" : " [y/N]"))).toLowerCase();
21
+ if (!answer) return fallback;
22
+ if (["y", "yes", "是"].includes(answer)) return true;
23
+ if (["n", "no", "否"].includes(answer)) return false;
24
+ this.text("请输入 y 或 n。");
25
+ }
26
+ }
27
+ async selectMany(labels: string[], defaults: number[]): Promise<number[]> {
28
+ labels.forEach((label, index) => this.text(" " + (defaults.includes(index) ? "[x] " : "[ ] ") + (index + 1) + ". " + label));
29
+ while (true) {
30
+ const answer = await this.ask("选择插件编号(逗号分隔,回车保留默认,0 不选)");
31
+ if (!answer) return defaults;
32
+ if (answer === "0") return [];
33
+ const parts = answer.split(/[,,\s]+/);
34
+ if (parts.every(part => /^\d+$/.test(part) && Number(part) >= 1 && Number(part) <= labels.length)) {
35
+ return [...new Set(parts.map(part => Number(part) - 1))];
36
+ }
37
+ this.text("请输入列表中的编号。");
38
+ }
39
+ }
40
+ }