@oyasmi/pipiclaw 0.6.0 → 0.6.2

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.
@@ -16,6 +16,7 @@ export declare class ChannelRunner implements AgentRunner {
16
16
  private readonly settingsManager;
17
17
  private readonly modelRegistry;
18
18
  private readonly memoryLifecycle;
19
+ private readonly memoryCandidateStore;
19
20
  private readonly sessionResourceGate;
20
21
  private readonly sessionReady;
21
22
  private subAgentDiscovery;
@@ -42,7 +43,9 @@ export declare class ChannelRunner implements AgentRunner {
42
43
  private refreshSessionResources;
43
44
  private initializeSession;
44
45
  private reloadSessionResources;
46
+ private bindSessionExtensions;
45
47
  private ensureSessionReady;
48
+ private maybeRunPreventiveCompactionForIncomingText;
46
49
  private refreshSubAgentDiscovery;
47
50
  private reportSettingsDiagnostics;
48
51
  private reportConfigDiagnostics;
@@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "fs/promises";
4
4
  import { dirname, join, resolve } from "path";
5
5
  import * as log from "../log.js";
6
6
  import { buildFirstTurnMemoryBootstrap as renderFirstTurnMemoryBootstrap } from "../memory/bootstrap.js";
7
- import { createMemoryCandidateCache } from "../memory/candidates.js";
7
+ import { createMemoryCandidateStore } from "../memory/candidates.js";
8
8
  import { getChannelMemoryPath } from "../memory/files.js";
9
9
  import { MemoryLifecycle } from "../memory/lifecycle.js";
10
10
  import { recallRelevantMemory } from "../memory/recall.js";
@@ -22,6 +22,7 @@ import { loadToolsConfigWithDiagnostics } from "../tools/config.js";
22
22
  import { createPipiclawTools } from "../tools/index.js";
23
23
  import { createCommandExtension } from "./command-extension.js";
24
24
  import { renderBuiltInHelp } from "./commands.js";
25
+ import { estimateIncomingMessageTokens, getPreventiveCompactionDecision } from "./context-budget.js";
25
26
  import { clipUserInput } from "./progress-formatter.js";
26
27
  import { buildAppendSystemPrompt } from "./prompt-builder.js";
27
28
  import { createRunQueue } from "./run-queue.js";
@@ -68,6 +69,7 @@ export class ChannelRunner {
68
69
  this.sessionManager = SessionManager.open(contextFile, channelDir);
69
70
  this.settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);
70
71
  this.reportSettingsDiagnostics();
72
+ this.memoryCandidateStore = createMemoryCandidateStore();
71
73
  // Create AuthStorage and ModelRegistry
72
74
  const authStorage = AuthStorage.create(AUTH_CONFIG_PATH);
73
75
  this.modelRegistry = createModelRegistry(authStorage, MODELS_CONFIG_PATH);
@@ -167,16 +169,17 @@ export class ChannelRunner {
167
169
  this.runState.queue = runQueue.queue;
168
170
  try {
169
171
  await this.ensureSessionReady();
170
- // Ensure channel directory exists
171
- await mkdir(this.channelDir, { recursive: true });
172
- const candidateCache = createMemoryCandidateCache();
172
+ this.memoryLifecycle.noteUserTurnStarted();
173
173
  const clippedInput = clipUserInput(ctx.message.text, MAX_USER_MESSAGE_CHARS);
174
174
  const userMessage = this.formatUserMessage(clippedInput, ctx.message.userName);
175
- let promptText = this.shouldPreserveRawInput(ctx.message.text) ? clippedInput : userMessage;
175
+ const preserveRawInput = this.shouldPreserveRawInput(ctx.message.text);
176
+ await this.maybeRunPreventiveCompactionForIncomingText(preserveRawInput ? clippedInput : userMessage);
177
+ // Ensure channel directory exists
178
+ await mkdir(this.channelDir, { recursive: true });
179
+ let promptText = preserveRawInput ? clippedInput : userMessage;
176
180
  let recalledContextText = "";
177
181
  let durableMemoryBootstrapText = "";
178
- this.memoryLifecycle.noteUserTurnStarted();
179
- if (!this.shouldPreserveRawInput(ctx.message.text)) {
182
+ if (!preserveRawInput) {
180
183
  const recallSettings = this.settingsManager.getMemoryRecallSettings();
181
184
  if (recallSettings.enabled) {
182
185
  const recall = await recallRelevantMemory({
@@ -190,7 +193,7 @@ export class ChannelRunner {
190
193
  autoRerank: HAN_REGEX.test(clippedInput),
191
194
  model: this.session.model ?? this.activeModel,
192
195
  resolveApiKey: async (model) => getApiKeyForModel(this.modelRegistry, model),
193
- candidateCache,
196
+ candidateStore: this.memoryCandidateStore,
194
197
  });
195
198
  if (recall.renderedText) {
196
199
  recalledContextText = recall.renderedText;
@@ -234,10 +237,20 @@ export class ChannelRunner {
234
237
  this.runState.errorMessage &&
235
238
  !this.runState.finalResponseDelivered) {
236
239
  try {
237
- const errorSummary = this.runState.errorMessage.length > 240
240
+ const baseErrorSummary = this.runState.errorMessage.length > 240
238
241
  ? `${this.runState.errorMessage.slice(0, 237)}...`
239
242
  : this.runState.errorMessage;
240
- await ctx.replaceMessage(`_Sorry, something went wrong._\n\n\`${errorSummary}\``);
243
+ const compactionSummary = this.runState.lastCompactionError &&
244
+ this.runState.lastCompactionError !== this.runState.errorMessage
245
+ ? this.runState.lastCompactionError.length > 240
246
+ ? `${this.runState.lastCompactionError.slice(0, 237)}...`
247
+ : this.runState.lastCompactionError
248
+ : undefined;
249
+ const detailLines = [`\`${baseErrorSummary}\``];
250
+ if (compactionSummary) {
251
+ detailLines.push(`Recovery: \`${compactionSummary}\``);
252
+ }
253
+ await ctx.replaceMessage(`_Sorry, something went wrong._\n\n${detailLines.join("\n\n")}`);
241
254
  }
242
255
  catch (err) {
243
256
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -370,8 +383,10 @@ export class ChannelRunner {
370
383
  if (clippedText !== text.trim()) {
371
384
  log.logWarning(`[${this.channelId}] Queued message exceeded ${MAX_USER_MESSAGE_CHARS} chars and was clipped`);
372
385
  }
386
+ const queuedMessage = this.formatUserMessage(clippedText, userName);
387
+ await this.maybeRunPreventiveCompactionForIncomingText(queuedMessage);
373
388
  await this.sessionResourceGate.runPrompt(async () => {
374
- await this.session.prompt(this.formatUserMessage(clippedText, userName), {
389
+ await this.session.prompt(queuedMessage, {
375
390
  streamingBehavior: delivery,
376
391
  });
377
392
  });
@@ -392,6 +407,7 @@ export class ChannelRunner {
392
407
  }
393
408
  async initializeSession() {
394
409
  await this.reloadSessionResources();
410
+ await this.bindSessionExtensions();
395
411
  }
396
412
  async reloadSessionResources() {
397
413
  this.settingsManager.reload();
@@ -402,9 +418,56 @@ export class ChannelRunner {
402
418
  this.rebuildSessionTools();
403
419
  await this.session.reload();
404
420
  }
421
+ async bindSessionExtensions() {
422
+ await this.session.bindExtensions({
423
+ commandContextActions: {
424
+ waitForIdle: () => this.session.agent.waitForIdle(),
425
+ newSession: async (options) => {
426
+ const success = await this.session.newSession(options);
427
+ return { cancelled: !success };
428
+ },
429
+ fork: async (entryId) => {
430
+ const result = await this.session.fork(entryId);
431
+ return { cancelled: result.cancelled };
432
+ },
433
+ navigateTree: async (targetId, options) => {
434
+ const result = await this.session.navigateTree(targetId, options);
435
+ return { cancelled: result.cancelled };
436
+ },
437
+ switchSession: async (sessionPath) => {
438
+ const success = await this.session.switchSession(sessionPath);
439
+ return { cancelled: !success };
440
+ },
441
+ reload: async () => {
442
+ await this.refreshSessionResources();
443
+ },
444
+ },
445
+ });
446
+ }
405
447
  async ensureSessionReady() {
406
448
  await this.sessionReady;
407
449
  }
450
+ async maybeRunPreventiveCompactionForIncomingText(incomingText) {
451
+ const currentModel = this.session.model ?? this.activeModel;
452
+ const contextUsage = this.session.getContextUsage();
453
+ const contextTokens = contextUsage?.tokens;
454
+ const incomingTokens = estimateIncomingMessageTokens(incomingText);
455
+ const decision = getPreventiveCompactionDecision(contextTokens, incomingTokens, currentModel.contextWindow);
456
+ if (!decision.shouldCompact) {
457
+ return;
458
+ }
459
+ const currentTokens = contextTokens ?? 0;
460
+ const startedAt = Date.now();
461
+ log.logInfo(`[${this.channelId}] Preventive compaction triggered: projected ${decision.projectedTokens}/${currentModel.contextWindow} tokens (current=${currentTokens}, incoming≈${incomingTokens}), threshold=${decision.thresholdTokens}`);
462
+ try {
463
+ await this.session.compact();
464
+ log.logInfo(`[${this.channelId}] Preventive compaction complete in ${Date.now() - startedAt}ms`);
465
+ }
466
+ catch (error) {
467
+ const message = error instanceof Error ? error.message : String(error);
468
+ log.logWarning(`[${this.channelId}] Preventive compaction failed`, message);
469
+ }
470
+ }
408
471
  refreshSubAgentDiscovery() {
409
472
  this.modelRegistry.refresh();
410
473
  const discovery = discoverSubAgents(this.workspaceDir, this.modelRegistry.getAvailable());
@@ -439,6 +502,7 @@ export class ChannelRunner {
439
502
  sandboxConfig: this.sandboxConfig,
440
503
  getSubAgentDiscovery: () => this.subAgentDiscovery,
441
504
  getMemoryRecallSettings: () => this.settingsManager.getMemoryRecallSettings(),
505
+ memoryCandidateStore: this.memoryCandidateStore,
442
506
  securityConfig: securityLoad.config,
443
507
  toolsConfig: toolsLoad.config,
444
508
  });
@@ -0,0 +1,9 @@
1
+ export declare const PREVENTIVE_COMPACTION_THRESHOLD_RATIO = 0.75;
2
+ export interface PreventiveCompactionDecision {
3
+ shouldCompact: boolean;
4
+ projectedTokens: number | null;
5
+ thresholdTokens: number;
6
+ ratio: number;
7
+ }
8
+ export declare function estimateIncomingMessageTokens(text: string): number;
9
+ export declare function getPreventiveCompactionDecision(contextTokens: number | null | undefined, incomingTokens: number, contextWindow: number, thresholdRatio?: number): PreventiveCompactionDecision;
@@ -0,0 +1,31 @@
1
+ export const PREVENTIVE_COMPACTION_THRESHOLD_RATIO = 0.75;
2
+ const ESTIMATED_CHARS_PER_TOKEN = 3;
3
+ export function estimateIncomingMessageTokens(text) {
4
+ if (!text) {
5
+ return 0;
6
+ }
7
+ return Math.ceil(text.length / ESTIMATED_CHARS_PER_TOKEN);
8
+ }
9
+ export function getPreventiveCompactionDecision(contextTokens, incomingTokens, contextWindow, thresholdRatio = PREVENTIVE_COMPACTION_THRESHOLD_RATIO) {
10
+ const normalizedContextWindow = Number.isFinite(contextWindow) ? Math.max(0, Math.floor(contextWindow)) : 0;
11
+ const normalizedIncomingTokens = Number.isFinite(incomingTokens) && incomingTokens > 0 ? Math.floor(incomingTokens) : 0;
12
+ const normalizedRatio = Number.isFinite(thresholdRatio) && thresholdRatio > 0
13
+ ? Math.min(thresholdRatio, 1)
14
+ : PREVENTIVE_COMPACTION_THRESHOLD_RATIO;
15
+ const thresholdTokens = Math.floor(normalizedContextWindow * normalizedRatio);
16
+ if (contextTokens === null || contextTokens === undefined || !Number.isFinite(contextTokens) || contextTokens < 0) {
17
+ return {
18
+ shouldCompact: false,
19
+ projectedTokens: null,
20
+ thresholdTokens,
21
+ ratio: normalizedRatio,
22
+ };
23
+ }
24
+ const projectedTokens = Math.floor(contextTokens) + normalizedIncomingTokens;
25
+ return {
26
+ shouldCompact: normalizedContextWindow > 0 && projectedTokens >= thresholdTokens,
27
+ projectedTokens,
28
+ thresholdTokens,
29
+ ratio: normalizedRatio,
30
+ };
31
+ }
@@ -195,16 +195,23 @@ export async function handleSessionEvent(event, context) {
195
195
  return;
196
196
  }
197
197
  if (isAutoCompactionStartEvent(event)) {
198
- log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
199
- queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", "Compacting context..."), false), "compaction start");
198
+ const label = event.reason === "manual" ? "Compacting context..." : "Compacting context...";
199
+ log.logInfo(`Compaction started (reason: ${event.reason})`);
200
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", label), false), "compaction start");
200
201
  return;
201
202
  }
202
203
  if (isAutoCompactionEndEvent(event)) {
203
204
  if (event.result) {
204
- log.logInfo(`Auto-compaction complete: ${event.result.tokensBefore} tokens compacted`);
205
+ runState.lastCompactionError = undefined;
206
+ log.logInfo(`Compaction complete: ${event.result.tokensBefore} tokens compacted`);
205
207
  }
206
208
  else if (event.aborted) {
207
- log.logInfo("Auto-compaction aborted");
209
+ log.logInfo("Compaction aborted");
210
+ }
211
+ else if (event.errorMessage) {
212
+ runState.lastCompactionError = event.errorMessage;
213
+ log.logWarning("Compaction failed", event.errorMessage);
214
+ queue.enqueue(() => ctx.respond(formatProgressEntry("error", truncate(event.errorMessage ?? "Compaction failed", 200)), false), "compaction error");
208
215
  }
209
216
  return;
210
217
  }
@@ -65,10 +65,12 @@ export function isTurnEndEvent(value) {
65
65
  return hasEventType(value, "turn_end") && "message" in value && Array.isArray(value.toolResults);
66
66
  }
67
67
  export function isAutoCompactionStartEvent(value) {
68
- return hasEventType(value, "auto_compaction_start") && (value.reason === "threshold" || value.reason === "overflow");
68
+ return ((hasEventType(value, "auto_compaction_start") && (value.reason === "threshold" || value.reason === "overflow")) ||
69
+ (hasEventType(value, "compaction_start") &&
70
+ (value.reason === "threshold" || value.reason === "overflow" || value.reason === "manual")));
69
71
  }
70
72
  export function isAutoCompactionEndEvent(value) {
71
- return hasEventType(value, "auto_compaction_end");
73
+ return hasEventType(value, "auto_compaction_end") || hasEventType(value, "compaction_end");
72
74
  }
73
75
  export function isAutoRetryStartEvent(value) {
74
76
  return (hasEventType(value, "auto_retry_start") &&
@@ -44,6 +44,7 @@ export interface RunState {
44
44
  totalUsage: UsageTotals;
45
45
  stopReason: string;
46
46
  errorMessage: string | undefined;
47
+ lastCompactionError: string | undefined;
47
48
  finalOutcome: FinalOutcome;
48
49
  finalResponseDelivered: boolean;
49
50
  }
@@ -118,11 +119,17 @@ export type SessionEvent = {
118
119
  type: "auto_compaction_start";
119
120
  reason: "threshold" | "overflow";
120
121
  } | {
121
- type: "auto_compaction_end";
122
+ type: "compaction_start";
123
+ reason: "manual" | "threshold" | "overflow";
124
+ } | {
125
+ type: "auto_compaction_end" | "compaction_end";
126
+ reason?: "manual" | "threshold" | "overflow";
122
127
  result?: {
123
128
  tokensBefore: number;
124
129
  };
125
130
  aborted?: boolean;
131
+ errorMessage?: string;
132
+ willRetry?: boolean;
126
133
  } | {
127
134
  type: "auto_retry_start";
128
135
  attempt: number;
@@ -149,10 +156,10 @@ export type TurnEndEvent = Extract<SessionEvent, {
149
156
  type: "turn_end";
150
157
  }>;
151
158
  export type AutoCompactionStartEvent = Extract<SessionEvent, {
152
- type: "auto_compaction_start";
159
+ type: "auto_compaction_start" | "compaction_start";
153
160
  }>;
154
161
  export type AutoCompactionEndEvent = Extract<SessionEvent, {
155
- type: "auto_compaction_end";
162
+ type: "auto_compaction_end" | "compaction_end";
156
163
  }>;
157
164
  export type AutoRetryStartEvent = Extract<SessionEvent, {
158
165
  type: "auto_retry_start";
@@ -15,6 +15,7 @@ export function createEmptyRunState() {
15
15
  },
16
16
  stopReason: "stop",
17
17
  errorMessage: undefined,
18
+ lastCompactionError: undefined,
18
19
  finalOutcome: { kind: "none" },
19
20
  finalResponseDelivered: false,
20
21
  };
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { type BuiltInCommand, type BuiltInCommandName, parseBuiltInCommand, rend
3
3
  export { type AgentRunner, getOrCreateRunner } from "./agent/index.js";
4
4
  export { type AppendSystemPromptOptions, buildAppendSystemPrompt } from "./agent/prompt-builder.js";
5
5
  export { getAgentConfig, getSoul, loadPipiclawSkills, } from "./agent/workspace-resources.js";
6
- export { type BuildMemoryCandidatesOptions, buildMemoryCandidates, type MemoryCandidate, } from "./memory/candidates.js";
6
+ export { type BuildMemoryCandidatesOptions, buildMemoryCandidates, createMemoryCandidateStore, type MemoryCandidate, type MemoryCandidateStore, } from "./memory/candidates.js";
7
7
  export { type BackgroundMaintenanceResult, type ConsolidationRunOptions, type InlineConsolidationResult, runBackgroundMaintenance, runInlineConsolidation, } from "./memory/consolidation.js";
8
8
  export { ensureChannelMemoryFiles, ensureChannelMemoryFilesSync, getChannelSessionPath, readChannelSession, rewriteChannelSession, } from "./memory/files.js";
9
9
  export { type ConsolidationReason, MemoryLifecycle, type MemoryLifecycleOptions } from "./memory/lifecycle.js";
@@ -16,7 +16,7 @@ export { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_C
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";
19
- export { createEventsWatcher, EventsWatcher, type ImmediateEvent, type OneShotEvent, type PeriodicEvent, type ScheduledEvent, } from "./runtime/events.js";
19
+ export { createEventsWatcher, type EventAction, EventsWatcher, type ImmediateEvent, type OneShotEvent, type PeriodicEvent, type ScheduledEvent, } from "./runtime/events.js";
20
20
  export { ChannelStore, type LoggedMessage, type LoggedSubAgentRun } from "./runtime/store.js";
21
21
  export { createExecutor, type ExecOptions, type ExecResult, type Executor, parseSandboxArg, type SandboxConfig, validateSandbox, } from "./sandbox.js";
22
22
  export { type PipiclawMemoryRecallSettings, type PipiclawSessionMemorySettings, type PipiclawSettings, PipiclawSettingsManager, } from "./settings.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { parseBuiltInCommand, renderBuiltInHelp, } from "./agent/commands.js";
3
3
  export { getOrCreateRunner } from "./agent/index.js";
4
4
  export { buildAppendSystemPrompt } from "./agent/prompt-builder.js";
5
5
  export { getAgentConfig, getSoul, loadPipiclawSkills, } from "./agent/workspace-resources.js";
6
- export { buildMemoryCandidates, } from "./memory/candidates.js";
6
+ export { buildMemoryCandidates, createMemoryCandidateStore, } from "./memory/candidates.js";
7
7
  export { runBackgroundMaintenance, runInlineConsolidation, } from "./memory/consolidation.js";
8
8
  export { ensureChannelMemoryFiles, ensureChannelMemoryFilesSync, getChannelSessionPath, readChannelSession, rewriteChannelSession, } from "./memory/files.js";
9
9
  export { MemoryLifecycle } from "./memory/lifecycle.js";
@@ -12,10 +12,13 @@ export interface MemoryCandidate {
12
12
  export interface BuildMemoryCandidatesOptions {
13
13
  workspaceDir: string;
14
14
  channelDir: string;
15
- cache?: MemoryCandidateCache;
16
15
  }
17
- export interface MemoryCandidateCache {
18
- entries: Map<string, Promise<MemoryCandidate[]>>;
16
+ export declare class MemoryCandidateStore {
17
+ private files;
18
+ private inflight;
19
+ invalidate(path?: string): void;
20
+ getCandidates(options: BuildMemoryCandidatesOptions): Promise<MemoryCandidate[]>;
21
+ private loadFileCandidates;
19
22
  }
20
- export declare function createMemoryCandidateCache(): MemoryCandidateCache;
21
- export declare function buildMemoryCandidates(options: BuildMemoryCandidatesOptions): Promise<MemoryCandidate[]>;
23
+ export declare function createMemoryCandidateStore(): MemoryCandidateStore;
24
+ export declare function buildMemoryCandidates(options: BuildMemoryCandidatesOptions, store?: MemoryCandidateStore): Promise<MemoryCandidate[]>;
@@ -1,12 +1,7 @@
1
- import { readFile } from "fs/promises";
1
+ import { readFile, stat } from "fs/promises";
2
2
  import { join } from "path";
3
3
  import { splitH1Sections, splitH2Sections } from "../shared/markdown-sections.js";
4
4
  import { getChannelHistoryPath, getChannelMemoryPath, getChannelSessionPath } from "./files.js";
5
- export function createMemoryCandidateCache() {
6
- return {
7
- entries: new Map(),
8
- };
9
- }
10
5
  function normalizeContent(content) {
11
6
  return content.replace(/\r/g, "").trim();
12
7
  }
@@ -24,6 +19,9 @@ function slugify(value) {
24
19
  .replace(/[^a-z0-9]+/g, "-")
25
20
  .replace(/^-+|-+$/g, "") || "section");
26
21
  }
22
+ function sameFingerprint(a, b) {
23
+ return a.exists === b.exists && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.size === b.size;
24
+ }
27
25
  function inferPriority(source, title) {
28
26
  const normalizedTitle = title.trim().toLowerCase();
29
27
  if (source === "channel-session") {
@@ -76,9 +74,6 @@ function buildCandidate(source, path, title, content, timestamp, searchText) {
76
74
  priority: inferPriority(source, title),
77
75
  };
78
76
  }
79
- function buildCacheKey(options) {
80
- return `${options.workspaceDir}\u0000${options.channelDir}`;
81
- }
82
77
  function buildWorkspaceOrChannelMemoryCandidates(source, path, content) {
83
78
  const sections = splitH2Sections(content);
84
79
  if (sections.length === 0 && content) {
@@ -98,41 +93,96 @@ function buildSessionCandidates(path, content) {
98
93
  : `${sessionTitle.trim()}\n${section.content}`));
99
94
  }
100
95
  function buildHistoryCandidates(path, content) {
101
- return splitH2Sections(content)
102
- .filter((section) => section.content.trim())
103
- .map((section) => buildCandidate("channel-history", path, section.heading, section.content, section.heading));
96
+ const sections = splitH2Sections(content).filter((section) => section.content.trim());
97
+ if (sections.length === 0) {
98
+ return [];
99
+ }
100
+ const foldedSections = sections.filter((section) => section.heading.startsWith("Folded History Through "));
101
+ const recentSectionLimit = 8;
102
+ const recentSections = sections.slice(-recentSectionLimit);
103
+ const selectedSections = Array.from(new Set([...foldedSections, ...recentSections]));
104
+ return selectedSections.map((section) => buildCandidate("channel-history", path, section.heading, section.content, section.heading));
104
105
  }
105
- async function buildMemoryCandidatesUncached(options) {
106
- const workspaceMemoryPath = join(options.workspaceDir, "MEMORY.md");
107
- const channelMemoryPath = getChannelMemoryPath(options.channelDir);
108
- const channelSessionPath = getChannelSessionPath(options.channelDir);
109
- const channelHistoryPath = getChannelHistoryPath(options.channelDir);
110
- const [workspaceMemory, channelMemory, channelSession, channelHistory] = await Promise.all([
111
- readOptionalFile(workspaceMemoryPath),
112
- readOptionalFile(channelMemoryPath),
113
- readOptionalFile(channelSessionPath),
114
- readOptionalFile(channelHistoryPath),
115
- ]);
116
- return [
117
- ...buildSessionCandidates(channelSessionPath, channelSession),
118
- ...buildWorkspaceOrChannelMemoryCandidates("channel-memory", channelMemoryPath, channelMemory),
119
- ...buildWorkspaceOrChannelMemoryCandidates("workspace-memory", workspaceMemoryPath, workspaceMemory),
120
- ...buildHistoryCandidates(channelHistoryPath, channelHistory),
121
- ];
106
+ async function readFingerprint(path) {
107
+ try {
108
+ const stats = await stat(path);
109
+ return {
110
+ exists: true,
111
+ mtimeMs: stats.mtimeMs,
112
+ ctimeMs: stats.ctimeMs,
113
+ size: stats.size,
114
+ };
115
+ }
116
+ catch {
117
+ return {
118
+ exists: false,
119
+ mtimeMs: 0,
120
+ ctimeMs: 0,
121
+ size: 0,
122
+ };
123
+ }
122
124
  }
123
- export async function buildMemoryCandidates(options) {
124
- if (!options.cache) {
125
- return buildMemoryCandidatesUncached(options);
125
+ export class MemoryCandidateStore {
126
+ constructor() {
127
+ this.files = new Map();
128
+ this.inflight = new Map();
129
+ }
130
+ invalidate(path) {
131
+ if (!path) {
132
+ this.files.clear();
133
+ this.inflight.clear();
134
+ return;
135
+ }
136
+ this.files.delete(path);
137
+ this.inflight.delete(path);
126
138
  }
127
- const key = buildCacheKey(options);
128
- const cached = options.cache.entries.get(key);
129
- if (cached) {
130
- return cached;
139
+ async getCandidates(options) {
140
+ const definitions = [
141
+ {
142
+ path: getChannelSessionPath(options.channelDir),
143
+ build: buildSessionCandidates,
144
+ },
145
+ {
146
+ path: getChannelMemoryPath(options.channelDir),
147
+ build: (path, content) => buildWorkspaceOrChannelMemoryCandidates("channel-memory", path, content),
148
+ },
149
+ {
150
+ path: join(options.workspaceDir, "MEMORY.md"),
151
+ build: (path, content) => buildWorkspaceOrChannelMemoryCandidates("workspace-memory", path, content),
152
+ },
153
+ {
154
+ path: getChannelHistoryPath(options.channelDir),
155
+ build: buildHistoryCandidates,
156
+ },
157
+ ];
158
+ const candidateGroups = await Promise.all(definitions.map(async (definition) => this.loadFileCandidates(definition.path, definition.build)));
159
+ return candidateGroups.flat();
160
+ }
161
+ async loadFileCandidates(path, build) {
162
+ const pending = this.inflight.get(path);
163
+ if (pending) {
164
+ return pending;
165
+ }
166
+ const work = (async () => {
167
+ const fingerprint = await readFingerprint(path);
168
+ const cached = this.files.get(path);
169
+ if (cached && sameFingerprint(cached.fingerprint, fingerprint)) {
170
+ return cached.candidates;
171
+ }
172
+ const content = fingerprint.exists ? await readOptionalFile(path) : "";
173
+ const candidates = build(path, content);
174
+ this.files.set(path, { fingerprint, candidates });
175
+ return candidates;
176
+ })().finally(() => {
177
+ this.inflight.delete(path);
178
+ });
179
+ this.inflight.set(path, work);
180
+ return work;
131
181
  }
132
- const pending = buildMemoryCandidatesUncached(options).catch((error) => {
133
- options.cache?.entries.delete(key);
134
- throw error;
135
- });
136
- options.cache.entries.set(key, pending);
137
- return pending;
182
+ }
183
+ export function createMemoryCandidateStore() {
184
+ return new MemoryCandidateStore();
185
+ }
186
+ export async function buildMemoryCandidates(options, store = createMemoryCandidateStore()) {
187
+ return store.getCandidates(options);
138
188
  }
@@ -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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "crypto";
1
2
  import { existsSync, mkdirSync, writeFileSync } from "fs";
2
3
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
3
4
  import { dirname, join } from "path";
@@ -62,7 +63,7 @@ function normalizeContent(content) {
62
63
  }
63
64
  async function writeAtomically(path, content) {
64
65
  await mkdir(dirname(path), { recursive: true });
65
- const tempPath = `${path}.tmp`;
66
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
66
67
  await writeFile(tempPath, content, "utf-8");
67
68
  await rename(tempPath, path);
68
69
  }
@@ -14,7 +14,7 @@ export interface MemoryLifecycleOptions {
14
14
  }
15
15
  export declare class MemoryLifecycle {
16
16
  private options;
17
- private backgroundQueue;
17
+ private durableMemoryQueue;
18
18
  private sessionRefreshQueue;
19
19
  private turnsSinceSessionUpdate;
20
20
  private toolCallsSinceSessionUpdate;
@@ -39,12 +39,14 @@ export declare class MemoryLifecycle {
39
39
  private refreshSessionMemory;
40
40
  private runSessionRefreshSerial;
41
41
  private requestThresholdSessionRefresh;
42
- private enqueueBackgroundJob;
42
+ private runDurableMemoryJobSerial;
43
+ private enqueueDurableMemoryJob;
43
44
  private hasPendingAssistantSnapshot;
44
45
  private markDurableConsolidationCheckpoint;
45
46
  private logConsolidationResult;
46
47
  private scheduleIdleConsolidation;
47
48
  private runPreflightConsolidation;
49
+ private runPreflightConsolidationNow;
48
50
  private handleSessionBeforeCompact;
49
51
  private handleSessionCompact;
50
52
  private handleSessionBeforeSwitch;