@shawnstack/quickforge 1.7.9 → 1.7.11
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/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/index-BgMvQW1b.js +66 -0
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +125 -43
- package/server/auto-archive.mjs +55 -8
- package/server/auto-compaction.mjs +0 -2
- package/server/context-usage.mjs +9 -7
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
- package/server/maintenance/export-session-state-v1.mjs +5 -13
- package/server/routes/backup.mjs +8 -1
- package/server/routes/storage.mjs +85 -48
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-persistence-lock.mjs +17 -6
- package/server/session-state-backup.mjs +60 -7
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +133 -194
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +359 -289
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +240 -448
- package/server/utils/logger.mjs +15 -4
- package/server/utils/process-tree.mjs +14 -2
- package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
- package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
- package/dist/assets/index-D-J_8Smf.css +0 -3
- package/dist/assets/index-DFASc15l.js +0 -66
- package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
- package/server/session-state-cutover.mjs +0 -370
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{i as e,n as t}from"./rolldown-runtime-DWdDZTNf.js";import{Bt as n,Gt as r,J as i,Jt as a,b as o,n as s}from"./icons-DM_qJ7VJ.js";import{n as c}from"./react-vendor-BpWW_Nvp.js";import{An as l,Dn as u,Pn as d}from"./index-
|
|
1
|
+
import{i as e,n as t}from"./rolldown-runtime-DWdDZTNf.js";import{Bt as n,Gt as r,J as i,Jt as a,b as o,n as s}from"./icons-DM_qJ7VJ.js";import{n as c}from"./react-vendor-BpWW_Nvp.js";import{An as l,Dn as u,Pn as d}from"./index-BgMvQW1b.js";var f=t({SkillsDialog:()=>_,SkillsManagerPanel:()=>g}),p=e(a(),1),m=c();async function h(e){let t=await e.json().catch(()=>null);if(!e.ok)throw Error(t?.error||`HTTP ${e.status}`);return t}function g({active:e=!0,scope:t,project:a,embedded:c=!1,onClose:f,onSaved:g}){let[_,v]=(0,p.useState)([]),[y,b]=(0,p.useState)(()=>new Set),[x,S]=(0,p.useState)(``),[C,w]=(0,p.useState)([]),[T,E]=(0,p.useState)(!1),[D,O]=(0,p.useState)(!1),[k,A]=(0,p.useState)(``),j=t===`project`,[M,N]=(0,p.useState)(null),[P,F]=(0,p.useState)(null),[I,L]=(0,p.useState)(!1),[R,z]=(0,p.useState)(``),B=(0,p.useCallback)(()=>{N(null),F(null),z(``)},[]),V=(0,p.useCallback)(()=>{B(),f?.()},[f,B]);(0,p.useEffect)(()=>{if(!e||j&&!a)return;let t=!1;return(async()=>{E(!0),A(``);try{let e=j?`/api/skills?projectId=${encodeURIComponent(a.id)}`:`/api/skills?scope=global`,n=await h(await fetch(e));if(t)return;v(Array.isArray(n.skills)?n.skills:[]),b(new Set(Array.isArray(n.selectedSkills)?n.selectedSkills:[])),w(Array.isArray(n.searchPaths)?n.searchPaths:[])}catch(e){t||A(e instanceof Error?e.message:d(`failedToLoadSkills`))}finally{t||E(!1)}})(),()=>{t=!0}},[e,a,j]),(0,p.useEffect)(()=>{if(!e||c)return;let t=e=>{e.key===`Escape`&&(M?B():D||V())};return document.addEventListener(`keydown`,t),()=>document.removeEventListener(`keydown`,t)},[e,V,c,D,M,B]);let H=(0,p.useMemo)(()=>{let e=x.trim().toLowerCase();return e?_.filter(t=>[t.name,t.displayName,t.description,t.source,t.compatibility,t.allowedTools,...t.tags??[],...t.triggers??[]].filter(Boolean).join(` `).toLowerCase().includes(e)):_},[x,_]),U=async e=>{N(e),F(null),z(``),L(!0);try{let t=new URLSearchParams({name:e});j&&a?(t.set(`scope`,`project`),t.set(`projectId`,a.id)):t.set(`scope`,`global`),F(await h(await fetch(`/api/skills/content?${t}`)))}catch(e){z(e instanceof Error?e.message:d(`failedToReadSkill`))}finally{L(!1)}};if(!e||j&&!a)return null;let W=e=>{if(D)return;let t=new Set(y);t.has(e)?t.delete(e):t.add(e),b(t),G(t)},G=async e=>{O(!0),A(``);try{let n=await h(await fetch(j?`/api/skills/project`:`/api/skills/global`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(j?{projectId:a.id,selectedSkills:[...e]}:{selectedSkills:[...e]})}));g({scope:t,project:j?n.projects?.find(e=>e.id===a.id)??{...a,skills:n.selectedSkills}:void 0,projects:n.projects,selectedSkills:n.selectedSkills})}catch(e){b(y),A(e instanceof Error?e.message:d(`failedToSaveSkills`))}finally{O(!1)}},K=d(j?`projectSkills`:`globalSkills`),q=j?d(`projectSkillsDescription`,{project:a.name}):d(`globalSkillsDescription`),J=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`div`,{className:`quickforge-settings-divider p-3`,children:(0,m.jsxs)(`div`,{className:`quickforge-settings-inline-field`,children:[(0,m.jsx)(o,{className:`pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/60`}),(0,m.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),placeholder:d(`searchSkills`),className:`quickforge-settings-input`,disabled:T||D})]})}),k?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:k}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsx)(`span`,{className:`quickforge-settings-row-title`,children:d(`availableSkills`)}),(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-info`,children:d(`selectedSkillsCount`,{count:y.size})})]}),(0,m.jsx)(`div`,{className:c?``:`max-h-[46vh] overflow-y-auto`,children:T?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):H.length===0?(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noMatchingSkills`)}):H.map(e=>{let t=y.has(e.name);return(0,m.jsxs)(`div`,{className:l(`quickforge-settings-list-item`,t&&`bg-muted/12`),children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-main`,children:[(0,m.jsx)(`div`,{className:`quickforge-settings-row-title`,children:e.displayName||e.name}),e.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:e.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta`,children:[e.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.source}):null,e.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.compatibility}):null,e.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.allowedTools}):null,e.tags?.slice(0,5).map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]})]}),(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-actions`,onClick:e=>e.stopPropagation(),children:[(0,m.jsxs)(`label`,{className:`quickforge-settings-switch`,"aria-disabled":D?`true`:`false`,children:[(0,m.jsx)(`input`,{type:`checkbox`,checked:t,disabled:D,onChange:()=>W(e.name),"aria-label":t?`Disable ${e.name}`:`Enable ${e.name}`}),(0,m.jsx)(`span`,{"aria-hidden":`true`})]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:()=>void U(e.name),disabled:D||I,title:d(`readSkill`),"aria-label":`${d(`readSkill`)}: ${e.displayName||e.name}`,children:(0,m.jsx)(n,{className:`size-4`})})]})]},e.name)})})]}),Y=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`button`,{type:`button`,className:`quickforge-settings-button quickforge-settings-button-secondary`,onClick:B,disabled:I,children:[(0,m.jsx)(r,{className:`mr-2 size-4`}),d(`backToSkillList`)]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-main min-w-0`,children:(0,m.jsx)(`div`,{className:`quickforge-settings-row-title truncate`,children:P?.displayName||P?.name||M})})]}),(0,m.jsx)(`div`,{className:`p-4`,children:I?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):R?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:R}):P?(0,m.jsxs)(m.Fragment,{children:[P.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description mb-3`,children:P.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta mb-3`,children:[P.version?(0,m.jsxs)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:[`v`,P.version]}):null,P.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.source}):null,P.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.compatibility}):null,P.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.allowedTools}):null,P.license?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.license}):null,P.tags?.map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]}),P.triggers?.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description mb-3`,children:[`Triggers: `,P.triggers.join(`, `)]}):null,P.instructions?(0,m.jsx)(`pre`,{className:`whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 px-4 py-3 font-mono text-sm leading-6 text-foreground/80`,children:P.instructions}):(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noSkillContent`)})]}):null})]});return c?(0,m.jsx)(`div`,{className:`quickforge-settings-stack`,children:(0,m.jsx)(`section`,{className:`quickforge-settings-section`,"aria-label":K,children:M?Y:(0,m.jsxs)(m.Fragment,{children:[C.length?(0,m.jsx)(`div`,{className:`px-5 pt-4`,children:(0,m.jsxs)(`span`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
|
|
2
2
|
`)})]})}):null,J]})})}):(0,m.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4`,onClick:e=>{e.target===e.currentTarget&&!D&&V()},children:(0,m.jsxs)(`div`,{className:`flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-background shadow-quickforge`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-row-main`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-row-title`,children:[K,(0,m.jsx)(u,{label:q})]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:q}),C.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
|
|
3
3
|
`)})]}):null]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:V,disabled:D,"aria-label":d(`close`),children:(0,m.jsx)(s,{className:`size-4`})})]}),(0,m.jsx)(`div`,{className:`min-h-0 flex-1 overflow-y-auto`,children:M?Y:J})]})})}function _({open:e,scope:t,project:n,onOpenChange:r,onSaved:i}){return(0,m.jsx)(g,{active:e,scope:t,project:n,onClose:()=>r(!1),onSaved:i})}export{f as n,_ as t};
|
package/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="QuickForge" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<title>速构 QuickForge</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-BgMvQW1b.js"></script>
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DWdDZTNf.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/pi-ai-Cr2ee4a0.js">
|
|
17
17
|
<link rel="modulepreload" crossorigin href="/assets/dist-yDHbi5ux.js">
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
<link rel="modulepreload" crossorigin href="/assets/react-vendor-BpWW_Nvp.js">
|
|
23
23
|
<link rel="modulepreload" crossorigin href="/assets/logger-8REuLPGK.js">
|
|
24
24
|
<link rel="modulepreload" crossorigin href="/assets/model-display-label-CURwH0xM.js">
|
|
25
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
26
|
-
<link rel="modulepreload" crossorigin href="/assets/skills-dialog-
|
|
27
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
25
|
+
<link rel="modulepreload" crossorigin href="/assets/index-BgMvQW1b.js">
|
|
26
|
+
<link rel="modulepreload" crossorigin href="/assets/skills-dialog-DYp35JM3.js">
|
|
27
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CB1g2D0f.css">
|
|
28
28
|
</head>
|
|
29
29
|
<body>
|
|
30
30
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/acp/server.mjs
CHANGED
|
@@ -922,16 +922,13 @@ export async function runQuickForgeAcpStdio() {
|
|
|
922
922
|
console.log = (...args) => console.error(...args)
|
|
923
923
|
let sqliteStorage
|
|
924
924
|
try {
|
|
925
|
-
const [
|
|
925
|
+
const [storageModule, sqliteModule, sessionIndexModule] = await Promise.all([
|
|
926
926
|
import('../storage.mjs'),
|
|
927
927
|
import('../sqlite/database.mjs'),
|
|
928
928
|
import('../session-index-service.mjs'),
|
|
929
929
|
])
|
|
930
|
-
|
|
931
|
-
await
|
|
932
|
-
await sqliteModule.initializeSqliteStorage()
|
|
933
|
-
sessionIndexModule.configureSessionIndex({ readBuckets: readPhysicalSessionMetadataBuckets })
|
|
934
|
-
registerSessionMetadataCommitHook(sessionIndexModule.syncSessionMetadataCommit)
|
|
930
|
+
await storageModule.ensureStorage()
|
|
931
|
+
sqliteStorage = await sqliteModule.initializeSqliteStorage()
|
|
935
932
|
await sessionIndexModule.initializeSessionIndex()
|
|
936
933
|
const quickForgeAgent = await createQuickForgeAcpAgent()
|
|
937
934
|
const stream = ndJsonStream(
|
package/server/agent-manager.mjs
CHANGED
|
@@ -28,8 +28,8 @@ import {
|
|
|
28
28
|
validateModelReference,
|
|
29
29
|
} from './agent-profile-schema.mjs'
|
|
30
30
|
import { projectContextFromId, defaultGlobalWorkspaceContext, readProjectConfig } from './project-config.mjs'
|
|
31
|
-
import { ensureStorage, readStore, atomicUpdate,
|
|
32
|
-
import {
|
|
31
|
+
import { ensureStorage, readStore, atomicUpdate, readSessionValue, tempAgentsDir } from './storage.mjs'
|
|
32
|
+
import { readSessionStateRecord, saveSessionStatePair, deleteSessionState, storedMessagesState, sessionMessagesTailDigest } from './session-state-service.mjs'
|
|
33
33
|
import { logger } from './utils/logger.mjs'
|
|
34
34
|
import { publishChannelSessionChanged } from './channels/event-relay.mjs'
|
|
35
35
|
import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
|
|
@@ -1967,6 +1967,10 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1967
1967
|
persistedMessageCount: Number.isInteger(config.persistedMessageCount) && config.persistedMessageCount >= 0 ? config.persistedMessageCount : null,
|
|
1968
1968
|
persistedTailDigest: typeof config.persistedTailDigest === 'string' ? config.persistedTailDigest : null,
|
|
1969
1969
|
persistConflictCount: 0,
|
|
1970
|
+
// Set when an authoritative persist is skipped after CAS conflicts; cleared
|
|
1971
|
+
// on the next successful persist. Surfaced via getSessionState/getSessionStatus
|
|
1972
|
+
// so the UI can warn the user instead of silently losing messages.
|
|
1973
|
+
persistDegraded: null,
|
|
1970
1974
|
lastAutoCompactAt: null,
|
|
1971
1975
|
lastAutoCompactRejected: null,
|
|
1972
1976
|
memoryEnabled: initialMemoryEnabled,
|
|
@@ -2153,6 +2157,26 @@ function canonicalStateJson(value) {
|
|
|
2153
2157
|
return JSON.stringify(value)
|
|
2154
2158
|
}
|
|
2155
2159
|
|
|
2160
|
+
/**
|
|
2161
|
+
* Mark the session as persist-degraded (an authoritative persist was skipped
|
|
2162
|
+
* after CAS conflicts) and notify connected clients so the UI can surface a
|
|
2163
|
+
* non-blocking warning instead of silently losing messages.
|
|
2164
|
+
*/
|
|
2165
|
+
function markPersistDegraded(session, attempts) {
|
|
2166
|
+
session.persistDegraded = { at: new Date().toISOString(), attempts }
|
|
2167
|
+
emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: true })
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Clear the degraded marker after a successful persist and notify clients so
|
|
2172
|
+
* the UI warning disappears.
|
|
2173
|
+
*/
|
|
2174
|
+
function clearPersistDegraded(session) {
|
|
2175
|
+
if (!session.persistDegraded) return
|
|
2176
|
+
session.persistDegraded = null
|
|
2177
|
+
emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: false })
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2156
2180
|
/**
|
|
2157
2181
|
* Persist a full session record in one authoritative SQLite transaction with
|
|
2158
2182
|
* revision CAS. When the CAS fails because a concurrent metadata-only change
|
|
@@ -2201,6 +2225,7 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
|
|
|
2201
2225
|
session.persistedMessageCount = Array.isArray(sessionData.messages) ? sessionData.messages.length : 0
|
|
2202
2226
|
}
|
|
2203
2227
|
session.persistedTailDigest = sessionMessagesTailDigest(sessionData.messages)
|
|
2228
|
+
clearPersistDegraded(session)
|
|
2204
2229
|
return { ...saved, metadata: mergedMetadata }
|
|
2205
2230
|
} catch (error) {
|
|
2206
2231
|
if (error?.errorCode !== 'SESSION_STATE_CONFLICT') throw error
|
|
@@ -2222,11 +2247,13 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
|
|
|
2222
2247
|
}
|
|
2223
2248
|
session.persistConflictCount += 1
|
|
2224
2249
|
logger.warn(`Session ${sessionId} persist conflict: storage changed agent-owned fields; skipping overwrite`, { sessionId })
|
|
2250
|
+
markPersistDegraded(session, attempt)
|
|
2225
2251
|
return null
|
|
2226
2252
|
}
|
|
2227
2253
|
}
|
|
2228
2254
|
session.persistConflictCount += 1
|
|
2229
2255
|
logger.warn(`Session ${sessionId} persist still conflicting after ${maxAttempts} merge retries`, { sessionId, reason: lastConflict?.message })
|
|
2256
|
+
markPersistDegraded(session, maxAttempts)
|
|
2230
2257
|
return null
|
|
2231
2258
|
}
|
|
2232
2259
|
|
|
@@ -2239,14 +2266,7 @@ async function persistSessionUnlocked(session) {
|
|
|
2239
2266
|
|
|
2240
2267
|
if (messages.length === 0) {
|
|
2241
2268
|
try {
|
|
2242
|
-
|
|
2243
|
-
else {
|
|
2244
|
-
await deleteSessionValue(sessionId)
|
|
2245
|
-
await atomicSessionMetadataUpdate(scope, projectId, (data) => {
|
|
2246
|
-
delete data[sessionId]
|
|
2247
|
-
return data
|
|
2248
|
-
})
|
|
2249
|
-
}
|
|
2269
|
+
deleteSessionState(sessionId, { expectedRevision: session.persistedStorageRevision })
|
|
2250
2270
|
session.persistedStorageRevision = null
|
|
2251
2271
|
session.persistedStateVersion = null
|
|
2252
2272
|
session.persistedStateJson = null
|
|
@@ -2353,34 +2373,14 @@ async function persistSessionUnlocked(session) {
|
|
|
2353
2373
|
idleRetention: session.idleRetention || undefined,
|
|
2354
2374
|
}
|
|
2355
2375
|
|
|
2356
|
-
// Write body + metadata as one authoritative SQLite transaction
|
|
2376
|
+
// Write body + metadata as one authoritative SQLite transaction.
|
|
2357
2377
|
let persistedMetadata
|
|
2358
2378
|
try {
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
persistedMetadata = saved.metadata
|
|
2365
|
-
} else {
|
|
2366
|
-
await writeSessionValue(sessionId, sessionData)
|
|
2367
|
-
await atomicSessionMetadataUpdate(scope, projectId, (data) => {
|
|
2368
|
-
const existingMetadata = data[sessionId]
|
|
2369
|
-
const archivedAt = existingMetadata?.archivedAt
|
|
2370
|
-
// Merge onto the existing record so storage-only fields the in-memory
|
|
2371
|
-
// session does not own (pinnedAt, archivedAt, …) survive the rebuild.
|
|
2372
|
-
persistedMetadata = {
|
|
2373
|
-
...existingMetadata,
|
|
2374
|
-
...metadata,
|
|
2375
|
-
...(archivedAt ? { archivedAt } : {}),
|
|
2376
|
-
}
|
|
2377
|
-
data[sessionId] = persistedMetadata
|
|
2378
|
-
return data
|
|
2379
|
-
})
|
|
2380
|
-
if (persistedMetadata?.archivedAt) {
|
|
2381
|
-
await writeSessionValue(sessionId, { ...sessionData, archivedAt: persistedMetadata.archivedAt })
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2379
|
+
const saved = await persistAuthoritativeSessionState(session, sessionData, metadata)
|
|
2380
|
+
if (!saved) return null
|
|
2381
|
+
session.persistedStorageRevision = saved.revision
|
|
2382
|
+
session.persistedStateVersion = saved.stateVersion
|
|
2383
|
+
persistedMetadata = saved.metadata
|
|
2384
2384
|
} catch (err) {
|
|
2385
2385
|
logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
|
|
2386
2386
|
return null
|
|
@@ -2403,8 +2403,27 @@ async function persistSessionUnlocked(session) {
|
|
|
2403
2403
|
return persistedMetadata || metadata
|
|
2404
2404
|
}
|
|
2405
2405
|
|
|
2406
|
+
// Persist slower than this (queue wait + synchronous SQLite write) is worth a
|
|
2407
|
+
// warning: large sessions write big synchronous transactions that stall the
|
|
2408
|
+
// event loop and make every in-flight request (including /restore) wait.
|
|
2409
|
+
const SLOW_PERSIST_LOG_MS = 1_000
|
|
2410
|
+
|
|
2406
2411
|
async function persistSession(session) {
|
|
2407
|
-
|
|
2412
|
+
const startedAt = performance.now()
|
|
2413
|
+
// Serialize per session (per-row revision CAS guarantees correctness);
|
|
2414
|
+
// cross-session persists run independently instead of piling onto one
|
|
2415
|
+
// global chain that also gates destroyAgent.
|
|
2416
|
+
const lockKey = `session:${session.sessionId}`
|
|
2417
|
+
const result = await withSessionPersistenceLock(() => persistSessionUnlocked(session), lockKey)
|
|
2418
|
+
const durationMs = performance.now() - startedAt
|
|
2419
|
+
if (durationMs >= SLOW_PERSIST_LOG_MS) {
|
|
2420
|
+
logger.warn(`Session ${session.sessionId} persist took ${Math.round(durationMs)}ms (queue wait + write)`, {
|
|
2421
|
+
sessionId: session.sessionId,
|
|
2422
|
+
durationMs: Math.round(durationMs),
|
|
2423
|
+
messageCount: session.agent?.state?.messages?.length ?? 0,
|
|
2424
|
+
})
|
|
2425
|
+
}
|
|
2426
|
+
return result
|
|
2408
2427
|
}
|
|
2409
2428
|
|
|
2410
2429
|
export async function persistSessionState(session) {
|
|
@@ -2956,6 +2975,7 @@ export function getSessionState(sessionId) {
|
|
|
2956
2975
|
acpSession: session.harness === AGENT_HARNESS_OPENCODE ? session.agent.state.acpSession : undefined,
|
|
2957
2976
|
isStreaming: session.abortPending ? false : session.agent.state.isStreaming,
|
|
2958
2977
|
errorMessage: session.agent.state.errorMessage,
|
|
2978
|
+
persistDegraded: session.persistDegraded ? true : undefined,
|
|
2959
2979
|
}
|
|
2960
2980
|
}
|
|
2961
2981
|
|
|
@@ -2988,6 +3008,7 @@ export function getSessionStatus(sessionId) {
|
|
|
2988
3008
|
errorMessage: session.agent.state.errorMessage,
|
|
2989
3009
|
messageCount: messages.length,
|
|
2990
3010
|
lastMessageTimestamp: lastMessage?.timestamp ?? null,
|
|
3011
|
+
persistDegraded: session.persistDegraded ? true : undefined,
|
|
2991
3012
|
}
|
|
2992
3013
|
}
|
|
2993
3014
|
|
|
@@ -3019,6 +3040,21 @@ export function releaseSse(sessionId) {
|
|
|
3019
3040
|
if (session) session.sseConnected = false
|
|
3020
3041
|
}
|
|
3021
3042
|
|
|
3043
|
+
/**
|
|
3044
|
+
* Count sessions with a live SSE connection — the background-migration idle
|
|
3045
|
+
* signal's "no active stream" input
|
|
3046
|
+
* (docs/architecture/session-storage-background-migration-design.zh-CN.md
|
|
3047
|
+
* §3.3). Derived from the per-session sseConnected flags the SSE route
|
|
3048
|
+
* already maintains, so no extra bookkeeping is required.
|
|
3049
|
+
*/
|
|
3050
|
+
export function countActiveSseStreams() {
|
|
3051
|
+
let count = 0
|
|
3052
|
+
for (const session of agentSessions.values()) {
|
|
3053
|
+
if (session.sseConnected) count += 1
|
|
3054
|
+
}
|
|
3055
|
+
return count
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3022
3058
|
/**
|
|
3023
3059
|
* Get the event bus for a session (for SSE connections).
|
|
3024
3060
|
*/
|
|
@@ -3069,14 +3105,33 @@ export async function destroyAgent(sessionId) {
|
|
|
3069
3105
|
agentSessions.delete(sessionId)
|
|
3070
3106
|
}
|
|
3071
3107
|
|
|
3108
|
+
// In-flight restores keyed by sessionId. Concurrent route handlers
|
|
3109
|
+
// (POST /restore, GET /state, GET /messages, GET /status, SSE) all fall back
|
|
3110
|
+
// to restoreAgent; without dedupe each raced through createAgent and the last
|
|
3111
|
+
// agentSessions.set overwrote the others, leaking the overwritten sessions
|
|
3112
|
+
// (listeners, idle/persist timers, OpenCode child processes) forever.
|
|
3113
|
+
const pendingRestores = new Map()
|
|
3114
|
+
|
|
3072
3115
|
/**
|
|
3073
3116
|
* Try to restore an agent session from persisted storage.
|
|
3117
|
+
* Concurrent calls for the same session share one in-flight restore.
|
|
3074
3118
|
* Returns the restored session, or null if not found.
|
|
3075
3119
|
*/
|
|
3076
|
-
export
|
|
3120
|
+
export function restoreAgent(sessionId) {
|
|
3077
3121
|
const existing = agentSessions.get(sessionId)
|
|
3078
3122
|
if (existing) return existing
|
|
3079
3123
|
|
|
3124
|
+
const inFlight = pendingRestores.get(sessionId)
|
|
3125
|
+
if (inFlight) return inFlight
|
|
3126
|
+
|
|
3127
|
+
const restorePromise = restoreAgentUnlocked(sessionId).finally(() => {
|
|
3128
|
+
pendingRestores.delete(sessionId)
|
|
3129
|
+
})
|
|
3130
|
+
pendingRestores.set(sessionId, restorePromise)
|
|
3131
|
+
return restorePromise
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
async function restoreAgentUnlocked(sessionId) {
|
|
3080
3135
|
try {
|
|
3081
3136
|
const sessionData = await readSessionValue(sessionId)
|
|
3082
3137
|
if (!sessionData) {
|
|
@@ -3089,8 +3144,8 @@ export async function restoreAgent(sessionId) {
|
|
|
3089
3144
|
// Read the authoritative storage record once: the body (split marker),
|
|
3090
3145
|
// revision for CAS, and the stored message rows (count + tail digest) used
|
|
3091
3146
|
// for split-representation conflict detection after restore.
|
|
3092
|
-
const record =
|
|
3093
|
-
const storedMessages =
|
|
3147
|
+
const record = readSessionStateRecord(sessionId)
|
|
3148
|
+
const storedMessages = record?.state?.messageStorage === 'split'
|
|
3094
3149
|
? storedMessagesState(sessionId)
|
|
3095
3150
|
: null
|
|
3096
3151
|
|
|
@@ -3117,9 +3172,9 @@ export async function restoreAgent(sessionId) {
|
|
|
3117
3172
|
openCodeUsage: sessionData.openCodeUsage || null,
|
|
3118
3173
|
idleRetention: sessionData.idleRetention || null,
|
|
3119
3174
|
stateVersion: sessionData.stateVersion,
|
|
3120
|
-
persistedStateVersion:
|
|
3121
|
-
persistedStorageRevision:
|
|
3122
|
-
persistedStateJson:
|
|
3175
|
+
persistedStateVersion: sessionData.stateVersion,
|
|
3176
|
+
persistedStorageRevision: record?.revision ?? null,
|
|
3177
|
+
persistedStateJson: canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})),
|
|
3123
3178
|
persistedMessageStorage: record?.state?.messageStorage === 'split' ? 'split' : null,
|
|
3124
3179
|
persistedMessageCount: storedMessages?.count ?? (Array.isArray(sessionData.messages) ? sessionData.messages.length : 0),
|
|
3125
3180
|
persistedTailDigest: storedMessages?.tailDigest || sessionMessagesTailDigest(sessionData.messages),
|
|
@@ -3223,6 +3278,33 @@ export async function refreshAllSessionTools() {
|
|
|
3223
3278
|
return result
|
|
3224
3279
|
}
|
|
3225
3280
|
|
|
3281
|
+
/**
|
|
3282
|
+
* Refresh the model binding of every active QuickForge session after model
|
|
3283
|
+
* configuration changes (custom providers, maxTokens, ...). OpenCode owns its
|
|
3284
|
+
* model selection and streaming sessions are skipped (runPrompt re-resolves
|
|
3285
|
+
* the binding on the next message). Only sessions whose model actually
|
|
3286
|
+
* changed get a state event.
|
|
3287
|
+
*/
|
|
3288
|
+
export async function refreshAllSessionModels() {
|
|
3289
|
+
for (const [sessionId, session] of agentSessions) {
|
|
3290
|
+
if (session.harness !== AGENT_HARNESS_QUICKFORGE) continue
|
|
3291
|
+
if (session.agent?.state?.isStreaming) continue
|
|
3292
|
+
try {
|
|
3293
|
+
const before = JSON.stringify(session.model ?? null)
|
|
3294
|
+
await refreshSessionModelBinding(session)
|
|
3295
|
+
const after = JSON.stringify(session.model ?? null)
|
|
3296
|
+
if (before === after) continue
|
|
3297
|
+
const state = getSessionState(sessionId)
|
|
3298
|
+
emitSessionEvent(session, { type: 'state', ...state })
|
|
3299
|
+
} catch (error) {
|
|
3300
|
+
// The model may have been deleted (model_not_configured); runPrompt will
|
|
3301
|
+
// surface the same error on the next message. Log and keep other
|
|
3302
|
+
// sessions refreshing.
|
|
3303
|
+
logger.error(`Failed to refresh model for session ${sessionId}:`, error, { sessionId })
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3226
3308
|
export async function updateSessionTitle(sessionId, title) {
|
|
3227
3309
|
let session = agentSessions.get(sessionId)
|
|
3228
3310
|
if (!session) session = await restoreAgent(sessionId)
|
package/server/auto-archive.mjs
CHANGED
|
@@ -7,10 +7,20 @@ const defaultStorage = { atomicSessionValueUpdate, atomicUpdate, atomicSessionRe
|
|
|
7
7
|
export const AUTO_ARCHIVE_SETTINGS_KEY = 'auto-archive-settings'
|
|
8
8
|
export const AUTO_ARCHIVE_AFTER_MS = 30 * 24 * 60 * 60 * 1000
|
|
9
9
|
const AUTO_ARCHIVE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
export const AUTO_ARCHIVE_INITIAL_DELAY_MS = 30_000
|
|
10
11
|
|
|
11
12
|
let autoArchiveTimer = null
|
|
13
|
+
let autoArchiveInitialTimer = null
|
|
12
14
|
let autoArchiveRunning = false
|
|
13
15
|
|
|
16
|
+
// Yield one macrotask turn between heavy archive commits: each archive is a
|
|
17
|
+
// synchronous SQLite transaction (or JSON read-modify-write) that blocks the
|
|
18
|
+
// thread while it runs, and without yields a large batch of back-to-back
|
|
19
|
+
// archives starves every pending HTTP request for the whole batch.
|
|
20
|
+
function yieldToEventLoop() {
|
|
21
|
+
return new Promise((resolve) => setImmediate(resolve))
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export function normalizeAutoArchiveSettings(value) {
|
|
15
25
|
if (!value || typeof value !== 'object') return { enabled: false }
|
|
16
26
|
return { enabled: value.enabled === true }
|
|
@@ -65,8 +75,25 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
|
|
|
65
75
|
const metadataStore = await storage.readStore('sessions-metadata')
|
|
66
76
|
const candidates = []
|
|
67
77
|
|
|
78
|
+
// Metadata-first scan: metadata alone decides activeness whenever it carries
|
|
79
|
+
// any timestamp, so the common case never loads the session body (loading and
|
|
80
|
+
// JSON.parsing every 2.9GB-library body is what stalled the event loop for
|
|
81
|
+
// ~minutes on startup). The body is only read when metadata has no usable
|
|
82
|
+
// timestamp at all. The scan is therefore deliberately conservative — a body
|
|
83
|
+
// newer than its (stale) metadata is treated as a candidate — which is safe:
|
|
84
|
+
// a wrongly suspected candidate is rejected by the full-state re-check inside
|
|
85
|
+
// the archive transactions below, and a missed candidate is simply picked up
|
|
86
|
+
// by the next scan. persistSession refreshes metadata timestamps on every
|
|
87
|
+
// save, so active sessions always stay fresh in metadata.
|
|
68
88
|
for (const [sessionId, metadata] of Object.entries(metadataStore)) {
|
|
69
89
|
if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running') continue
|
|
90
|
+
const metadataActivityTime = sessionActivityTime(metadata)
|
|
91
|
+
if (metadataActivityTime !== null) {
|
|
92
|
+
if (metadataActivityTime >= cutoff) continue
|
|
93
|
+
candidates.push(sessionId)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
// Metadata carries no timestamp at all: fall back to the body.
|
|
70
97
|
const session = await storage.readSessionValue(sessionId)
|
|
71
98
|
if (!session || session.archivedAt) continue
|
|
72
99
|
const activityTime = sessionActivityTime(metadata, session)
|
|
@@ -75,10 +102,13 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
|
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
const archivedSessionIds = []
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const sessionId of candidates) {
|
|
105
|
+
// Authoritative: archive body + metadata in one SQLite transaction (CAS
|
|
106
|
+
// retried on concurrent metadata-only changes), so no half-archived state.
|
|
107
|
+
if (typeof storage.atomicSessionRecordUpdate === 'function') {
|
|
108
|
+
for (const [index, sessionId] of candidates.entries()) {
|
|
109
|
+
// Let pending requests run between archive transactions (not before the
|
|
110
|
+
// first candidate — nothing to yield from yet).
|
|
111
|
+
if (index > 0) await yieldToEventLoop()
|
|
82
112
|
const updated = await storage.atomicSessionRecordUpdate(sessionId, ({ state, metadata }) => {
|
|
83
113
|
const activityTime = sessionActivityTime(metadata, state)
|
|
84
114
|
if (state.archivedAt || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) return null
|
|
@@ -87,7 +117,10 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
|
|
|
87
117
|
if (updated?.state?.archivedAt === archivedAt) archivedSessionIds.push(sessionId)
|
|
88
118
|
}
|
|
89
119
|
} else {
|
|
90
|
-
for (const sessionId of candidates) {
|
|
120
|
+
for (const [index, sessionId] of candidates.entries()) {
|
|
121
|
+
// Let pending requests run between archive transactions (not before the
|
|
122
|
+
// first candidate — nothing to yield from yet).
|
|
123
|
+
if (index > 0) await yieldToEventLoop()
|
|
91
124
|
const updatedSession = await storage.atomicSessionValueUpdate(sessionId, (session) => {
|
|
92
125
|
const activityTime = sessionActivityTime(session)
|
|
93
126
|
if (session.archivedAt || activityTime === null || activityTime >= cutoff) return session
|
|
@@ -134,16 +167,30 @@ export async function archiveInactiveSessions(options = {}) {
|
|
|
134
167
|
}
|
|
135
168
|
}
|
|
136
169
|
|
|
137
|
-
|
|
170
|
+
// `options` exists for tests (inject storage/now like archiveInactiveSessions);
|
|
171
|
+
// production callers pass nothing and get defaultStorage + Date.now().
|
|
172
|
+
export function startAutoArchiveRunner(options = {}) {
|
|
138
173
|
if (autoArchiveTimer) return
|
|
139
174
|
autoArchiveTimer = setInterval(() => {
|
|
140
|
-
archiveInactiveSessions().catch((error) => logger.error('Automatic conversation archive failed:', error))
|
|
175
|
+
archiveInactiveSessions(options).catch((error) => logger.error('Automatic conversation archive failed:', error))
|
|
141
176
|
}, AUTO_ARCHIVE_CHECK_INTERVAL_MS)
|
|
142
177
|
autoArchiveTimer.unref?.()
|
|
143
|
-
|
|
178
|
+
// Delay the first pass off the startup path: right after listen() the server
|
|
179
|
+
// is absorbing the browser's initial request burst, and the scan + archive
|
|
180
|
+
// transactions on a large library can block the event loop for a long
|
|
181
|
+
// stretch. The timer is unref'd so it never keeps the process alive.
|
|
182
|
+
autoArchiveInitialTimer = setTimeout(() => {
|
|
183
|
+
autoArchiveInitialTimer = null
|
|
184
|
+
archiveInactiveSessions(options).catch((error) => logger.error('Initial automatic conversation archive failed:', error))
|
|
185
|
+
}, AUTO_ARCHIVE_INITIAL_DELAY_MS)
|
|
186
|
+
autoArchiveInitialTimer.unref?.()
|
|
144
187
|
}
|
|
145
188
|
|
|
146
189
|
export function stopAutoArchiveRunner() {
|
|
190
|
+
if (autoArchiveInitialTimer) {
|
|
191
|
+
clearTimeout(autoArchiveInitialTimer)
|
|
192
|
+
autoArchiveInitialTimer = null
|
|
193
|
+
}
|
|
147
194
|
if (!autoArchiveTimer) return
|
|
148
195
|
clearInterval(autoArchiveTimer)
|
|
149
196
|
autoArchiveTimer = null
|
|
@@ -295,7 +295,6 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
|
|
|
295
295
|
estimatedInputTokens: 0,
|
|
296
296
|
knownInputTokens: 0,
|
|
297
297
|
inputTokenSource: 'estimated',
|
|
298
|
-
reservedOutputTokens: 0,
|
|
299
298
|
totalTokens: 0,
|
|
300
299
|
contextWindow,
|
|
301
300
|
percent: 0,
|
|
@@ -306,7 +305,6 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
|
|
|
306
305
|
systemPromptTokens: 0,
|
|
307
306
|
messagesTokens: 0,
|
|
308
307
|
toolsTokens: 0,
|
|
309
|
-
reservedOutputTokens: 0,
|
|
310
308
|
},
|
|
311
309
|
}
|
|
312
310
|
}
|
package/server/context-usage.mjs
CHANGED
|
@@ -50,7 +50,6 @@ function localMessagesTokens(messages) {
|
|
|
50
50
|
|
|
51
51
|
export function estimateContextUsage({ systemPrompt, messages, tools, model, minimumProviderUsageIndex = 0 }) {
|
|
52
52
|
const contextWindow = Number(model?.contextWindow) || 0
|
|
53
|
-
const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
|
|
54
53
|
const normalizedMessages = normalizeMessagesForTokenEstimate(messages)
|
|
55
54
|
const coreEstimate = estimateContextTokens(normalizedMessages)
|
|
56
55
|
const systemPromptTokens = textTokens(systemPrompt)
|
|
@@ -68,8 +67,11 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
|
|
|
68
67
|
const inputTokens = providerBasedContextTokens > 0
|
|
69
68
|
? Math.max(estimatedInputTokens, providerBasedContextTokens)
|
|
70
69
|
: estimatedInputTokens
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
// 上下文占用按纯输入口径统计:percent = inputTokens / contextWindow。
|
|
71
|
+
// 真实请求的 max_tokens 由 pi-ai `clampMaxTokensToContext` 按窗口收缩,
|
|
72
|
+
// 统计侧不再预留输出 token;totalTokens 字段仅为兼容保留,恒等于 inputTokens。
|
|
73
|
+
const totalTokens = inputTokens
|
|
74
|
+
const percent = contextWindow > 0 ? Math.round((inputTokens / contextWindow) * 1000) / 10 : 0
|
|
73
75
|
const inputTokenSource = providerBasedContextTokens > 0
|
|
74
76
|
? providerBasedContextTokens >= estimatedInputTokens ? 'provider' : 'mixed'
|
|
75
77
|
: 'estimated'
|
|
@@ -80,7 +82,6 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
|
|
|
80
82
|
knownInputTokens: providerBasedContextTokens,
|
|
81
83
|
providerContextTokens: providerBasedContextTokens,
|
|
82
84
|
inputTokenSource,
|
|
83
|
-
reservedOutputTokens,
|
|
84
85
|
totalTokens,
|
|
85
86
|
contextWindow,
|
|
86
87
|
percent,
|
|
@@ -88,7 +89,6 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
|
|
|
88
89
|
systemPromptTokens,
|
|
89
90
|
messagesTokens,
|
|
90
91
|
toolsTokens,
|
|
91
|
-
reservedOutputTokens,
|
|
92
92
|
providerUsageTokens,
|
|
93
93
|
trailingTokens: providerUsageTokens > 0
|
|
94
94
|
? Math.max(0, Number(coreEstimate.trailingTokens) || 0)
|
|
@@ -101,13 +101,15 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model, min
|
|
|
101
101
|
|
|
102
102
|
export function shouldCompactContextByPercent(usage, thresholdPercent) {
|
|
103
103
|
const contextWindow = Number(usage?.contextWindow) || 0
|
|
104
|
-
|
|
104
|
+
// 阈值按纯输入占用判断:inputTokens / contextWindow ≥ thresholdPercent 即触发,
|
|
105
|
+
// 不包含预留输出 token(真实请求的 max_tokens 由 pi-ai 按窗口收缩)。
|
|
106
|
+
const inputTokens = Math.max(0, Number(usage?.inputTokens) || 0)
|
|
105
107
|
const threshold = Math.min(100, Math.max(0, Number(thresholdPercent) || 0))
|
|
106
108
|
if (!contextWindow) return false
|
|
107
109
|
|
|
108
110
|
const thresholdTokens = Math.ceil(contextWindow * threshold / 100)
|
|
109
111
|
const reserveTokens = Math.min(contextWindow, Math.max(0, contextWindow - thresholdTokens + 1))
|
|
110
|
-
return shouldCompact(
|
|
112
|
+
return shouldCompact(inputTokens, contextWindow, {
|
|
111
113
|
enabled: true,
|
|
112
114
|
reserveTokens,
|
|
113
115
|
keepRecentTokens: 0,
|