@shawnstack/quickforge 1.4.0 → 1.5.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 (65) hide show
  1. package/README.md +12 -12
  2. package/bin/quickforge.mjs +9 -0
  3. package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
  4. package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
  5. package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
  6. package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
  7. package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
  8. package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
  9. package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
  10. package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
  11. package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
  12. package/dist/assets/icons-47L5YLKz.js +1 -0
  13. package/dist/assets/index-CqfScETb.js +1200 -0
  14. package/dist/assets/index-DzkBgHZf.css +3 -0
  15. package/dist/assets/{monaco-DG4TcBMc.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-CiCXOLb5.js → react-vendor-DunfCFfp.js} +1 -1
  17. package/dist/favicon.svg +16 -1
  18. package/dist/index.html +5 -5
  19. package/dist/manifest.webmanifest +30 -30
  20. package/package.json +3 -2
  21. package/server/acp/server.mjs +921 -0
  22. package/server/agent-manager.mjs +283 -45
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/approval-store.mjs +13 -1
  26. package/server/auto-compaction.mjs +111 -112
  27. package/server/channels/process-channel.mjs +278 -0
  28. package/server/channels/providers/wechat.mjs +271 -0
  29. package/server/channels/registry.mjs +58 -0
  30. package/server/context-usage.mjs +108 -0
  31. package/server/custom-commands.mjs +157 -28
  32. package/server/frontmatter.mjs +167 -0
  33. package/server/index.mjs +52 -3
  34. package/server/mcp/registry.mjs +40 -0
  35. package/server/project-config.mjs +43 -6
  36. package/server/routes/agent-profiles.mjs +6 -2
  37. package/server/routes/agent.mjs +13 -2
  38. package/server/routes/channels.mjs +145 -0
  39. package/server/routes/mcp.mjs +7 -1
  40. package/server/routes/models.mjs +68 -0
  41. package/server/routes/project.mjs +34 -4
  42. package/server/routes/scheduled-tasks.mjs +6 -5
  43. package/server/routes/shared-conversation.mjs +1 -1
  44. package/server/routes/storage.mjs +4 -2
  45. package/server/routes/system.mjs +27 -0
  46. package/server/routes/tools.mjs +17 -6
  47. package/server/routes/workspace.mjs +138 -0
  48. package/server/session-utils.mjs +10 -2
  49. package/server/storage.mjs +30 -2
  50. package/server/subagents.mjs +8 -6
  51. package/server/system-prompt.mjs +3 -2
  52. package/server/tools/definitions.mjs +19 -1
  53. package/server/tools/index.mjs +83 -0
  54. package/server/utils/package-update.mjs +156 -0
  55. package/dist/assets/AgentProfilesPage-C79teCgh.js +0 -1
  56. package/dist/assets/ChatPanelHost-BjdIshtX.js +0 -195
  57. package/dist/assets/PluginsPage-Dt7Iiddo.js +0 -1
  58. package/dist/assets/ScheduledTasksPage-C047y3p3.js +0 -2
  59. package/dist/assets/SharedConversationPage-8X8kfztQ.js +0 -1
  60. package/dist/assets/TerminalDock-CEuJNf0m.js +0 -2
  61. package/dist/assets/WorkspaceInspector-BIa5gLVs.js +0 -3
  62. package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +0 -6
  63. package/dist/assets/icons-Dsc5yL3l.js +0 -1
  64. package/dist/assets/index-CPAWYhzz.css +0 -3
  65. package/dist/assets/index-YTL26wyJ.js +0 -814
