@oyasmi/pipiclaw 0.5.9 → 0.6.1

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 (51) hide show
  1. package/dist/agent/channel-runner.d.ts +8 -0
  2. package/dist/agent/channel-runner.js +132 -24
  3. package/dist/agent/context-budget.d.ts +9 -0
  4. package/dist/agent/context-budget.js +31 -0
  5. package/dist/agent/session-events.js +11 -4
  6. package/dist/agent/type-guards.js +4 -2
  7. package/dist/agent/types.d.ts +10 -3
  8. package/dist/agent/types.js +1 -0
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +2 -2
  11. package/dist/memory/candidates.d.ts +8 -5
  12. package/dist/memory/candidates.js +92 -42
  13. package/dist/memory/consolidation.js +13 -4
  14. package/dist/memory/recall.d.ts +2 -2
  15. package/dist/memory/recall.js +2 -3
  16. package/dist/memory/session.js +2 -2
  17. package/dist/memory/sidecar-worker.d.ts +1 -0
  18. package/dist/memory/sidecar-worker.js +56 -1
  19. package/dist/paths.d.ts +1 -0
  20. package/dist/paths.js +1 -0
  21. package/dist/runtime/bootstrap.d.ts +1 -0
  22. package/dist/runtime/bootstrap.js +52 -13
  23. package/dist/runtime/delivery.js +101 -12
  24. package/dist/runtime/dingtalk.d.ts +11 -1
  25. package/dist/runtime/dingtalk.js +69 -24
  26. package/dist/runtime/events.d.ts +17 -2
  27. package/dist/runtime/events.js +107 -19
  28. package/dist/security/command-guard.js +4 -0
  29. package/dist/security/config.d.ts +6 -0
  30. package/dist/security/config.js +38 -6
  31. package/dist/security/path-guard.js +4 -0
  32. package/dist/security/platform.d.ts +1 -0
  33. package/dist/security/platform.js +3 -0
  34. package/dist/settings.d.ts +4 -1
  35. package/dist/settings.js +31 -6
  36. package/dist/shared/config-diagnostics.d.ts +7 -0
  37. package/dist/shared/config-diagnostics.js +3 -0
  38. package/dist/subagents/tool.d.ts +2 -0
  39. package/dist/subagents/tool.js +2 -3
  40. package/dist/tools/config.d.ts +7 -0
  41. package/dist/tools/config.js +63 -7
  42. package/dist/tools/index.d.ts +5 -0
  43. package/dist/tools/index.js +3 -2
  44. package/dist/web/client.d.ts +1 -0
  45. package/dist/web/client.js +30 -18
  46. package/dist/web/config.d.ts +1 -0
  47. package/dist/web/config.js +1 -0
  48. package/dist/web/fetch.d.ts +1 -0
  49. package/dist/web/fetch.js +7 -5
  50. package/dist/web/search-providers.js +6 -3
  51. package/package.json +1 -1
@@ -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;
@@ -12,8 +12,8 @@ const HISTORY_LENGTH_THRESHOLD = 8_000;
12
12
  const HISTORY_BLOCK_THRESHOLD = 5;
13
13
  const HISTORY_RECENT_BLOCKS_TO_KEEP = 3;
14
14
  const INLINE_CONSOLIDATION_TIMEOUT_MS = 20_000;
15
- const MEMORY_CLEANUP_TIMEOUT_MS = 30_000;
16
- const HISTORY_FOLDING_TIMEOUT_MS = 30_000;
15
+ const MEMORY_CLEANUP_TIMEOUT_MS = 120_000;
16
+ const HISTORY_FOLDING_TIMEOUT_MS = 120_000;
17
17
  const INLINE_CONSOLIDATION_SYSTEM_PROMPT = `You are a runtime memory consolidation worker for Pipiclaw.
18
18
 
19
19
  Return strict JSON only. Do not wrap in Markdown fences.
@@ -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) {
@@ -1,5 +1,5 @@
1
1
  import type { Api, Model } from "@mariozechner/pi-ai";
2
- import { type MemoryCandidate, type MemoryCandidateCache } from "./candidates.js";
2
+ import { type MemoryCandidate, type MemoryCandidateStore } from "./candidates.js";
3
3
  export interface RecallRequest {
4
4
  query: string;
5
5
  workspaceDir: string;
@@ -12,7 +12,7 @@ export interface RecallRequest {
12
12
  autoRerank?: boolean;
13
13
  model: Model<Api>;
14
14
  resolveApiKey: (model: Model<Api>) => Promise<string>;
15
- candidateCache?: MemoryCandidateCache;
15
+ candidateStore?: MemoryCandidateStore;
16
16
  }
17
17
  export interface RecalledMemory {
18
18
  source: MemoryCandidate["source"];
@@ -1,6 +1,6 @@
1
1
  import { parseJsonObject } from "../shared/llm-json.js";
2
2
  import { HAN_REGEX } from "../shared/text-utils.js";
3
- import { buildMemoryCandidates } from "./candidates.js";
3
+ import { buildMemoryCandidates, createMemoryCandidateStore, } from "./candidates.js";
4
4
  import { COMMON_CHINESE_WORDS } from "./chinese-words.js";
5
5
  import { runSidecarTask } from "./sidecar-worker.js";
6
6
  const RERANK_SYSTEM_PROMPT = `You are selecting which memory snippets are most relevant to the current user turn.
