@shawnstack/quickforge 1.6.12 → 1.7.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 (46) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/AgentProfilesPage-DQ8URegq.js +1 -0
  3. package/dist/assets/ChatPanelHost-vi1tDqlJ.js +291 -0
  4. package/dist/assets/{PluginsPage-qXTjDrpd.js → PluginsPage-B8bHMwfv.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-AaUz5el2.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CnFDmGvW.js → SettingsWorkspacePage-BOP9CtoI.js} +112 -105
  7. package/dist/assets/{SharedConversationPage-CkQ-XwYS.js → SharedConversationPage-9A4L9UcQ.js} +1 -1
  8. package/dist/assets/TerminalDock-DQOXpaDe.js +2 -0
  9. package/dist/assets/WorkspaceInspector-DaM7TJHb.js +13 -0
  10. package/dist/assets/icons-ko_i0WpN.js +1 -0
  11. package/dist/assets/index-B2eauKpo.css +3 -0
  12. package/dist/assets/index-B8Awq5FV.js +63 -0
  13. package/dist/assets/{mcp-servers-dialog-CsRQxx7A.js → mcp-servers-dialog-XE4K8PCO.js} +2 -2
  14. package/dist/assets/{monaco-CdOyGlWD.js → monaco-C13M09wD.js} +1 -1
  15. package/dist/assets/{react-vendor-VqdnQHHS.js → react-vendor-2RKYr-A4.js} +1 -1
  16. package/dist/assets/{skills-dialog-CmVLi_h6.js → skills-dialog-MpoxntiV.js} +1 -1
  17. package/dist/index.html +6 -6
  18. package/dist/licenses/material-icon-theme.txt +8 -0
  19. package/package.json +1 -1
  20. package/server/agent-manager.mjs +182 -39
  21. package/server/auto-archive.mjs +137 -0
  22. package/server/auto-compaction.mjs +15 -18
  23. package/server/conversation-compaction.mjs +69 -3
  24. package/server/custom-commands.mjs +17 -0
  25. package/server/image-generation.mjs +130 -0
  26. package/server/index.mjs +12 -1
  27. package/server/provider-config.mjs +78 -0
  28. package/server/routes/agent.mjs +9 -0
  29. package/server/routes/session-assets.mjs +49 -0
  30. package/server/routes/shared-conversation.mjs +10 -0
  31. package/server/routes/storage.mjs +4 -0
  32. package/server/routes/tools.mjs +1 -1
  33. package/server/routes/workspace.mjs +5 -2
  34. package/server/session-assets.mjs +134 -0
  35. package/server/session-persistence-lock.mjs +9 -0
  36. package/server/storage.mjs +24 -1
  37. package/server/tools/definitions.mjs +10 -0
  38. package/server/tools/index.mjs +2 -0
  39. package/dist/assets/AgentProfilesPage-CooGLweI.js +0 -1
  40. package/dist/assets/ChatPanelHost-TEbv0lnL.js +0 -260
  41. package/dist/assets/ScheduledTasksPage-BBor8Yb2.js +0 -2
  42. package/dist/assets/TerminalDock-CvqBBMPg.js +0 -2
  43. package/dist/assets/WorkspaceInspector-6JGc6VGb.js +0 -13
  44. package/dist/assets/icons-C7j5jdKo.js +0 -1
  45. package/dist/assets/index-DxNzlPmc.js +0 -63
  46. package/dist/assets/index-HVxfqXfB.css +0 -3
@@ -28,11 +28,14 @@ import {
28
28
  import { projectContextFromId, defaultGlobalWorkspaceContext, readProjectConfig } from './project-config.mjs'
29
29
  import { ensureStorage, readStore, atomicUpdate, atomicSessionMetadataUpdate, readSessionValue, writeSessionValue, deleteSessionValue, tempAgentsDir } from './storage.mjs'
30
30
  import { logger } from './utils/logger.mjs'
31
+ import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
31
32
  import { getGlobalMemoryRevision, isGlobalMemoryEnabled } from './global-memory.mjs'
32
33
  import { buildSystemPrompt, generateAiTitle, generateTitle } from './session-utils.mjs'
33
34
  import { restoreReasoningContentInPayload } from './reasoning-cache.mjs'
34
35
  import {
35
36
  compactConversation,
37
+ compactionMessageDetails,
38
+ isCompactSummaryMessage,
36
39
  parseCompactArgs,
37
40
  saveCompactBackup,
38
41
  } from './conversation-compaction.mjs'
@@ -93,6 +96,8 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
93
96
  includeMcpTools = true,
94
97
  includePluginTools = true,
95
98
  parentSessionId = null,
99
+ sessionId = null,
100
+ scope = 'global',
96
101
  } = options
