byterover-cli 1.0.4 → 1.0.5

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 (72) hide show
  1. package/README.md +13 -2
  2. package/dist/commands/curate.js +1 -1
  3. package/dist/commands/main.d.ts +13 -0
  4. package/dist/commands/main.js +53 -2
  5. package/dist/commands/query.js +1 -1
  6. package/dist/constants.d.ts +1 -1
  7. package/dist/constants.js +1 -1
  8. package/dist/core/domain/cipher/llm/registry.js +53 -2
  9. package/dist/core/domain/cipher/llm/types.d.ts +2 -0
  10. package/dist/core/domain/cipher/process/types.d.ts +7 -0
  11. package/dist/core/domain/cipher/session/session-metadata.d.ts +178 -0
  12. package/dist/core/domain/cipher/session/session-metadata.js +147 -0
  13. package/dist/core/domain/knowledge/markdown-writer.d.ts +15 -18
  14. package/dist/core/domain/knowledge/markdown-writer.js +232 -34
  15. package/dist/core/domain/knowledge/relation-parser.d.ts +25 -39
  16. package/dist/core/domain/knowledge/relation-parser.js +39 -61
  17. package/dist/core/domain/transport/schemas.d.ts +37 -2
  18. package/dist/core/domain/transport/schemas.js +23 -2
  19. package/dist/core/interfaces/cipher/i-session-persistence.d.ts +133 -0
  20. package/dist/core/interfaces/cipher/i-session-persistence.js +7 -0
  21. package/dist/core/interfaces/cipher/message-types.d.ts +6 -0
  22. package/dist/core/interfaces/executor/i-curate-executor.d.ts +2 -2
  23. package/dist/core/interfaces/i-context-file-reader.d.ts +3 -0
  24. package/dist/core/interfaces/usecase/{i-clear-use-case.d.ts → i-reset-use-case.d.ts} +1 -1
  25. package/dist/infra/cipher/agent/agent-schemas.d.ts +6 -6
  26. package/dist/infra/cipher/agent/service-initializer.js +4 -4
  27. package/dist/infra/cipher/file-system/context-tree-file-system-factory.js +3 -2
  28. package/dist/infra/cipher/file-system/file-system-service.js +1 -0
  29. package/dist/infra/cipher/http/internal-llm-http-service.js +3 -5
  30. package/dist/infra/cipher/interactive-loop.js +3 -1
  31. package/dist/infra/cipher/llm/context/context-manager.js +40 -16
  32. package/dist/infra/cipher/llm/formatters/gemini-formatter.d.ts +13 -0
  33. package/dist/infra/cipher/llm/formatters/gemini-formatter.js +98 -6
  34. package/dist/infra/cipher/llm/generators/byterover-content-generator.js +6 -2
  35. package/dist/infra/cipher/llm/thought-parser.d.ts +21 -0
  36. package/dist/infra/cipher/llm/thought-parser.js +27 -0
  37. package/dist/infra/cipher/llm/tool-output-processor.d.ts +10 -0
  38. package/dist/infra/cipher/llm/tool-output-processor.js +80 -7
  39. package/dist/infra/cipher/process/process-service.js +11 -3
  40. package/dist/infra/cipher/session/chat-session.d.ts +7 -2
  41. package/dist/infra/cipher/session/chat-session.js +90 -52
  42. package/dist/infra/cipher/session/session-metadata-store.d.ts +52 -0
  43. package/dist/infra/cipher/session/session-metadata-store.js +406 -0
  44. package/dist/infra/cipher/tools/implementations/curate-tool.js +113 -35
  45. package/dist/infra/cipher/tools/implementations/task-tool.js +1 -0
  46. package/dist/infra/context-tree/file-context-file-reader.js +4 -0
  47. package/dist/infra/core/task-processor.d.ts +2 -2
  48. package/dist/infra/process/process-manager.d.ts +10 -1
  49. package/dist/infra/process/process-manager.js +16 -6
  50. package/dist/infra/process/transport-handlers.js +31 -0
  51. package/dist/infra/repl/commands/index.js +5 -2
  52. package/dist/infra/repl/commands/new-command.d.ts +14 -0
  53. package/dist/infra/repl/commands/new-command.js +61 -0
  54. package/dist/infra/repl/commands/{clear-command.d.ts → reset-command.d.ts} +2 -2
  55. package/dist/infra/repl/commands/{clear-command.js → reset-command.js} +10 -10
  56. package/dist/infra/usecase/generate-rules-use-case.js +2 -2
  57. package/dist/infra/usecase/init-use-case.js +4 -4
  58. package/dist/infra/usecase/logout-use-case.js +1 -1
  59. package/dist/infra/usecase/push-use-case.js +1 -1
  60. package/dist/infra/usecase/{clear-use-case.d.ts → reset-use-case.d.ts} +5 -5
  61. package/dist/infra/usecase/{clear-use-case.js → reset-use-case.js} +5 -5
  62. package/dist/resources/prompts/curate.yml +68 -13
  63. package/dist/resources/tools/curate.txt +60 -15
  64. package/dist/tui/components/inline-prompts/inline-confirm.js +2 -2
  65. package/dist/tui/components/onboarding/onboarding-flow.js +1 -0
  66. package/dist/tui/views/command-view.js +15 -0
  67. package/dist/utils/file-validator.js +9 -7
  68. package/oclif.manifest.json +3 -3
  69. package/package.json +1 -1
  70. package/dist/config/context-tree-domains.d.ts +0 -29
  71. package/dist/config/context-tree-domains.js +0 -29
  72. /package/dist/core/interfaces/usecase/{i-clear-use-case.js → i-reset-use-case.js} +0 -0