@@ -0,0 +1,179 @@
1
+ import { existsSync, promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { dataDir } from './storage.mjs'
5
+ import { firstOptionalBoolean, firstString, parseFrontmatter, splitDelimitedList } from './frontmatter.mjs'
6
+
7
+ const DEFAULT_MAX_RUNTIME_MS = 30 * 60 * 1000
8
+ const DEFAULT_MAX_TOOL_CALLS = 300
9
+ const nameRegex = /^[a-z][a-z0-9_-]{1,39}$/
10
+ const allowedToolNames = new Set(['read_file', 'grep_files', 'write_file', 'edit_file', 'run_command'])
11
+
12
+ const toolAliases = new Map([
13
+ ['Read', 'read_file'],
14
+ ['Grep', 'grep_files'],
15
+ ['Bash', 'run_command'],
16
+ ['Write', 'write_file'],
17
+ ['Edit', 'edit_file'],
18
+ ])
19
+
20
+ const claudeUserAgentsDir = path.join(os.homedir(), '.claude', 'agents')
21
+ const userAgentsDir = path.join(dataDir, 'agents')
22
+
23
+ function normalizeString(value) {
24
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
25
+ }
26
+
27
+ function normalizeName(value) {
28
+ const name = normalizeString(value)?.toLowerCase()
29
+ return name && nameRegex.test(name) ? name : null
30
+ }
31
+
32
+ function normalizeRuntime(value) {
33
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_RUNTIME_MS
34
+ const parsed = Number(value)
35
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_RUNTIME_MS
36
+ return Math.min(Math.max(Math.round(parsed), 1000), DEFAULT_MAX_RUNTIME_MS)
37
+ }
38
+
39
+ function normalizeToolCalls(value) {
40
+ if (value === undefined || value === null || value === '') return DEFAULT_MAX_TOOL_CALLS
41
+ const parsed = Number(value)
42
+ if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_MAX_TOOL_CALLS
43
+ return Math.min(parsed, DEFAULT_MAX_TOOL_CALLS)
44
+ }
45
+
46
+ function normalizeTools(value) {
47
+ const tools = []
48
+ const seen = new Set()
49
+ for (const item of splitDelimitedList(value)) {
50
+ const mapped = toolAliases.get(item) || item
51
+ if (!allowedToolNames.has(mapped) || seen.has(mapped)) continue
52
+ seen.add(mapped)
53
+ tools.push(mapped)
54
+ }
55
+ return tools.length ? tools : ['read_file', 'grep_files']
56
+ }
57
+
58
+ function hasMutationTool(allowedTools) {
59
+ return allowedTools.some((toolName) => toolName === 'write_file' || toolName === 'edit_file')
60
+ }
61
+
62
+ export function agentProfileFromMarkdown(file, text, options = {}) {
63
+ const parsed = parseFrontmatter(text)
64
+ if (!parsed.body) return null
65
+
66
+ const metadata = parsed.metadata || {}
67
+ const name = normalizeName(metadata.name) || normalizeName(path.basename(file, '.md'))
68
+ if (!name) return null
69
+ if (options.reservedNames?.has(name)) return null
70
+
71
+ const allowedTools = normalizeTools(
72
+ metadata.tools ?? metadata['allowed-tools'] ?? metadata.allowedTools,
73
+ )
74
+ const label = firstString(metadata.label, metadata.displayName, metadata.title) || name
75
+ const enabledAsSubagent = firstOptionalBoolean(
76
+ metadata['enabled-as-subagent'],
77
+ metadata.enabled_as_subagent,
78
+ metadata.enabledAsSubagent,
79
+ )
80
+
81
+ return {
82
+ id: `${options.idPrefix || 'file'}:${name}`,
83
+ name,
84
+ label: label.slice(0, 80),
85
+ description: String(firstString(metadata.description) || '').slice(0, 500),
86
+ systemPrompt: parsed.body,
87
+ allowedTools,
88
+ maxRuntimeMs: normalizeRuntime(metadata['max-runtime-ms'] ?? metadata.max_runtime_ms ?? metadata.maxRuntimeMs),
89
+ maxToolCalls: normalizeToolCalls(metadata['max-tool-calls'] ?? metadata.max_tool_calls ?? metadata.maxToolCalls),
90
+ enabledAsSubagent: enabledAsSubagent === undefined ? true : enabledAsSubagent,
91
+ builtin: false,
92
+ source: options.source || 'file',
93
+ readonly: true,
94
+ filePath: file,
95
+ relativePath: options.relativePath || path.basename(file),
96
+ allowFileMutations: hasMutationTool(allowedTools),
97
+ createdAt: 'file',
98
+ updatedAt: 'file',
99
+ }
100
+ }
101
+
102
+ async function listAgentFilesFromDirectory(dir, options = {}) {
103
+ if (!dir || !existsSync(dir)) return []
104
+ let entries
105
+ try {
106
+ entries = await fs.readdir(dir, { withFileTypes: true })
107
+ } catch (error) {
108
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR' || error?.code === 'EACCES' || error?.code === 'EPERM') return []
109
+ throw error
110
+ }
111
+
112
+ const profiles = []
113
+ for (const entry of entries) {
114
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue
115
+ const file = path.join(dir, entry.name)
116
+ try {
117
+ const relativePath = options.relativeRoot
118
+ ? `${options.relativeRoot}/${entry.name}`.replace(/\\/g, '/')
119
+ : entry.name
120
+ const profile = agentProfileFromMarkdown(file, await fs.readFile(file, 'utf8'), {
121
+ ...options,
122
+ relativePath,
123
+ })
124
+ if (profile) profiles.push(profile)
125
+ } catch (error) {
126
+ console.warn(`Failed to load agent profile ${file}:`, error.message || error)
127
+ }
128
+ }
129
+ return profiles
130
+ }
131
+
132
+ function projectClaudeAgentsDir(workspaceRoot) {
133
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.claude', 'agents') : ''
134
+ }
135
+
136
+ function projectQuickForgeAgentsDir(workspaceRoot) {
137
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.quickforge', 'agents') : ''
138
+ }
139
+
140
+ export async function loadUserAgentProfiles(options = {}) {
141
+ const byName = new Map()
142
+ const sources = [
143
+ { dir: claudeUserAgentsDir, source: 'user-claude', relativeRoot: '~/.claude/agents', idPrefix: 'user-claude' },
144
+ { dir: userAgentsDir, source: 'user', relativeRoot: '~/.quickforge/agents', idPrefix: 'user' },
145
+ ]
146
+ for (const source of sources) {
147
+ for (const profile of await listAgentFilesFromDirectory(source.dir, { ...options, ...source })) {
148
+ byName.set(profile.name, profile)
149
+ }
150
+ }
151
+ return [...byName.values()]
152
+ }
153
+
154
+ export async function loadProjectAgentProfiles(workspaceRoot, options = {}) {
155
+ if (!workspaceRoot) return []
156
+ const byName = new Map()
157
+ const sources = [
158
+ { dir: projectClaudeAgentsDir(workspaceRoot), source: 'project-claude', relativeRoot: '.claude/agents', idPrefix: 'project-claude' },
159
+ { dir: projectQuickForgeAgentsDir(workspaceRoot), source: 'project', relativeRoot: '.quickforge/agents', idPrefix: 'project' },
160
+ ]
161
+ for (const source of sources) {
162
+ for (const profile of await listAgentFilesFromDirectory(source.dir, { ...options, ...source })) {
163
+ byName.set(profile.name, profile)
164
+ }
165
+ }
166
+ return [...byName.values()]
167
+ }
168
+
169
+ export async function loadFileAgentProfiles(workspaceRoot, options = {}) {
170
+ const byName = new Map()
171
+ for (const profile of await loadUserAgentProfiles(options)) byName.set(profile.name, profile)
172
+ for (const profile of await loadProjectAgentProfiles(workspaceRoot, options)) byName.set(profile.name, profile)
173
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
174
+ }
175
+
176
+ export const agentProfileSearchPaths = {
177
+ global: ['~/.claude/agents', '~/.quickforge/agents'],
178
+ project: ['<project>/.claude/agents', '<project>/.quickforge/agents'],
179
+ }
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto'
2
2
  import { readStore, atomicUpdate } from './storage.mjs'
3
3
  import { subagentDefinitions } from './subagents.mjs'
4
4
  import { workspaceTools } from './tools/definitions.mjs'
5
+ import { defaultGlobalWorkspaceContext, projectContextFromId } from './project-config.mjs'
6
+ import { loadFileAgentProfiles } from './agent-profile-files.mjs'
5
7
 
6
8
  const STORE = 'custom-agents'
7
9
  const RESERVED_NAMES = new Set(subagentDefinitions.map((definition) => definition.name))
@@ -56,6 +58,9 @@ function builtinProfileFromSubagent(definition) {
56
58
  maxToolCalls: definition.maxToolCalls || DEFAULT_MAX_TOOL_CALLS,
57
59
  enabledAsSubagent: true,
58
60
  builtin: true,
61
+ source: 'builtin',
62
+ readonly: true,
63
+ allowFileMutations: definition.allowFileMutations === true,
59
64
  createdAt: 'builtin',
60
65
  updatedAt: 'builtin',
61
66
  }
@@ -92,6 +97,9 @@ function normalizeProfileInput(input, existing = null, { creating = false } = {}
92
97
  maxToolCalls: normalizeOptionalPositiveInteger(input?.maxToolCalls ?? existing?.maxToolCalls, DEFAULT_MAX_TOOL_CALLS, 300),
93
98
  enabledAsSubagent: input?.enabledAsSubagent === undefined ? Boolean(existing?.enabledAsSubagent ?? true) : input.enabledAsSubagent === true,
94
99
  builtin: false,
100
+ source: 'store',
101
+ readonly: false,
102
+ allowFileMutations: allowedTools.some((toolName) => toolName === 'write_file' || toolName === 'edit_file'),
95
103
  createdAt: existing?.createdAt || now,
96
104
  updatedAt: now,
97
105
  }
@@ -102,20 +110,61 @@ async function readCustomAgentMap() {
102
110
  return data && typeof data === 'object' ? data : {}
103
111
  }
104
112
 
113
+ async function resolveWorkspaceRoot(options = {}) {
114
+ if (options.workspaceRoot) return options.workspaceRoot
115
+ if (options.projectId) {
116
+ try {
117
+ return (await projectContextFromId(options.projectId))?.workspaceRoot || null
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+ return defaultGlobalWorkspaceContext()?.workspaceRoot || null
123
+ }
124
+
125
+ function mergeProfiles({ builtin = [], file = [], custom = [] }) {
126
+ const reservedNames = new Set(builtin.map((profile) => profile.name))
127
+ const byName = new Map()
128
+
129
+ for (const profile of builtin) {
130
+ byName.set(profile.name, profile)
131
+ }
132
+ for (const profile of file) {
133
+ if (!profile?.name || reservedNames.has(profile.name)) continue
134
+ byName.set(profile.name, profile)
135
+ }
136
+ for (const profile of custom) {
137
+ if (!profile?.id) continue
138
+ if (!reservedNames.has(profile.name) && !byName.has(profile.name)) byName.set(profile.name, profile)
139
+ }
140
+
141
+ return [...byName.values()].sort((a, b) => {
142
+ if (a.builtin && !b.builtin) return -1
143
+ if (!a.builtin && b.builtin) return 1
144
+ return a.name.localeCompare(b.name)
145
+ })
146
+ }
147
+
105
148
  export async function listAgentProfiles(options = {}) {
106
149
  const custom = Object.values(await readCustomAgentMap())
107
- const profiles = [...listBuiltinAgentProfiles(), ...custom]
150
+ const workspaceRoot = await resolveWorkspaceRoot(options)
151
+ const file = await loadFileAgentProfiles(workspaceRoot, { reservedNames: RESERVED_NAMES })
152
+ const profiles = mergeProfiles({ builtin: listBuiltinAgentProfiles(), file, custom })
108
153
  return options.includeDisabled ? profiles : profiles.filter((profile) => profile.enabledAsSubagent || profile.builtin || profile.enabledAsSubagent === false)
109
154
  }
110
155
 
111
- export async function listSubagentProfiles() {
112
- return (await listAgentProfiles({ includeDisabled: true })).filter((profile) => profile.enabledAsSubagent)
156
+ export async function listSubagentProfiles(options = {}) {
157
+ return (await listAgentProfiles({ ...options, includeDisabled: true })).filter((profile) => profile.enabledAsSubagent)
113
158
  }
114
159
 
115
- export async function getAgentProfile(idOrName) {
160
+ export async function getAgentProfile(idOrName, options = {}) {
116
161
  const key = String(idOrName || '').trim().toLowerCase()
117
162
  if (!key) return null
118
- return (await listAgentProfiles({ includeDisabled: true })).find((profile) => profile.id === key || profile.name === key) || null
163
+ const profiles = await listAgentProfiles({ ...options, includeDisabled: true })
164
+ const byName = profiles.find((profile) => profile.name === key)
165
+ if (byName) return byName
166
+ const custom = Object.values(await readCustomAgentMap())
167
+ return custom.find((profile) => profile?.id === key) || profiles.find((profile) => profile.id === key) || null
119
168
  }
120
169
 
121
170
  export async function createCustomAgentProfile(input) {
@@ -167,7 +216,12 @@ export function agentProfileSnapshot(profile) {
167
216
  allowedTools: [...profile.allowedTools],
168
217
  maxRuntimeMs: profile.maxRuntimeMs,
169
218
  maxToolCalls: profile.maxToolCalls,
219
+ enabledAsSubagent: profile.enabledAsSubagent === true,
170
220
  builtin: profile.builtin === true,
221
+ source: profile.source || (profile.builtin ? 'builtin' : 'store'),
222
+ readonly: profile.readonly === true || profile.builtin === true,
223
+ filePath: profile.filePath,
224
+ relativePath: profile.relativePath,
171
225
  }
172
226
  }
173
227
 
@@ -24,6 +24,14 @@ export const commandRestrictedTools = new Set([
24
24
  'run_subagent',
25
25
  ])
26
26
 
27
+ export const planAllowedTools = new Set([
28
+ 'read_file',
29
+ 'grep_files',
30
+ 'activate_skill',
31
+ 'read_skill_resource',
32
+ 'run_subagent',
33
+ ])
34
+
27
35
  export const safeReadTools = new Set([
28
36
  'read_file',
29
37
  'grep_files',
@@ -45,7 +53,11 @@ export const pendingAutoCompactApprovals = new Map()
45
53
 
46
54
  export function commandToolPermissionError(session, toolName) {
47
55
  const permissions = session?.activeCommandPermissions
48
- if (!permissions || !commandRestrictedTools.has(toolName)) return null
56
+ if (!permissions) return null
57
+ if (session?.activeCommandName === 'plan' && !planAllowedTools.has(toolName)) {
58
+ return `Command /plan is read-only and cannot use ${toolName}.`
59
+ }
60
+ if (!commandRestrictedTools.has(toolName)) return null
49
61
  if (toolName === 'run_command' && permissions.allowCommands === false) {
50
62
  return `Command /${session.activeCommandName} does not allow running shell commands.`
51
63
  }
@@ -1,5 +1,6 @@
1
1
  import { readStore } from './storage.mjs'
2
2
  import { compactConversation, saveCompactBackup } from './conversation-compaction.mjs'
3
+ import { estimateContextUsage, shouldCompactContextByPercent } from './context-usage.mjs'
3
4
 
4
5
  export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
5
6
 
@@ -44,14 +45,6 @@ function safeJson(value) {
44
45
  }
45
46
  }
46
47
 
47
- function estimateTextTokens(value) {
48
- const text = String(value || '')
49
- if (!text) return 0
50
- const cjkChars = text.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length ?? 0
51
- const otherChars = Math.max(0, text.length - cjkChars)
52
- return Math.ceil(cjkChars + otherChars / 3.5)
53
- }
54
-
55
48
  function contentToText(content) {
56
49
  if (typeof content === 'string') return content
57
50
  if (!Array.isArray(content)) return ''
@@ -65,19 +58,6 @@ function contentToText(content) {
65
58
  }).filter(Boolean).join('\n')
66
59
  }
67
60
 
68
- function estimateMessageTokens(message) {
69
- if (!message || typeof message !== 'object') return 0
70
- const parts = [message.role || '', contentToText(message.content)]
71
- if (message.toolName) parts.push(message.toolName)
72
- if (message.toolCallId) parts.push(message.toolCallId)
73
- if (message.attachments !== undefined) parts.push(safeJson(message.attachments))
74
- return estimateTextTokens(parts.join('\n'))
75
- }
76
-
77
- function estimateMessagesTokens(messages) {
78
- return (Array.isArray(messages) ? messages : []).reduce((total, message) => total + estimateMessageTokens(message), 0)
79
- }
80
-
81
61
  function estimateMessagesChars(messages) {
82
62
  return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
83
63
  if (!message || typeof message !== 'object') return total
@@ -85,50 +65,6 @@ function estimateMessagesChars(messages) {
85
65
  }, 0)
86
66
  }
87
67
 
88
- function messageTimestampMs(message) {
89
- const timestamp = message?.timestamp
90
- if (typeof timestamp === 'number') return timestamp
91
- if (typeof timestamp === 'string') {
92
- const parsed = Date.parse(timestamp)
93
- return Number.isNaN(parsed) ? 0 : parsed
94
- }
95
- return 0
96
- }
97
-
98
- function latestCompactTimestampMs(session) {
99
- return messageTimestampMs(session?.contextCompaction?.summaryMessage)
100
- }
101
-
102
- function latestKnownInputTokens(messages, sinceTimestamp = 0) {
103
- let latestTimestamp = -1
104
- let latestInput = 0
105
- for (const message of Array.isArray(messages) ? messages : []) {
106
- if (message?.role !== 'assistant' || !message.usage) continue
107
- const timestamp = messageTimestampMs(message)
108
- if (sinceTimestamp > 0 && timestamp <= sinceTimestamp) continue
109
- if (timestamp < latestTimestamp) continue
110
- const input = Math.max(0, Number(message.usage.input ?? message.usage.totalTokens) || 0)
111
- if (input <= 0) continue
112
- latestTimestamp = timestamp
113
- latestInput = input
114
- }
115
- return latestInput
116
- }
117
-
118
- export function estimateContextUsage({ systemPrompt, messages, tools, model, knownInputTokens = 0 }) {
119
- const contextWindow = Number(model?.contextWindow) || 0
120
- const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
121
- const estimatedInputTokens =
122
- estimateTextTokens(systemPrompt) +
123
- estimateMessagesTokens(messages) +
124
- estimateTextTokens(safeJson(tools))
125
- const knownInput = Math.max(0, Number(knownInputTokens) || 0)
126
- const inputTokens = Math.max(estimatedInputTokens, knownInput)
127
- const totalTokens = inputTokens + reservedOutputTokens
128
- const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
129
- return { inputTokens, estimatedInputTokens, knownInputTokens: knownInput, reservedOutputTokens, totalTokens, contextWindow, percent }
130
- }
131
-
132
68
  function isUserMessage(message) {
133
69
  return message?.role === 'user' || message?.role === 'user-with-attachments'
134
70
  }
@@ -215,16 +151,104 @@ export function buildAutoCompactLoopMessages(session, messages) {
215
151
  return [summaryMessage, ...source.slice(compactedUpToIndex)]
216
152
  }
217
153
 
154
+ export async function compactSessionInPlace({
155
+ session,
156
+ messages,
157
+ keepRecentTurns = DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns,
158
+ minSourceChars = DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars,
159
+ usage,
160
+ thresholdPercent,
161
+ emitSessionEvent,
162
+ persistSession,
163
+ reason = 'manual_compact',
164
+ summaryIntro = 'The previous conversation has been compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
165
+ onBeforePersist,
166
+ }) {
167
+ const source = Array.isArray(messages) ? messages : []
168
+ const normalizedKeepRecentTurns = clampNumber(keepRecentTurns, DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns, 1, 20)
169
+ const normalizedMinSourceChars = clampNumber(minSourceChars, DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars, 0, 200000)
170
+ const tailStart = tailStartForRecentTurns(source, normalizedKeepRecentTurns)
171
+ const sourceMessages = buildCompactionSourceMessages(session, source, tailStart)
172
+ if (sourceMessages.length < 2 || estimateMessagesChars(sourceMessages) < normalizedMinSourceChars) {
173
+ return { compacted: false, usage, reason: 'not_enough_history' }
174
+ }
175
+
176
+ const result = await compactConversation({
177
+ messages: sourceMessages,
178
+ model: session.model,
179
+ thinkingLevel: session.thinkingLevel,
180
+ getApiKey: session.getApiKey,
181
+ keepTurns: 0,
182
+ })
183
+
184
+ if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
185
+
186
+ await saveCompactBackup(session.sessionId, sourceMessages)
187
+ const summaryMessage = userTextMessage([
188
+ summaryIntro,
189
+ '',
190
+ '<compact_summary>',
191
+ result.summary,
192
+ '</compact_summary>',
193
+ ].join('\n'))
194
+ session.contextCompaction = {
195
+ summaryMessage,
196
+ compactedUpToIndex: tailStart,
197
+ compactedAt: new Date().toISOString(),
198
+ keepRecentTurns: normalizedKeepRecentTurns,
199
+ sourceMessageCount: source.length,
200
+ usageBefore: usage,
201
+ thresholdPercent,
202
+ }
203
+ onBeforePersist?.({ result, sourceMessages, tailStart, summaryMessage })
204
+ await persistSession?.(session)
205
+ const contextUsage = estimateSessionContextUsage(session, source)
206
+ emitSessionEvent?.(session, {
207
+ type: 'auto_compact_completed',
208
+ reason,
209
+ usage,
210
+ thresholdPercent,
211
+ contextCompaction: session.contextCompaction,
212
+ contextUsage,
213
+ })
214
+ emitSessionEvent?.(session, {
215
+ type: 'messages_replaced',
216
+ reason,
217
+ messages: source,
218
+ contextCompaction: session.contextCompaction,
219
+ contextUsage,
220
+ })
221
+ return { compacted: true, usage, result, sourceMessages, tailStart }
222
+ }
223
+
218
224
  export function estimateSessionContextUsage(session, messages = session?.agent?.state?.messages ?? []) {
219
225
  if (!session?.agent?.state) return null
220
226
  const sourceMessages = Array.isArray(messages) ? messages : []
221
227
  const contextWindow = Number(session.model?.contextWindow) || 0
222
228
  if (sourceMessages.length === 0) {
223
- return { inputTokens: 0, estimatedInputTokens: 0, knownInputTokens: 0, reservedOutputTokens: 0, totalTokens: 0, contextWindow, percent: 0 }
229
+ return {
230
+ inputTokens: 0,
231
+ estimatedInputTokens: 0,
232
+ knownInputTokens: 0,
233
+ inputTokenSource: 'estimated',
234
+ reservedOutputTokens: 0,
235
+ totalTokens: 0,
236
+ contextWindow,
237
+ percent: 0,
238
+ isCompacted: false,
239
+ originalMessageCount: 0,
240
+ effectiveMessageCount: 0,
241
+ breakdown: {
242
+ systemPromptTokens: 0,
243
+ messagesTokens: 0,
244
+ toolsTokens: 0,
245
+ reservedOutputTokens: 0,
246
+ },
247
+ }
224
248
  }
225
249
 
226
- // Cache by input identity. estimateContextUsage() scans every message with a
227
- // tokenizer regex (O(n)) and JSON-stringifies the full tools array, but its
250
+ // Cache by input identity. Context usage delegates message token estimation
251
+ // to pi-agent-core and JSON-stringifies the full tools array, but its
228
252
  // inputs (messages, model, systemPrompt, tools, contextCompaction) are stable
229
253
  // within a run and only change on discrete events (message_end, tool result,
230
254
  // compaction). Reference equality makes the cache check essentially free, so
@@ -255,14 +279,18 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
255
279
  }
256
280
 
257
281
  const loopMessages = buildAutoCompactLoopMessages(session, sourceMessages)
258
- const knownInputTokens = latestKnownInputTokens(sourceMessages, latestCompactTimestampMs(session))
259
282
  const value = estimateContextUsage({
260
283
  systemPrompt: session.agent.state.systemPrompt,
261
284
  messages: loopMessages,
262
285
  tools: session.agent.state.tools,
263
286
  model: session.model,
264
- knownInputTokens,
265
287
  })
288
+ value.isCompacted = loopMessages !== sourceMessages
289
+ value.originalMessageCount = sourceMessages.length
290
+ value.effectiveMessageCount = loopMessages.length
291
+ if (session.contextCompaction?.summaryMessage) {
292
+ value.compactedUpToIndex = Math.min(sourceMessages.length, Math.max(0, Number(session.contextCompaction.compactedUpToIndex) || 0))
293
+ }
266
294
 
267
295
  session._contextUsageCache = { key: cacheKey, value }
268
296
  return value
@@ -275,16 +303,14 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
275
303
  if (signal?.aborted) return { compacted: false, reason: 'aborted' }
276
304
 
277
305
  const loopMessages = buildAutoCompactLoopMessages(session, messages)
278
- const knownInputTokens = latestKnownInputTokens(messages, latestCompactTimestampMs(session))
279
306
  const usage = estimateContextUsage({
280
307
  systemPrompt: session.agent.state.systemPrompt,
281
308
  messages: loopMessages,
282
309
  tools: session.agent.state.tools,
283
310
  model: session.model,
284
- knownInputTokens,
285
311
  })
286
312
  if (!usage.contextWindow) return { compacted: false, usage, reason: 'missing_context_window' }
287
- if (usage.percent < settings.thresholdPercent) return { compacted: false, usage, reason: 'below_threshold' }
313
+ if (!shouldCompactContextByPercent(usage, settings.thresholdPercent)) return { compacted: false, usage, reason: 'below_threshold' }
288
314
  if (shouldSuppressAfterRejection(session, messages, usage)) return { compacted: false, usage, reason: 'user_rejected_recently' }
289
315
 
290
316
  const now = Date.now()
@@ -318,50 +344,23 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
318
344
 
319
345
  session.autoCompacting = true
320
346
  try {
321
- const result = await compactConversation({
322
- messages: sourceMessages,
323
- model: session.model,
324
- thinkingLevel: session.thinkingLevel,
325
- getApiKey: session.getApiKey,
326
- keepTurns: 0,
327
- })
328
-
329
- if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
330
-
331
- await saveCompactBackup(session.sessionId, sourceMessages)
332
- const summaryMessage = userTextMessage([
333
- 'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
334
- '',
335
- '<compact_summary>',
336
- result.summary,
337
- '</compact_summary>',
338
- ].join('\n'))
339
- session.contextCompaction = {
340
- summaryMessage,
341
- compactedUpToIndex: tailStart,
342
- compactedAt: new Date().toISOString(),
347
+ const result = await compactSessionInPlace({
348
+ session,
349
+ messages,
343
350
  keepRecentTurns: settings.keepRecentTurns,
344
- sourceMessageCount: messages.length,
345
- usageBefore: usage,
346
- thresholdPercent: settings.thresholdPercent,
347
- }
348
- clearAutoCompactRejected(session)
349
- session.lastAutoCompactAt = now
350
- await persistSession(session)
351
- emitSessionEvent(session, {
352
- type: 'auto_compact_completed',
351
+ minSourceChars: settings.minSourceChars,
353
352
  usage,
354
353
  thresholdPercent: settings.thresholdPercent,
355
- contextCompaction: session.contextCompaction,
356
- contextUsage: estimateSessionContextUsage(session, messages),
357
- })
358
- emitSessionEvent(session, {
359
- type: 'messages_replaced',
354
+ emitSessionEvent,
355
+ persistSession,
360
356
  reason: 'auto_compact',
361
- messages,
362
- contextCompaction: session.contextCompaction,
363
- contextUsage: estimateSessionContextUsage(session, messages),
357
+ summaryIntro: 'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
358
+ onBeforePersist: () => {
359
+ clearAutoCompactRejected(session)
360
+ session.lastAutoCompactAt = now
361
+ },
364
362
  })
363
+ if (!result.compacted) return result
365
364
  return { compacted: true, usage }
366
365
  } catch (error) {
367
366
  logger?.warn?.(`Auto compact failed for session ${session.sessionId}:`, error?.message || error, { sessionId: session.sessionId })