@oyasmi/pipiclaw 0.5.4 → 0.5.6

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 (47) hide show
  1. package/README.md +38 -2
  2. package/dist/agent/channel-runner.d.ts +1 -0
  3. package/dist/agent/channel-runner.js +3 -0
  4. package/dist/agent/prompt-builder.js +13 -11
  5. package/dist/agent/runner-factory.d.ts +2 -0
  6. package/dist/agent/runner-factory.js +6 -0
  7. package/dist/agent/types.d.ts +1 -0
  8. package/dist/agent/workspace-resources.d.ts +2 -3
  9. package/dist/agent/workspace-resources.js +5 -15
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/memory/lifecycle.d.ts +2 -1
  13. package/dist/memory/lifecycle.js +19 -1
  14. package/dist/paths.js +1 -1
  15. package/dist/runtime/bootstrap.d.ts +22 -1
  16. package/dist/runtime/bootstrap.js +94 -26
  17. package/dist/runtime/channel-paths.d.ts +3 -0
  18. package/dist/runtime/channel-paths.js +13 -0
  19. package/dist/runtime/dingtalk.js +2 -1
  20. package/dist/runtime/store.js +3 -6
  21. package/dist/security/command-guard.d.ts +16 -0
  22. package/dist/security/command-guard.js +447 -0
  23. package/dist/security/config.d.ts +4 -0
  24. package/dist/security/config.js +82 -0
  25. package/dist/security/logger.d.ts +2 -0
  26. package/dist/security/logger.js +18 -0
  27. package/dist/security/path-guard.d.ts +2 -0
  28. package/dist/security/path-guard.js +237 -0
  29. package/dist/security/types.d.ts +66 -0
  30. package/dist/security/types.js +1 -0
  31. package/dist/subagents/tool.d.ts +2 -0
  32. package/dist/subagents/tool.js +31 -7
  33. package/dist/tools/attach.d.ts +7 -1
  34. package/dist/tools/attach.js +36 -1
  35. package/dist/tools/bash.d.ts +4 -0
  36. package/dist/tools/bash.js +38 -0
  37. package/dist/tools/edit.d.ts +7 -1
  38. package/dist/tools/edit.js +42 -2
  39. package/dist/tools/index.d.ts +7 -1
  40. package/dist/tools/index.js +29 -3
  41. package/dist/tools/read.d.ts +7 -1
  42. package/dist/tools/read.js +36 -1
  43. package/dist/tools/write-content.d.ts +5 -0
  44. package/dist/tools/write-content.js +32 -11
  45. package/dist/tools/write.d.ts +7 -1
  46. package/dist/tools/write.js +10 -3
  47. package/package.json +2 -1
