@oyasmi/pipiclaw 0.5.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/agent/channel-runner.d.ts +5 -0
  2. package/dist/agent/channel-runner.js +59 -15
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/memory/consolidation.js +11 -2
  6. package/dist/memory/session.js +2 -2
  7. package/dist/memory/sidecar-worker.d.ts +1 -0
  8. package/dist/memory/sidecar-worker.js +56 -1
  9. package/dist/paths.d.ts +1 -0
  10. package/dist/paths.js +1 -0
  11. package/dist/runtime/bootstrap.d.ts +1 -0
  12. package/dist/runtime/bootstrap.js +50 -11
  13. package/dist/runtime/delivery.js +56 -5
  14. package/dist/runtime/dingtalk.d.ts +2 -0
  15. package/dist/runtime/dingtalk.js +14 -4
  16. package/dist/runtime/events.d.ts +3 -0
  17. package/dist/runtime/events.js +30 -5
  18. package/dist/security/command-guard.js +4 -0
  19. package/dist/security/config.d.ts +6 -0
  20. package/dist/security/config.js +38 -6
  21. package/dist/security/path-guard.js +4 -0
  22. package/dist/security/platform.d.ts +1 -0
  23. package/dist/security/platform.js +3 -0
  24. package/dist/settings.d.ts +4 -1
  25. package/dist/settings.js +31 -6
  26. package/dist/shared/config-diagnostics.d.ts +7 -0
  27. package/dist/shared/config-diagnostics.js +3 -0
  28. package/dist/tools/config.d.ts +7 -0
  29. package/dist/tools/config.js +63 -7
  30. package/dist/tools/index.d.ts +3 -0
  31. package/dist/tools/index.js +2 -2
  32. package/dist/web/client.d.ts +1 -0
  33. package/dist/web/client.js +30 -18
  34. package/dist/web/config.d.ts +1 -0
  35. package/dist/web/config.js +1 -0
  36. package/dist/web/fetch.d.ts +1 -0
  37. package/dist/web/fetch.js +7 -5
  38. package/dist/web/search-providers.js +6 -3
  39. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { type SandboxConfig } from "../sandbox.js";
4
4
  import { type BuiltInCommand } from "./commands.js";
5
5
  import { type AgentRunner } from "./types.js";