97
102
  const allowedTools = allowedToolNames ? new Set(allowedToolNames) : null
98
103
  const isAllowed = (definition) => !allowedTools || allowedTools.has(definition.name)
@@ -107,7 +112,11 @@ async function createServerTools(projectId, projectContext, skillsContext, inclu
107
112
  projectSkillNames: skillsContext.projectSkillNames,
108
113
  workspaceRoot: projectContext?.workspaceRoot,
109
114
  })
110
- const toolContext = { ...projectContext, ...skillToolContext }
115
+ const toolContext = {
116
+ ...projectContext,
117
+ ...skillToolContext,
118
+ ...(sessionId ? { sessionId, scope, projectId } : {}),
119
+ }
111
120
  const tools = skillTools
112
121
  .filter(isAllowed)
113
122
  .map((definition) => wrapToolDefinition(definition, toolContext, toolPermissions))
@@ -150,8 +159,14 @@ async function rebuildSessionTools(session) {
150
159
  includeSubagentTool: false,
151
160
  includeMcpTools: false,
152
161
  parentSessionId: session.sessionId,
162
+ sessionId: session.sessionId,
163
+ scope: session.scope,
153
164
  }
154
- : { parentSessionId: session.sessionId },
165
+ : {
166
+ parentSessionId: session.sessionId,
167
+ sessionId: session.sessionId,
168
+ scope: session.scope,
169
+ },
155
170
  )
156
171
  }
157
172
 
@@ -329,7 +344,7 @@ function createAutoCompactApprovalPromise(session, details = {}) {
329
344
  })
330
345
  }
331
346
 
332
- function assistantTextMessage(text, model) {
347
+ function assistantTextMessage(text, model, details) {
333
348
  return {
334
349
  role: 'assistant',
335
350
  content: [{ type: 'text', text }],
@@ -346,14 +361,16 @@ function assistantTextMessage(text, model) {
346
361
  },
347
362
  stopReason: 'stop',
348
363
  timestamp: Date.now(),
364
+ ...(details ? { details } : {}),
349
365
  }
350
366
  }
351
367
 
352
- function userTextMessage(text) {
368
+ function userTextMessage(text, details) {
353
369
  return {
354
370
  role: 'user',
355
371
  content: [{ type: 'text', text }],
356
372
  timestamp: Date.now(),
373
+ ...(details ? { details } : {}),
357
374
  }
358
375
  }
359
376
 
@@ -523,12 +540,12 @@ async function summarySession(session, initialUserMessage, summaryOptions) {
523
540
  '<compact_summary>',
524
541
  result.summary,
525
542
  '</compact_summary>',
526
- ].join('\n'))
543
+ ].join('\n'), compactionMessageDetails('summary'))
527
544
  const notice = assistantTextMessage([
528
545
  `已基于当前对话创建压缩后的新对话:原 ${result.originalCount} 条消息 → ${result.recentTail.length + 2} 条消息。`,
529
546
  `当前原对话已完整保留,保留最近 ${result.keepTurns} 个用户回合原文,估算新对话上下文减少约 ${reduction}%。`,
530
547
  '压缩前历史已保存到本地备份。',
531
- ].join('\n'), session.model)
548
+ ].join('\n'), session.model, compactionMessageDetails('notice'))
532
549
 
533
550
  const compactedMessages = [summaryMessage, notice, ...result.recentTail]
534
551
  const titleSourceMessages = [summaryMessage, ...result.recentTail]
