@pennyclaw/auto-compact 0.1.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.
- package/README.md +34 -0
- package/dist/index.js +478 -0
- package/openclaw.plugin.json +27 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# auto-compact
|
|
2
|
+
|
|
3
|
+
Idle + threshold based transcript compaction with optional aggressive tool pruning.
|
|
4
|
+
|
|
5
|
+
## Requires
|
|
6
|
+
- `gateway.http.endpoints.chatCompletions.enabled = true`
|
|
7
|
+
|
|
8
|
+
## Config
|
|
9
|
+
|
|
10
|
+
```json5
|
|
11
|
+
plugins: {
|
|
12
|
+
entries: {
|
|
13
|
+
"auto-compact": {
|
|
14
|
+
enabled: true,
|
|
15
|
+
config: {
|
|
16
|
+
idleMinutes: 15,
|
|
17
|
+
contextTokensThreshold: 100000,
|
|
18
|
+
triggerMode: "or", // "or" | "and"
|
|
19
|
+
keepTurns: 5,
|
|
20
|
+
aggressive: false, // true = prune tool_result before summary
|
|
21
|
+
modelOverride: null // null = default model
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Behavior
|
|
29
|
+
- Keeps the last N turns intact (user + assistant + tool calls).
|
|
30
|
+
- Summarizes everything before that into a single assistant message prefixed with `[context_summary]`.
|
|
31
|
+
- Uses rolling summary (previous summary is used as base).
|
|
32
|
+
- Chunking + fallback to avoid overflow.
|
|
33
|
+
- When `aggressive=true`, prunes all tool_result content before summary.
|
|
34
|
+
- Credentials are **never included**; only `/secrets` file paths are referenced.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
function estimateTokens(msg) {
|
|
6
|
+
const content = normalizeContentToText(msg.content);
|
|
7
|
+
return Math.ceil(content.length / 4);
|
|
8
|
+
}
|
|
9
|
+
var SAFETY_MARGIN = 1.2;
|
|
10
|
+
var MAX_CHUNK_TOKENS = 6e3;
|
|
11
|
+
var MAX_SUMMARY_TOKENS = 800;
|
|
12
|
+
var SUMMARY_PREFIX = "[context_summary]";
|
|
13
|
+
var inMemoryLocks = /* @__PURE__ */ new Map();
|
|
14
|
+
var lastRunAt = /* @__PURE__ */ new Map();
|
|
15
|
+
function looksLikeGroupId(from) {
|
|
16
|
+
const lower = from.toLowerCase();
|
|
17
|
+
if (lower.includes(":group:")) return "group";
|
|
18
|
+
if (lower.includes(":channel:")) return "channel";
|
|
19
|
+
if (lower.endsWith("@g.us")) return "group";
|
|
20
|
+
return "dm";
|
|
21
|
+
}
|
|
22
|
+
function stripChannelPrefix(value, channelId) {
|
|
23
|
+
const prefix = `${channelId}:`;
|
|
24
|
+
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
25
|
+
}
|
|
26
|
+
async function withFileLock(key, fn) {
|
|
27
|
+
if (inMemoryLocks.get(key)) return;
|
|
28
|
+
inMemoryLocks.set(key, true);
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
} finally {
|
|
32
|
+
inMemoryLocks.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isValidSessionFile(sessionFile, sessionsDir) {
|
|
36
|
+
if (!sessionFile || !sessionsDir) return false;
|
|
37
|
+
if (!sessionFile.endsWith(".jsonl")) return false;
|
|
38
|
+
const resolved = path.resolve(sessionFile);
|
|
39
|
+
const base = path.resolve(sessionsDir) + path.sep;
|
|
40
|
+
return resolved.startsWith(base);
|
|
41
|
+
}
|
|
42
|
+
function parseEntryTimestamp(entry) {
|
|
43
|
+
const ts = entry?.timestamp;
|
|
44
|
+
if (typeof ts === "number" && Number.isFinite(ts)) return ts < 1e12 ? ts * 1e3 : ts;
|
|
45
|
+
if (typeof ts === "string") {
|
|
46
|
+
const parsed = Date.parse(ts);
|
|
47
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
48
|
+
}
|
|
49
|
+
return void 0;
|
|
50
|
+
}
|
|
51
|
+
async function getLastMessageTimestampBefore(sessionFile, cutoffMs) {
|
|
52
|
+
if (!fs.existsSync(sessionFile)) return void 0;
|
|
53
|
+
const stat = await fs.promises.stat(sessionFile);
|
|
54
|
+
const fileSize = stat.size;
|
|
55
|
+
const maxBytes = 512 * 1024;
|
|
56
|
+
const chunkSize = 64 * 1024;
|
|
57
|
+
let bytesRead = 0;
|
|
58
|
+
let position = fileSize;
|
|
59
|
+
let buffer = "";
|
|
60
|
+
const handle = await fs.promises.open(sessionFile, "r");
|
|
61
|
+
try {
|
|
62
|
+
while (position > 0 && bytesRead < maxBytes) {
|
|
63
|
+
const readSize = Math.min(chunkSize, position);
|
|
64
|
+
position -= readSize;
|
|
65
|
+
const buf = Buffer.alloc(readSize);
|
|
66
|
+
await handle.read(buf, 0, readSize, position);
|
|
67
|
+
bytesRead += readSize;
|
|
68
|
+
buffer = buf.toString("utf-8") + buffer;
|
|
69
|
+
const lines = buffer.split("\n");
|
|
70
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
if (!line || !line.trim()) continue;
|
|
73
|
+
try {
|
|
74
|
+
const entry = JSON.parse(line);
|
|
75
|
+
if (entry?.type !== "message") continue;
|
|
76
|
+
const ts = parseEntryTimestamp(entry);
|
|
77
|
+
if (typeof ts !== "number") continue;
|
|
78
|
+
if (ts < cutoffMs) return ts;
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
buffer = lines[0] || "";
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
await handle.close();
|
|
87
|
+
}
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
function normalizeContentToText(content) {
|
|
91
|
+
if (typeof content === "string") return content;
|
|
92
|
+
if (Array.isArray(content)) {
|
|
93
|
+
return content.map((c) => typeof c?.text === "string" ? c.text : typeof c === "string" ? c : "").filter(Boolean).join("\n");
|
|
94
|
+
}
|
|
95
|
+
if (content && typeof content === "object") {
|
|
96
|
+
if (typeof content.text === "string") return content.text;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.stringify(content);
|
|
99
|
+
} catch {
|
|
100
|
+
return String(content);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
function pruneToolResultMessage(entry, placeholder) {
|
|
106
|
+
if (!entry || entry.type !== "message") return false;
|
|
107
|
+
const msg = entry.message;
|
|
108
|
+
if (!msg || msg.role !== "tool" && msg.role !== "toolResult") return false;
|
|
109
|
+
const content = msg.content;
|
|
110
|
+
if (typeof content === "string") {
|
|
111
|
+
if (content === placeholder) return false;
|
|
112
|
+
msg.content = placeholder;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(content)) {
|
|
116
|
+
if (content.length === 1 && content[0]?.type === "text" && content[0]?.text === placeholder) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
msg.content = [{ type: "text", text: placeholder }];
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (content && typeof content === "object" && "text" in content) {
|
|
123
|
+
if (content.text === placeholder) return false;
|
|
124
|
+
msg.content = { ...content, text: placeholder };
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
msg.content = [{ type: "text", text: placeholder }];
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
function estimateMessagesTokens(messages) {
|
|
131
|
+
return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
|
|
132
|
+
}
|
|
133
|
+
function chunkMessagesByMaxTokens(messages, maxTokens) {
|
|
134
|
+
if (messages.length === 0) return [];
|
|
135
|
+
const chunks = [];
|
|
136
|
+
let current = [];
|
|
137
|
+
let currentTokens = 0;
|
|
138
|
+
for (const message of messages) {
|
|
139
|
+
const messageTokens = estimateTokens(message) * SAFETY_MARGIN;
|
|
140
|
+
if (current.length > 0 && currentTokens + messageTokens > maxTokens) {
|
|
141
|
+
chunks.push(current);
|
|
142
|
+
current = [];
|
|
143
|
+
currentTokens = 0;
|
|
144
|
+
}
|
|
145
|
+
current.push(message);
|
|
146
|
+
currentTokens += messageTokens;
|
|
147
|
+
if (messageTokens > maxTokens) {
|
|
148
|
+
chunks.push(current);
|
|
149
|
+
current = [];
|
|
150
|
+
currentTokens = 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (current.length > 0) chunks.push(current);
|
|
154
|
+
return chunks;
|
|
155
|
+
}
|
|
156
|
+
function isOversizedForSummary(msg, contextWindow) {
|
|
157
|
+
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
|
|
158
|
+
return tokens > contextWindow * 0.5;
|
|
159
|
+
}
|
|
160
|
+
async function callChatCompletions(params) {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timeout = setTimeout(() => controller.abort(), params.timeoutMs ?? 3e4);
|
|
163
|
+
try {
|
|
164
|
+
const body = {
|
|
165
|
+
messages: [
|
|
166
|
+
{ role: "system", content: params.systemPrompt },
|
|
167
|
+
{ role: "user", content: params.userPrompt }
|
|
168
|
+
],
|
|
169
|
+
max_tokens: MAX_SUMMARY_TOKENS,
|
|
170
|
+
temperature: 0.2
|
|
171
|
+
};
|
|
172
|
+
if (params.modelOverride) body.model = params.modelOverride;
|
|
173
|
+
const res = await fetch(`${params.baseUrl}/v1/chat/completions`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
...params.token ? { Authorization: `Bearer ${params.token}`, "X-OpenClaw-Token": params.token } : {}
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify(body),
|
|
180
|
+
signal: controller.signal
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
const txt = await res.text().catch(() => "");
|
|
184
|
+
throw new Error(`chatCompletions ${res.status}: ${txt}`);
|
|
185
|
+
}
|
|
186
|
+
const json = await res.json();
|
|
187
|
+
const content = json?.choices?.[0]?.message?.content ?? json?.output_text;
|
|
188
|
+
if (!content || typeof content !== "string") {
|
|
189
|
+
throw new Error("chatCompletions: empty response");
|
|
190
|
+
}
|
|
191
|
+
return content.trim();
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function buildSummaryPrompt(params) {
|
|
197
|
+
const lines = [];
|
|
198
|
+
if (params.previousSummary) {
|
|
199
|
+
lines.push("Previous summary (for continuity):");
|
|
200
|
+
lines.push(params.previousSummary);
|
|
201
|
+
lines.push("");
|
|
202
|
+
}
|
|
203
|
+
lines.push("Conversation chunk:");
|
|
204
|
+
for (const msg of params.chunk) {
|
|
205
|
+
const role = msg.role ?? "message";
|
|
206
|
+
const content = normalizeContentToText(msg.content);
|
|
207
|
+
if (!content) continue;
|
|
208
|
+
lines.push(`${role.toUpperCase()}: ${content}`);
|
|
209
|
+
}
|
|
210
|
+
if (params.secretsList.length > 0) {
|
|
211
|
+
lines.push("");
|
|
212
|
+
lines.push("Known credential file paths (DO NOT include secrets, only paths):");
|
|
213
|
+
for (const p of params.secretsList) lines.push(`- ${p}`);
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
async function summarizeWithFallback(params) {
|
|
218
|
+
const { messages } = params;
|
|
219
|
+
if (messages.length === 0) return params.previousSummary ?? "No prior history.";
|
|
220
|
+
const chunks = chunkMessagesByMaxTokens(messages, MAX_CHUNK_TOKENS);
|
|
221
|
+
let summary = params.previousSummary;
|
|
222
|
+
try {
|
|
223
|
+
for (const chunk of chunks) {
|
|
224
|
+
summary = await callChatCompletions({
|
|
225
|
+
baseUrl: params.baseUrl,
|
|
226
|
+
token: params.token,
|
|
227
|
+
modelOverride: params.modelOverride,
|
|
228
|
+
systemPrompt: params.systemPrompt,
|
|
229
|
+
userPrompt: buildSummaryPrompt({
|
|
230
|
+
chunk,
|
|
231
|
+
previousSummary: summary,
|
|
232
|
+
secretsList: params.secretsList
|
|
233
|
+
})
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return summary ?? "No prior history.";
|
|
237
|
+
} catch (fullError) {
|
|
238
|
+
}
|
|
239
|
+
const smallMessages = [];
|
|
240
|
+
const oversizedNotes = [];
|
|
241
|
+
for (const msg of messages) {
|
|
242
|
+
if (isOversizedForSummary(msg, params.contextWindow)) {
|
|
243
|
+
const role = msg.role ?? "message";
|
|
244
|
+
const tokens = estimateTokens(msg);
|
|
245
|
+
oversizedNotes.push(`[Large ${role} (~${Math.round(tokens / 1e3)}K tokens) omitted from summary]`);
|
|
246
|
+
} else {
|
|
247
|
+
smallMessages.push(msg);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (smallMessages.length > 0) {
|
|
251
|
+
try {
|
|
252
|
+
const chunks2 = chunkMessagesByMaxTokens(smallMessages, MAX_CHUNK_TOKENS);
|
|
253
|
+
let summary2 = params.previousSummary;
|
|
254
|
+
for (const chunk of chunks2) {
|
|
255
|
+
summary2 = await callChatCompletions({
|
|
256
|
+
baseUrl: params.baseUrl,
|
|
257
|
+
token: params.token,
|
|
258
|
+
modelOverride: params.modelOverride,
|
|
259
|
+
systemPrompt: params.systemPrompt,
|
|
260
|
+
userPrompt: buildSummaryPrompt({
|
|
261
|
+
chunk,
|
|
262
|
+
previousSummary: summary2,
|
|
263
|
+
secretsList: params.secretsList
|
|
264
|
+
})
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const notes = oversizedNotes.length > 0 ? `
|
|
268
|
+
|
|
269
|
+
${oversizedNotes.join("\n")}` : "";
|
|
270
|
+
return (summary2 ?? "No prior history.") + notes;
|
|
271
|
+
} catch {
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). Summary unavailable due to size limits.`;
|
|
275
|
+
}
|
|
276
|
+
function extractSecretsList() {
|
|
277
|
+
const candidates = ["/home/node/.openclaw/workspace/secrets", "/secrets"];
|
|
278
|
+
for (const dir of candidates) {
|
|
279
|
+
try {
|
|
280
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue;
|
|
281
|
+
const files = fs.readdirSync(dir).map((f) => path.join(dir, f));
|
|
282
|
+
if (files.length > 0) return files;
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
function findPreviousSummary(messages) {
|
|
289
|
+
let previousSummary;
|
|
290
|
+
const filtered = [];
|
|
291
|
+
for (const msg of messages) {
|
|
292
|
+
if (msg.role === "assistant") {
|
|
293
|
+
const content = normalizeContentToText(msg.content);
|
|
294
|
+
if (content.startsWith(SUMMARY_PREFIX)) {
|
|
295
|
+
previousSummary = content.replace(SUMMARY_PREFIX, "").trim();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
filtered.push(msg);
|
|
300
|
+
}
|
|
301
|
+
return { previousSummary, filtered };
|
|
302
|
+
}
|
|
303
|
+
function generateId() {
|
|
304
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 8);
|
|
305
|
+
}
|
|
306
|
+
function register(api) {
|
|
307
|
+
api.logger.info("auto-compact: loaded v0.0.2");
|
|
308
|
+
api.on("message_received", async (event, ctx) => {
|
|
309
|
+
const pluginCfg = api.pluginConfig ?? {};
|
|
310
|
+
const idleMinutes = Number.isFinite(pluginCfg.idleMinutes) ? Number(pluginCfg.idleMinutes) : 15;
|
|
311
|
+
const contextTokensThreshold = Number.isFinite(pluginCfg.contextTokensThreshold) ? Number(pluginCfg.contextTokensThreshold) : 1e5;
|
|
312
|
+
const triggerMode = pluginCfg.triggerMode === "and" ? "and" : "or";
|
|
313
|
+
const keepTurns = Number.isFinite(pluginCfg.keepTurns) ? Math.max(1, Number(pluginCfg.keepTurns)) : 5;
|
|
314
|
+
const aggressive = Boolean(pluginCfg.aggressive);
|
|
315
|
+
const modelOverride = typeof pluginCfg.modelOverride === "string" && pluginCfg.modelOverride.trim() ? pluginCfg.modelOverride.trim() : null;
|
|
316
|
+
if (!idleMinutes && !contextTokensThreshold) return;
|
|
317
|
+
const from = typeof event.from === "string" ? event.from.trim() : "";
|
|
318
|
+
if (!from) return;
|
|
319
|
+
const channelId = typeof ctx.channelId === "string" ? ctx.channelId : "";
|
|
320
|
+
const conversationId = typeof ctx.conversationId === "string" && ctx.conversationId.trim() ? ctx.conversationId.trim() : typeof event?.metadata?.to === "string" ? event.metadata.to : from;
|
|
321
|
+
const peerIdRaw = conversationId || from;
|
|
322
|
+
const peerId = channelId ? stripChannelPrefix(peerIdRaw, channelId) : peerIdRaw;
|
|
323
|
+
const peerKind = looksLikeGroupId(peerIdRaw);
|
|
324
|
+
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
325
|
+
cfg: api.config,
|
|
326
|
+
channel: channelId || "telegram",
|
|
327
|
+
accountId: ctx.accountId ?? void 0,
|
|
328
|
+
peer: { kind: peerKind, id: peerId }
|
|
329
|
+
});
|
|
330
|
+
const storePath = api.runtime.channel.session.resolveStorePath(
|
|
331
|
+
api.config.session?.store,
|
|
332
|
+
{ agentId: route.agentId }
|
|
333
|
+
);
|
|
334
|
+
if (!fs.existsSync(storePath)) return;
|
|
335
|
+
let store = {};
|
|
336
|
+
try {
|
|
337
|
+
store = JSON.parse(await fs.promises.readFile(storePath, "utf-8"));
|
|
338
|
+
} catch {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
let sessionKey = route.sessionKey;
|
|
342
|
+
if (!store[sessionKey]) {
|
|
343
|
+
const keys = Object.keys(store);
|
|
344
|
+
const match = keys.find((key) => {
|
|
345
|
+
const e = store[key] || {};
|
|
346
|
+
const candidates = [e.origin?.from, e.origin?.to, e.lastTo, e.deliveryContext?.to].filter(Boolean);
|
|
347
|
+
return candidates.includes(conversationId) || candidates.includes(peerIdRaw) || candidates.includes(peerId);
|
|
348
|
+
});
|
|
349
|
+
if (match) sessionKey = match;
|
|
350
|
+
}
|
|
351
|
+
const entry = store[sessionKey];
|
|
352
|
+
if (!entry) return;
|
|
353
|
+
const sessionsDir = path.dirname(storePath);
|
|
354
|
+
const sessionFile = typeof entry.sessionFile === "string" && entry.sessionFile.trim() || path.join(sessionsDir, `${entry.sessionId}.jsonl`);
|
|
355
|
+
if (!isValidSessionFile(sessionFile, sessionsDir)) return;
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
const recentRunAt = lastRunAt.get(sessionFile) ?? 0;
|
|
358
|
+
if (now - recentRunAt < 6e4) return;
|
|
359
|
+
let cutoff = typeof event?.timestamp === "number" ? event.timestamp : typeof event?.timestamp === "string" ? Date.parse(event.timestamp) : now;
|
|
360
|
+
if (!Number.isFinite(cutoff)) cutoff = now;
|
|
361
|
+
if (cutoff < 1e12) cutoff *= 1e3;
|
|
362
|
+
const lastTs = await getLastMessageTimestampBefore(sessionFile, cutoff);
|
|
363
|
+
const idleOk = idleMinutes > 0 && typeof lastTs === "number" && now - lastTs >= idleMinutes * 60 * 1e3;
|
|
364
|
+
const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : 0;
|
|
365
|
+
const thresholdOk = contextTokensThreshold > 0 && contextTokens >= contextTokensThreshold;
|
|
366
|
+
let shouldRun = false;
|
|
367
|
+
if (idleMinutes > 0 && contextTokensThreshold > 0) {
|
|
368
|
+
shouldRun = triggerMode === "and" ? idleOk && thresholdOk : idleOk || thresholdOk;
|
|
369
|
+
} else if (idleMinutes > 0) {
|
|
370
|
+
shouldRun = idleOk;
|
|
371
|
+
} else if (contextTokensThreshold > 0) {
|
|
372
|
+
shouldRun = thresholdOk;
|
|
373
|
+
}
|
|
374
|
+
api.logger.info(
|
|
375
|
+
`auto-compact: idleOk=${idleOk} thresholdOk=${thresholdOk} contextTokens=${contextTokens} idleMinutes=${idleMinutes} threshold=${contextTokensThreshold} triggerMode=${triggerMode}`
|
|
376
|
+
);
|
|
377
|
+
if (!shouldRun) return;
|
|
378
|
+
const chatCompletionsEnabled = api.config?.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
|
|
379
|
+
if (!chatCompletionsEnabled) {
|
|
380
|
+
api.logger.warn("auto-compact: chatCompletions endpoint disabled; skipping summary");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const token = api.config?.gateway?.auth?.token;
|
|
384
|
+
const port = api.config?.gateway?.port ?? 18789;
|
|
385
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
386
|
+
const systemPrompt = "You are summarizing chat history to reduce token usage. Return a concise, factual summary. Always include: decisions, configuration/parameters, credentials (paths only, never secrets), current task status, and important file/paths. Do not include secrets or raw credentials.";
|
|
387
|
+
await withFileLock(sessionFile, async () => {
|
|
388
|
+
const raw = await fs.promises.readFile(sessionFile, "utf-8");
|
|
389
|
+
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
390
|
+
if (lines.length === 0) return;
|
|
391
|
+
const entries = lines.map((line) => {
|
|
392
|
+
try {
|
|
393
|
+
return JSON.parse(line);
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}).filter(Boolean);
|
|
398
|
+
const sessionHeaderIndex = entries.findIndex((e) => e?.type === "session");
|
|
399
|
+
const header = sessionHeaderIndex >= 0 ? entries[sessionHeaderIndex] : null;
|
|
400
|
+
const userMessageIndices = [];
|
|
401
|
+
for (let i = 0; i < entries.length; i++) {
|
|
402
|
+
const e = entries[i];
|
|
403
|
+
if (e?.type === "message" && e?.message?.role === "user") userMessageIndices.push(i);
|
|
404
|
+
}
|
|
405
|
+
api.logger.info(`auto-compact: userMessages=${userMessageIndices.length} keepTurns=${keepTurns}`);
|
|
406
|
+
if (userMessageIndices.length < keepTurns) return;
|
|
407
|
+
const keepStartIndex = userMessageIndices[userMessageIndices.length - keepTurns];
|
|
408
|
+
const keptEntries = entries.slice(keepStartIndex);
|
|
409
|
+
const summarySourceEntries = entries.slice(sessionHeaderIndex >= 0 ? sessionHeaderIndex + 1 : 0, keepStartIndex);
|
|
410
|
+
const summaryMessages = summarySourceEntries.filter((e) => e?.type === "message" && e?.message).map((e) => ({
|
|
411
|
+
role: e.message.role,
|
|
412
|
+
content: e.message.content,
|
|
413
|
+
timestamp: e.message.timestamp ?? e.timestamp ?? Date.now()
|
|
414
|
+
}));
|
|
415
|
+
const { previousSummary, filtered } = findPreviousSummary(summaryMessages);
|
|
416
|
+
const secretsList = extractSecretsList();
|
|
417
|
+
let summaryText;
|
|
418
|
+
try {
|
|
419
|
+
summaryText = await summarizeWithFallback({
|
|
420
|
+
messages: filtered,
|
|
421
|
+
baseUrl,
|
|
422
|
+
token,
|
|
423
|
+
modelOverride,
|
|
424
|
+
previousSummary,
|
|
425
|
+
secretsList,
|
|
426
|
+
contextWindow: 16e3,
|
|
427
|
+
systemPrompt
|
|
428
|
+
});
|
|
429
|
+
} catch (err) {
|
|
430
|
+
api.logger.error(`auto-compact: summary failed ${err instanceof Error ? err.message : String(err)}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (secretsList.length > 0) {
|
|
434
|
+
summaryText += `
|
|
435
|
+
|
|
436
|
+
Credentials (paths only):
|
|
437
|
+
${secretsList.map((p) => `- ${p}`).join("\n")}`;
|
|
438
|
+
}
|
|
439
|
+
const compactionEntry = {
|
|
440
|
+
type: "compaction",
|
|
441
|
+
id: generateId(),
|
|
442
|
+
parentId: header?.id ?? keptEntries[0]?.parentId,
|
|
443
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
444
|
+
summary: `${SUMMARY_PREFIX}
|
|
445
|
+
${summaryText}`,
|
|
446
|
+
firstKeptEntryId: keptEntries[0]?.id,
|
|
447
|
+
tokensBefore: Math.round(estimateMessagesTokens(summaryMessages) * SAFETY_MARGIN)
|
|
448
|
+
};
|
|
449
|
+
const tmp = `${sessionFile}.auto-compact.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const line of lines) {
|
|
452
|
+
if (!line.trim()) continue;
|
|
453
|
+
let entry2;
|
|
454
|
+
try {
|
|
455
|
+
entry2 = JSON.parse(line);
|
|
456
|
+
} catch {
|
|
457
|
+
out.push(line);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (entry2?.id && entry2.id === compactionEntry.firstKeptEntryId) {
|
|
461
|
+
entry2.parentId = compactionEntry.id;
|
|
462
|
+
}
|
|
463
|
+
if (aggressive) {
|
|
464
|
+
pruneToolResultMessage(entry2, "[pruned due to compact]");
|
|
465
|
+
}
|
|
466
|
+
out.push(JSON.stringify(entry2));
|
|
467
|
+
}
|
|
468
|
+
out.push(JSON.stringify(compactionEntry));
|
|
469
|
+
await fs.promises.writeFile(tmp, out.join("\n"), "utf-8");
|
|
470
|
+
await fs.promises.rename(tmp, sessionFile);
|
|
471
|
+
lastRunAt.set(sessionFile, Date.now());
|
|
472
|
+
api.logger.info(`auto-compact: wrote compaction entry in ${sessionFile}`);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
export {
|
|
477
|
+
register as default
|
|
478
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "auto-compact",
|
|
3
|
+
"name": "Auto Compact",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "Idle + threshold based transcript compaction with optional aggressive tool pruning.",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"idleMinutes": { "type": "number", "default": 15, "minimum": 1 },
|
|
12
|
+
"contextTokensThreshold": { "type": "number", "default": 100000, "minimum": 1 },
|
|
13
|
+
"triggerMode": { "type": "string", "enum": ["or", "and"], "default": "or" },
|
|
14
|
+
"keepTurns": { "type": "number", "default": 5, "minimum": 1 },
|
|
15
|
+
"aggressive": { "type": "boolean", "default": false },
|
|
16
|
+
"modelOverride": { "type": ["string", "null"], "default": null }
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"uiHints": {
|
|
20
|
+
"idleMinutes": { "label": "Idle minutes" },
|
|
21
|
+
"contextTokensThreshold": { "label": "Context tokens threshold" },
|
|
22
|
+
"triggerMode": { "label": "Trigger mode (or/and)" },
|
|
23
|
+
"keepTurns": { "label": "Keep turns" },
|
|
24
|
+
"aggressive": { "label": "Aggressive (prune tool_result first)" },
|
|
25
|
+
"modelOverride": { "label": "Model override" }
|
|
26
|
+
}
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pennyclaw/auto-compact",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Idle + threshold based transcript compaction with optional aggressive tool pruning.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/index.js",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"plugin",
|
|
15
|
+
"compact",
|
|
16
|
+
"summary"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "esbuild index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@mariozechner/pi-coding-agent"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"esbuild": "^0.27.3"
|
|
24
|
+
}
|
|
25
|
+
}
|