6
6
  export declare class ChannelRunner implements AgentRunner {
7
+ private readonly executor;
7
8
  private readonly sandboxConfig;
8
9
  private readonly channelId;
9
10
  private readonly channelDir;
@@ -43,6 +44,10 @@ export declare class ChannelRunner implements AgentRunner {
43
44
  private reloadSessionResources;
44
45
  private ensureSessionReady;
45
46
  private refreshSubAgentDiscovery;
47
+ private reportSettingsDiagnostics;
48
+ private reportConfigDiagnostics;
49
+ private buildRuntimeTools;
50
+ private rebuildSessionTools;
46
51
  private subscribeToSessionEvents;
47
52
  private buildFirstTurnMemoryBootstrap;
48
53
  }
@@ -12,10 +12,13 @@ import { getApiKeyForModel } from "../models/api-keys.js";
12
12
  import { resolveInitialModel } from "../models/utils.js";
13
13
  import { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from "../paths.js";
14
14
  import { createExecutor } from "../sandbox.js";
15
+ import { loadSecurityConfigWithDiagnostics } from "../security/config.js";
15
16
  import { PipiclawSettingsManager } from "../settings.js";
17
+ import { formatConfigDiagnostic } from "../shared/config-diagnostics.js";
16
18
  import { HAN_REGEX } from "../shared/text-utils.js";
17
19
  import { isRecord } from "../shared/type-guards.js";
18
20
  import { discoverSubAgents, formatSubAgentList } from "../subagents/discovery.js";
21
+ import { loadToolsConfigWithDiagnostics } from "../tools/config.js";
19
22
  import { createPipiclawTools } from "../tools/index.js";
20
23
  import { createCommandExtension } from "./command-extension.js";
21
24
  import { renderBuiltInHelp } from "./commands.js";
@@ -54,6 +57,7 @@ export class ChannelRunner {
54
57
  this.channelId = channelId;
55
58
  this.channelDir = channelDir;
56
59
  const executor = createExecutor(sandboxConfig);
60
+ this.executor = executor;
57
61
  this.workspaceDir = resolve(dirname(channelDir));
58
62
  this.workspacePath = executor.getWorkspacePath(this.workspaceDir);
59
63
  // Initial skill summaries
@@ -63,6 +67,7 @@ export class ChannelRunner {
63
67
  const contextFile = join(channelDir, "context.jsonl");
64
68
  this.sessionManager = SessionManager.open(contextFile, channelDir);
65
69
  this.settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);
70
+ this.reportSettingsDiagnostics();
66
71
  // Create AuthStorage and ModelRegistry
67
72
  const authStorage = AuthStorage.create(AUTH_CONFIG_PATH);
68
73
  this.modelRegistry = createModelRegistry(authStorage, MODELS_CONFIG_PATH);
@@ -71,19 +76,7 @@ export class ChannelRunner {
71
76
  log.logInfo(`Using model: ${this.activeModel.provider}/${this.activeModel.id} (${this.activeModel.name})`);
72
77
  this.subAgentDiscovery = this.refreshSubAgentDiscovery();
73
78
  // Create tools
74
- const tools = createPipiclawTools({
75
- executor,
76
- getCurrentModel: () => this.activeModel,
77
- getAvailableModels: () => this.modelRegistry.getAvailable(),
78
- resolveApiKey: async (model) => getApiKeyForModel(this.modelRegistry, model),
79
- workspaceDir: this.workspaceDir,
80
- channelDir: this.channelDir,
81
- workspacePath: this.workspacePath,
82
- channelId: this.channelId,
83
- sandboxConfig: this.sandboxConfig,
84
- getSubAgentDiscovery: () => this.subAgentDiscovery,
85
- getMemoryRecallSettings: () => this.settingsManager.getMemoryRecallSettings(),
86
- });
79
+ const tools = this.buildRuntimeTools();
87
80
  // Create agent
88
81
  this.agent = new Agent({
89
82
  initialState: {
@@ -241,7 +234,10 @@ export class ChannelRunner {
241
234
  this.runState.errorMessage &&
242
235
  !this.runState.finalResponseDelivered) {
243
236
  try {
244
- await ctx.replaceMessage("_Sorry, something went wrong_");
237
+ const errorSummary = this.runState.errorMessage.length > 240
238
+ ? `${this.runState.errorMessage.slice(0, 237)}...`
239
+ : this.runState.errorMessage;
240
+ await ctx.replaceMessage(`_Sorry, something went wrong._\n\n\`${errorSummary}\``);
245
241
  }
246
242
  catch (err) {
247
243
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -258,6 +254,16 @@ export class ChannelRunner {
258
254
  log.logWarning("Failed to delete message for silent response", errMsg);
259
255
  }
260
256
  }
257
+ else if (this.runState.stopReason === "aborted" && !this.runState.finalResponseDelivered) {
258
+ try {
259
+ await ctx.deleteMessage();
260
+ log.logInfo("Aborted response - discarded active delivery state");
261
+ }
262
+ catch (err) {
263
+ const errMsg = err instanceof Error ? err.message : String(err);
264
+ log.logWarning("Failed to discard active delivery state after abort", errMsg);
265
+ }
266
+ }
261
267
  else if (finalOutcomeText && !this.runState.finalResponseDelivered) {
262
268
  try {
263
269
  await ctx.replaceMessage(finalOutcomeText);
@@ -388,10 +394,12 @@ export class ChannelRunner {
388
394
  await this.reloadSessionResources();
389
395
  }
390
396
  async reloadSessionResources() {
397
+ this.settingsManager.reload();
398
+ this.reportSettingsDiagnostics();
391
399
  const skills = loadPipiclawSkills(this.channelDir, this.workspacePath);
392
400
  this.currentSkills = skills;
393
401
  this.subAgentDiscovery = this.refreshSubAgentDiscovery();
394
- this.firstTurnMemoryBootstrapPending = true;
402
+ this.rebuildSessionTools();
395
403
  await this.session.reload();
396
404
  }
397
405
  async ensureSessionReady() {
@@ -405,6 +413,42 @@ export class ChannelRunner {
405
413
  }
406
414
  return discovery;
407
415
  }
416
+ reportSettingsDiagnostics() {
417
+ for (const { scope, error } of this.settingsManager.drainErrors()) {
418
+ log.logWarning(`[${this.channelId}] Failed to load ${scope} settings`, `${error.message}\n${join(APP_HOME_DIR, "settings.json")}`);
419
+ }
420
+ }
421
+ reportConfigDiagnostics(diagnostics) {
422
+ for (const diagnostic of diagnostics) {
423
+ log.logWarning(`[${this.channelId}] ${formatConfigDiagnostic(diagnostic)}`, diagnostic.path);
424
+ }
425
+ }
426
+ buildRuntimeTools() {
427
+ const securityLoad = loadSecurityConfigWithDiagnostics(APP_HOME_DIR);
428
+ const toolsLoad = loadToolsConfigWithDiagnostics(APP_HOME_DIR);
429
+ this.reportConfigDiagnostics([...securityLoad.diagnostics, ...toolsLoad.diagnostics]);
430
+ return createPipiclawTools({
431
+ executor: this.executor,
432
+ getCurrentModel: () => this.activeModel,
433
+ getAvailableModels: () => this.modelRegistry.getAvailable(),
434
+ resolveApiKey: async (model) => getApiKeyForModel(this.modelRegistry, model),
435
+ workspaceDir: this.workspaceDir,
436
+ channelDir: this.channelDir,
437
+ workspacePath: this.workspacePath,
438
+ channelId: this.channelId,
439
+ sandboxConfig: this.sandboxConfig,
440
+ getSubAgentDiscovery: () => this.subAgentDiscovery,
441
+ getMemoryRecallSettings: () => this.settingsManager.getMemoryRecallSettings(),
442
+ securityConfig: securityLoad.config,
443
+ toolsConfig: toolsLoad.config,
444
+ });
445
+ }
446
+ rebuildSessionTools() {
447
+ const tools = this.buildRuntimeTools();
448
+ this.agent.setTools(tools);
449
+ this.session._baseToolsOverride =
450
+ Object.fromEntries(tools.map((tool) => [tool.name, tool]));
451
+ }
408
452
  // === Session event subscription ===
409
453
  subscribeToSessionEvents() {
410
454
  this.session.subscribe(async (event) => {
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { renderSessionMemory, type SessionMemoryState, type SessionMemoryUpdateO
12
12
  export { runSidecarTask, type SidecarResult, type SidecarTask, } from "./memory/sidecar-worker.js";
13
13
  export { getApiKeyForModel } from "./models/api-keys.js";
14
14
  export { findExactModelReferenceMatch, findModelReferenceMatch, formatModelList, formatModelReference, resolveInitialModel, } from "./models/utils.js";
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, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "./paths.js";
15
+ export { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SECURITY_CONFIG_PATH, SETTINGS_CONFIG_PATH, SUB_AGENTS_DIR, SUB_AGENTS_DIR_NAME, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "./paths.js";
16
16
  export { ensureChannelDir, getChannelDir, getChannelDirName, } from "./runtime/channel-paths.js";
17
17
  export { createDingTalkContext } from "./runtime/delivery.js";
18
18
  export { type BusyMessageMode, DingTalkBot, type DingTalkConfig, type DingTalkContext, type DingTalkEvent, type DingTalkHandler, } from "./runtime/dingtalk.js";
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ export { renderSessionMemory, updateChannelSessionMemory, } from "./memory/sessi
12
12
  export { runSidecarTask, } from "./memory/sidecar-worker.js";
13
13
  export { getApiKeyForModel } from "./models/api-keys.js";
14
14
  export { findExactModelReferenceMatch, findModelReferenceMatch, formatModelList, formatModelReference, resolveInitialModel, } from "./models/utils.js";
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, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "./paths.js";
15
+ export { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SECURITY_CONFIG_PATH, SETTINGS_CONFIG_PATH, SUB_AGENTS_DIR, SUB_AGENTS_DIR_NAME, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "./paths.js";
16
16
  export { ensureChannelDir, getChannelDir, getChannelDirName, } from "./runtime/channel-paths.js";
17
17
  export { createDingTalkContext } from "./runtime/delivery.js";
18
18
  export { DingTalkBot, } from "./runtime/dingtalk.js";
@@ -4,7 +4,7 @@ import { splitH2Sections } from "../shared/markdown-sections.js";
4
4
  import { clipText } from "../shared/text-utils.js";
5
5
  import { buildStandardMessages } from "../shared/type-guards.js";
6
6
  import { appendChannelHistoryBlock, appendChannelMemoryUpdate, readChannelHistory, readChannelMemory, readChannelSession, rewriteChannelHistory, rewriteChannelMemory, } from "./files.js";
7
- import { runSidecarTask } from "./sidecar-worker.js";
7
+ import { runRetriedSidecarTask, runSidecarTask } from "./sidecar-worker.js";
8
8
  const INLINE_TRANSCRIPT_MAX_CHARS = 28_000;
9
9
  const MEMORY_CLEANUP_LENGTH_THRESHOLD = 5_000;
10
10
  const MEMORY_UPDATE_BLOCK_THRESHOLD = 4;
@@ -152,7 +152,16 @@ ${currentHistory || "(empty)"}
152
152
 
153
153
  Conversation chunk to persist:
154
154
  ${transcript || "(empty)"}`;
155
- const rawResponse = await runWorkerPrompt("memory-inline-consolidation", options.model, options.resolveApiKey, INLINE_CONSOLIDATION_SYSTEM_PROMPT, prompt, INLINE_CONSOLIDATION_TIMEOUT_MS);
155
+ const result = await runRetriedSidecarTask({
156
+ name: "memory-inline-consolidation",
157
+ model: options.model,
158
+ resolveApiKey: options.resolveApiKey,
159
+ systemPrompt: INLINE_CONSOLIDATION_SYSTEM_PROMPT,
160
+ prompt,
161
+ timeoutMs: INLINE_CONSOLIDATION_TIMEOUT_MS,
162
+ parse: (text) => text.trim(),
163
+ });
164
+ const rawResponse = result.output;
156
165
  return parseConsolidationResponse(rawResponse);
157
166
  }
158
167
  export async function runInlineConsolidation(options) {
@@ -6,7 +6,7 @@ import { splitH1Sections } from "../shared/markdown-sections.js";
6
6
  import { clipText } from "../shared/text-utils.js";
7
7
  import { buildStandardMessages, isRecord } from "../shared/type-guards.js";
8
8
  import { readChannelMemory, readChannelSession, rewriteChannelSession } from "./files.js";
9
- import { runSidecarTask, SidecarParseError } from "./sidecar-worker.js";
9
+ import { runRetriedSidecarTask, SidecarParseError } from "./sidecar-worker.js";
10
10
  const SESSION_TRANSCRIPT_MAX_CHARS = 20_000;
11
11
  const SESSION_MEMORY_MAX_CHARS = 4_000;
12
12
  const SESSION_ITEM_LIMIT = 12;
@@ -220,7 +220,7 @@ export async function updateChannelSessionMemory(options) {
220
220
  const currentState = parseRenderedSessionMemory(currentSession);
221
221
  let update;
222
222
  try {
223
- const result = await runSidecarTask({
223
+ const result = await runRetriedSidecarTask({
224
224
  name: "session-memory-update",
225
225
  model: options.model,
226
226
  resolveApiKey: options.resolveApiKey,
@@ -24,3 +24,4 @@ export declare class SidecarParseError extends Error {
24
24
  constructor(taskName: string, rawText: string, cause: unknown);
25
25
  }
26
26
  export declare function runSidecarTask<T>(task: SidecarTask<T>): Promise<SidecarResult<T>>;
27
+ export declare function runRetriedSidecarTask<T>(task: SidecarTask<T>): Promise<SidecarResult<T>>;
@@ -1,6 +1,8 @@
1
1
  import { Agent } from "@mariozechner/pi-agent-core";
2
2
  import { convertToLlm } from "@mariozechner/pi-coding-agent";
3
3
  import { extractAssistantText } from "../shared/text-utils.js";
4
+ const SIDE_CAR_RETRY_DELAY_MS = 2_000;
5
+ const SIDE_CAR_MAX_ATTEMPTS = 2;
4
6
  export class SidecarTimeoutError extends Error {
5
7
  constructor(taskName, timeoutMs) {
6
8
  super(`Sidecar task "${taskName}" timed out after ${timeoutMs}ms`);
@@ -18,6 +20,40 @@ export class SidecarParseError extends Error {
18
20
  this.cause = cause;
19
21
  }
20
22
  }
23
+ function createAbortError(taskName, reason) {
24
+ return reason instanceof Error ? reason : new Error(`Sidecar task "${taskName}" aborted`);
25
+ }
26
+ function isExternalAbort(task) {
27
+ return task.signal?.aborted === true;
28
+ }
29
+ function delay(ms, task) {
30
+ if (ms <= 0) {
31
+ return Promise.resolve();
32
+ }
33
+ return new Promise((resolve, reject) => {
34
+ const signal = task.signal;
35
+ const timer = setTimeout(() => {
36
+ removeAbortListener();
37
+ resolve();
38
+ }, ms);
39
+ const abort = () => {
40
+ clearTimeout(timer);
41
+ removeAbortListener();
42
+ reject(createAbortError(task.name, signal?.reason));
43
+ };
44
+ const removeAbortListener = () => {
45
+ signal?.removeEventListener("abort", abort);
46
+ };
47
+ if (!signal) {
48
+ return;
49
+ }
50
+ if (signal.aborted) {
51
+ abort();
52
+ return;
53
+ }
54
+ signal.addEventListener("abort", abort, { once: true });
55
+ });
56
+ }
21
57
  export async function runSidecarTask(task) {
22
58
  const apiKey = await task.resolveApiKey(task.model);
23
59
  const worker = new Agent({
@@ -76,7 +112,7 @@ export async function runSidecarTask(task) {
76
112
  blockers.push(new Promise((_, reject) => {
77
113
  const abort = () => {
78
114
  abortWorker();
79
- reject(signal.reason instanceof Error ? signal.reason : new Error(`Sidecar task "${task.name}" aborted`));
115
+ reject(createAbortError(task.name, signal.reason));
80
116
  };
81
117
  if (signal.aborted) {
82
118
  abort();
@@ -96,3 +132,22 @@ export async function runSidecarTask(task) {
96
132
  removeAbortListener();
97
133
  }
98
134
  }
135
+ export async function runRetriedSidecarTask(task) {
136
+ let lastError;
137
+ for (let attempt = 1; attempt <= SIDE_CAR_MAX_ATTEMPTS; attempt++) {
138
+ if (isExternalAbort(task)) {
139
+ throw createAbortError(task.name, task.signal?.reason);
140
+ }
141
+ try {
142
+ return await runSidecarTask(task);
143
+ }
144
+ catch (error) {
145
+ lastError = error;
146
+ if (attempt >= SIDE_CAR_MAX_ATTEMPTS || error instanceof SidecarParseError || isExternalAbort(task)) {
147
+ throw error;
148
+ }
149
+ await delay(SIDE_CAR_RETRY_DELAY_MS, task);
150
+ }
151
+ }
152
+ throw lastError instanceof Error ? lastError : new Error(`Sidecar task "${task.name}" failed`);
153
+ }
package/dist/paths.d.ts CHANGED
@@ -8,3 +8,4 @@ export declare const AUTH_CONFIG_PATH: string;
8
8
  export declare const MODELS_CONFIG_PATH: string;
9
9
  export declare const SETTINGS_CONFIG_PATH: string;
10
10
  export declare const TOOLS_CONFIG_PATH: string;
11
+ export declare const SECURITY_CONFIG_PATH: string;
package/dist/paths.js CHANGED
@@ -10,3 +10,4 @@ export const AUTH_CONFIG_PATH = join(APP_HOME_DIR, "auth.json");
10
10
  export const MODELS_CONFIG_PATH = join(APP_HOME_DIR, "models.json");
11
11
  export const SETTINGS_CONFIG_PATH = join(APP_HOME_DIR, "settings.json");
12
12
  export const TOOLS_CONFIG_PATH = join(APP_HOME_DIR, "tools.json");
13
+ export const SECURITY_CONFIG_PATH = join(APP_HOME_DIR, "security.json");
@@ -10,6 +10,7 @@ export interface BootstrapPaths {
10
10
  modelsConfigPath: string;
11
11
  settingsConfigPath: string;
12
12
  toolsConfigPath: string;
13
+ securityConfigPath: string;
13
14
  }
14
15
  export interface BootstrapIO {
15
16
  log: (...args: unknown[]) => void;
@@ -5,8 +5,12 @@ import { getOrCreateRunner } from "../agent/index.js";
5
5
  import { resetRunner } from "../agent/runner-factory.js";
6
6
  import * as log from "../log.js";
7
7
  import { ensureChannelMemoryFilesSync } from "../memory/files.js";
8
- import { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SETTINGS_CONFIG_PATH, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "../paths.js";
8
+ import { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SECURITY_CONFIG_PATH, SETTINGS_CONFIG_PATH, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "../paths.js";
9
9
  import { parseSandboxArg, validateSandbox } from "../sandbox.js";
10
+ import { loadSecurityConfigWithDiagnostics } from "../security/config.js";
11
+ import { PipiclawSettingsManager } from "../settings.js";
12
+ import { formatConfigDiagnostic } from "../shared/config-diagnostics.js";
13
+ import { loadToolsConfigWithDiagnostics } from "../tools/config.js";
10
14
  import { ensureChannelDir } from "./channel-paths.js";
11
15
  import { createDingTalkContext } from "./delivery.js";
12
16
  import { DingTalkBot, } from "./dingtalk.js";
@@ -121,6 +125,17 @@ const TOOLS_CONFIG_TEMPLATE = {
121
125
  "If needed, copy _examples.proxy to tools.web.proxy.",
122
126
  ],
123
127
  };
128
+ const SECURITY_CONFIG_TEMPLATE = {
129
+ pathGuard: {
130
+ enabled: true,
131
+ },
132
+ commandGuard: {
133
+ enabled: true,
134
+ },
135
+ networkGuard: {
136
+ enabled: false,
137
+ },
138
+ };
124
139
  const SHUTDOWN_WAIT_MS = 15000;
125
140
  const SHUTDOWN_FLUSH_WAIT_MS = 25000;
126
141
  const SHUTDOWN_ABORT_WAIT_MS = 5000;
@@ -133,6 +148,7 @@ export const DEFAULT_BOOTSTRAP_PATHS = {
133
148
  modelsConfigPath: MODELS_CONFIG_PATH,
134
149
  settingsConfigPath: SETTINGS_CONFIG_PATH,
135
150
  toolsConfigPath: TOOLS_CONFIG_PATH,
151
+ securityConfigPath: SECURITY_CONFIG_PATH,
136
152
  };
137
153
  export class BootstrapExitError extends Error {
138
154
  constructor(code, message) {
@@ -181,6 +197,7 @@ export function bootstrapAppHome(paths = DEFAULT_BOOTSTRAP_PATHS) {
181
197
  writeJsonFileIfMissing(paths.modelsConfigPath, MODELS_CONFIG_TEMPLATE, "models.json", created);
182
198
  writeJsonFileIfMissing(paths.settingsConfigPath, {}, "settings.json", created);
183
199
  writeJsonFileIfMissing(paths.toolsConfigPath, TOOLS_CONFIG_TEMPLATE, "tools.json", created);
200
+ writeJsonFileIfMissing(paths.securityConfigPath, SECURITY_CONFIG_TEMPLATE, "security.json", created);
184
201
  return { created, channelTemplateCreated };
185
202
  }
186
203
  function isPlaceholderString(value) {
@@ -305,6 +322,14 @@ export function createRuntimeContext(options) {
305
322
  const activeTasks = new Set();
306
323
  let shuttingDown = false;
307
324
  let shutdownPromise = null;
325
+ const archiveIncomingMessage = async (channelId, message, contextLabel) => {
326
+ try {
327
+ await store.logMessage(channelId, message);
328
+ }
329
+ catch (err) {
330
+ log.logWarning(`[${channelId}] Failed to archive ${contextLabel}`, err instanceof Error ? err.message : String(err));
331
+ }
332
+ };
308
333
  const getState = (channelId) => {
309
334
  let state = channelStates.get(channelId);
310
335
  if (!state) {
@@ -328,6 +353,7 @@ export function createRuntimeContext(options) {
328
353
  const state = channelStates.get(channelId);
329
354
  if (state?.running) {
330
355
  state.stopRequested = true;
356
+ _bot.discardCard(channelId);
331
357
  void state.runner.abort().catch((err) => {
332
358
  log.logWarning(`[${channelId}] Failed to abort run`, err instanceof Error ? err.message : String(err));
333
359
  });
@@ -340,7 +366,7 @@ export function createRuntimeContext(options) {
340
366
  }
341
367
  const state = getState(event.channelId);
342
368
  const trimmedQueueText = queueText.trim();
343
- await store.logMessage(event.channelId, {
369
+ await archiveIncomingMessage(event.channelId, {
344
370
  date: new Date().toISOString(),
345
371
  ts: event.ts,
346
372
  user: event.user,
@@ -349,7 +375,7 @@ export function createRuntimeContext(options) {
349
375
  isBot: false,
350
376
  deliveryMode: mode,
351
377
  skipContextSync: true,
352
- });
378
+ }, `${mode} message`);
353
379
  try {
354
380
  if (mode === "followUp") {
355
381
  await state.runner.queueFollowUp(trimmedQueueText, event.userName);
@@ -380,15 +406,15 @@ export function createRuntimeContext(options) {
380
406
  const task = (async () => {
381
407
  state.running = true;
382
408
  state.stopRequested = false;
383
- await store.logMessage(event.channelId, {
384
- date: new Date().toISOString(),
385
- ts: event.ts,
386
- user: event.user,
387
- userName: event.userName,
388
- text: event.text,
389
- isBot: false,
390
- });
391
409
  try {
410
+ await archiveIncomingMessage(event.channelId, {
411
+ date: new Date().toISOString(),
412
+ ts: event.ts,
413
+ user: event.user,
414
+ userName: event.userName,
415
+ text: event.text,
416
+ isBot: false,
417
+ }, "user message");
392
418
  const ctx = createDingTalkContext(event, bot, store);
393
419
  const builtInCommand = parseBuiltInCommand(event.text);
394
420
  if (builtInCommand) {
@@ -397,6 +423,9 @@ export function createRuntimeContext(options) {
397
423
  return;
398
424
  }
399
425
  log.logInfo(`[${event.channelId}] Starting run: ${event.text.substring(0, 50)}`);
426
+ if (!_isEvent) {
427
+ ctx.primeCard(350);
428
+ }
400
429
  const result = await state.runner.run(ctx, store);
401
430
  if (result.stopReason === "aborted" && state.stopRequested) {
402
431
  log.logInfo(`[${event.channelId}] Stopped`);
@@ -511,6 +540,16 @@ export async function bootstrap(argv, options = {}) {
511
540
  }
512
541
  const dingtalkConfig = loadConfig(paths, io);
513
542
  dingtalkConfig.stateDir = paths.workspaceDir;
543
+ const settingsManager = new PipiclawSettingsManager(paths.appHomeDir);
544
+ for (const { scope, error } of settingsManager.drainErrors()) {
545
+ log.logWarning(`Failed to load ${scope} settings`, `${error.message}\n${paths.settingsConfigPath}`);
546
+ }
547
+ for (const diagnostic of loadToolsConfigWithDiagnostics(paths.appHomeDir).diagnostics) {
548
+ log.logWarning(formatConfigDiagnostic(diagnostic), diagnostic.path);
549
+ }
550
+ for (const diagnostic of loadSecurityConfigWithDiagnostics(paths.appHomeDir).diagnostics) {
551
+ log.logWarning(formatConfigDiagnostic(diagnostic), diagnostic.path);
552
+ }
514
553
  await validateSandbox(sandbox);
515
554
  log.logStartup(paths.workspaceDir, sandbox.type === "host" ? "host" : `docker:${sandbox.container}`);
516
555
  const runtime = createRuntimeContext({
@@ -1,5 +1,6 @@
1
1
  import * as log from "../log.js";
2
2
  const MIN_UPDATE_INTERVAL_MS = 800;
3
+ const NO_CONTENT = "";
3
4
  class ChannelDeliveryController {
4
5
  constructor(event, bot, store) {
5
6
  this.event = event;
@@ -12,9 +13,12 @@ class ChannelDeliveryController {
12
13
  this.running = false;
13
14
  this.closed = false;
14
15
  this.finalResponseDelivered = false;
16
+ this.cardWarmupScheduled = false;
17
+ this.cardWarmupTriggered = false;
15
18
  this.progressWindowStartedAt = 0;
16
19
  this.lastDeliveredAt = 0;
17
20
  this.timer = null;
21
+ this.cardWarmupTimer = null;
18
22
  this.flushWaiters = [];
19
23
  }
20
24
  buildContext() {
@@ -37,19 +41,57 @@ class ChannelDeliveryController {
37
41
  setTyping: async (_isTyping) => { },
38
42
  setWorking: async (_working) => { },
39
43
  deleteMessage: async () => this.silence(),
44
+ primeCard: (delayMs) => this.primeCard(delayMs),
40
45
  flush: async () => this.flush(),
41
46
  close: async () => this.close(),
42
47
  };
43
48
  }
49
+ primeCard(delayMs) {
50
+ if (this.closed || this.finalResponseDelivered || this.cardWarmupScheduled || this.cardWarmupTriggered) {
51
+ return;
52
+ }
53
+ this.cardWarmupScheduled = true;
54
+ this.cardWarmupTimer = setTimeout(() => {
55
+ this.cardWarmupScheduled = false;
56
+ this.cardWarmupTimer = null;
57
+ void this.triggerCardWarmup();
58
+ }, Math.max(0, delayMs));
59
+ }
60
+ async triggerCardWarmup() {
61
+ if (this.closed || this.finalResponseDelivered || this.desiredRevision > 0) {
62
+ return;
63
+ }
64
+ this.cardWarmupTriggered = true;
65
+ try {
66
+ await this.bot.ensureCard(this.event.channelId);
67
+ }
68
+ catch (err) {
69
+ log.logWarning(`[${this.event.channelId}] Failed to warm AI card`, err instanceof Error ? err.message : String(err));
70
+ this.bot.discardCard(this.event.channelId);
71
+ }
72
+ }
73
+ clearCardWarmup() {
74
+ this.cardWarmupScheduled = false;
75
+ if (this.cardWarmupTimer) {
76
+ clearTimeout(this.cardWarmupTimer);
77
+ this.cardWarmupTimer = null;
78
+ }
79
+ }
80
+ archiveBotResponse(text) {
81
+ void this.store.logBotResponse(this.event.channelId, text, Date.now().toString()).catch((err) => {
82
+ log.logWarning(`[${this.event.channelId}] Failed to archive bot response`, err instanceof Error ? err.message : String(err));
83
+ });
84
+ }
44
85
  async appendProgress(text, shouldLog) {
45
86
  if (this.closed || this.finalResponseDelivered || !text.trim())
46
87
  return;
88
+ this.clearCardWarmup();
47
89
  this.progressText = this.progressText ? `${this.progressText}\n\n${text}` : text;
48
90
  if (this.progressWindowStartedAt === 0) {
49
91
  this.progressWindowStartedAt = Date.now();
50
92
  }
51
93
  if (shouldLog) {
52
- await this.store.logBotResponse(this.event.channelId, text, Date.now().toString());
94
+ this.archiveBotResponse(text);
53
95
  }
54
96
  this.mode = "progress";
55
97
  this.bumpRevision(false);
@@ -57,8 +99,9 @@ class ChannelDeliveryController {
57
99
  async sendFinal(text, shouldLog) {
58
100
  if (this.closed || this.finalResponseDelivered)
59
101
  return this.finalResponseDelivered;
102
+ this.clearCardWarmup();
60
103
  if (shouldLog) {
61
- await this.store.logBotResponse(this.event.channelId, text, Date.now().toString());
104
+ this.archiveBotResponse(text);
62
105
  }
63
106
  const delivered = await this.bot.sendPlain(this.event.channelId, text);
64
107
  if (!delivered) {
@@ -72,6 +115,7 @@ class ChannelDeliveryController {
72
115
  async replaceWithFinal(text) {
73
116
  if (this.closed || this.finalResponseDelivered)
74
117
  return;
118
+ this.clearCardWarmup();
75
119
  this.progressText = text;
76
120
  this.mode = "finalize-with-fallback";
77
121
  this.bumpRevision(true);
@@ -79,6 +123,7 @@ class ChannelDeliveryController {
79
123
  async silence() {
80
124
  if (this.closed)
81
125
  return;
126
+ this.clearCardWarmup();
82
127
  this.finalResponseDelivered = true;
83
128
  this.mode = "silent";
84
129
  this.bumpRevision(true);
@@ -138,8 +183,8 @@ class ChannelDeliveryController {
138
183
  }
139
184
  }
140
185
  else if (mode === "finalize-existing") {
141
- if (content) {
142
- touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, this.progressText);
186
+ if (content || this.cardWarmupTriggered) {
187
+ touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, content ? this.progressText : NO_CONTENT);
143
188
  if (!touchedRemote) {
144
189
  this.bot.discardCard(this.event.channelId);
145
190
  }
@@ -160,7 +205,12 @@ class ChannelDeliveryController {
160
205
  }
161
206
  }
162
207
  else if (mode === "silent") {
163
- this.bot.discardCard(this.event.channelId);
208
+ if (this.cardWarmupTriggered) {
209
+ touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, NO_CONTENT);
210
+ }
211
+ if (!touchedRemote) {
212
+ this.bot.discardCard(this.event.channelId);
213
+ }
164
214
  }
165
215
  }
166
216
  catch (err) {
@@ -209,6 +259,7 @@ class ChannelDeliveryController {
209
259
  return;
210
260
  }
211
261
  this.closed = true;
262
+ this.clearCardWarmup();
212
263
  await this.flush();
213
264
  }
214
265
  }
@@ -34,6 +34,7 @@ export interface DingTalkContext {
34
34
  setTyping: (isTyping: boolean) => Promise<void>;
35
35
  setWorking: (working: boolean) => Promise<void>;
36
36
  deleteMessage: () => Promise<void>;
37
+ primeCard: (delayMs: number) => void;
37
38
  flush: () => Promise<void>;
38
39
  close: () => Promise<void>;
39
40
  }
@@ -61,6 +62,7 @@ export declare class DingTalkBot {
61
62
  private isReconnecting;
62
63
  private isStopped;
63
64
  private reconnectAttempts;
65
+ private hasReportedReady;
64
66
  private processedIds;
65
67
  private processedIdsOrder;
66
68
  constructor(handler: DingTalkHandler, config: DingTalkConfig);