package/README.md CHANGED
@@ -11,6 +11,7 @@ npm package: [`@oyasmi/pipiclaw`](https://www.npmjs.com/package/@oyasmi/pipiclaw
11
11
  - 配置手册:[docs/configuration.md](./docs/configuration.md)
12
12
  - 事件与子代理使用指南:[docs/events-and-sub-agents.md](./docs/events-and-sub-agents.md)
13
13
  - 部署与运维指南:[docs/deployment-and-operations.md](./docs/deployment-and-operations.md)
14
+ - 安全文档:[docs/security.md](./docs/security.md)
14
15
 
15
16
  ## 功能特性(Features)
16
17
 
@@ -22,6 +23,20 @@ npm package: [`@oyasmi/pipiclaw`](https://www.npmjs.com/package/@oyasmi/pipiclaw
22
23
  - 支持预定义子代理(sub-agent)和临时内联子代理(inline sub-agent)
23
24
  - 支持立即、单次、周期三类事件调度
24
25
  - 支持自定义模型提供方(provider)和模型(model)配置
26
+ - 内置工具层安全防护:`bash` 命令守卫、文件路径守卫、敏感路径拒绝、阻断审计日志
27
+
28
+ ## 安全说明(Security)
29
+
30
+ Pipiclaw 当前已经内置一轮工具层安全增强:
31
+
32
+ - `bash` 会拦截明显高风险命令
33
+ - `read` / `write` / `edit` / `attach` 会做统一路径检查
34
+ - 默认允许访问用户主目录中的普通工作文件,但会拒绝常见凭据、私钥、浏览器资料、系统敏感文件等位置
35
+ - 可通过 `~/.pi/pipiclaw/security.json` 做实例级策略调整
36
+
37
+ 如果你要了解默认策略、已知边界、推荐模板和完整配置示例,请直接看:
38
+
39
+ - [docs/security.md](./docs/security.md)
25
40
 
26
41
  ## 快速开始(Quickstart)
27
42
 
@@ -140,11 +155,20 @@ pipiclaw
140
155
  ├── SOUL.md
141
156
  ├── AGENTS.md
142
157
  ├── MEMORY.md
158
+ ├── ENVIRONMENT.md
143
159
  ├── events/
144
160
  ├── skills/
145
161
  └── sub-agents/
146
162
  ```
147
163
 
164
+ 默认 app home 是 `~/.pi/pipiclaw/`。如果你希望把 Pipiclaw 的所有配置和运行文件放到别处,可以在启动前设置:
165
+
166
+ ```bash
167
+ export PIPICLAW_HOME=/your/custom/pipiclaw-home
168
+ ```
169
+
170
+ 设置后,`channel.json`、`auth.json`、`models.json`、`settings.json` 和整个 `workspace/` 都会改为从这个目录读取和写入。
171
+
148
172
  如果 `channel.json` 仍然是初始化模板,程序会提示你补全配置后再启动。这是正常行为。
149
173
 
150
174
  #### 4. 创建钉钉应用(Create a DingTalk App)
@@ -326,6 +350,8 @@ pipiclaw
326
350
 
327
351
  ### 配置文件(Config Files)
328
352
 
353
+ 默认根目录是 `~/.pi/pipiclaw/`;如果设置了 `PIPICLAW_HOME`,下面这些路径都会切换到该目录下。
354
+
329
355
  | 文件 | 用途 |
330
356
  |------|------|
331
357
  | `~/.pi/pipiclaw/channel.json` | 钉钉应用配置 |
@@ -338,6 +364,7 @@ pipiclaw
338
364
  | 变量 | 用途 |
339
365
  |----------|------|
340
366
  | `ANTHROPIC_API_KEY` | Anthropic API Key |
367
+ | `PIPICLAW_HOME` | 覆盖默认的 `~/.pi/pipiclaw/` 根目录 |
341
368
  | `PIPICLAW_DEBUG` | 调试模式,会把上下文写到 `last_prompt.json` |
342
369
  | `DINGTALK_FORCE_PROXY` | 设为 `true` 时保留 axios 代理设置 |
343
370
 
@@ -385,6 +412,7 @@ Pipiclaw 的核心不是一个临时机器人实例,而是一组长期存在
385
412
  ├── SOUL.md
386
413
  ├── AGENTS.md
387
414
  ├── MEMORY.md
415
+ ├── ENVIRONMENT.md
388
416
  ├── events/
389
417
  ├── skills/
390
418
  ├── sub-agents/
@@ -395,8 +423,7 @@ Pipiclaw 的核心不是一个临时机器人实例,而是一组长期存在
395
423
  │ ├── .channel-meta.json
396
424
  │ ├── context.jsonl
397
425
  │ ├── log.jsonl
398
- ├── subagent-runs.jsonl
399
- │ └── skills/
426
+ └── subagent-runs.jsonl
400
427
  └── group_{conversationId}/
401
428
  └── ...
402
429
  ```
@@ -511,8 +538,17 @@ npm run check
511
538
 
512
539
  - `npm run typecheck`
513
540
  - `npm run test`
541
+ - `npm run test:e2e`
514
542
  - `npm run check`
515
543
 
544
+ 端到端测试说明:
545
+
546
+ - `npm run test:e2e` 会运行“除钉钉渠道外”的完整 E2E
547
+ - 它会使用真实 runtime、真实 `ChannelStore`、真实工具/记忆/Sidecar/LLM
548
+ - 只 mock 钉钉传输层,不连接真实钉钉 Stream
549
+ - 运行前需要可用模型凭据:优先读取 `${PIPICLAW_HOME:-~/.pi/pipiclaw}/auth.json`,否则回退到 `ANTHROPIC_API_KEY`
550
+ - E2E 默认不包含在 `npm run test` 中,避免日常测试被真实 LLM 依赖和调用成本影响
551
+
516
552
  ## 许可证(License)
517
553
 
518
554
  Apache License 2.0. See [LICENSE](./LICENSE).
@@ -30,6 +30,7 @@ export declare class ChannelRunner implements AgentRunner {
30
30
  handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;
31
31
  queueSteer(text: string, userName?: string): Promise<void>;
32
32
  queueFollowUp(text: string, userName?: string): Promise<void>;
33
+ flushMemoryForShutdown(): Promise<void>;
33
34
  abort(): Promise<void>;
34
35
  private sendCommandReply;
35
36
  private requireQueuedMessage;
@@ -323,6 +323,9 @@ export class ChannelRunner {
323
323
  async queueFollowUp(text, userName) {
324
324
  await this.queueBusyMessage("followUp", this.requireQueuedMessage(text, "followup"), userName);
325
325
  }
326
+ async flushMemoryForShutdown() {
327
+ await this.memoryLifecycle.flushForShutdown();
328
+ }
326
329
  async abort() {
327
330
  await this.session.abort();
328
331
  }
@@ -31,6 +31,7 @@ ${workspacePath}/
31
31
  ├── SOUL.md # Your identity/personality (read-only)
32
32
  ├── AGENTS.md # Custom behavior instructions (read-only)
33
33
  ├── MEMORY.md # Stable workspace memory (admin-managed, read on demand)
34
+ ├── ENVIRONMENT.md # Environment facts and notable machine-level changes (read on demand)
34
35
  ├── sub-agents/ # Predefined sub-agent definitions
35
36
  ├── skills/ # Global CLI tools you create
36
37
  ├── events/ # Scheduled events
@@ -39,9 +40,7 @@ ${workspacePath}/
39
40
  ├── MEMORY.md # Channel durable memory (read on demand, runtime-managed)
40
41
  ├── HISTORY.md # Channel summarized history (read on demand, runtime-managed)
41
42
  ├── log.jsonl # Raw message archive (cold storage)
42
- ├── context.jsonl # Raw session archive (cold storage)
43
- ├── scratch/ # Your working directory
44
- └── skills/ # Channel-specific tools`);
43
+ └── context.jsonl # Raw session archive (cold storage)`);
45
44
  sections.push(`## Events
46
45
  You can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \`${workspacePath}/events/\`.
47
46
 
@@ -80,6 +79,8 @@ Memory files are not preloaded into session context. Read them explicitly when m
80
79
  ### Files
81
80
  - Workspace memory: ${workspacePath}/MEMORY.md
82
81
  Stable shared background memory. Admin-managed. Read on demand.
82
+ - Workspace environment: ${workspacePath}/ENVIRONMENT.md
83
+ Durable environment facts and notable machine-level changes. Read on demand when environment state or prior machine changes matter.
83
84
  - Channel session memory: ${channelPath}/SESSION.md
84
85
  Current working state for this channel. Runtime-managed. Read on demand. Prefer this when current task state matters.
85
86
  - Channel memory: ${channelPath}/MEMORY.md
@@ -92,20 +93,21 @@ Memory files are not preloaded into session context. Read them explicitly when m
92
93
  - SESSION.md is the primary runtime-managed working-state artifact for current active work.
93
94
  - The runtime automatically consolidates channel MEMORY.md and HISTORY.md before compaction or session trimming.
94
95
  - Workspace MEMORY.md is not updated by normal runtime consolidation.
96
+ - ENVIRONMENT.md is not normal conversational memory. Read it only when environment history or machine state matters.
95
97
 
96
98
  ### Cold Storage
97
99
  - ${channelPath}/log.jsonl is a raw archive. It is not normal memory and is not proactively loaded.
98
100
  - ${channelPath}/context.jsonl is a raw session archive. It is not normal memory and is not proactively loaded.
99
101
 
100
102
  When a task depends on prior decisions, preferences, or long-running work, prefer SESSION.md first for current state, then MEMORY.md, then HISTORY.md.`);
101
- sections.push(`## System Configuration Log
102
- Maintain ${workspacePath}/SYSTEM.md to log all environment modifications:
103
- - Installed packages (apk add, npm install, pip install)
104
- - Environment variables set
105
- - Config files modified
106
- - Skill dependencies installed
107
-
108
- Update this file whenever you modify the environment.`);
103
+ sections.push(`## Environment Log
104
+ Maintain ${workspacePath}/ENVIRONMENT.md to record durable environment changes when they matter:
105
+ - Installed packages or tools that future work depends on
106
+ - Important environment variables or credential sources
107
+ - Config files modified outside normal project code
108
+ - Runtime prerequisites that affect future sessions
109
+
110
+ Keep it factual and concise. Do not use it for task progress or conversation summaries.`);
109
111
  sections.push(`## Tools
110
112
  - read: Read files
111
113
  - edit: Surgical file edits
@@ -1,3 +1,5 @@
1
1
  import type { SandboxConfig } from "../sandbox.js";
2
2
  import type { AgentRunner } from "./types.js";
3
3
  export declare function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner;
4
+ export declare function resetRunner(channelId: string): void;
5
+ export declare function resetAllRunners(): void;
@@ -8,3 +8,9 @@ export function getOrCreateRunner(sandboxConfig, channelId, channelDir) {
8
8
  channelRunners.set(channelId, runner);
9
9
  return runner;
10
10
  }
11
+ export function resetRunner(channelId) {
12
+ channelRunners.delete(channelId);
13
+ }
14
+ export function resetAllRunners() {
15
+ channelRunners.clear();
16
+ }
@@ -10,6 +10,7 @@ export interface AgentRunner {
10
10
  handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;
11
11
  queueSteer(text: string, userName?: string): Promise<void>;
12
12
  queueFollowUp(text: string, userName?: string): Promise<void>;
13
+ flushMemoryForShutdown(): Promise<void>;
13
14
  abort(): Promise<void>;
14
15
  }
15
16
  export type FinalOutcome = {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Workspace resource loaders for pipiclaw:
3
- * SOUL.md, AGENTS.md, and workspace/channel skills.
3
+ * SOUL.md, AGENTS.md, and workspace-level skills.
4
4
  */
5
5
  import { type Skill } from "@mariozechner/pi-coding-agent";
6
6
  /**
@@ -14,7 +14,6 @@ export declare function getSoul(workspaceDir: string): string;
14
14
  */
15
15
  export declare function getAgentConfig(channelDir: string): string;
16
16
  /**
17
- * Load skills from both workspace-level and channel-level skill directories.
18
- * Channel-level skills override global skills with the same name.
17
+ * Load skills from the workspace-level skill directory only.
19
18
  */
20
19
  export declare function loadPipiclawSkills(channelDir: string, workspacePath: string): Skill[];
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Workspace resource loaders for pipiclaw:
3
- * SOUL.md, AGENTS.md, and workspace/channel skills.
3
+ * SOUL.md, AGENTS.md, and workspace-level skills.
4
4
  */
5
5
  import { loadSkillsFromDir } from "@mariozechner/pi-coding-agent";
6
6
  import { existsSync, readFileSync } from "fs";
@@ -44,11 +44,9 @@ export function getAgentConfig(channelDir) {
44
44
  return "";
45
45
  }
46
46
  /**
47
- * Load skills from both workspace-level and channel-level skill directories.
48
- * Channel-level skills override global skills with the same name.
47
+ * Load skills from the workspace-level skill directory only.
49
48
  */
50
49
  export function loadPipiclawSkills(channelDir, workspacePath) {
51
- const skillMap = new Map();
52
50
  const hostWorkspacePath = join(channelDir, "..");
53
51
  const translatePath = (hostPath) => {
54
52
  if (hostPath.startsWith(hostWorkspacePath)) {
@@ -56,19 +54,11 @@ export function loadPipiclawSkills(channelDir, workspacePath) {
56
54
  }
57
55
  return hostPath;
58
56
  };
59
- // Load workspace-level skills (global)
60
57
  const workspaceSkillsDir = join(hostWorkspacePath, "skills");
61
- for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" }).skills) {
58
+ const skills = loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" }).skills;
59
+ for (const skill of skills) {
62
60
  skill.filePath = translatePath(skill.filePath);
63
61
  skill.baseDir = translatePath(skill.baseDir);
64
- skillMap.set(skill.name, skill);
65
62
  }
66
- // Load channel-specific skills
67
- const channelSkillsDir = join(channelDir, "skills");
68
- for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" }).skills) {
69
- skill.filePath = translatePath(skill.filePath);
70
- skill.baseDir = translatePath(skill.baseDir);
71
- skillMap.set(skill.name, skill);
72
- }
73
- return Array.from(skillMap.values());
63
+ return skills;
74
64
  }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { runSidecarTask, type SidecarResult, type SidecarTask, } from "./memory/
13
13
  export { getApiKeyForModel } from "./models/api-keys.js";
14
14
  export { findExactModelReferenceMatch, formatModelList, formatModelReference, resolveInitialModel, } from "./models/utils.js";
15
15
  export { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SETTINGS_CONFIG_PATH, SUB_AGENTS_DIR, SUB_AGENTS_DIR_NAME, WORKSPACE_DIR, } from "./paths.js";
16
+ export { ensureChannelDir, getChannelDir, getChannelDirName, } from "./runtime/channel-paths.js";
16
17
  export { createDingTalkContext } from "./runtime/delivery.js";
17
18
  export { type BusyMessageMode, DingTalkBot, type DingTalkConfig, type DingTalkContext, type DingTalkEvent, type DingTalkHandler, } from "./runtime/dingtalk.js";
18
19
  export { createEventsWatcher, EventsWatcher, type ImmediateEvent, type OneShotEvent, type PeriodicEvent, type ScheduledEvent, } from "./runtime/events.js";
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { runSidecarTask, } from "./memory/sidecar-worker.js";
13
13
  export { getApiKeyForModel } from "./models/api-keys.js";
14
14
  export { findExactModelReferenceMatch, formatModelList, formatModelReference, resolveInitialModel, } from "./models/utils.js";
15
15
  export { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SETTINGS_CONFIG_PATH, SUB_AGENTS_DIR, SUB_AGENTS_DIR_NAME, WORKSPACE_DIR, } from "./paths.js";
16
+ export { ensureChannelDir, getChannelDir, getChannelDirName, } from "./runtime/channel-paths.js";
16
17
  export { createDingTalkContext } from "./runtime/delivery.js";
17
18
  export { DingTalkBot, } from "./runtime/dingtalk.js";
18
19
  export { createEventsWatcher, EventsWatcher, } from "./runtime/events.js";
@@ -2,7 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
2
2
  import type { Api, Model } from "@mariozechner/pi-ai";
3
3
  import type { ExtensionFactory, SessionEntry } from "@mariozechner/pi-coding-agent";
4
4
  import type { PipiclawSessionMemorySettings } from "../settings.js";
5
- export type ConsolidationReason = "compaction" | "new-session" | "idle";
5
+ export type ConsolidationReason = "compaction" | "new-session" | "idle" | "shutdown";
6
6
  export interface MemoryLifecycleOptions {
7
7
  channelId: string;
8
8
  channelDir: string;
@@ -33,6 +33,7 @@ export declare class MemoryLifecycle {
33
33
  noteUserTurnStarted(): void;
34
34
  noteToolCall(): void;
35
35
  noteCompletedAssistantTurn(): void;
36
+ flushForShutdown(): Promise<void>;
36
37
  private clearIdleConsolidationTimer;
37
38
  private shouldForceRefreshFor;
38
39
  private refreshSessionMemory;
@@ -73,6 +73,18 @@ export class MemoryLifecycle {
73
73
  }
74
74
  this.scheduleIdleConsolidation();
75
75
  }
76
+ async flushForShutdown() {
77
+ this.clearIdleConsolidationTimer();
78
+ const run = async () => {
79
+ if (!this.hasPendingAssistantSnapshot()) {
80
+ return;
81
+ }
82
+ await this.runPreflightConsolidation("shutdown");
83
+ };
84
+ const resultPromise = this.backgroundQueue.then(run, run);
85
+ this.backgroundQueue = resultPromise.then(() => undefined, () => undefined);
86
+ await resultPromise;
87
+ }
76
88
  clearIdleConsolidationTimer() {
77
89
  if (!this.idleConsolidationTimer) {
78
90
  return;
@@ -84,7 +96,13 @@ export class MemoryLifecycle {
84
96
  if (!settings.enabled) {
85
97
  return false;
86
98
  }
87
- return reason === "compaction" ? settings.forceRefreshBeforeCompact : settings.forceRefreshBeforeNewSession;
99
+ if (reason === "compaction") {
100
+ return settings.forceRefreshBeforeCompact;
101
+ }
102
+ if (reason === "new-session") {
103
+ return settings.forceRefreshBeforeNewSession;
104
+ }
105
+ return false;
88
106
  }
89
107
  async refreshSessionMemory(request) {
90
108
  const settings = this.options.getSessionMemorySettings();
package/dist/paths.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  export const APP_NAME = "pipiclaw";
4
- export const APP_HOME_DIR = join(homedir(), ".pi", APP_NAME);
4
+ export const APP_HOME_DIR = process.env.PIPICLAW_HOME ?? join(homedir(), ".pi", APP_NAME);
5
5
  export const WORKSPACE_DIR = join(APP_HOME_DIR, "workspace");
6
6
  export const SUB_AGENTS_DIR_NAME = "sub-agents";
7
7
  export const SUB_AGENTS_DIR = join(WORKSPACE_DIR, SUB_AGENTS_DIR_NAME);
@@ -1,5 +1,5 @@
1
1
  import { type SandboxConfig } from "../sandbox.js";
2
- import { DingTalkBot, type DingTalkConfig } from "./dingtalk.js";
2
+ import { DingTalkBot, type DingTalkConfig, type DingTalkHandler } from "./dingtalk.js";
3
3
  import { ChannelStore } from "./store.js";
4
4
  export interface BootstrapPaths {
5
5
  appName: string;
@@ -33,6 +33,11 @@ export interface AppContext {
33
33
  store: ChannelStore;
34
34
  shutdown: () => Promise<void>;
35
35
  }
36
+ export interface RuntimeContext {
37
+ handler: DingTalkHandler;
38
+ store: ChannelStore;
39
+ shutdown: (reason?: NodeJS.Signals | "manual") => Promise<void>;
40
+ }
36
41
  export declare const DEFAULT_BOOTSTRAP_PATHS: BootstrapPaths;
37
42
  export declare class BootstrapExitError extends Error {
38
43
  readonly code: number;
@@ -44,4 +49,20 @@ export declare function bootstrapAppHome(paths?: BootstrapPaths): BootstrapResul
44
49
  export declare function printBootstrapSummary(result: BootstrapResult, io?: BootstrapIO, paths?: BootstrapPaths): void;
45
50
  export declare function loadConfig(paths?: BootstrapPaths, io?: BootstrapIO): DingTalkConfig;
46
51
  export declare function parseArgs(argv: string[], paths?: BootstrapPaths, io?: BootstrapIO): ParsedArgs;
52
+ interface RuntimeContextOptions {
53
+ paths: BootstrapPaths;
54
+ sandbox: SandboxConfig;
55
+ dingtalkConfig: DingTalkConfig;
56
+ createBot?: (handler: DingTalkHandler, config: DingTalkConfig) => DingTalkBot;
57
+ createEventsWatcher?: (workspaceDir: string, bot: DingTalkBot) => {
58
+ start(): void;
59
+ stop(): void;
60
+ };
61
+ startServices?: boolean;
62
+ registerSignalHandlers?: boolean;
63
+ }
64
+ export declare function createRuntimeContext(options: RuntimeContextOptions): RuntimeContext & {
65
+ bot: DingTalkBot;
66
+ };
47
67
  export declare function bootstrap(argv: string[], options?: BootstrapOptions): Promise<AppContext>;
68
+ export {};
@@ -2,10 +2,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { parseBuiltInCommand } from "../agent/commands.js";
4
4
  import { getOrCreateRunner } from "../agent/index.js";
5
+ import { resetRunner } from "../agent/runner-factory.js";
5
6
  import * as log from "../log.js";
6
7
  import { ensureChannelMemoryFilesSync } from "../memory/files.js";
7
8
  import { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SETTINGS_CONFIG_PATH, WORKSPACE_DIR, } from "../paths.js";
8
9
  import { parseSandboxArg, validateSandbox } from "../sandbox.js";
10
+ import { ensureChannelDir } from "./channel-paths.js";
9
11
  import { createDingTalkContext } from "./delivery.js";
10
12
  import { DingTalkBot, } from "./dingtalk.js";
11
13
  import { createEventsWatcher } from "./events.js";
@@ -68,6 +70,26 @@ This file stores stable workspace-level memory.
68
70
 
69
71
  <!-- Put long-lived project facts here. -->
70
72
  `;
73
+ const DEFAULT_ENVIRONMENT = `# Environment
74
+
75
+ This file records durable environment facts and notable machine-level changes.
76
+
77
+ - Record installed tools, runtime prerequisites, and important config changes here.
78
+ - Keep entries concise and factual.
79
+ - Do not use this file for task progress, conversation summaries, or project-specific decisions.
80
+
81
+ ## Environment Facts
82
+
83
+ <!-- Put stable machine or runtime facts here. -->
84
+
85
+ ## Installed Tools
86
+
87
+ <!-- Record durable tools or dependencies that were installed for this workspace. -->
88
+
89
+ ## Config Changes
90
+
91
+ <!-- Record important config or environment changes that affect future work. -->
92
+ `;
71
93
  const CHANNEL_CONFIG_TEMPLATE = {
72
94
  clientId: "your-dingtalk-client-id",
73
95
  clientSecret: "your-dingtalk-client-secret",
@@ -78,6 +100,7 @@ const CHANNEL_CONFIG_TEMPLATE = {
78
100
  };
79
101
  const MODELS_CONFIG_TEMPLATE = { providers: {} };
80
102
  const SHUTDOWN_WAIT_MS = 15000;
103
+ const SHUTDOWN_FLUSH_WAIT_MS = 25000;
81
104
  const SHUTDOWN_ABORT_WAIT_MS = 5000;
82
105
  export const DEFAULT_BOOTSTRAP_PATHS = {
83
106
  appName: APP_NAME,
@@ -139,6 +162,7 @@ export function bootstrapAppHome(paths = DEFAULT_BOOTSTRAP_PATHS) {
139
162
  writeTextFileIfMissing(join(paths.workspaceDir, "SOUL.md"), DEFAULT_SOUL, "workspace/SOUL.md", created);
140
163
  writeTextFileIfMissing(join(paths.workspaceDir, "AGENTS.md"), DEFAULT_AGENT, "workspace/AGENTS.md", created);
141
164
  writeTextFileIfMissing(join(paths.workspaceDir, "MEMORY.md"), DEFAULT_MEMORY, "workspace/MEMORY.md", created);
165
+ writeTextFileIfMissing(join(paths.workspaceDir, "ENVIRONMENT.md"), DEFAULT_ENVIRONMENT, "workspace/ENVIRONMENT.md", created);
142
166
  const channelTemplateCreated = writeJsonFileIfMissing(paths.channelConfigPath, CHANNEL_CONFIG_TEMPLATE, "channel.json", created);
143
167
  writeJsonFileIfMissing(paths.authConfigPath, {}, "auth.json", created);
144
168
  writeJsonFileIfMissing(paths.modelsConfigPath, MODELS_CONFIG_TEMPLATE, "models.json", created);
@@ -247,25 +271,22 @@ function waitForTasks(tasks, timeoutMs) {
247
271
  }),
248
272
  ]);
249
273
  }
250
- export async function bootstrap(argv, options = {}) {
251
- const env = options.env ?? process.env;
252
- const io = options.io ?? console;
253
- const paths = options.paths ?? DEFAULT_BOOTSTRAP_PATHS;
254
- const registerSignalHandlers = options.registerSignalHandlers ?? true;
255
- const startServices = options.startServices ?? true;
256
- sanitizeProxyEnv(env);
257
- const parsedArgs = parseArgs(argv, paths, io);
258
- const sandbox = parsedArgs.sandbox;
259
- const bootstrapResult = bootstrapAppHome(paths);
260
- printBootstrapSummary(bootstrapResult, io, paths);
261
- if (bootstrapResult.channelTemplateCreated) {
262
- io.error(`Fill in ${paths.channelConfigPath} and run \`${paths.appName}\` again.`);
263
- throw new BootstrapExitError(1);
274
+ function flushInactiveChannelMemory(channelStates) {
275
+ const flushes = [];
276
+ for (const [channelId, state] of channelStates) {
277
+ if (state.running) {
278
+ continue;
279
+ }
280
+ flushes.push(state.runner.flushMemoryForShutdown().catch((err) => {
281
+ log.logWarning(`[${channelId}] Failed to flush memory during shutdown`, err instanceof Error ? err.message : String(err));
282
+ }));
264
283
  }
265
- const dingtalkConfig = loadConfig(paths, io);
266
- dingtalkConfig.stateDir = paths.workspaceDir;
267
- await validateSandbox(sandbox);
268
- const store = new ChannelStore({ workingDir: paths.workspaceDir });
284
+ return flushes;
285
+ }
286
+ export function createRuntimeContext(options) {
287
+ const startServices = options.startServices ?? true;
288
+ const registerSignalHandlers = options.registerSignalHandlers ?? true;
289
+ const store = new ChannelStore({ workingDir: options.paths.workspaceDir });
269
290
  const channelStates = new Map();
270
291
  const activeTasks = new Set();
271
292
  let shuttingDown = false;
@@ -273,11 +294,11 @@ export async function bootstrap(argv, options = {}) {
273
294
  const getState = (channelId) => {
274
295
  let state = channelStates.get(channelId);
275
296
  if (!state) {
276
- const channelDir = join(paths.workspaceDir, channelId);
297
+ const channelDir = ensureChannelDir(options.paths.workspaceDir, channelId);
277
298
  ensureChannelMemoryFilesSync(channelDir);
278
299
  state = {
279
300
  running: false,
280
- runner: getOrCreateRunner(sandbox, channelId, channelDir),
301
+ runner: getOrCreateRunner(options.sandbox, channelId, channelDir),
281
302
  stopRequested: false,
282
303
  };
283
304
  channelStates.set(channelId, state);
@@ -383,10 +404,13 @@ export async function bootstrap(argv, options = {}) {
383
404
  }
384
405
  },
385
406
  };
386
- log.logStartup(paths.workspaceDir, sandbox.type === "host" ? "host" : `docker:${sandbox.container}`);
387
- const bot = new DingTalkBot(handler, dingtalkConfig);
388
- const eventsWatcher = createEventsWatcher(paths.workspaceDir, bot);
389
- const shutdownWithReason = async (reason) => {
407
+ const bot = options.createBot
408
+ ? options.createBot(handler, options.dingtalkConfig)
409
+ : new DingTalkBot(handler, options.dingtalkConfig);
410
+ const eventsWatcher = options.createEventsWatcher
411
+ ? options.createEventsWatcher(options.paths.workspaceDir, bot)
412
+ : createEventsWatcher(options.paths.workspaceDir, bot);
413
+ const shutdownWithReason = async (reason = "manual") => {
390
414
  if (shutdownPromise) {
391
415
  return shutdownPromise;
392
416
  }
@@ -421,6 +445,17 @@ export async function bootstrap(argv, options = {}) {
421
445
  }
422
446
  }
423
447
  }
448
+ const flushes = flushInactiveChannelMemory(channelStates);
449
+ if (flushes.length > 0) {
450
+ log.logInfo(`Flushing memory for ${flushes.length} inactive channel(s) before shutdown`);
451
+ const flushed = await waitForTasks(flushes, SHUTDOWN_FLUSH_WAIT_MS);
452
+ if (!flushed) {
453
+ log.logWarning(`Shutdown memory flush exceeded ${SHUTDOWN_FLUSH_WAIT_MS}ms`);
454
+ }
455
+ }
456
+ for (const channelId of channelStates.keys()) {
457
+ resetRunner(channelId);
458
+ }
424
459
  })();
425
460
  return shutdownPromise;
426
461
  };
@@ -441,10 +476,43 @@ export async function bootstrap(argv, options = {}) {
441
476
  void bot.start();
442
477
  }
443
478
  return {
444
- bot,
479
+ handler,
445
480
  store,
481
+ bot,
482
+ shutdown: shutdownWithReason,
483
+ };
484
+ }
485
+ export async function bootstrap(argv, options = {}) {
486
+ const env = options.env ?? process.env;
487
+ const io = options.io ?? console;
488
+ const paths = options.paths ?? DEFAULT_BOOTSTRAP_PATHS;
489
+ const registerSignalHandlers = options.registerSignalHandlers ?? true;
490
+ const startServices = options.startServices ?? true;
491
+ sanitizeProxyEnv(env);
492
+ const parsedArgs = parseArgs(argv, paths, io);
493
+ const sandbox = parsedArgs.sandbox;
494
+ const bootstrapResult = bootstrapAppHome(paths);
495
+ printBootstrapSummary(bootstrapResult, io, paths);
496
+ if (bootstrapResult.channelTemplateCreated) {
497
+ io.error(`Fill in ${paths.channelConfigPath} and run \`${paths.appName}\` again.`);
498
+ throw new BootstrapExitError(1);
499
+ }
500
+ const dingtalkConfig = loadConfig(paths, io);
501
+ dingtalkConfig.stateDir = paths.workspaceDir;
502
+ await validateSandbox(sandbox);
503
+ log.logStartup(paths.workspaceDir, sandbox.type === "host" ? "host" : `docker:${sandbox.container}`);
504
+ const runtime = createRuntimeContext({
505
+ paths,
506
+ sandbox,
507
+ dingtalkConfig,
508
+ registerSignalHandlers,
509
+ startServices,
510
+ });
511
+ return {
512
+ bot: runtime.bot,
513
+ store: runtime.store,
446
514
  shutdown: async () => {
447
- await shutdownWithReason("manual");
515
+ await runtime.shutdown("manual");
448
516
  },
449
517
  };
450
518
  }
@@ -0,0 +1,3 @@
1
+ export declare function getChannelDirName(channelId: string): string;
2
+ export declare function getChannelDir(baseDir: string, channelId: string): string;
3
+ export declare function ensureChannelDir(baseDir: string, channelId: string): string;
@@ -0,0 +1,13 @@
1
+ import { mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ export function getChannelDirName(channelId) {
4
+ return channelId.replaceAll("/", "__");
5
+ }
6
+ export function getChannelDir(baseDir, channelId) {
7
+ return join(baseDir, getChannelDirName(channelId));
8
+ }
9
+ export function ensureChannelDir(baseDir, channelId) {
10
+ const channelDir = getChannelDir(baseDir, channelId);
11
+ mkdirSync(channelDir, { recursive: true });
12
+ return channelDir;
13
+ }
@@ -14,6 +14,7 @@ import { dirname, join } from "path";
14
14
  import { parseBuiltInCommand, renderBuiltInHelp } from "../agent/commands.js";
15
15
  import * as log from "../log.js";
16
16
  import { isRecord } from "../shared/type-guards.js";
17
+ import { getChannelDir } from "./channel-paths.js";
17
18
  class ChannelQueue {
18
19
  constructor() {
19
20
  this.queue = [];
@@ -761,6 +762,6 @@ export class DingTalkBot {
761
762
  getConversationMetaPath(channelId) {
762
763
  if (!this.config.stateDir)
763
764
  return null;
764
- return join(this.config.stateDir, channelId, ".channel-meta.json");
765
+ return join(getChannelDir(this.config.stateDir, channelId), ".channel-meta.json");
765
766
  }
766
767
  }
@@ -1,6 +1,7 @@
1
1
  import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "fs";
2
2
  import { appendFile, writeFile } from "fs/promises";
3
3
  import { dirname, join } from "path";
4
+ import { ensureChannelDir, getChannelDir } from "./channel-paths.js";
4
5
  const MAX_LOG_SIZE_BYTES = 1_000_000;
5
6
  const DEDUPE_TTL_MS = 60_000;
6
7
  const DEDUPE_CLEANUP_INTERVAL_MS = 30_000;
@@ -19,11 +20,7 @@ export class ChannelStore {
19
20
  * Get or create the directory for a channel/DM
20
21
  */
21
22
  getChannelDir(channelId) {
22
- const dir = join(this.workingDir, channelId);
23
- if (!existsSync(dir)) {
24
- mkdirSync(dir, { recursive: true });
25
- }
26
- return dir;
23
+ return ensureChannelDir(this.workingDir, channelId);
27
24
  }
28
25
  /**
29
26
  * Log a message to the channel's log.jsonl raw archive.
@@ -102,7 +99,7 @@ export class ChannelStore {
102
99
  * Returns null if no log exists
103
100
  */
104
101
  getLastTimestamp(channelId) {
105
- const logPath = join(this.workingDir, channelId, "log.jsonl");
102
+ const logPath = join(getChannelDir(this.workingDir, channelId), "log.jsonl");
106
103
  if (!existsSync(logPath)) {
107
104
  return null;
108
105
  }