@@ -466,8 +466,7 @@ export async function recallRelevantMemory(request) {
466
466
  const candidates = await buildMemoryCandidates({
467
467
  workspaceDir: request.workspaceDir,
468
468
  channelDir: request.channelDir,
469
- cache: request.candidateCache,
470
- });
469
+ }, request.candidateStore ?? createMemoryCandidateStore());
471
470
  const filteredCandidates = request.allowedSources?.length
472
471
  ? candidates.filter((candidate) => request.allowedSources?.includes(candidate.source))
473
472
  : candidates;
@@ -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,8 +125,19 @@ 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
- const SHUTDOWN_FLUSH_WAIT_MS = 25000;
140
+ const SHUTDOWN_FLUSH_WAIT_MS = 45000;
126
141
  const SHUTDOWN_ABORT_WAIT_MS = 5000;
127
142
  export const DEFAULT_BOOTSTRAP_PATHS = {
128
143
  appName: APP_NAME,
@@ -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`);
@@ -423,7 +452,7 @@ export function createRuntimeContext(options) {
423
452
  : new DingTalkBot(handler, options.dingtalkConfig);
424
453
  const eventsWatcher = options.createEventsWatcher
425
454
  ? options.createEventsWatcher(options.paths.workspaceDir, bot)
426
- : createEventsWatcher(options.paths.workspaceDir, bot);
455
+ : createEventsWatcher(options.paths.workspaceDir, bot, loadSecurityConfigWithDiagnostics(options.paths.appHomeDir).config.commandGuard);
427
456
  const shutdownWithReason = async (reason = "manual") => {
428
457
  if (shutdownPromise) {
429
458
  return shutdownPromise;
@@ -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,21 +1,30 @@
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;
6
7
  this.bot = bot;
7
8
  this.store = store;
8
- this.progressText = "";
9
+ this.progressSegments = [];
10
+ this.cachedProgressText = "";
11
+ this.progressTextDirty = false;
9
12
  this.mode = "progress";
10
13
  this.desiredRevision = 0;
11
14
  this.appliedRevision = 0;
12
15
  this.running = false;
13
16
  this.closed = false;
14
17
  this.finalResponseDelivered = false;
18
+ this.cardWarmupScheduled = false;
19
+ this.cardWarmupTriggered = false;
15
20
  this.progressWindowStartedAt = 0;
16
21
  this.lastDeliveredAt = 0;
17
22
  this.timer = null;
23
+ this.cardWarmupTimer = null;
18
24
  this.flushWaiters = [];
25
+ this.sentProgressChars = 0;
26
+ this.replayRequired = false;
27
+ this.finalReplacementText = "";
19
28
  }
20
29
  buildContext() {
21
30
  return {
@@ -37,19 +46,61 @@ class ChannelDeliveryController {
37
46
  setTyping: async (_isTyping) => { },
38
47
  setWorking: async (_working) => { },
39
48
  deleteMessage: async () => this.silence(),
49
+ primeCard: (delayMs) => this.primeCard(delayMs),
40
50
  flush: async () => this.flush(),
41
51
  close: async () => this.close(),
42
52
  };
43
53
  }
54
+ primeCard(delayMs) {
55
+ if (this.closed || this.finalResponseDelivered || this.cardWarmupScheduled || this.cardWarmupTriggered) {
56
+ return;
57
+ }
58
+ this.cardWarmupScheduled = true;
59
+ this.cardWarmupTimer = setTimeout(() => {
60
+ this.cardWarmupScheduled = false;
61
+ this.cardWarmupTimer = null;
62
+ void this.triggerCardWarmup();
63
+ }, Math.max(0, delayMs));
64
+ }
65
+ async triggerCardWarmup() {
66
+ if (this.closed || this.finalResponseDelivered || this.desiredRevision > 0) {
67
+ return;
68
+ }
69
+ this.cardWarmupTriggered = true;
70
+ try {
71
+ await this.bot.ensureCard(this.event.channelId);
72
+ }
73
+ catch (err) {
74
+ log.logWarning(`[${this.event.channelId}] Failed to warm AI card`, err instanceof Error ? err.message : String(err));
75
+ this.bot.discardCard(this.event.channelId);
76
+ }
77
+ }
78
+ clearCardWarmup() {
79
+ this.cardWarmupScheduled = false;
80
+ if (this.cardWarmupTimer) {
81
+ clearTimeout(this.cardWarmupTimer);
82
+ this.cardWarmupTimer = null;
83
+ }
84
+ }
85
+ archiveBotResponse(text) {
86
+ void this.store.logBotResponse(this.event.channelId, text, Date.now().toString()).catch((err) => {
87
+ log.logWarning(`[${this.event.channelId}] Failed to archive bot response`, err instanceof Error ? err.message : String(err));
88
+ });
89
+ }
44
90
  async appendProgress(text, shouldLog) {
45
91
  if (this.closed || this.finalResponseDelivered || !text.trim())
46
92
  return;
47
- this.progressText = this.progressText ? `${this.progressText}\n\n${text}` : text;
93
+ this.clearCardWarmup();
94
+ if (this.progressSegments.length > 0) {
95
+ this.progressSegments.push("\n\n");
96
+ }
97
+ this.progressSegments.push(text);
98
+ this.progressTextDirty = true;
48
99
  if (this.progressWindowStartedAt === 0) {
49
100
  this.progressWindowStartedAt = Date.now();
50
101
  }
51
102
  if (shouldLog) {
52
- await this.store.logBotResponse(this.event.channelId, text, Date.now().toString());
103
+ this.archiveBotResponse(text);
53
104
  }
54
105
  this.mode = "progress";
55
106
  this.bumpRevision(false);
@@ -57,8 +108,9 @@ class ChannelDeliveryController {
57
108
  async sendFinal(text, shouldLog) {
58
109
  if (this.closed || this.finalResponseDelivered)
59
110
  return this.finalResponseDelivered;
111
+ this.clearCardWarmup();
60
112
  if (shouldLog) {
61
- await this.store.logBotResponse(this.event.channelId, text, Date.now().toString());
113
+ this.archiveBotResponse(text);
62
114
  }
63
115
  const delivered = await this.bot.sendPlain(this.event.channelId, text);
64
116
  if (!delivered) {
@@ -72,13 +124,15 @@ class ChannelDeliveryController {
72
124
  async replaceWithFinal(text) {
73
125
  if (this.closed || this.finalResponseDelivered)
74
126
  return;
75
- this.progressText = text;
127
+ this.clearCardWarmup();
128
+ this.finalReplacementText = text;
76
129
  this.mode = "finalize-with-fallback";
77
130
  this.bumpRevision(true);
78
131
  }
79
132
  async silence() {
80
133
  if (this.closed)
81
134
  return;
135
+ this.clearCardWarmup();
82
136
  this.finalResponseDelivered = true;
83
137
  this.mode = "silent";
84
138
  this.bumpRevision(true);
@@ -114,6 +168,7 @@ class ChannelDeliveryController {
114
168
  try {
115
169
  while (this.appliedRevision < this.desiredRevision) {
116
170
  const mode = this.mode;
171
+ const progressText = this.getProgressText();
117
172
  const throttleBaseAt = this.lastDeliveredAt > 0 ? this.lastDeliveredAt : this.progressWindowStartedAt;
118
173
  if (mode === "progress" && throttleBaseAt > 0) {
119
174
  const remaining = MIN_UPDATE_INTERVAL_MS - (Date.now() - throttleBaseAt);
@@ -126,31 +181,48 @@ class ChannelDeliveryController {
126
181
  }
127
182
  }
128
183
  const revision = this.desiredRevision;
129
- const content = this.progressText.trim();
184
+ const content = progressText.trim();
185
+ const replacementText = this.finalReplacementText;
130
186
  let touchedRemote = false;
131
187
  try {
132
188
  if (mode === "progress") {
133
189
  if (content) {
134
- touchedRemote = await this.bot.streamToCard(this.event.channelId, this.progressText);
190
+ const nextSentChars = progressText.length;
191
+ if (this.replayRequired) {
192
+ touchedRemote = await this.bot.replaceCard(this.event.channelId, progressText);
193
+ }
194
+ else {
195
+ const delta = progressText.slice(this.sentProgressChars);
196
+ touchedRemote = delta ? await this.bot.appendToCard(this.event.channelId, delta) : true;
197
+ }
135
198
  if (!touchedRemote) {
136
199
  this.bot.discardCard(this.event.channelId);
200
+ this.replayRequired = true;
201
+ }
202
+ else {
203
+ this.sentProgressChars = nextSentChars;
204
+ this.replayRequired = false;
137
205
  }
138
206
  }
139
207
  }
140
208
  else if (mode === "finalize-existing") {
141
- if (content) {
142
- touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, this.progressText);
209
+ if (content || this.cardWarmupTriggered) {
210
+ touchedRemote = await this.bot.replaceCard(this.event.channelId, content ? progressText : NO_CONTENT, true);
143
211
  if (!touchedRemote) {
144
212
  this.bot.discardCard(this.event.channelId);
145
213
  }
214
+ else {
215
+ this.sentProgressChars = progressText.length;
216
+ this.replayRequired = false;
217
+ }
146
218
  }
147
219
  else {
148
220
  this.bot.discardCard(this.event.channelId);
149
221
  }
150
222
  }
151
223
  else if (mode === "finalize-with-fallback") {
152
- if (content) {
153
- touchedRemote = await this.bot.finalizeCard(this.event.channelId, this.progressText);
224
+ if (replacementText.trim()) {
225
+ touchedRemote = await this.bot.finalizeCard(this.event.channelId, replacementText);
154
226
  if (!touchedRemote) {
155
227
  this.bot.discardCard(this.event.channelId);
156
228
  }
@@ -160,12 +232,20 @@ class ChannelDeliveryController {
160
232
  }
161
233
  }
162
234
  else if (mode === "silent") {
163
- this.bot.discardCard(this.event.channelId);
235
+ if (this.cardWarmupTriggered) {
236
+ touchedRemote = await this.bot.replaceCard(this.event.channelId, NO_CONTENT, true);
237
+ }
238
+ if (!touchedRemote) {
239
+ this.bot.discardCard(this.event.channelId);
240
+ }
164
241
  }
165
242
  }
166
243
  catch (err) {
167
244
  log.logWarning(`[${this.event.channelId}] Delivery sync failed`, err instanceof Error ? err.message : String(err));
168
245
  this.bot.discardCard(this.event.channelId);
246
+ if (mode === "progress") {
247
+ this.replayRequired = true;
248
+ }
169
249
  }
170
250
  if (touchedRemote) {
171
251
  this.lastDeliveredAt = Date.now();
@@ -209,8 +289,17 @@ class ChannelDeliveryController {
209
289
  return;
210
290
  }
211
291
  this.closed = true;
292
+ this.clearCardWarmup();
212
293
  await this.flush();
213
294
  }
295
+ getProgressText() {
296
+ if (!this.progressTextDirty) {
297
+ return this.cachedProgressText;
298
+ }
299
+ this.cachedProgressText = this.progressSegments.join("");
300
+ this.progressTextDirty = false;
301
+ return this.cachedProgressText;
302
+ }
214
303
  }
215
304
  export function createDingTalkContext(event, bot, store) {
216
305
  return new ChannelDeliveryController(event, bot, store).buildContext();
@@ -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);
@@ -92,7 +94,15 @@ export declare class DingTalkBot {
92
94
  */
93
95
  ensureCard(channelId: string): Promise<void>;
94
96
  /**
95
- * Stream content to the active AI Card for a channel.
97
+ * Replace the active card content with a full snapshot.
98
+ */
99
+ replaceCard(channelId: string, content: string, finalize?: boolean, failed?: boolean): Promise<boolean>;
100
+ /**
101
+ * Append a delta to the active card transcript.
102
+ */
103
+ appendToCard(channelId: string, content: string, finalize?: boolean, failed?: boolean): Promise<boolean>;
104
+ /**
105
+ * Stream content to the active AI Card for a channel using full replacement semantics.
96
106
  */
97
107
  streamToCard(channelId: string, content: string, finalize?: boolean): Promise<boolean>;
98
108
  /**