@@ -249,6 +249,8 @@ export const TransportLlmEventList = [
249
249
  export const TransportAgentEventNames = {
250
250
  CONNECTED: 'agent:connected',
251
251
  DISCONNECTED: 'agent:disconnected',
252
+ NEW_SESSION: 'agent:newSession',
253
+ NEW_SESSION_CREATED: 'agent:newSessionCreated',
252
254
  REGISTER: 'agent:register',
253
255
  RESTART: 'agent:restart',
254
256
  RESTARTED: 'agent:restarted',
@@ -272,10 +274,10 @@ export const TransportSessionEventNames = {
272
274
  * Internal message, not exposed to external clients
273
275
  */
274
276
  export const TaskExecuteSchema = z.object({
275
- /** Client ID that created the task (for response routing) */
276
- clientId: z.string(),
277
277
  /** Client's working directory for file validation */
278
278
  clientCwd: z.string().optional(),
279
+ /** Client ID that created the task (for response routing) */
280
+ clientId: z.string(),
279
281
  /** Task content/prompt */
280
282
  content: z.string(),
281
283
  /** Optional file paths for curate --files */
@@ -560,3 +562,22 @@ export const AgentRestartResponseSchema = z.object({
560
562
  /** Whether the restart was initiated successfully */
561
563
  success: z.boolean(),
562
564
  });
565
+ /**
566
+ * Request to create a new session (end current, start fresh).
567
+ * Used by /new command to start a fresh conversation.
568
+ */
569
+ export const AgentNewSessionRequestSchema = z.object({
570
+ /** Optional reason for new session (for logging) */
571
+ reason: z.string().optional(),
572
+ });
573
+ /**
574
+ * Response after new session is created.
575
+ */
576
+ export const AgentNewSessionResponseSchema = z.object({
577
+ /** Error message if session creation failed */
578
+ error: z.string().optional(),
579
+ /** The new session ID */
580
+ sessionId: z.string().optional(),
581
+ /** Whether the new session was created successfully */
582
+ success: z.boolean(),
583
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Interface for Session Persistence Operations
3
+ *
4
+ * Defines the contract for session metadata storage and retrieval.
5
+ * Implementation uses JSON files in .brv/sessions/ directory.
6
+ */
7
+ import type { ActiveSessionPointer, SessionInfo, SessionMetadata } from '../../domain/cipher/session/session-metadata.js';
8
+ /**
9
+ * Session retention configuration for auto-cleanup.
10
+ */
11
+ export interface SessionRetentionConfig {
12
+ /** Maximum age in days before auto-cleanup */
13
+ maxAgeDays: number;
14
+ /** Maximum number of sessions to keep */
15
+ maxCount: number;
16
+ /** Whether to run cleanup on startup */
17
+ runOnStartup: boolean;
18
+ }
19
+ /**
20
+ * Result of session cleanup operation.
21
+ */
22
+ export interface SessionCleanupResult {
23
+ /** Number of corrupted session files removed */
24
+ corruptedRemoved: number;
25
+ /** Number of sessions deleted due to age */
26
+ deletedByAge: number;
27
+ /** Number of sessions deleted due to count limit */
28
+ deletedByCount: number;
29
+ /** Total sessions remaining after cleanup */
30
+ remaining: number;
31
+ }
32
+ /**
33
+ * Interface for session metadata persistence.
34
+ *
35
+ * Manages session metadata stored in .brv/sessions/ directory:
36
+ * - active.json: Current active session pointer
37
+ * - session-*.json: Individual session metadata files
38
+ */
39
+ export interface ISessionPersistence {
40
+ /**
41
+ * Clean up expired sessions based on retention policy.
42
+ *
43
+ * @param config - Retention configuration
44
+ * @returns Cleanup result with counts
45
+ */
46
+ cleanupSessions(config: SessionRetentionConfig): Promise<SessionCleanupResult>;
47
+ /**
48
+ * Clear the active session pointer.
49
+ * Removes .brv/sessions/active.json
50
+ */
51
+ clearActiveSession(): Promise<void>;
52
+ /**
53
+ * Delete a session and its metadata.
54
+ *
55
+ * @param sessionId - Session ID to delete
56
+ * @returns True if session was deleted, false if not found
57
+ */
58
+ deleteSession(sessionId: string): Promise<boolean>;
59
+ /**
60
+ * Get the currently active session pointer.
61
+ *
62
+ * @returns Active session pointer or null if no active session
63
+ */
64
+ getActiveSession(): Promise<ActiveSessionPointer | null>;
65
+ /**
66
+ * Get session metadata by ID.
67
+ *
68
+ * @param sessionId - Session ID to look up
69
+ * @returns Session metadata or null if not found
70
+ */
71
+ getSession(sessionId: string): Promise<null | SessionMetadata>;
72
+ /**
73
+ * Check if the active session pointer is stale (process not running).
74
+ *
75
+ * @returns True if active session exists but process is not running
76
+ */
77
+ isActiveSessionStale(): Promise<boolean>;
78
+ /**
79
+ * Validate that a session belongs to the current working directory.
80
+ *
81
+ * @param sessionId - Session ID to validate
82
+ * @returns True if session belongs to current project
83
+ */
84
+ isSessionForCurrentProject(sessionId: string): Promise<boolean>;
85
+ /**
86
+ * List all session metadata files.
87
+ *
88
+ * @returns Array of session info sorted by lastUpdated (newest first)
89
+ */
90
+ listSessions(): Promise<SessionInfo[]>;
91
+ /**
92
+ * Mark a session as ended.
93
+ * Updates the session status to 'ended' and lastUpdated timestamp.
94
+ *
95
+ * @param sessionId - Session ID to mark as ended
96
+ */
97
+ markSessionEnded(sessionId: string): Promise<void>;
98
+ /**
99
+ * Mark a session as interrupted (e.g., process crashed).
100
+ * Updates the session status to 'interrupted'.
101
+ *
102
+ * @param sessionId - Session ID to mark as interrupted
103
+ */
104
+ markSessionInterrupted(sessionId: string): Promise<void>;
105
+ /**
106
+ * Save session metadata to disk.
107
+ * Creates or updates the session metadata file.
108
+ *
109
+ * @param metadata - Session metadata to save
110
+ */
111
+ saveSession(metadata: SessionMetadata): Promise<void>;
112
+ /**
113
+ * Set the active session pointer.
114
+ * Creates or updates .brv/sessions/active.json
115
+ *
116
+ * @param sessionId - Session ID to set as active
117
+ */
118
+ setActiveSession(sessionId: string): Promise<void>;
119
+ /**
120
+ * Set session title from first user message.
121
+ *
122
+ * @param sessionId - Session ID to update
123
+ * @param title - Session title
124
+ */
125
+ setSessionTitle(sessionId: string, title: string): Promise<void>;
126
+ /**
127
+ * Update session activity (lastUpdated timestamp and message count).
128
+ *
129
+ * @param sessionId - Session ID to update
130
+ * @param messageCount - Current message count
131
+ */
132
+ updateSessionActivity(sessionId: string, messageCount: number): Promise<void>;
133
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Interface for Session Persistence Operations
3
+ *
4
+ * Defines the contract for session metadata storage and retrieval.
5
+ * Implementation uses JSON files in .brv/sessions/ directory.
6
+ */
7
+ export {};
@@ -302,6 +302,12 @@ export interface ToolCall {
302
302
  * Unique identifier for this tool call
303
303
  */
304
304
  id: string;
305
+ /**
306
+ * Thought signature for Gemini 3+ models.
307
+ * Required by Gemini 3 preview models to validate function calls.
308
+ * Uses 'skip_thought_signature_validator' as synthetic signature.
309
+ */
310
+ thoughtSignature?: string;
305
311
  /**
306
312
  * The type of tool call (currently only 'function' is supported)
307
313
  */
@@ -4,10 +4,10 @@ import type { ICipherAgent } from '../cipher/i-cipher-agent.js';
4
4
  * Agent uses its default session (Single-Session pattern).
5
5
  */
6
6
  export interface CurateExecuteOptions {
7
- /** Context content to curate */
8
- content: string;
9
7
  /** Client's working directory for file validation (defaults to process.cwd() if not provided) */
10
8
  clientCwd?: string;
9
+ /** Context content to curate */
10
+ content: string;
11
11
  /** Optional file paths for --files flag */
12
12
  files?: string[];
13
13
  /** Task ID for event routing (required for concurrent task isolation) */
@@ -1,11 +1,14 @@
1
+ import type { Narrative, RawConcept } from '../domain/knowledge/markdown-writer.js';
1
2
  /**
2
3
  * Represents the content of a context file with extracted metadata.
3
4
  */
4
5
  export type ContextFileContent = {
5
6
  /** The raw content of the file */
6
7
  content: string;
8
+ narrative?: Narrative;
7
9
  /** Relative path within the context tree (e.g., "structure/context.md") */
8
10
  path: string;
11
+ rawConcept?: RawConcept;
9
12
  /** Title extracted from the first heading, or the relative path if no heading found */
10
13
  title: string;
11
14
  };
@@ -1,4 +1,4 @@
1
- export interface IClearUseCase {
1
+ export interface IResetUseCase {
2
2
  run(options: {
3
3
  directory?: string;
4
4
  skipConfirmation: boolean;
@@ -49,13 +49,13 @@ export declare const FileSystemConfigSchema: z.ZodObject<{
49
49
  maxFileSize: z.ZodOptional<z.ZodNumber>;
50
50
  workingDirectory: z.ZodOptional<z.ZodString>;
51
51
  }, "strict", z.ZodTypeAny, {
52
+ workingDirectory?: string | undefined;
52
53
  allowedExtensions?: string[] | undefined;
53
54
  maxFileSize?: number | undefined;
54
- workingDirectory?: string | undefined;
55
55
  }, {
56
+ workingDirectory?: string | undefined;
56
57
  allowedExtensions?: string[] | undefined;
57
58
  maxFileSize?: number | undefined;
58
- workingDirectory?: string | undefined;
59
59
  }>;
60
60
  export type FileSystemConfig = z.input<typeof FileSystemConfigSchema>;
61
61
  export type ValidatedFileSystemConfig = z.output<typeof FileSystemConfigSchema>;
@@ -105,13 +105,13 @@ export declare const AgentConfigSchema: z.ZodObject<{
105
105
  maxFileSize: z.ZodOptional<z.ZodNumber>;
106
106
  workingDirectory: z.ZodOptional<z.ZodString>;
107
107
  }, "strict", z.ZodTypeAny, {
108
+ workingDirectory?: string | undefined;
108
109
  allowedExtensions?: string[] | undefined;
109
110
  maxFileSize?: number | undefined;
110
- workingDirectory?: string | undefined;
111
111
  }, {
112
+ workingDirectory?: string | undefined;
112
113
  allowedExtensions?: string[] | undefined;
113
114
  maxFileSize?: number | undefined;
114
- workingDirectory?: string | undefined;
115
115
  }>>;
116
116
  httpReferer: z.ZodOptional<z.ZodString>;
117
117
  llm: z.ZodDefault<z.ZodObject<{
@@ -182,9 +182,9 @@ export declare const AgentConfigSchema: z.ZodObject<{
182
182
  storageDir: string;
183
183
  } | undefined;
184
184
  fileSystem?: {
185
+ workingDirectory?: string | undefined;
185
186
  allowedExtensions?: string[] | undefined;
186
187
  maxFileSize?: number | undefined;
187
- workingDirectory?: string | undefined;
188
188
  } | undefined;
189
189
  httpReferer?: string | undefined;
190
190
  openRouterApiKey?: string | undefined;
@@ -208,9 +208,9 @@ export declare const AgentConfigSchema: z.ZodObject<{
208
208
  maxTotalSize?: number | undefined;
209
209
  } | undefined;
210
210
  fileSystem?: {
211
+ workingDirectory?: string | undefined;
211
212
  allowedExtensions?: string[] | undefined;
212
213
  maxFileSize?: number | undefined;
213
- workingDirectory?: string | undefined;
214
214
  } | undefined;
215
215
  httpReferer?: string | undefined;
216
216
  llm?: {
@@ -153,7 +153,7 @@ export async function createCipherAgentServices(config, agentEventBus) {
153
153
  // - Existing sessions → BlobHistoryStorage (no migration)
154
154
  historyStorage = new DualFormatHistoryStorage(blobHistoryStorage, granularStorage);
155
155
  // Create CompactionService for context overflow management
156
- const tokenizer = new GeminiTokenizer(config.model ?? 'gemini-2.5-pro');
156
+ const tokenizer = new GeminiTokenizer(config.model ?? 'gemini-3-flash-preview');
157
157
  compactionService = new CompactionService(messageStorage, tokenizer, {
158
158
  overflowThreshold: 0.85, // 85% triggers compaction check
159
159
  protectedTurns: 2, // Protect first 2 user turns from pruning
@@ -216,7 +216,7 @@ export function createSessionServices(sessionId, sharedServices, httpConfig, llm
216
216
  httpReferer: llmConfig.httpReferer,
217
217
  maxIterations: llmConfig.maxIterations ?? 50,
218
218
  maxTokens: llmConfig.maxTokens ?? 8192,
219
- model: llmConfig.model ?? 'google/gemini-2.5-pro',
219
+ model: llmConfig.model ?? 'google/gemini-3-flash-preview',
220
220
  siteName: llmConfig.siteName,
221
221
  temperature: llmConfig.temperature ?? 0.7,
222
222
  verbose: llmConfig.verbose ?? false,
@@ -244,7 +244,7 @@ export function createSessionServices(sessionId, sharedServices, httpConfig, llm
244
244
  // Step 2: Create base content generator
245
245
  let generator = new ByteRoverContentGenerator(httpService, {
246
246
  maxTokens: llmConfig.maxTokens ?? 8192,
247
- model: llmConfig.model ?? 'gemini-2.5-pro',
247
+ model: llmConfig.model ?? 'gemini-3-flash-preview',
248
248
  temperature: llmConfig.temperature ?? 0.7,
249
249
  });
250
250
  // Step 3: Wrap with retry decorator
@@ -263,7 +263,7 @@ export function createSessionServices(sessionId, sharedServices, httpConfig, llm
263
263
  llmService = new ByteRoverLLMService(sessionId, generator, {
264
264
  maxIterations: llmConfig.maxIterations ?? 50,
265
265
  maxTokens: llmConfig.maxTokens ?? 8192,
266
- model: llmConfig.model ?? 'gemini-2.5-pro',
266
+ model: llmConfig.model ?? 'gemini-3-flash-preview',
267
267
  temperature: llmConfig.temperature ?? 0.7,
268
268
  verbose: llmConfig.verbose ?? false,
269
269
  }, {
@@ -14,8 +14,9 @@ export function createContextTreeFileSystem(baseWorkingDirectory) {
14
14
  allowedPaths: ['.'],
15
15
  // Use default blocked extensions
16
16
  blockedExtensions: ['.exe', '.dll', '.so', '.dylib'],
17
- // Block any attempt to escape via parent directory traversal
18
- blockedPaths: ['..', '../'],
17
+ // No additional blocked paths needed - path traversal outside .brv/context-tree/
18
+ // is prevented by PathValidator.isPathTraversal() and isPathAllowed()
19
+ blockedPaths: [],
19
20
  // Reasonable file size limit
20
21
  maxFileSize: 10 * 1024 * 1024, // 10MB
21
22
  // Restrict working directory to context tree
@@ -390,6 +390,7 @@ export class FileSystemService {
390
390
  * - XML-wrapped output for clearer LLM parsing
391
391
  * - Preview metadata (first 20 lines)
392
392
  */
393
+ // eslint-disable-next-line complexity -- Multiple file type handling paths (image/PDF, binary, text) are inherent to the functionality
393
394
  async readFile(filePath, options = {}) {
394
395
  this.ensureInitialized();
395
396
  // Resolve relative paths against working directory
@@ -60,8 +60,8 @@ export class ByteRoverLlmHttpService {
60
60
  const request = {
61
61
  executionMetadata: JSON.stringify(executionMetadata ?? {}),
62
62
  params: {
63
- config: JSON.stringify(config),
64
- contents: JSON.stringify(contents),
63
+ config,
64
+ contents,
65
65
  model,
66
66
  },
67
67
  project_id: this.config.projectId,
@@ -87,9 +87,7 @@ export class ByteRoverLlmHttpService {
87
87
  const response = await httpClient.post(url, request, {
88
88
  timeout: this.config.timeout,
89
89
  });
90
- // Parse the JSON string response
91
- const content = JSON.parse(response.data);
92
- return content;
90
+ return response.data;
93
91
  }
94
92
  /**
95
93
  * Detect LLM provider from model identifier.
@@ -128,6 +128,7 @@ function setupEventListeners(eventBus, spinnerState) {
128
128
  }
129
129
  process.stdout.write('\n' + chalk.red(payload.error) + '\n\n');
130
130
  };
131
+ // eslint-disable-next-line no-warning-comments -- Tracked for v0.5.0 release
131
132
  // TODO(v0.5.0): Move to outer scope for better performance
132
133
  // eslint-disable-next-line unicorn/consistent-function-scoping
133
134
  const uiListener = (payload) => {
@@ -167,6 +168,7 @@ function setupEventListeners(eventBus, spinnerState) {
167
168
  }
168
169
  }
169
170
  };
171
+ // eslint-disable-next-line no-warning-comments -- Tracked for v0.5.0 release
170
172
  // TODO(v0.5.0): Move to outer scope for better performance
171
173
  // eslint-disable-next-line unicorn/consistent-function-scoping
172
174
  const logListener = (payload) => {
@@ -272,7 +274,7 @@ async function executePrompt(prompt, agent, state, eventBus) {
272
274
  */
273
275
  export async function startInteractiveLoop(agent, options) {
274
276
  // Display welcome message
275
- displayWelcome(options?.sessionId ?? 'cipher-agent-session', options?.model ?? 'gemini-2.5-pro', options?.eventBus);
277
+ displayWelcome(options?.sessionId ?? 'cipher-agent-session', options?.model ?? 'gemini-3-flash-preview', options?.eventBus);
276
278
  // Create readline interface
277
279
  const rl = readline.createInterface({
278
280
  input: process.stdin,
@@ -79,10 +79,14 @@ export class ContextManager {
79
79
  role: 'assistant',
80
80
  toolCalls,
81
81
  };
82
- this.messages.push(message);
83
- // Auto-save to persistent storage (non-blocking)
84
- this.persistHistory().catch((error) => {
85
- this.logger.error('Failed to persist history after assistant message', { error, sessionId: this.sessionId });
82
+ await this.mutex.withLock(async () => {
83
+ this.messages.push(message);
84
+ try {
85
+ await this.persistHistory();
86
+ }
87
+ catch (error) {
88
+ this.logger.error('Failed to persist history after assistant message', { error, sessionId: this.sessionId });
89
+ }
86
90
  });
87
91
  }
88
92
  /**
@@ -95,10 +99,14 @@ export class ContextManager {
95
99
  content,
96
100
  role: 'system',
97
101
  };
98
- this.messages.push(message);
99
- // Auto-save to persistent storage (non-blocking)
100
- this.persistHistory().catch((error) => {
101
- this.logger.error('Failed to persist history after system message', { error, sessionId: this.sessionId });
102
+ await this.mutex.withLock(async () => {
103
+ this.messages.push(message);
104
+ try {
105
+ await this.persistHistory();
106
+ }
107
+ catch (error) {
108
+ this.logger.error('Failed to persist history after system message', { error, sessionId: this.sessionId });
109
+ }
102
110
  });
103
111
  }
104
112
  /**
@@ -167,16 +175,18 @@ export class ContextManager {
167
175
  * @param _fileData - Optional file data (not yet implemented)
168
176
  */
169
177
  async addUserMessage(content, _imageData, _fileData) {
170
- // Simple implementation: just use text content
171
- // Image and file support can be added later
172
178
  const message = {
173
179
  content,
174
180
  role: 'user',
175
181
  };
176
- this.messages.push(message);
177
- // Auto-save to persistent storage (non-blocking)
178
- this.persistHistory().catch((error) => {
179
- this.logger.error('Failed to persist history after user message', { error, sessionId: this.sessionId });
182
+ await this.mutex.withLock(async () => {
183
+ this.messages.push(message);
184
+ try {
185
+ await this.persistHistory();
186
+ }
187
+ catch (error) {
188
+ this.logger.error('Failed to persist history after user message', { error, sessionId: this.sessionId });
189
+ }
180
190
  });
181
191
  }
182
192
  /**
@@ -559,8 +569,22 @@ export class ContextManager {
559
569
  if (typeof result === 'string') {
560
570
  return result;
561
571
  }
562
- // Convert to JSON string
563
- const jsonString = JSON.stringify(result, null, 2);
572
+ // Convert to JSON string with special type handling
573
+ const jsonString = JSON.stringify(result, (_, val) => {
574
+ // Convert BigInt to string
575
+ if (typeof val === 'bigint') {
576
+ return val.toString();
577
+ }
578
+ // Convert functions to their string representation
579
+ if (typeof val === 'function') {
580
+ return `[Function: ${val.name || 'anonymous'}]`;
581
+ }
582
+ // Convert Symbols to string
583
+ if (typeof val === 'symbol') {
584
+ return val.toString();
585
+ }
586
+ return val;
587
+ }, 2);
564
588
  // Limit size to prevent extremely large results
565
589
  const MAX_RESULT_LENGTH = 50_000;
566
590
  if (jsonString.length > MAX_RESULT_LENGTH) {
@@ -34,6 +34,7 @@ export declare class GeminiMessageFormatter implements IMessageFormatter<Content
34
34
  /**
35
35
  * Formats assistant message to Gemini's Content format.
36
36
  * Maps 'assistant' role to 'model' and includes both text and tool calls.
37
+ * For Gemini 3+ models, includes thoughtSignature on function calls.
37
38
  */
38
39
  private formatAssistantMessage;
39
40
  /**
@@ -67,3 +68,15 @@ export declare class GeminiMessageFormatter implements IMessageFormatter<Content
67
68
  */
68
69
  private generateToolCallId;
69
70
  }
71
+ /**
72
+ * Ensures that function calls in the active conversation loop have thought signatures.
73
+ * Required for Gemini 3+ preview models.
74
+ *
75
+ * The "active loop" starts from the last user text message in the conversation.
76
+ * Only the first function call in each model turn needs a thought signature.
77
+ *
78
+ * @param contents Array of Content objects formatted for Gemini API
79
+ * @param model The model being used (only applies to Gemini 3+ models)
80
+ * @returns Modified contents with thought signatures added where needed
81
+ */
82
+ export declare function ensureActiveLoopHasThoughtSignatures(contents: Content[], model: string): Content[];
@@ -1,3 +1,10 @@
1
+ import { isGemini3Model, SYNTHETIC_THOUGHT_SIGNATURE } from '../thought-parser.js';
2
+ /**
3
+ * Type guard to check if a part has a thoughtSignature property.
4
+ */
5
+ function hasThoughtSignature(part) {
6
+ return 'thoughtSignature' in part;
7
+ }
1
8
  /**
2
9
  * Message formatter for Google Gemini API.
3
10
  *
@@ -64,24 +71,30 @@ export class GeminiMessageFormatter {
64
71
  return [];
65
72
  }
66
73
  const textParts = [];
67
- const functionCalls = [];
74
+ const functionCallsWithSignatures = [];
68
75
  // Extract text and function calls from response parts
69
76
  for (const part of candidate.content.parts) {
70
77
  if ('text' in part && part.text) {
71
78
  textParts.push(part.text);
72
79
  }
73
80
  if ('functionCall' in part && part.functionCall) {
74
- functionCalls.push(part.functionCall);
81
+ // Extract thoughtSignature if present (Gemini 3+ models)
82
+ const thoughtSignature = hasThoughtSignature(part) ? part.thoughtSignature : undefined;
83
+ functionCallsWithSignatures.push({
84
+ fc: part.functionCall,
85
+ thoughtSignature,
86
+ });
75
87
  }
76
88
  }
77
89
  // Convert to internal message format
78
- const toolCalls = functionCalls.length > 0
79
- ? functionCalls.map((fc) => ({
90
+ const toolCalls = functionCallsWithSignatures.length > 0
91
+ ? functionCallsWithSignatures.map(({ fc, thoughtSignature }) => ({
80
92
  function: {
81
93
  arguments: JSON.stringify(fc.args ?? {}),
82
94
  name: fc.name ?? '',
83
95
  },
84
96
  id: this.generateToolCallId(fc.name ?? ''),
97
+ thoughtSignature,
85
98
  type: 'function',
86
99
  }))
87
100
  : undefined;
@@ -106,6 +119,7 @@ export class GeminiMessageFormatter {
106
119
  /**
107
120
  * Formats assistant message to Gemini's Content format.
108
121
  * Maps 'assistant' role to 'model' and includes both text and tool calls.
122
+ * For Gemini 3+ models, includes thoughtSignature on function calls.
109
123
  */
110
124
  formatAssistantMessage(msg) {
111
125
  const parts = [];
@@ -116,12 +130,17 @@ export class GeminiMessageFormatter {
116
130
  // Add tool calls if present
117
131
  if (msg.toolCalls) {
118
132
  for (const tc of msg.toolCalls) {
119
- parts.push({
133
+ const functionCallPart = {
120
134
  functionCall: {
121
135
  args: JSON.parse(tc.function.arguments),
122
136
  name: tc.function.name,
123
137
  },
124
- });
138
+ };
139
+ // Include thoughtSignature if present (required for Gemini 3+ models)
140
+ if (tc.thoughtSignature) {
141
+ functionCallPart.thoughtSignature = tc.thoughtSignature;
142
+ }
143
+ parts.push(functionCallPart);
125
144
  }
126
145
  }
127
146
  return {
@@ -251,3 +270,76 @@ export class GeminiMessageFormatter {
251
270
  return `call_${timestamp}_${random}_${toolName}`;
252
271
  }
253
272
  }
273
+ /**
274
+ * Ensures that function calls in the active conversation loop have thought signatures.
275
+ * Required for Gemini 3+ preview models.
276
+ *
277
+ * The "active loop" starts from the last user text message in the conversation.
278
+ * Only the first function call in each model turn needs a thought signature.
279
+ *
280
+ * @param contents Array of Content objects formatted for Gemini API
281
+ * @param model The model being used (only applies to Gemini 3+ models)
282
+ * @returns Modified contents with thought signatures added where needed
283
+ */
284
+ export function ensureActiveLoopHasThoughtSignatures(contents, model) {
285
+ // Only apply to Gemini 3+ models
286
+ if (!isGemini3Model(model)) {
287
+ return contents;
288
+ }
289
+ // Find the last user turn with text message (start of active loop)
290
+ let activeLoopStartIndex = -1;
291
+ for (let i = contents.length - 1; i >= 0; i--) {
292
+ const content = contents[i];
293
+ if (content.role === 'user' && content.parts?.some((part) => 'text' in part && part.text)) {
294
+ activeLoopStartIndex = i;
295
+ break;
296
+ }
297
+ }
298
+ // No user text message found - nothing to do
299
+ if (activeLoopStartIndex === -1) {
300
+ return contents;
301
+ }
302
+ // Create shallow copy to avoid mutating original
303
+ const newContents = [...contents];
304
+ // Process each content from active loop start to end
305
+ for (let i = activeLoopStartIndex; i < newContents.length; i++) {
306
+ const content = newContents[i];
307
+ // Only process model turns with parts
308
+ if (content.role !== 'model' || !content.parts) {
309
+ continue;
310
+ }
311
+ const newParts = [...content.parts];
312
+ const updatedContent = addThoughtSignatureToFirstFunctionCall(newParts, content);
313
+ if (updatedContent) {
314
+ newContents[i] = updatedContent;
315
+ }
316
+ }
317
+ return newContents;
318
+ }
319
+ /**
320
+ * Adds thought signature to the first function call in parts if missing.
321
+ * Returns updated content or null if no modification needed.
322
+ */
323
+ function addThoughtSignatureToFirstFunctionCall(parts, content) {
324
+ for (let j = 0; j < parts.length; j++) {
325
+ const part = parts[j];
326
+ if (!part || !('functionCall' in part) || !part.functionCall) {
327
+ continue;
328
+ }
329
+ // Check if thoughtSignature already exists using type guard
330
+ if (hasThoughtSignature(part) && part.thoughtSignature) {
331
+ return null; // Already has signature, no modification needed
332
+ }
333
+ // Add synthetic thought signature
334
+ const partWithSignature = {
335
+ ...part,
336
+ thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
337
+ };
338
+ parts[j] = partWithSignature;
339
+ return {
340
+ ...content,
341
+ parts,
342
+ };
343
+ }
344
+ return null; // No function call found
345
+ }