personal-bot 0.2.9 → 0.2.10

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 (58) hide show
  1. package/README.md +22 -11
  2. package/README.zh-CN.md +20 -9
  3. package/docs/README.md +3 -2
  4. package/docs/agent-collaboration.md +1 -1
  5. package/docs/dsh.md +4 -6
  6. package/docs/file-storage.md +7 -4
  7. package/docs/history.md +2 -9
  8. package/docs/installation-design.md +4 -1
  9. package/docs/model-context.md +4 -4
  10. package/docs/technical-design.md +11 -9
  11. package/docs/workspace-configuration.md +55 -0
  12. package/lib/client.js +28 -3
  13. package/lib/dsh.js +0 -5
  14. package/lib/extension/dsh/command.js +14 -10
  15. package/lib/extension/dsh/history.js +1 -1
  16. package/lib/extension/dsh/management.d.ts +4 -0
  17. package/lib/extension/dsh/management.js +20 -5
  18. package/lib/extension/dsh/workspace.d.ts +7 -1
  19. package/lib/extension/dsh/workspace.js +68 -1
  20. package/lib/extension/setup-cli.js +1 -1
  21. package/lib/integrations/dsh/skills.d.ts +4 -0
  22. package/lib/integrations/dsh/skills.js +27 -0
  23. package/lib/model/history.d.ts +1 -0
  24. package/lib/model/workspace-config.d.ts +9 -0
  25. package/lib/model/workspace-config.js +1 -0
  26. package/lib/services/history/history-service.d.ts +1 -2
  27. package/lib/services/history/history-service.js +27 -40
  28. package/lib/services/workspace/workspace-config.d.ts +7 -0
  29. package/lib/services/workspace/workspace-config.js +50 -0
  30. package/lib/services/workspace/workspace-preparation.d.ts +1 -0
  31. package/lib/services/workspace/workspace-preparation.js +8 -0
  32. package/lib/storage/history-files.d.ts +5 -1
  33. package/lib/storage/history-files.js +68 -15
  34. package/lib/ui/dsh/management.js +27 -3
  35. package/npm-shrinkwrap.json +7 -3
  36. package/package.json +7 -4
  37. package/python/README.md +2 -2
  38. package/src/dsh.ts +0 -5
  39. package/src/extension/command.ts +25 -8
  40. package/src/extension/dsh/command.ts +13 -8
  41. package/src/extension/dsh/history.ts +2 -2
  42. package/src/extension/dsh/management.ts +21 -5
  43. package/src/extension/dsh/workspace.ts +62 -5
  44. package/src/extension/events.ts +18 -1
  45. package/src/extension/lifecycle.ts +9 -2
  46. package/src/extension/setup-cli.ts +1 -1
  47. package/src/extension/subagent-entry.ts +4 -1
  48. package/src/integrations/dsh/skills.ts +30 -0
  49. package/src/integrations/pi/skills.ts +52 -0
  50. package/src/model/history.ts +1 -0
  51. package/src/model/workspace-config.ts +10 -0
  52. package/src/pbot.ts +2 -3
  53. package/src/services/history/history-service.ts +15 -25
  54. package/src/services/workspace/workspace-config.ts +49 -0
  55. package/src/services/workspace/workspace-preparation.ts +8 -0
  56. package/src/storage/history-files.ts +53 -15
  57. package/src/ui/dsh/management.ts +20 -3
  58. package/docs/dsh.en.md +0 -339
@@ -7,11 +7,19 @@ import { resourceRoot } from "../common/paths.js";
7
7
  import { agentText } from "../integrations/agent-resources.js";
8
8
  import type { Confirmations } from "../ui/confirmation.js";
9
9
  import type { Lifecycle } from "./lifecycle.js";
10
+ import { readWorkspaceConfig, workspaceDisabledMessage } from "../services/workspace/workspace-config.js";
11
+ import { workspaceSkillPaths } from "../integrations/pi/skills.js";
10
12
 
11
13
  export function registerEvents(pi: ExtensionAPI, lifecycle: Lifecycle, confirmations: Confirmations) {
12
14
  pi.on("session_start", async (_event, ctx) => {
13
15
  try {
14
16
  await lifecycle.open(ctx.cwd);
17
+ if (!lifecycle.active) {
18
+ ctx.ui.setStatus("pbot", lifecycle.configuration?.error ? "PBot 配置错误" : "PBot 未启用 · /pbot init");
19
+ if (ctx.hasUI && lifecycle.configuration) ctx.ui.notify(workspaceDisabledMessage(lifecycle.configuration), lifecycle.configuration.error ? "error" : "info");
20
+ return;
21
+ }
22
+ ctx.ui.setStatus("pbot", "PBot 已启动");
15
23
  if (ctx.mode === "tui") ctx.ui.setStatus("pbot-python", "Python 准备中");
16
24
  else ctx.ui.notify("正在准备共享 Python 环境;首次安装可能需要数分钟。", "info");
17
25
  try { await lifecycle.get().preparePython(); }
@@ -21,10 +29,15 @@ export function registerEvents(pi: ExtensionAPI, lifecycle: Lifecycle, confirmat
21
29
  else if (ctx.hasUI) ctx.ui.notify(message, "warning"); else console.error(message);
22
30
  } finally { if (ctx.mode === "tui") ctx.ui.setStatus("pbot-python", undefined); }
23
31
  } catch (error) {
32
+ ctx.ui.setStatus("pbot", "PBot 启动失败");
24
33
  if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : "pbot 启动失败。", "error");
25
34
  else throw error;
26
35
  }
27
36
  });
