@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.
- package/README.md +12 -12
- package/bin/quickforge.mjs +9 -0
- package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
- package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
- package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
- package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
- package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
- package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
- package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
- package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
- package/dist/assets/icons-47L5YLKz.js +1 -0
- package/dist/assets/index-CqfScETb.js +1200 -0
- package/dist/assets/index-DzkBgHZf.css +3 -0
- package/dist/assets/{monaco-DG4TcBMc.js → monaco-CGq6uVF1.js} +1 -1
- package/dist/assets/{react-vendor-CiCXOLb5.js → react-vendor-DunfCFfp.js} +1 -1
- package/dist/favicon.svg +16 -1
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +30 -30
- package/package.json +3 -2
- package/server/acp/server.mjs +921 -0
- package/server/agent-manager.mjs +283 -45
- package/server/agent-profile-files.mjs +179 -0
- package/server/agent-profiles.mjs +59 -5
- package/server/approval-store.mjs +13 -1
- package/server/auto-compaction.mjs +111 -112
- package/server/channels/process-channel.mjs +278 -0
- package/server/channels/providers/wechat.mjs +271 -0
- package/server/channels/registry.mjs +58 -0
- package/server/context-usage.mjs +108 -0
- package/server/custom-commands.mjs +157 -28
- package/server/frontmatter.mjs +167 -0
- package/server/index.mjs +52 -3
- package/server/mcp/registry.mjs +40 -0
- package/server/project-config.mjs +43 -6
- package/server/routes/agent-profiles.mjs +6 -2
- package/server/routes/agent.mjs +13 -2
- package/server/routes/channels.mjs +145 -0
- package/server/routes/mcp.mjs +7 -1
- package/server/routes/models.mjs +68 -0
- package/server/routes/project.mjs +34 -4
- package/server/routes/scheduled-tasks.mjs +6 -5
- package/server/routes/shared-conversation.mjs +1 -1
- package/server/routes/storage.mjs +4 -2
- package/server/routes/system.mjs +27 -0
- package/server/routes/tools.mjs +17 -6
- package/server/routes/workspace.mjs +138 -0
- package/server/session-utils.mjs +10 -2
- package/server/storage.mjs +30 -2
- package/server/subagents.mjs +8 -6
- package/server/system-prompt.mjs +3 -2
- package/server/tools/definitions.mjs +19 -1
- package/server/tools/index.mjs +83 -0
- package/server/utils/package-update.mjs +156 -0
- package/dist/assets/AgentProfilesPage-C79teCgh.js +0 -1
- package/dist/assets/ChatPanelHost-BjdIshtX.js +0 -195
- package/dist/assets/PluginsPage-Dt7Iiddo.js +0 -1
- package/dist/assets/ScheduledTasksPage-C047y3p3.js +0 -2
- package/dist/assets/SharedConversationPage-8X8kfztQ.js +0 -1
- package/dist/assets/TerminalDock-CEuJNf0m.js +0 -2
- package/dist/assets/WorkspaceInspector-BIa5gLVs.js +0 -3
- package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +0 -6
- package/dist/assets/icons-Dsc5yL3l.js +0 -1
- package/dist/assets/index-CPAWYhzz.css +0 -3
- 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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
227
|
-
//
|
|
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
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
})
|
|
358
|
-
emitSessionEvent(session, {
|
|
359
|
-
type: 'messages_replaced',
|
|
354
|
+
emitSessionEvent,
|
|
355
|
+
persistSession,
|
|
360
356
|
reason: 'auto_compact',
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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 })
|