@@ -700,7 +717,8 @@ async function clearSession(session) {
700
717
  session.startedAt = null
701
718
  session.finishedAt = new Date().toISOString()
702
719
  session.title = 'New chat'
703
- session.titleGenerated = false
720
+ session.titleSource = 'default'
721
+ session.titleGenerationId += 1
704
722
  session.agent.state.isStreaming = false
705
723
  session.agent.state.streamingMessage = undefined
706
724
  session.agent.state.errorMessage = undefined
@@ -776,6 +794,15 @@ async function resolveCommandState(session, userMessage, promptCommand = null) {
776
794
  if (internalResponse?.plan) {
777
795
  return planCommandState(userMessage, internalResponse.args)
778
796
  }
797
+ if (internalResponse?.init) {
798
+ if (!session.projectId) return { textResponse: 'Initialization requires an active project chat.' }
799
+ return {
800
+ userMessage,
801
+ commandPrompt: formatInitCommandPrompt(),
802
+ permissions: { allowEdit: true, allowCommands: true, allowSubagents: true },
803
+ commandName: 'init',
804
+ }
805
+ }
779
806
  if (internalResponse?.review) {
780
807
  return {
781
808
  userMessage,
@@ -817,6 +844,53 @@ async function resolveCommandState(session, userMessage, promptCommand = null) {
817
844
  }
818
845
  }
819
846
 
847
+ function formatInitCommandPrompt() {
848
+ return `<init_command_invocation name="init">
849
+ This /init command applies only to the current user request. Work in the current repository root. Inspect the repository as needed, then create or update the root-level \`AGENTS.md\`. If the file already exists, read it first and preserve useful repository-specific guidance while bringing it in line with the requirements below. Do not modify unrelated files.
850
+
851
+ Generate a file named AGENTS.md that serves as a contributor guide for this repository.
852
+ Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section.
853
+ Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project.
854
+
855
+ Document Requirements
856
+
857
+ - Title the document "Repository Guidelines".
858
+ - Use Markdown headings (#, ##, etc.) for structure.
859
+ - Keep the document concise. 200-400 words is optimal.
860
+ - Keep explanations short, direct, and specific to this repository.
861
+ - Provide examples where helpful (commands, directory paths, naming patterns).
862
+ - Maintain a professional, instructional tone.
863
+
864
+ Recommended Sections
865
+
866
+ Project Structure & Module Organization
867
+
868
+ - Outline the project structure, including where the source code, tests, and assets are located.
869
+
870
+ Build, Test, and Development Commands
871
+
872
+ - List key commands for building, testing, and running locally (e.g., npm test, make build).
873
+ - Briefly explain what each command does.
874
+
875
+ Coding Style & Naming Conventions
876
+
877
+ - Specify indentation rules, language-specific style preferences, and naming patterns.
878
+ - Include any formatting or linting tools used.
879
+
880
+ Testing Guidelines
881
+
882
+ - Identify testing frameworks and coverage requirements.
883
+ - State test naming conventions and how to run tests.
884
+
885
+ Commit & Pull Request Guidelines
886
+
887
+ - Summarize commit message conventions found in the project’s Git history.
888
+ - Outline pull request requirements (descriptions, linked issues, screenshots, etc.).
889
+
890
+ (Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions.
891
+ </init_command_invocation>`
892
+ }
893
+
820
894
  function formatPlanCommandPrompt(task) {
821
895
  const taskText = String(task || '').trim()
822
896
  return `<plan_command_invocation name="plan">
@@ -1247,14 +1321,7 @@ function applyActiveCapabilityPrompt(messages, capabilityPrompt) {
1247
1321
 
1248
1322
  function compactSummaryIndex(messages) {
1249
1323
  for (let index = messages.length - 1; index >= 0; index--) {
1250
- const message = messages[index]
1251
- const content = message?.content
1252
- const text = typeof content === 'string'
1253
- ? content
1254
- : Array.isArray(content)
1255
- ? content.filter((block) => block?.type === 'text').map((block) => block.text ?? '').join('\n')
1256
- : ''
1257
- if (message?.role === 'user' && text.includes('<compact_summary>')) return index
1324
+ if (isCompactSummaryMessage(messages[index])) return index
1258
1325
  }
1259
1326
  return -1
1260
1327
  }
@@ -1372,11 +1439,13 @@ export async function createAgent(sessionId, config = {}) {
1372
1439
  messages = [],
1373
1440
  systemPrompt = null,
1374
1441
  title = 'New chat',
1442
+ titleSource = title === 'New chat' ? 'default' : 'manual',
1375
1443
  createdAt = new Date().toISOString(),
1376
1444
  lastModified = null,
1377
1445
  contextCompaction = null,
1378
1446
  agentProfile = null,
1379
1447
  idleRetention = null,
1448
+ stateVersion = 0,
1380
1449
  } = config
1381
1450
  const accessMode = normalizeAccessMode(rawAccessMode, yoloMode)
1382
1451
  const resolvedYoloMode = yoloModeFromAccessMode(accessMode)
@@ -1450,8 +1519,14 @@ export async function createAgent(sessionId, config = {}) {
1450
1519
  includeSubagentTool: false,
1451
1520
  includeMcpTools: false,
1452
1521
  parentSessionId: sessionId,
1522
+ sessionId,
1523
+ scope,
1453
1524
  }
1454
- : { parentSessionId: sessionId },
1525
+ : {
1526
+ parentSessionId: sessionId,
1527
+ sessionId,
1528
+ scope,
1529
+ },
1455
1530
  )
1456
1531
 
1457
1532
  // Resolve API key
@@ -1531,6 +1606,7 @@ export async function createAgent(sessionId, config = {}) {
1531
1606
  thinkingLevel: resolvedThinkingLevel,
1532
1607
  scope,
1533
1608
  title,
1609
+ titleSource,
1534
1610
  createdAt,
1535
1611
  lastModified,
1536
1612
  globalSkillNames: skillsContext.globalSkillNames,
@@ -1544,7 +1620,10 @@ export async function createAgent(sessionId, config = {}) {
1544
1620
  eventBus,
1545
1621
  idleTimer: null,
1546
1622
  persistTimer: null,
1547
- titleGenerated: false,
1623
+ titleGenerationId: 0,
1624
+ titleGenerationPromise: null,
1625
+ titleGenerationPromiseId: null,
1626
+ sessionCreatedEmitted: messages.length > 0,
1548
1627
  toolTimings: new Map(),
1549
1628
  getApiKey,
1550
1629
  contextCompaction,
@@ -1552,7 +1631,7 @@ export async function createAgent(sessionId, config = {}) {
1552
1631
  idleRetention,
1553
1632
  lastTransformedContextMessages: null,
1554
1633
  autoCompacting: false,
1555
- stateVersion: 0,
1634
+ stateVersion: Number.isFinite(stateVersion) ? Math.max(0, stateVersion) : 0,
1556
1635
  lastAutoCompactAt: null,
1557
1636
  lastAutoCompactRejected: null,
1558
1637
  memoryEnabled: initialMemoryEnabled,
@@ -1623,11 +1702,17 @@ export async function createAgent(sessionId, config = {}) {
1623
1702
  const isInitialUserMessage = (event.message?.role === 'user' || event.message?.role === 'user-with-attachments')
1624
1703
  && session.agent.state.messages.length === 1
1625
1704
  if (isInitialUserMessage) {
1626
- // External ACP channels run in a separate process from the web UI. Persist
1627
- // the first user message immediately so the session becomes visible on
1628
- // disk before a long/failed agent run can exit or be restarted.
1705
+ // Persist the first user message before announcing the session or
1706
+ // starting the asynchronous AI title request.
1629
1707
  try {
1630
- await flushSessionPersist(session)
1708
+ const metadata = await flushSessionPersist(session)
1709
+ if (metadata) {
1710
+ if (!session.sessionCreatedEmitted) {
1711
+ session.sessionCreatedEmitted = true
1712
+ emitSessionEvent(session, { type: 'session_created', metadata })
1713
+ }
1714
+ scheduleSessionTitleGeneration(session, event.message)
1715
+ }
1631
1716
  } catch (err) {
1632
1717
  logger.error(`Failed to persist initial user message for session ${sessionId}:`, err, { sessionId })
1633
1718
  }
@@ -1672,8 +1757,8 @@ function sessionLastModifiedFromMessages(messages, fallback) {
1672
1757
  /**
1673
1758
  * Persist session data to storage.
1674
1759
  */
1675
- async function persistSession(session) {
1676
- const { sessionId, agent, scope, projectId, title, createdAt, lastModified: storedLastModified, status, startedAt, finishedAt, model, thinkingLevel, accessMode, yoloMode, contextCompaction } = session
1760
+ async function persistSessionUnlocked(session) {
1761
+ const { sessionId, agent, scope, projectId, title, titleSource, createdAt, lastModified: storedLastModified, status, startedAt, finishedAt, model, thinkingLevel, accessMode, yoloMode, contextCompaction } = session
1677
1762
  const messages = agent.state.messages
1678
1763
 
1679
1764
  if (messages.length === 0) {
@@ -1694,6 +1779,7 @@ async function persistSession(session) {
1694
1779
  const sessionData = {
1695
1780
  id: sessionId,
1696
1781
  title,
1782
+ titleSource,
1697
1783
  model,
1698
1784
  thinkingLevel,
1699
1785
  accessMode,
@@ -1708,6 +1794,7 @@ async function persistSession(session) {
1708
1794
  taskFinishedAt: finishedAt,
1709
1795
  contextCompaction: contextCompaction || undefined,
1710
1796
  idleRetention: session.idleRetention || undefined,
1797
+ stateVersion: Number.isFinite(session.stateVersion) ? session.stateVersion : 0,
1711
1798
  }
1712
1799
  session.lastModified = lastModified
1713
1800
 
@@ -1744,6 +1831,7 @@ async function persistSession(session) {
1744
1831
  const metadata = {
1745
1832
  id: sessionId,
1746
1833
  title,
1834
+ titleSource,
1747
1835
  createdAt: createdAt || now,
1748
1836
  lastModified,
1749
1837
  messageCount: messages.length,
@@ -1788,7 +1876,14 @@ async function persistSession(session) {
1788
1876
  }
1789
1877
  } catch (err) {
1790
1878
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
1879
+ return null
1791
1880
  }
1881
+
1882
+ return metadata
1883
+ }
1884
+
1885
+ async function persistSession(session) {
1886
+ return withSessionPersistenceLock(() => persistSessionUnlocked(session))
1792
1887
  }
1793
1888
 
1794
1889
  export async function persistSessionState(session) {
@@ -1830,7 +1925,7 @@ async function flushSessionPersist(session) {
1830
1925
  clearTimeout(session.persistTimer)
1831
1926
  session.persistTimer = null
1832
1927
  }
1833
- await persistSession(session)
1928
+ return persistSession(session)
1834
1929
  }
1835
1930
 
1836
1931
  export function rollbackStartIndexFromMessage(messages, messageIndex) {
@@ -1888,6 +1983,39 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
1888
1983
  return { session: getSessionState(sessionId), rollbackIndex }
1889
1984
  }
1890
1985
 
1986
+ export function canApplyGeneratedTitle(session, generationId, expectedTitle) {
1987
+ return session.titleGenerationId === generationId
1988
+ && session.titleSource === 'fallback'
1989
+ && session.title === expectedTitle
1990
+ }
1991
+
1992
+ function scheduleSessionTitleGeneration(session, userMessage) {
1993
+ if (session.titleSource !== 'fallback') return
1994
+ if (session.titleGenerationPromise && session.titleGenerationPromiseId === session.titleGenerationId) return
1995
+ const generationId = ++session.titleGenerationId
1996
+ const expectedTitle = session.title
1997
+ const promise = generateAiTitle([userMessage], session.model, session.thinkingLevel, session.getApiKey)
1998
+ .then(async (aiTitle) => {
1999
+ if (!aiTitle || aiTitle === 'New chat') return
2000
+ if (!canApplyGeneratedTitle(session, generationId, expectedTitle)) return
2001
+ session.title = aiTitle
2002
+ session.titleSource = 'ai'
2003
+ await persistSession(session)
2004
+ emitSessionEvent(session, { type: 'title_updated', title: aiTitle, titleSource: 'ai' })
2005
+ })
2006
+ .catch((err) => {
2007
+ logger.warn(`Title generation failed for session ${session.sessionId}:`, err.message || err, { sessionId: session.sessionId })
2008
+ })
2009
+ .finally(() => {
2010
+ if (session.titleGenerationPromise === promise) {
2011
+ session.titleGenerationPromise = null
2012
+ session.titleGenerationPromiseId = null
2013
+ }
2014
+ })
2015
+ session.titleGenerationPromise = promise
2016
+ session.titleGenerationPromiseId = generationId
2017
+ }
2018
+
1891
2019
  /**
1892
2020
  * Send a user message to the agent and start the agent loop.
1893
2021
  * Returns immediately; events are streamed via the event bus.
@@ -1940,24 +2068,14 @@ export async function runPrompt(sessionId, message, selectedCapabilities = [], p
1940
2068
  return compactSession(session, initialUserMessage, commandState.compact)
1941
2069
  }
1942
2070
 
1943
- // AI title generation on first user message (fire-and-forget, before agent runs)
1944
- if (!session.titleGenerated && session.title === 'New chat') {
1945
- // Set a simple fallback title immediately so the sidebar shows something
1946
- // meaningful even if AI title generation fails or is slow.
2071
+ // Set a meaningful fallback immediately. The AI title request starts only
2072
+ // after the first user message has been persisted by the message_end handler.
2073
+ if (session.titleSource === 'default' && session.title === 'New chat') {
1947
2074
  const simpleTitle = generateTitle([userMessage])
2075
+ session.titleSource = 'fallback'
1948
2076
  if (simpleTitle !== 'New chat') {
1949
2077
  session.title = simpleTitle
1950
2078
  }
1951
- session.titleGenerated = true
1952
- generateAiTitle([userMessage], session.model, session.thinkingLevel, session.getApiKey).then(async (aiTitle) => {
1953
- if (aiTitle && aiTitle !== 'New chat') {
1954
- session.title = aiTitle
1955
- await persistSession(session)
1956
- emitSessionEvent(session, { type: 'title_updated', title: aiTitle })
1957
- }
1958
- }).catch((err) => {
1959
- logger.warn(`Title generation failed for session ${sessionId}:`, err.message || err, { sessionId })
1960
- })
1961
2079
  }
1962
2080
 
1963
2081
  session.activeCommandName = commandState.commandName ?? null
@@ -2142,6 +2260,7 @@ export function getSessionState(sessionId) {
2142
2260
  model: session.model,
2143
2261
  thinkingLevel: session.thinkingLevel,
2144
2262
  title: session.title,
2263
+ titleSource: session.titleSource,
2145
2264
  createdAt: session.createdAt,
2146
2265
  lastModified: session.lastModified,
2147
2266
  stateVersion: session.stateVersion || 0,
@@ -2289,10 +2408,12 @@ export async function restoreAgent(sessionId) {
2289
2408
  thinkingLevel: sessionData.thinkingLevel || 'off',
2290
2409
  messages: sessionData.messages || [],
2291
2410
  title: sessionData.title || 'New chat',
2411
+ titleSource: sessionData.titleSource || (sessionData.title && sessionData.title !== 'New chat' ? 'ai' : 'default'),
2292
2412
  createdAt: sessionData.createdAt,
2293
2413
  lastModified: sessionData.lastModified,
2294
2414
  contextCompaction: sessionData.contextCompaction || null,
2295
2415
  idleRetention: sessionData.idleRetention || null,
2416
+ stateVersion: sessionData.stateVersion,
2296
2417
  })
2297
2418
  } catch (err) {
2298
2419
  logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
@@ -2387,6 +2508,28 @@ export async function refreshAllSessionTools() {
2387
2508
  return result
2388
2509
  }
2389
2510
 
2511
+ export async function updateSessionTitle(sessionId, title) {
2512
+ let session = agentSessions.get(sessionId)
2513
+ if (!session) session = await restoreAgent(sessionId)
2514
+ if (!session) {
2515
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
2516
+ }
2517
+ const normalizedTitle = typeof title === 'string' ? title.trim() : ''
2518
+ if (!normalizedTitle) {
2519
+ throw Object.assign(new Error('Title must not be empty'), { statusCode: 400 })
2520
+ }
2521
+ if (normalizedTitle.length > 200) {
2522
+ throw Object.assign(new Error('Title is too long'), { statusCode: 400 })
2523
+ }
2524
+
2525
+ session.titleGenerationId += 1
2526
+ session.title = normalizedTitle
2527
+ session.titleSource = 'manual'
2528
+ await persistSession(session)
2529
+ emitSessionEvent(session, { type: 'title_updated', title: normalizedTitle, titleSource: 'manual' })
2530
+ return { sessionId, title: normalizedTitle, titleSource: 'manual' }
2531
+ }
2532
+
2390
2533
  export async function updateSessionAccessMode(sessionId, accessMode) {
2391
2534
  const session = agentSessions.get(sessionId)
2392
2535
  if (!session) {
@@ -0,0 +1,137 @@
1
+ import { atomicSessionValueUpdate, atomicUpdate, readSessionValue, readStore } from './storage.mjs'
2
+ import { logger } from './utils/logger.mjs'
3
+ import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
4
+
5
+ const defaultStorage = { atomicSessionValueUpdate, atomicUpdate, readSessionValue, readStore }
6
+
7
+ export const AUTO_ARCHIVE_SETTINGS_KEY = 'auto-archive-settings'
8
+ export const AUTO_ARCHIVE_AFTER_MS = 30 * 24 * 60 * 60 * 1000
9
+ const AUTO_ARCHIVE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
10
+
11
+ let autoArchiveTimer = null
12
+ let autoArchiveRunning = false
13
+
14
+ export function normalizeAutoArchiveSettings(value) {
15
+ if (!value || typeof value !== 'object') return { enabled: false }
16
+ return { enabled: value.enabled === true }
17
+ }
18
+
19
+ function parseTimestamp(value) {
20
+ if (typeof value === 'number' && Number.isFinite(value)) return value
21
+ if (typeof value !== 'string') return null
22
+ const normalized = value.trim()
23
+ if (!normalized) return null
24
+ const numeric = Number(normalized)
25
+ if (Number.isFinite(numeric)) return numeric
26
+ const timestamp = Date.parse(normalized)
27
+ return Number.isNaN(timestamp) ? null : timestamp
28
+ }
29
+
30
+ function latestMessageTimestamp(messages) {
31
+ if (!Array.isArray(messages)) return null
32
+ let latest = null
33
+ for (const message of messages) {
34
+ const timestamp = parseTimestamp(message?.timestamp)
35
+ if (timestamp !== null && (latest === null || timestamp > latest)) latest = timestamp
36
+ }
37
+ return latest
38
+ }
39
+
40
+ export function sessionActivityTime(...values) {
41
+ let latest = null
42
+ for (const value of values) {
43
+ if (!value || typeof value !== 'object') continue
44
+ for (const candidate of [
45
+ parseTimestamp(value.lastModified),
46
+ latestMessageTimestamp(value.messages),
47
+ parseTimestamp(value.createdAt),
48
+ ]) {
49
+ if (candidate !== null && (latest === null || candidate > latest)) latest = candidate
50
+ }
51
+ }
52
+ return latest
53
+ }
54
+
55
+ async function archiveInactiveSessionsUnlocked(options = {}) {
56
+ const storage = options.storage || defaultStorage
57
+ const settings = await storage.readStore('settings')
58
+ if (!normalizeAutoArchiveSettings(settings[AUTO_ARCHIVE_SETTINGS_KEY]).enabled) {
59
+ return { archivedCount: 0, disabled: true }
60
+ }
61
+
62
+ const now = Number.isFinite(options.now) ? options.now : Date.now()
63
+ const cutoff = now - AUTO_ARCHIVE_AFTER_MS
64
+ const archivedAt = new Date(now).toISOString()
65
+ const metadataStore = await storage.readStore('sessions-metadata')
66
+ const candidates = []
67
+
68
+ for (const [sessionId, metadata] of Object.entries(metadataStore)) {
69
+ if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running') continue
70
+ const session = await storage.readSessionValue(sessionId)
71
+ if (!session || session.archivedAt) continue
72
+ const activityTime = sessionActivityTime(metadata, session)
73
+ if (activityTime === null || activityTime >= cutoff) continue
74
+ candidates.push(sessionId)
75
+ }
76
+
77
+ const archivedSessionIds = []
78
+ for (const sessionId of candidates) {
79
+ const updatedSession = await storage.atomicSessionValueUpdate(sessionId, (session) => {
80
+ const activityTime = sessionActivityTime(session)
81
+ if (session.archivedAt || activityTime === null || activityTime >= cutoff) return session
82
+ return { ...session, archivedAt }
83
+ })
84
+ if (!updatedSession || updatedSession.archivedAt !== archivedAt) continue
85
+
86
+ let metadataArchived = false
87
+ await storage.atomicUpdate('sessions-metadata', (current) => {
88
+ const metadata = current[sessionId]
89
+ const activityTime = sessionActivityTime(metadata, updatedSession)
90
+ if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) {
91
+ return current
92
+ }
93
+ current[sessionId] = { ...metadata, archivedAt }
94
+ metadataArchived = true
95
+ return current
96
+ })
97
+
98
+ if (metadataArchived) {
99
+ archivedSessionIds.push(sessionId)
100
+ } else {
101
+ await storage.atomicSessionValueUpdate(sessionId, (session) => {
102
+ if (session.archivedAt !== archivedAt) return session
103
+ const next = { ...session }
104
+ delete next.archivedAt
105
+ return next
106
+ })
107
+ }
108
+ }
109
+
110
+ if (archivedSessionIds.length > 0) logger.info(`Automatically archived ${archivedSessionIds.length} inactive conversation(s).`)
111
+ return { archivedCount: archivedSessionIds.length, archivedSessionIds, archivedAt }
112
+ }
113
+
114
+ export async function archiveInactiveSessions(options = {}) {
115
+ if (autoArchiveRunning) return { archivedCount: 0, skipped: true }
116
+ autoArchiveRunning = true
117
+ try {
118
+ return await withSessionPersistenceLock(() => archiveInactiveSessionsUnlocked(options))
119
+ } finally {
120
+ autoArchiveRunning = false
121
+ }
122
+ }
123
+
124
+ export function startAutoArchiveRunner() {
125
+ if (autoArchiveTimer) return
126
+ autoArchiveTimer = setInterval(() => {
127
+ archiveInactiveSessions().catch((error) => logger.error('Automatic conversation archive failed:', error))
128
+ }, AUTO_ARCHIVE_CHECK_INTERVAL_MS)
129
+ autoArchiveTimer.unref?.()
130
+ archiveInactiveSessions().catch((error) => logger.error('Initial automatic conversation archive failed:', error))
131
+ }
132
+
133
+ export function stopAutoArchiveRunner() {
134
+ if (!autoArchiveTimer) return
135
+ clearInterval(autoArchiveTimer)
136
+ autoArchiveTimer = null
137
+ }
@@ -1,5 +1,11 @@
1
1
  import { readStore } from './storage.mjs'
2
- import { compactConversation, saveCompactBackup } from './conversation-compaction.mjs'
2
+ import {
3
+ compactConversation,
4
+ compactionMessageDetails,
5
+ extractCompactSummaryText,
6
+ isCompactSummaryMessage,
7
+ saveCompactBackup,
8
+ } from './conversation-compaction.mjs'
3
9
  import { estimateContextUsage, shouldCompactContextByPercent } from './context-usage.mjs'
4
10
 
5
11
  export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
@@ -7,7 +13,7 @@ export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
7
13
  export const DEFAULT_AUTO_COMPACT_SETTINGS = {
8
14
  enabled: true,
9
15
  thresholdPercent: 80,
10
- keepRecentTurns: 2,
16
+ keepRecentTurns: 3,
11
17
  minSourceChars: 1600,
12
18
  requireConfirmation: true,
13
19
  }
@@ -66,7 +72,8 @@ function estimateMessagesChars(messages) {
66
72
  }
67
73
 
68
74
  function isUserMessage(message) {
69
- return message?.role === 'user' || message?.role === 'user-with-attachments'
75
+ return (message?.role === 'user' || message?.role === 'user-with-attachments')
76
+ && !isCompactSummaryMessage(message)
70
77
  }
71
78
 
72
79
  function tailStartForRecentTurns(messages, keepRecentTurns) {
@@ -108,22 +115,12 @@ function clearAutoCompactRejected(session) {
108
115
  session.lastAutoCompactRejected = null
109
116
  }
110
117
 
111
- function compactSummaryText(message) {
112
- const content = message?.content
113
- const text = typeof content === 'string'
114
- ? content
115
- : Array.isArray(content)
116
- ? content.filter((block) => block?.type === 'text').map((block) => block.text || '').join('\n')
117
- : ''
118
- const match = text.match(/<compact_summary>\s*([\s\S]*?)\s*<\/compact_summary>/)
119
- return match?.[1]?.trim() || text.trim()
120
- }
121
-
122
- function userTextMessage(text) {
118
+ function userTextMessage(text, details) {
123
119
  return {
124
120
  role: 'user',
125
121
  content: [{ type: 'text', text }],
126
122
  timestamp: Date.now(),
123
+ ...(details ? { details } : {}),
127
124
  }
128
125
  }
129
126
 
@@ -135,9 +132,9 @@ function buildCompactionSourceMessages(session, messages, tailStart) {
135
132
  'Existing rolling compact summary from earlier conversation history:',
136
133
  '',
137
134
  '<compact_summary>',
138
- compactSummaryText(previousSummary),
135
+ extractCompactSummaryText(previousSummary),
139
136
  '</compact_summary>',
140
- ].join('\n')))
137
+ ].join('\n'), compactionMessageDetails('summary')))
141
138
  }
142
139
  source.push(...messages.slice(session.contextCompaction?.compactedUpToIndex || 0, tailStart))
143
140
  return source
@@ -190,7 +187,7 @@ export async function compactSessionInPlace({
190
187
  '<compact_summary>',
191
188
  result.summary,
192
189
  '</compact_summary>',
193
- ].join('\n'))
190
+ ].join('\n'), compactionMessageDetails('summary'))
194
191
  session.contextCompaction = {
195
192
  summaryMessage,
196
193
  compactedUpToIndex: tailStart,