37
+ pi.on("resources_discover", (_event, ctx) => {
38
+ if (!lifecycle.active) return;
39
+ return { skillPaths: workspaceSkillPaths(pi, ctx.cwd) };
40
+ });
28
41
  pi.on("before_agent_start", async (event, ctx) => {
29
42
  const instance = lifecycle.active;
30
43
  if (!instance) return;
@@ -49,7 +62,11 @@ export function registerEvents(pi: ExtensionAPI, lifecycle: Lifecycle, confirmat
49
62
  pi.on("session_before_switch", () => { lifecycle.active?.cancelWork(); });
50
63
  pi.on("session_before_fork", () => { lifecycle.active?.cancelWork(); });
51
64
  pi.on("session_before_tree", () => { lifecycle.active?.cancelWork(); });
52
- pi.on("session_shutdown", event => lifecycle.close(event.reason === "reload"));
65
+ pi.on("session_shutdown", async (event, ctx) => {
66
+ const preserve = event.reason === "reload" && lifecycle.root && (await readWorkspaceConfig(lifecycle.root)).enabled;
67
+ try { await lifecycle.close(Boolean(preserve)); }
68
+ finally { ctx.ui.setStatus("pbot", undefined); ctx.ui.setStatus("pbot-python", undefined); }
69
+ });
53
70
  pi.on("tool_call", async (event, ctx) => {
54
71
  const instance = lifecycle.active;
55
72
  if (["write", "edit"].includes(event.toolName) && "path" in event.input && typeof event.input.path === "string") {
@@ -8,6 +8,8 @@ import type { AgentService } from "../services/agents/agent-service.js";
8
8
  import type { TeamService } from "../services/agents/team-service.js";
9
9
  import type { CallAccess } from "../tools/access.js";
10
10
  import type { HistoryService } from "../services/history/history-service.js";
11
+ import { readWorkspaceConfig, workspaceDisabledMessage } from "../services/workspace/workspace-config.js";
12
+ import type { WorkspaceConfiguration } from "../model/workspace-config.js";
11
13
 
12
14
  export class WorkspaceInstance implements CallAccess {
13
15
  readonly #controller = new AbortController();
@@ -62,16 +64,21 @@ export class WorkspaceInstance implements CallAccess {
62
64
  }
63
65
  export class Lifecycle {
64
66
  #active: WorkspaceInstance | undefined;
67
+ configuration: WorkspaceConfiguration | undefined;
68
+ root: string | undefined;
65
69
  constructor(private readonly create: (root: string) => WorkspaceInstance,
66
70
  private readonly register: (instance: WorkspaceInstance) => void, private readonly hide: () => void) {}
67
71
  get active() { return this.#active; }
68
72
  get() {
69
- requireThat(this.#active, "pbot 工作区实例不可用。");
73
+ requireThat(this.#active, this.configuration ? workspaceDisabledMessage(this.configuration) : "pbot 工作区实例不可用。");
70
74
  this.#active.check(); return this.#active;
71
75
  }
72
76
  async open(root: string) {
73
77
  await this.close();
74
- const instance = this.create(await realpath(root));
78
+ this.root = await realpath(root);
79
+ this.configuration = await readWorkspaceConfig(this.root);
80
+ if (!this.configuration.enabled) return;
81
+ const instance = this.create(this.root);
75
82
  this.#active = instance;
76
83
  try { this.register(instance); }
77
84
  catch (error) { await this.close(); throw error; }
@@ -92,7 +92,7 @@ export async function main(args: string[]): Promise<number> {
92
92
  const failed = result.results.some(item => item.state === "failed");
93
93
  if (!result.pi) return 1;
94
94
  ui.text("\n软件安装阶段结束。初始化、Python 环境和外部服务的状态由 Pi/pbot/插件分别报告。");
95
- if (await ui.confirm(failed ? "部分插件失败,是否仍先启动 Pi" : "立即启动 Pi", !failed)) {
95
+ if (await ui.confirm(failed ? "部分插件失败,是否仍启动 Pi 并初始化启用工作区" : "启动 Pi 并初始化启用工作区", !failed)) {
96
96
  process.stdin.pause();
97
97
  await host.start(result.pi, workspace);
98
98
  } else {
@@ -20,9 +20,10 @@ import { agentFileTools } from "../integrations/pi/agent-tools.js";
20
20
  import { resourceRoot } from "../common/paths.js";
21
21
  import { requireThat } from "../common/validation.js";
22
22
  import { packageVersion, releaseLine } from "../common/version.js";
23
+ import { readWorkspaceConfig, workspaceDisabledMessage } from "../services/workspace/workspace-config.js";
23
24
 
24
25
  /** Explicit child composition; never installs main commands or approval capabilities. */
25
- export default function subagent(pi: ExtensionAPI) {
26
+ export default async function subagent(pi: ExtensionAPI) {
26
27
  const id = process.env.PBOT_SUBAGENT_RUN_ID, directory = process.env.PBOT_SUBAGENT_DIR, generation = process.env.PBOT_SUBAGENT_GENERATION;
27
28
  requireThat(id && directory && generation, "缺少子会话启动身份。");
28
29
  const files = new AgentFiles(dirname(realpathSync(directory)));
@@ -30,6 +31,8 @@ export default function subagent(pi: ExtensionAPI) {
30
31
  const d = files.read(id, "descriptor.json", descriptor);
31
32
  requireThat(d.agentId === id && releaseLine(d.release) === releaseLine(packageVersion), "子会话身份或版本无效。");
32
33
  requireThat(files.read(id, "launch.json", launch).generation === generation, "子会话启动代次已失效。");
34
+ const configuration = await readWorkspaceConfig(d.workspace);
35
+ requireThat(configuration.enabled, workspaceDisabledMessage(configuration));
33
36
  const teams = new TeamService(d.workspace, files, new TeamFiles(files));
34
37
  const lifetime = new AbortController();
35
38
  let activeTeam: TeamLink | undefined, pendingPause: string | undefined;
@@ -0,0 +1,30 @@
1
+ import { join } from "node:path";
2
+ import type { Context } from "@deepseek-ai/cordis";
3
+ import type { Agent } from "@deepseek-ai/dsh-agent";
4
+ import { scopeParentOf } from "@deepseek-ai/dsh-scope";
5
+ import { FileSystemSkillProvider } from "@deepseek-ai/dsh-skill-filesystem";
6
+ import { resourceRoot } from "../../common/paths.js";
7
+
8
+ /** Keep bundled methods local to this Agent without overriding its native project/user methods. */
9
+ export function registerDshSkills(ctx: Context, agent: Agent): void {
10
+ let filesystem: FileSystemSkillProvider;
11
+ ctx.skills.registerProvider(control => {
12
+ filesystem = new FileSystemSkillProvider(ctx, control, {
13
+ providerName: "pbot", includeDefaultRoots: false, bundledSkillDir: join(resourceRoot, "skills"),
14
+ });
15
+ return {
16
+ name: "pbot",
17
+ async list(options) {
18
+ const inherited = await ctx.skills.snapshot({ ...options, scope: scopeParentOf(agent) });
19
+ const preferred = new Set(inherited.skills.filter(skill => skill.provider !== "pbot").map(skill => skill.name));
20
+ const listed = await filesystem.list(options);
21
+ const candidates = Array.isArray(listed) ? listed : listed.candidates;
22
+ return { candidates: candidates.filter(skill => !preferred.has(skill.name)),
23
+ complete: inherited.complete && (Array.isArray(listed) || listed.complete) };
24
+ },
25
+ get: (candidate, options) => filesystem.get(candidate, options),
26
+ };
27
+ });
28
+ ctx.effect(() => () => filesystem.dispose());
29
+ ctx.on("fs/observed", target => { filesystem.observeHostMutation(target.displayPath); });
30
+ }
@@ -0,0 +1,52 @@
1
+ import { basename, dirname, join, matchesGlob, normalize, relative, resolve, sep } from "node:path";
2
+ import { getAgentDir, loadSkillsFromDir, parseArgs, SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { resourceRoot } from "../../common/paths.js";
5
+
6
+ /** Dynamic resources follow the owning package's native include/exclude settings. */
7
+ export function workspaceSkillPaths(pi: ExtensionAPI, cwd: string): string[] {
8
+ if (parseArgs(process.argv.slice(2)).noSkills) return [];
9
+ const root = resolve(resourceRoot, ".."), origin = pi.getCommands().find(command => command.name === "pbot")?.sourceInfo;
10
+ const files = loadSkillsFromDir({ dir: join(resourceRoot, "skills"), source: "pbot" }).skills.map(skill => skill.filePath);
11
+ const posix = (path: string) => normalize(path).split(sep).join("/");
12
+ const matches = (file: string, pattern: string, exact = false) => {
13
+ const target = posix(pattern), parent = dirname(file);
14
+ const paths = [relative(root, file), file, relative(root, parent), parent];
15
+ if (!exact) paths.push(basename(file), basename(parent));
16
+ return paths.some(path => exact ? posix(path) === target : matchesGlob(posix(path), target));
17
+ };
18
+ const own = (entry: string | { source: string }) => {
19
+ const source = typeof entry === "string" ? entry : entry.source;
20
+ return source === origin?.source || [join(cwd, ".pi"), getAgentDir(), cwd].some(base => resolve(base, source) === root);
21
+ };
22
+ let entries = (pi.getSettings().packages ?? []).filter(own);
23
+ if (origin?.origin === "package" && (!entries.length || entries.every(entry => typeof entry !== "string" && entry.autoload === false))) {
24
+ const global = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false }).getGlobalSettings();
25
+ entries = [...(global.packages ?? []).filter(own), ...entries];
26
+ }
27
+ let selected = new Set(entries.length && entries.every(entry => typeof entry !== "string" && entry.autoload === false) ? [] : files);
28
+ for (const entry of entries) {
29
+ if (typeof entry === "string" || entry.skills === undefined) continue;
30
+ const patterns = entry.skills;
31
+ if (entry.autoload === false) {
32
+ for (const pattern of patterns) {
33
+ const exact = /^[+-]/.test(pattern), target = pattern.replace(/^[!+-]/, "");
34
+ for (const file of files.filter(file => matches(file, target, exact))) {
35
+ if (/^[!-]/.test(pattern)) selected.delete(file); else selected.add(file);
36
+ }
37
+ }
38
+ continue;
39
+ }
40
+ if (!patterns.length) { selected.clear(); continue; }
41
+ const includes = patterns.filter(pattern => !/^[!+-]/.test(pattern));
42
+ selected = new Set(files.filter(file => !includes.length || includes.some(pattern => matches(file, pattern))));
43
+ for (const prefix of ["!", "+", "-"]) {
44
+ for (const pattern of patterns.filter(pattern => pattern.startsWith(prefix))) {
45
+ for (const file of files.filter(file => matches(file, pattern.slice(1), prefix !== "!"))) {
46
+ if (prefix === "+") selected.add(file); else selected.delete(file);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return [...selected];
52
+ }
@@ -19,6 +19,7 @@ export interface HistoryNode extends HistoryExchange {
19
19
  ready: boolean;
20
20
  }
21
21
  export interface HistoryRecord {
22
+ host: HistoryHost;
22
23
  id: string;
23
24
  sessionId: string;
24
25
  name: string;
@@ -0,0 +1,10 @@
1
+ export interface WorkspaceConfig {
2
+ enabled?: boolean;
3
+ }
4
+
5
+ export interface WorkspaceConfiguration {
6
+ path: string;
7
+ present: boolean;
8
+ enabled: boolean;
9
+ error?: string;
10
+ }
package/src/pbot.ts CHANGED
@@ -13,7 +13,6 @@ import { HistoryService } from "./services/history/history-service.js";
13
13
  import { HistoryFiles } from "./storage/history-files.js";
14
14
  import { PiHistory } from "./integrations/pi/history.js";
15
15
  import { sessionRef } from "./storage/agent-files.js";
16
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
16
  import { join } from "node:path";
18
17
  import { registerHistoryEvents } from "./extension/history.js";
19
18
 
@@ -23,11 +22,11 @@ export default function pbot(pi: ExtensionAPI) {
23
22
  const confirmations = new Confirmations(), registry = new ToolRegistry(pi);
24
23
  const lifecycle = new Lifecycle(root => {
25
24
  const agents = new AgentService(root, agentDirectory(), new PiAgents());
26
- const history = new HistoryService(root, new HistoryFiles(join(root, ".pi/pbot/history")), new PiHistory(root, () =>
25
+ const history = new HistoryService(root, new HistoryFiles(join(root, ".pbot/history")), new PiHistory(root, () =>
27
26
  agents.files.list().filter(agent => agent.workspace === root).flatMap(agent => {
28
27
  const file = agents.files.optional(agent.agentId, "session-ref.json", sessionRef)?.file;
29
28
  return file ? [file] : [];
30
- })), new HistoryFiles(join(getAgentDir(), "pbot/history")), new HistoryFiles(join(root, ".dsh/pbot/history"), "dsh"));
29
+ })), new HistoryFiles(join(root, ".pbot/history"), "dsh"));
31
30
  return new WorkspaceInstance(root, new PythonEnvironmentService(root), agents, new TeamService(root, agents.files, agents.teamFiles), history);
32
31
  }, instance => registry.register(mainTools(instance.agents, instance.teams, instance.history, instance)), () => registry.hide());
33
32
  registerCommand(pi, lifecycle, confirmations);
@@ -4,7 +4,7 @@ import type { HistoryFiles } from "../../storage/history-files.js";
4
4
  import { requireThat } from "../../common/validation.js";
5
5
 
6
6
  /** Shared prefixes reuse nodes; only consecutive exchanges on one native branch can merge. */
7
- export function indexHistory(session: HistorySession, previous?: HistoryRecord, host: HistoryHost = "pi"): HistoryRecord {
7
+ export function indexHistory(session: HistorySession, previous?: HistoryRecord, host: HistoryHost = previous?.host ?? "pi"): HistoryRecord {
8
8
  const id = previous?.id ?? (host === "dsh" ? "dsh-" : "") + randomUUID(), nodes = new Map<string, HistoryNode>();
9
9
  const old = new Map(previous?.nodes.map(node => [node.id, node])), changed = new Set<string>();
10
10
  const add = (value: HistoryExchange, level: number, children: string[] = []): HistoryNode => {
@@ -37,7 +37,7 @@ export function indexHistory(session: HistorySession, previous?: HistoryRecord,
37
37
  from: dates[0] ?? "", to: dates.at(-1) ?? "", parents: [],
38
38
  children: [...roots].sort((a, b) => nodes.get(a)!.from.localeCompare(nodes.get(b)!.from)) });
39
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()] };
40
+ return { host, id, sessionId: session.sessionId, name: session.name, nodes: [...nodes.values()] };
41
41
  }
42
42
 
43
43
  export interface HistoryQuery { host?: HistoryHost; query?: string; sessionId?: string; from?: string; to?: string; offset?: number; limit?: number }
@@ -69,12 +69,12 @@ export class HistoryService {
69
69
  #sourceWarnings: string[] = [];
70
70
  #summaryError: string | undefined;
71
71
  constructor(readonly root: string, private readonly files: HistoryFiles, private readonly source: HistorySource,
72
- private previousFiles?: HistoryFiles, private readonly otherFiles?: HistoryFiles) {
72
+ private readonly otherFiles?: HistoryFiles) {
73
73
  requireThat(source.host === files.host && (!otherFiles || otherFiles.host !== files.host), "历史来源与归档目录不匹配。");
74
- requireThat(files.host === "pi" || !previousFiles, "dsh 不导入 Pi 旧归档。");
75
74
  }
76
75
  check() { requireThat(!this.#closed, "历史服务已关闭。"); }
77
- get warnings() { return [...this.#sourceWarnings, ...(this.#summaryError ? [this.files.host + " 摘要:" + this.#summaryError] : [])]; }
76
+ get warnings() { return [...new Set([...this.#sourceWarnings, ...this.files.warnings, ...(this.otherFiles?.warnings ?? []),
77
+ ...(this.#summaryError ? [this.files.host + " 摘要:" + this.#summaryError] : [])])]; }
78
78
  get status() { return { loaded: this.#loaded, organizing: Boolean(this.#work), pending: this.#loaded ? this.#records.filter(value => value.host === this.files.host)
79
79
  .reduce((total, value) => total + value.record.nodes.filter(node => node.kind === "summary" && !node.ready).length, 0) : null, warnings: this.warnings }; }
80
80
  refresh(location: HistoryLocation, signal?: AbortSignal): Promise<void> {
@@ -90,35 +90,25 @@ export class HistoryService {
90
90
  const warning = (host: HistoryHost, error: unknown) => { this.check(); signal?.throwIfAborted(); warnings.push(host + ": " + String(error)); };
91
91
  let known: HistoryRecord[] = [];
92
92
  try {
93
- const before = this.files.list(); known = before;
94
- let imports: HistoryRecord[] = [], importedPrevious = !this.previousFiles;
95
- if (this.previousFiles) {
96
- try { imports = this.previousFiles.list(this.root); importedPrevious = true; } catch (error) { warning("pi", error); }
97
- }
93
+ try { this.files.migrate(this.root); } finally { known = this.files.list(); }
94
+ const before = known;
98
95
  let sessions: HistorySession[] = [];
99
96
  try {
100
97
  const scanned = await this.source.scan(location, signal); sessions = scanned.sessions;
101
98
  warnings.push(...scanned.warnings.map(value => this.files.host + ": " + value));
102
99
  } catch (error) { warning(this.files.host, error); }
103
100
  this.check(); signal?.throwIfAborted();
104
- if (sessions.length || imports.length || (this.files.host === "pi" && before.some(value => "file" in value))) {
101
+ if (sessions.length) {
105
102
  known = this.files.control(() => {
106
103
  this.check(); signal?.throwIfAborted();
107
- const latest = this.files.list(), current = new Map(latest.map(record => [record.sessionId, record])), imported = new Set<string>();
108
- if (this.files.host === "pi") {
109
- for (const value of latest) if ("file" in value) {
110
- const { id, sessionId, name, nodes } = value, clean = { id, sessionId, name, nodes };
111
- this.files.save(clean); current.set(sessionId, clean);
112
- }
113
- for (const { id, sessionId, name, nodes } of imports) if (!current.has(sessionId)) {
114
- const value = { id, sessionId, name, nodes }; this.files.save(value); current.set(sessionId, value); imported.add(sessionId);
115
- }
116
- }
104
+ const latest = this.files.list(), current = new Map(latest.map(record => [record.sessionId, record]));
117
105
  for (const session of sessions) {
118
106
  const previous = current.get(session.sessionId), captured = before.find(record => record.sessionId === session.sessionId);
107
+ // An unreadable archive may own this session; avoid assigning it a second root ID.
108
+ if (!previous && this.files.warnings.length) continue;
119
109
  // A concurrent source update must not be overwritten by this older scan. Summary-only updates remain reusable.
120
110
  if (captured && rawSource(captured) !== rawSource(previous)) continue;
121
- if (!captured && previous && !imported.has(session.sessionId)) continue;
111
+ if (!captured && previous) continue;
122
112
  const value = indexHistory(session, previous, this.files.host);
123
113
  if (JSON.stringify(previous) !== JSON.stringify(value)) this.files.save(value);
124
114
  current.set(session.sessionId, value);
@@ -126,11 +116,11 @@ export class HistoryService {
126
116
  return [...current.values()];
127
117
  });
128
118
  }
129
- if (importedPrevious) this.previousFiles = undefined;
130
119
  } catch (error) { warning(this.files.host, error); }
131
- records.push(...known.map(record => ({ host: this.files.host, record })));
120
+ records.push(...known.map(record => ({ host: record.host, record })));
132
121
  if (this.otherFiles) {
133
- try { records.push(...this.otherFiles.list().map(record => ({ host: this.otherFiles!.host, record }))); }
122
+ try { this.otherFiles.migrate(this.root); } catch (error) { warning(this.otherFiles.host, error); }
123
+ try { records.push(...this.otherFiles.list().map(record => ({ host: record.host, record }))); }
134
124
  catch (error) { warning(this.otherFiles.host, error); }
135
125
  }
136
126
  this.check(); signal?.throwIfAborted(); this.#records = records; this.#sourceWarnings = warnings; this.#loaded = true;
@@ -0,0 +1,49 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { object } from "../../common/json.js";
4
+ import { readJsonFile, writeJsonFile } from "../../storage/atomic-json.js";
5
+
6
+ function config(value: unknown): Record<string, unknown> {
7
+ if (!object(value) || value.enabled !== undefined && typeof value.enabled !== "boolean") {
8
+ throw new Error("配置必须是 JSON 对象,enabled 必须是布尔值。");
9
+ }
10
+ return value;
11
+ }
12
+ import type { WorkspaceConfig, WorkspaceConfiguration } from "../../model/workspace-config.js";
13
+
14
+ export function workspaceConfigPath(root: string): string {
15
+ return join(root, ".pbot", "config.json");
16
+ }
17
+
18
+ /** Read only this workspace's configuration; parent directories never grant activation. */
19
+ export async function readWorkspaceConfig(root: string): Promise<WorkspaceConfiguration> {
20
+ const path = workspaceConfigPath(root);
21
+ let content: string;
22
+ try { content = await readFile(path, "utf8"); }
23
+ catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path, present: false, enabled: false };
25
+ return { path, present: true, enabled: false, error: `无法读取工作区配置 ${path}: ${String(error)}` };
26
+ }
27
+ try {
28
+ const value = config(JSON.parse(content));
29
+ return { path, present: true, enabled: (value as WorkspaceConfig).enabled === true };
30
+ } catch (error) {
31
+ return { path, present: true, enabled: false, error: `工作区配置无效 ${path}: ${String(error)}` };
32
+ }
33
+ }
34
+
35
+ /** Explicit init opts in this workspace while preserving its other configuration fields. */
36
+ export async function initializeWorkspaceConfig(root: string): Promise<void> {
37
+ const current = await readWorkspaceConfig(root);
38
+ if (current.error) throw new Error(current.error);
39
+ if (current.enabled) return;
40
+ const directory = dirname(current.path);
41
+ await mkdir(directory, { recursive: true });
42
+ const value = current.present ? readJsonFile(directory, "config.json", config) : {};
43
+ const written = writeJsonFile(directory, "config.json", { ...value, enabled: true }, config, !current.present);
44
+ if (!written) await initializeWorkspaceConfig(root);
45
+ }
46
+
47
+ export function workspaceDisabledMessage(configuration: WorkspaceConfiguration): string {
48
+ return configuration.error ?? `Personal Bot 未在此工作区启用。请执行 /pbot init 初始化并启用;配置文件:${configuration.path}`;
49
+ }
@@ -2,6 +2,14 @@ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { requireThat } from "../../common/validation.js";
4
4
  import { resourceRoot } from "../../common/paths.js";
5
+ import { initializeWorkspaceConfig, readWorkspaceConfig } from "./workspace-config.js";
6
+
7
+ export async function initializeWorkspace(root: string): Promise<void> {
8
+ const configuration = await readWorkspaceConfig(root);
9
+ if (configuration.error) throw new Error(configuration.error);
10
+ await prepareWorkspace(root);
11
+ await initializeWorkspaceConfig(root);
12
+ }
5
13
 
6
14
  export const directories = ["areas", "projects", "knowledge", "providers", "scratch", "agents"];
7
15
  export async function prepareWorkspace(root: string) {
@@ -1,14 +1,15 @@
1
- import { existsSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, rmdirSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import type { HistoryHost, HistoryRecord } from "../model/history.js";
4
- import { object } from "../common/json.js";
4
+ import { canonical, object } from "../common/json.js";
5
5
  import { requireThat } from "../common/validation.js";
6
- import { readJsonFile, writeJsonFile } from "./atomic-json.js";
6
+ import { internalPath, readJsonFile, syncDirectory, writeJsonFile } from "./atomic-json.js";
7
7
  import { fileId } from "./file-access.js";
8
8
 
9
9
  const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
10
10
  function record(value: unknown): HistoryRecord {
11
- requireThat(object(value) && typeof value.id === "string" && typeof value.sessionId === "string" &&
11
+ requireThat(object(value) && (value.host === "pi" || value.host === "dsh") &&
12
+ typeof value.id === "string" && typeof value.sessionId === "string" &&
12
13
  typeof value.name === "string" && Array.isArray(value.nodes), "历史索引无效。");
13
14
  for (const node of value.nodes) requireThat(object(node) && typeof node.id === "string" &&
14
15
  ["session", "exchange", "summary"].includes(String(node.kind)) && Number.isInteger(node.level) &&
@@ -20,21 +21,58 @@ function record(value: unknown): HistoryRecord {
20
21
  return result;
21
22
  }
22
23
  export class HistoryFiles {
24
+ #warnings: string[] = [];
23
25
  constructor(readonly directory: string, readonly host: HistoryHost = "pi") {}
24
- list(workspace?: string) {
26
+ get warnings(): readonly string[] { return this.#warnings; }
27
+ list() {
28
+ this.#warnings = [];
25
29
  if (!existsSync(this.directory)) return [];
26
- return readdirSync(this.directory).filter(name => name.endsWith(".json"))
27
- .map(name => readJsonFile(this.directory, name, value => {
28
- const result = record(value);
29
- requireThat(this.host !== "dsh" || result.id.startsWith("dsh-"), "dsh 历史根 ID 缺少来源前缀。");
30
- return result;
31
- }))
32
- .filter(value => workspace === undefined || ("workspace" in value && value.workspace === workspace));
30
+ const records: HistoryRecord[] = [];
31
+ for (const name of readdirSync(this.directory).filter(name => name.endsWith(".json"))) {
32
+ try {
33
+ const value = readJsonFile(this.directory, name, record);
34
+ requireThat(name === fileId(value.id) + ".json", "历史文件名与根 ID 不匹配。");
35
+ if (value.host === this.host) records.push(value);
36
+ } catch (error) { this.#warnings.push(join(this.directory, name) + ": " + String(error)); }
37
+ }
38
+ return records;
39
+ }
40
+ /** Publish each legacy archive with its directory's host before removing the old copy. */
41
+ migrate(root: string) {
42
+ const directory = join(root, "." + this.host, "pbot/history");
43
+ if (!existsSync(directory)) return;
44
+ this.control(() => {
45
+ if (!existsSync(directory)) return;
46
+ const legacyLock = join(directory, ".write");
47
+ mkdirSync(legacyLock, { mode: 0o700 });
48
+ try {
49
+ for (const name of readdirSync(directory).filter(name => name.endsWith(".json"))) {
50
+ const source = internalPath(directory, name), target = internalPath(this.directory, name);
51
+ requireThat(source !== target, "新旧历史目录指向同一位置。");
52
+ const value = readJsonFile(directory, name, value => {
53
+ requireThat(object(value) && (value.host === undefined || value.host === this.host), "旧历史归档与来源目录不匹配。");
54
+ return record({ ...value, host: this.host });
55
+ });
56
+ requireThat(name === fileId(value.id) + ".json", "历史文件名与根 ID 不匹配。");
57
+ if (!writeJsonFile(this.directory, name, value, record, true)) {
58
+ requireThat(canonical(readJsonFile(this.directory, name, record)) === canonical(value),
59
+ "历史归档迁移冲突,请核对新旧文件:" + name);
60
+ }
61
+ syncDirectory(this.directory);
62
+ unlinkSync(source); syncDirectory(directory);
63
+ }
64
+ } finally { rmdirSync(legacyLock); }
65
+ rmdirSync(directory);
66
+ const parent = dirname(directory);
67
+ syncDirectory(parent);
68
+ try { rmdirSync(parent); syncDirectory(dirname(parent)); }
69
+ catch (error) { if (!["ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; }
70
+ });
33
71
  }
34
72
  /** The callback is synchronous: no SDK reads or model work can hold this source's write lock. */
35
73
  control<T>(operation: () => T): T {
36
74
  mkdirSync(this.directory, { recursive: true, mode: 0o700 });
37
- const lock = join(this.directory, ".write");
75
+ const lock = join(this.directory, ".write-" + this.host);
38
76
  try { mkdirSync(lock, { mode: 0o700 }); }
39
77
  catch (error) {
40
78
  if ((error as NodeJS.ErrnoException).code === "EEXIST") throw Object.assign(new Error("历史来源写锁被占用:" + this.host), { code: "PBOT_HISTORY_BUSY" });
@@ -43,7 +81,7 @@ export class HistoryFiles {
43
81
  try { return operation(); } finally { rmdirSync(lock); }
44
82
  }
45
83
  save(value: HistoryRecord) {
46
- requireThat(this.host !== "dsh" || value.id.startsWith("dsh-"), "dsh 历史根 ID 缺少来源前缀。");
84
+ requireThat(value.host === this.host, "历史归档与来源不匹配。");
47
85
  mkdirSync(this.directory, { recursive: true, mode: 0o700 });
48
86
  writeJsonFile(this.directory, fileId(value.id) + ".json", value, record);
49
87
  }
@@ -48,6 +48,7 @@ export function showManagement(ctx: Context, parentSessionId: SessionId) {
48
48
  }
49
49
  function form(parent: HTMLElement) { const node = element("form"); parent.append(node); return node; }
50
50
  function activate(id: string) {
51
+ if (snapshot?.enabled === false && id !== "status") return;
51
52
  tab = id;
52
53
  for (const [key, section] of sections) section.hidden = key !== id;
53
54
  for (const [key, item] of tabs) { item.setAttribute("aria-selected", String(key === id)); item.tabIndex = key === id ? 0 : -1; }
@@ -88,7 +89,7 @@ export function showManagement(ctx: Context, parentSessionId: SessionId) {
88
89
  const status = section("status", "工作区"); pageTitle(status, "工作区", "");
89
90
  const counts = note(""), workspaceInfo = group(status); status.querySelector("header")!.after(counts); counts.classList.add("pbot-counts");
90
91
  const materials = group(status, "pbot-setting"); materials.append(element("h3", "资料目录"));
91
- button(materials, "准备目录", async () => { await command("init", undefined, "资料目录已准备。"); });
92
+ const initializeButton = button(materials, "准备目录", async () => { await command("init", undefined, "工作区已初始化并启用。"); });
92
93
  const pythonCard = group(status, "pbot-setting"), pythonInfo = group(pythonCard);
93
94
  const pythonButton = button(pythonCard, "准备 Python", async () => { await command("python setup", undefined, "Python 准备完成。"); });
94
95
  const diagnostic = details(status, "环境详情"), statusBody = block(""); statusBody.classList.add("pbot-raw"); diagnostic.append(statusBody);
@@ -367,12 +368,28 @@ export function showManagement(ctx: Context, parentSessionId: SessionId) {
367
368
  function renderSnapshot() {
368
369
  if (!snapshot) return;
369
370
  location.textContent = text(snapshot.workspaceRoot).split(/[\\/]/).filter(Boolean).at(-1) ?? ""; location.title = text(snapshot.workspaceRoot);
371
+ if (snapshot.enabled === false) {
372
+ const configuration = record(snapshot.configuration);
373
+ counts.textContent = configuration.error ? "Personal Bot 配置错误" : "Personal Bot 未启用";
374
+ workspaceInfo.replaceChildren(facts([["状态", configuration.error ? "配置错误" : "未启用"], ["工作区", snapshot.workspaceRoot], ["配置文件", configuration.path]]),
375
+ note(configuration.error ? "修复配置文件后执行 /pbot init。" : "执行 /pbot init,或点击下方按钮初始化并启用。"));
376
+ pythonInfo.replaceChildren(note("启用工作区后自动准备共享 Python 环境。"));
377
+ materials.hidden = false; initializeButton.textContent = "初始化并启用";
378
+ enabled(initializeButton, !configuration.error);
379
+ for (const [id, control] of tabs) enabled(control, id === "status");
380
+ enabled(pythonButton, false); activate("status");
381
+ statusBody.textContent = JSON.stringify(snapshot, null, 2);
382
+ if (configuration.error) inform(text(configuration.error), "error");
383
+ return;
384
+ }
385
+ for (const control of tabs.values()) enabled(control, true);
386
+ materials.hidden = false; initializeButton.textContent = "准备目录"; enabled(initializeButton, true);
370
387
  const agents = rows(snapshot.agents), teams = rows(snapshot.teams), python = record(snapshot.python), history = record(snapshot.history);
371
388
  tabCounts.get("experts")!.textContent = text(agents.length); tabCounts.get("teams")!.textContent = text(teams.length);
372
389
  const active = agents.filter(agent => ["starting", "busy", "stopping"].includes(text(agent.state))).length;
373
- counts.textContent = `专家 ${agents.length} · 执行中 ${active} · 团队 ${teams.length}`;
390
+ counts.textContent = `Personal Bot 已启动 · 专家 ${agents.length} · 执行中 ${active} · 团队 ${teams.length}`;
374
391
  const model = object(snapshot.model) ? snapshot.model : {};
375
- workspaceInfo.replaceChildren(facts([["工作区", snapshot.workspaceRoot], ["模型", model.model || "尚未选择"], ["Provider", model.provider || "—"],
392
+ workspaceInfo.replaceChildren(facts([["状态", "已启动"], ["工作区", snapshot.workspaceRoot], ["配置文件", record(snapshot.configuration).path], ["模型", model.model || "尚未选择"], ["Provider", model.provider || "—"],
376
393
  ["历史", history.organizing ? "整理中" : history.pending === null ? "查询时更新索引" : `${history.pending} 项待整理`]]));
377
394
  pythonInfo.replaceChildren(element("h3", "Python"), badge(python.state));
378
395
  if (python.error) pythonInfo.append(element("p", text(python.error), "pbot-warning"));