@clinebot/core 0.0.4 → 0.0.6

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 (79) hide show
  1. package/dist/agents/agent-config-parser.d.ts +1 -1
  2. package/dist/index.d.ts +5 -2
  3. package/dist/index.node.d.ts +1 -0
  4. package/dist/index.node.js +134 -107
  5. package/dist/runtime/session-runtime.d.ts +4 -2
  6. package/dist/session/default-session-manager.d.ts +5 -1
  7. package/dist/session/session-host.d.ts +3 -1
  8. package/dist/session/session-manager.d.ts +2 -1
  9. package/dist/session/unified-session-persistence-service.d.ts +4 -0
  10. package/dist/telemetry/ITelemetryAdapter.d.ts +54 -0
  11. package/dist/telemetry/LoggerTelemetryAdapter.d.ts +21 -0
  12. package/dist/telemetry/OpenTelemetryAdapter.d.ts +43 -0
  13. package/dist/telemetry/OpenTelemetryProvider.d.ts +41 -0
  14. package/dist/telemetry/TelemetryService.d.ts +34 -0
  15. package/dist/telemetry/opentelemetry.d.ts +3 -0
  16. package/dist/telemetry/opentelemetry.js +27 -0
  17. package/dist/{default-tools → tools}/schemas.d.ts +6 -0
  18. package/dist/types/config.d.ts +3 -2
  19. package/package.json +17 -3
  20. package/src/agents/agent-config-parser.ts +1 -1
  21. package/src/agents/hooks-config-loader.ts +19 -1
  22. package/src/index.node.ts +3 -0
  23. package/src/index.ts +35 -19
  24. package/src/providers/local-provider-service.ts +25 -7
  25. package/src/runtime/hook-file-hooks.test.ts +47 -0
  26. package/src/runtime/hook-file-hooks.ts +3 -0
  27. package/src/runtime/runtime-builder.test.ts +20 -0
  28. package/src/runtime/runtime-builder.ts +3 -2
  29. package/src/runtime/runtime-parity.test.ts +1 -1
  30. package/src/runtime/session-runtime.ts +4 -2
  31. package/src/session/default-session-manager.test.ts +72 -0
  32. package/src/session/default-session-manager.ts +63 -6
  33. package/src/session/session-host.ts +7 -2
  34. package/src/session/session-manager.ts +2 -1
  35. package/src/session/unified-session-persistence-service.ts +213 -23
  36. package/src/telemetry/ITelemetryAdapter.ts +94 -0
  37. package/src/telemetry/LoggerTelemetryAdapter.test.ts +42 -0
  38. package/src/telemetry/LoggerTelemetryAdapter.ts +114 -0
  39. package/src/telemetry/OpenTelemetryAdapter.test.ts +157 -0
  40. package/src/telemetry/OpenTelemetryAdapter.ts +348 -0
  41. package/src/telemetry/OpenTelemetryProvider.test.ts +113 -0
  42. package/src/telemetry/OpenTelemetryProvider.ts +322 -0
  43. package/src/telemetry/TelemetryService.test.ts +134 -0
  44. package/src/telemetry/TelemetryService.ts +141 -0
  45. package/src/telemetry/opentelemetry.ts +20 -0
  46. package/src/{default-tools → tools}/definitions.ts +35 -28
  47. package/src/{default-tools → tools}/schemas.ts +9 -0
  48. package/src/types/config.ts +3 -1
  49. /package/dist/{default-tools → tools}/constants.d.ts +0 -0
  50. /package/dist/{default-tools → tools}/definitions.d.ts +0 -0
  51. /package/dist/{default-tools → tools}/executors/apply-patch-parser.d.ts +0 -0
  52. /package/dist/{default-tools → tools}/executors/apply-patch.d.ts +0 -0
  53. /package/dist/{default-tools → tools}/executors/bash.d.ts +0 -0
  54. /package/dist/{default-tools → tools}/executors/editor.d.ts +0 -0
  55. /package/dist/{default-tools → tools}/executors/file-read.d.ts +0 -0
  56. /package/dist/{default-tools → tools}/executors/index.d.ts +0 -0
  57. /package/dist/{default-tools → tools}/executors/search.d.ts +0 -0
  58. /package/dist/{default-tools → tools}/executors/web-fetch.d.ts +0 -0
  59. /package/dist/{default-tools → tools}/index.d.ts +0 -0
  60. /package/dist/{default-tools → tools}/model-tool-routing.d.ts +0 -0
  61. /package/dist/{default-tools → tools}/presets.d.ts +0 -0
  62. /package/dist/{default-tools → tools}/types.d.ts +0 -0
  63. /package/src/{default-tools → tools}/constants.ts +0 -0
  64. /package/src/{default-tools → tools}/definitions.test.ts +0 -0
  65. /package/src/{default-tools → tools}/executors/apply-patch-parser.ts +0 -0
  66. /package/src/{default-tools → tools}/executors/apply-patch.ts +0 -0
  67. /package/src/{default-tools → tools}/executors/bash.ts +0 -0
  68. /package/src/{default-tools → tools}/executors/editor.ts +0 -0
  69. /package/src/{default-tools → tools}/executors/file-read.test.ts +0 -0
  70. /package/src/{default-tools → tools}/executors/file-read.ts +0 -0
  71. /package/src/{default-tools → tools}/executors/index.ts +0 -0
  72. /package/src/{default-tools → tools}/executors/search.ts +0 -0
  73. /package/src/{default-tools → tools}/executors/web-fetch.ts +0 -0
  74. /package/src/{default-tools → tools}/index.ts +0 -0
  75. /package/src/{default-tools → tools}/model-tool-routing.test.ts +0 -0
  76. /package/src/{default-tools → tools}/model-tool-routing.ts +0 -0
  77. /package/src/{default-tools → tools}/presets.test.ts +0 -0
  78. /package/src/{default-tools → tools}/presets.ts +0 -0
  79. /package/src/{default-tools → tools}/types.ts +0 -0
@@ -14,15 +14,14 @@ import {
14
14
  type ToolApprovalResult,
15
15
  } from "@clinebot/agents";
16
16
  import type { providers as LlmsProviders } from "@clinebot/llms";
17
- import { formatUserInputBlock, normalizeUserInput } from "@clinebot/shared";
17
+ import {
18
+ formatUserInputBlock,
19
+ type ITelemetryService,
20
+ normalizeUserInput,
21
+ } from "@clinebot/shared";
18
22
  import { setHomeDirIfUnset } from "@clinebot/shared/storage";
19
23
  import { nanoid } from "nanoid";
20
24
  import { resolveAndLoadAgentPlugins } from "../agents/plugin-config-loader";
21
- import {
22
- createBuiltinTools,
23
- type ToolExecutors,
24
- ToolPresets,
25
- } from "../default-tools";
26
25
  import { enrichPromptWithMentions } from "../input";
27
26
  import {
28
27
  createHookAuditHooks,
@@ -36,6 +35,7 @@ import {
36
35
  buildTeamProgressSummary,
37
36
  toTeamProgressLifecycleEvent,
38
37
  } from "../team";
38
+ import { createBuiltinTools, type ToolExecutors, ToolPresets } from "../tools";
39
39
  import { SessionSource, type SessionStatus } from "../types/common";
40
40
  import type { CoreSessionConfig } from "../types/config";
41
41
  import type { CoreSessionEvent } from "../types/events";
@@ -93,6 +93,7 @@ export interface DefaultSessionManagerOptions {
93
93
  toolPolicies?: AgentConfig["toolPolicies"];
94
94
  providerSettingsManager?: ProviderSettingsManager;
95
95
  oauthTokenManager?: RuntimeOAuthTokenManager;
96
+ telemetry?: ITelemetryService;
96
97
  requestToolApproval?: (
97
98
  request: ToolApprovalRequest,
98
99
  ) => Promise<ToolApprovalResult>;
@@ -124,6 +125,7 @@ export class DefaultSessionManager implements SessionManager {
124
125
  private readonly defaultToolPolicies?: AgentConfig["toolPolicies"];
125
126
  private readonly providerSettingsManager: ProviderSettingsManager;
126
127
  private readonly oauthTokenManager: RuntimeOAuthTokenManager;
128
+ private readonly defaultTelemetry?: ITelemetryService;
127
129
  private readonly defaultRequestToolApproval?: (
128
130
  request: ToolApprovalRequest,
129
131
  ) => Promise<ToolApprovalResult>;
@@ -147,6 +149,7 @@ export class DefaultSessionManager implements SessionManager {
147
149
  new RuntimeOAuthTokenManager({
148
150
  providerSettingsManager: this.providerSettingsManager,
149
151
  });
152
+ this.defaultTelemetry = options.telemetry;
150
153
  this.defaultRequestToolApproval = options.requestToolApproval;
151
154
  }
152
155
 
@@ -267,6 +270,7 @@ export class DefaultSessionManager implements SessionManager {
267
270
  ...input.config,
268
271
  hooks: effectiveHooks,
269
272
  extensions: effectiveExtensions,
273
+ telemetry: input.config.telemetry ?? this.defaultTelemetry,
270
274
  };
271
275
  const providerConfig =
272
276
  this.buildResolvedProviderConfig(effectiveConfigBase);
@@ -280,6 +284,7 @@ export class DefaultSessionManager implements SessionManager {
280
284
  hooks: effectiveHooks,
281
285
  extensions: effectiveExtensions,
282
286
  logger: effectiveConfig.logger,
287
+ telemetry: effectiveConfig.telemetry,
283
288
  onTeamEvent: (event: TeamEvent) => {
284
289
  void this.handleTeamEvent(sessionId, event);
285
290
  effectiveConfig.onTeamEvent?.(event);
@@ -291,6 +296,18 @@ export class DefaultSessionManager implements SessionManager {
291
296
  input.defaultToolExecutors ?? this.defaultToolExecutors,
292
297
  });
293
298
  const tools = [...runtime.tools, ...(effectiveConfig.extraTools ?? [])];
299
+ effectiveConfig.telemetry?.capture({
300
+ event: "session.started",
301
+ properties: {
302
+ sessionId,
303
+ source,
304
+ providerId: effectiveConfig.providerId,
305
+ modelId: effectiveConfig.modelId,
306
+ enableTools: effectiveConfig.enableTools,
307
+ enableSpawnAgent: effectiveConfig.enableSpawnAgent,
308
+ enableAgentTeams: effectiveConfig.enableAgentTeams,
309
+ },
310
+ });
294
311
  const agent = this.createAgentInstance({
295
312
  providerId: providerConfig.providerId,
296
313
  modelId: providerConfig.modelId,
@@ -330,6 +347,14 @@ export class DefaultSessionManager implements SessionManager {
330
347
  }),
331
348
  );
332
349
  }
350
+ if (event.type === "iteration_end") {
351
+ void this.invoke<void>(
352
+ "persistSessionMessages",
353
+ sessionId,
354
+ liveSession?.agent.getMessages() ?? [],
355
+ liveSession?.config.systemPrompt,
356
+ );
357
+ }
333
358
  this.emit({
334
359
  type: "agent_event",
335
360
  payload: {
@@ -401,6 +426,15 @@ export class DefaultSessionManager implements SessionManager {
401
426
  if (!session) {
402
427
  throw new Error(`session not found: ${input.sessionId}`);
403
428
  }
429
+ session.config.telemetry?.capture({
430
+ event: "session.input_sent",
431
+ properties: {
432
+ sessionId: input.sessionId,
433
+ promptLength: input.prompt.length,
434
+ userImageCount: input.userImages?.length ?? 0,
435
+ userFileCount: input.userFiles?.length ?? 0,
436
+ },
437
+ });
404
438
  try {
405
439
  const result = await this.runTurn(session, {
406
440
  prompt: input.prompt,
@@ -432,6 +466,10 @@ export class DefaultSessionManager implements SessionManager {
432
466
  if (!session) {
433
467
  return;
434
468
  }
469
+ session.config.telemetry?.capture({
470
+ event: "session.aborted",
471
+ properties: { sessionId },
472
+ });
435
473
  session.aborting = true;
436
474
  session.agent.abort();
437
475
  }
@@ -441,6 +479,10 @@ export class DefaultSessionManager implements SessionManager {
441
479
  if (!session) {
442
480
  return;
443
481
  }
482
+ session.config.telemetry?.capture({
483
+ event: "session.stopped",
484
+ properties: { sessionId },
485
+ });
444
486
  await this.shutdownSession(session, {
445
487
  status: "cancelled",
446
488
  exitCode: null,
@@ -565,6 +607,9 @@ export class DefaultSessionManager implements SessionManager {
565
607
  if (!prompt) {
566
608
  throw new Error("prompt cannot be empty");
567
609
  }
610
+ if (!session.artifacts && !session.pendingPrompt) {
611
+ session.pendingPrompt = prompt;
612
+ }
568
613
  await this.ensureSessionPersisted(session);
569
614
  await this.syncOAuthCredentials(session);
570
615
 
@@ -1201,4 +1246,16 @@ export class DefaultSessionManager implements SessionManager {
1201
1246
  apiKey: resolved.apiKey,
1202
1247
  });
1203
1248
  }
1249
+
1250
+ async updateSessionModel(sessionId: string, modelId: string): Promise<void> {
1251
+ const session = this.sessions.get(sessionId);
1252
+ if (!session) {
1253
+ throw new Error(`session not found: ${sessionId}`);
1254
+ }
1255
+ session.config.modelId = modelId;
1256
+ const agentWithConnection = session.agent as Agent & {
1257
+ updateConnection?: (overrides: { modelId?: string }) => void;
1258
+ };
1259
+ agentWithConnection.updateConnection?.({ modelId });
1260
+ }
1204
1261
  }
@@ -7,10 +7,11 @@ import type {
7
7
  ToolApprovalResult,
8
8
  } from "@clinebot/agents";
9
9
  import { getRpcServerDefaultAddress, getRpcServerHealth } from "@clinebot/rpc";
10
+ import type { ITelemetryService } from "@clinebot/shared";
10
11
  import { resolveSessionDataDir } from "@clinebot/shared/storage";
11
12
  import { nanoid } from "nanoid";
12
- import type { ToolExecutors } from "../default-tools";
13
13
  import { SqliteSessionStore } from "../storage/sqlite-session-store";
14
+ import type { ToolExecutors } from "../tools";
14
15
  import { DefaultSessionManager } from "./default-session-manager";
15
16
  import { RpcCoreSessionService } from "./rpc-session-service";
16
17
  import type { SessionManager } from "./session-manager";
@@ -33,6 +34,7 @@ export interface CreateSessionHostOptions {
33
34
  rpcConnectAttempts?: number;
34
35
  rpcConnectDelayMs?: number;
35
36
  defaultToolExecutors?: Partial<ToolExecutors>;
37
+ telemetry?: ITelemetryService;
36
38
  toolPolicies?: AgentConfig["toolPolicies"];
37
39
  requestToolApproval?: (
38
40
  request: ToolApprovalRequest,
@@ -190,13 +192,16 @@ export async function resolveSessionBackend(
190
192
  export async function createSessionHost(
191
193
  options: CreateSessionHostOptions,
192
194
  ): Promise<SessionHost> {
195
+ const distinctId = resolveHostDistinctId(options.distinctId);
196
+ options.telemetry?.setDistinctId(distinctId);
193
197
  const backend =
194
198
  options.sessionService ?? (await resolveSessionBackend(options));
195
199
  return new DefaultSessionManager({
196
200
  sessionService: backend,
197
201
  defaultToolExecutors: options.defaultToolExecutors,
202
+ telemetry: options.telemetry,
198
203
  toolPolicies: options.toolPolicies,
199
204
  requestToolApproval: options.requestToolApproval,
200
- distinctId: resolveHostDistinctId(options.distinctId),
205
+ distinctId,
201
206
  });
202
207
  }
@@ -16,7 +16,7 @@ export interface StartSessionInput {
16
16
  userFiles?: string[];
17
17
  userInstructionWatcher?: import("../agents").UserInstructionConfigWatcher;
18
18
  onTeamRestored?: () => void;
19
- defaultToolExecutors?: Partial<import("../default-tools").ToolExecutors>;
19
+ defaultToolExecutors?: Partial<import("../tools").ToolExecutors>;
20
20
  toolPolicies?: import("@clinebot/agents").AgentConfig["toolPolicies"];
21
21
  requestToolApproval?: (
22
22
  request: import("@clinebot/agents").ToolApprovalRequest,
@@ -64,4 +64,5 @@ export interface SessionManager {
64
64
  readTranscript(sessionId: string, maxChars?: number): Promise<string>;
65
65
  readHooks(sessionId: string, limit?: number): Promise<unknown[]>;
66
66
  subscribe(listener: (event: CoreSessionEvent) => void): () => void;
67
+ updateSessionModel?(sessionId: string, modelId: string): Promise<void>;
67
68
  }
@@ -10,7 +10,7 @@ import type {
10
10
  SubAgentStartContext,
11
11
  } from "@clinebot/agents";
12
12
  import type { providers as LlmsProviders } from "@clinebot/llms";
13
- import { resolveRootSessionId } from "@clinebot/shared";
13
+ import { normalizeUserInput, resolveRootSessionId } from "@clinebot/shared";
14
14
  import { nanoid } from "nanoid";
15
15
  import { z } from "zod";
16
16
  import type { SessionStatus } from "../types/common";
@@ -48,6 +48,58 @@ function stringifyMetadataJson(
48
48
  return JSON.stringify(metadata);
49
49
  }
50
50
 
51
+ function normalizeSessionTitle(title?: string | null): string | undefined {
52
+ const trimmed = title?.trim();
53
+ return trimmed ? trimmed.slice(0, 120) : undefined;
54
+ }
55
+
56
+ function deriveSessionTitleFromPrompt(
57
+ prompt?: string | null,
58
+ ): string | undefined {
59
+ const normalizedPrompt = normalizeUserInput(prompt ?? "").trim();
60
+ if (!normalizedPrompt) {
61
+ return undefined;
62
+ }
63
+ const firstLine = normalizedPrompt.split("\n")[0]?.trim();
64
+ return normalizeSessionTitle(firstLine);
65
+ }
66
+
67
+ function normalizeMetadataForStorage(
68
+ metadata: Record<string, unknown> | null | undefined,
69
+ ): Record<string, unknown> | undefined {
70
+ if (!metadata) {
71
+ return undefined;
72
+ }
73
+ const next = { ...metadata };
74
+ if (typeof next.title === "string") {
75
+ const normalizedTitle = normalizeSessionTitle(next.title);
76
+ if (normalizedTitle) {
77
+ next.title = normalizedTitle;
78
+ } else {
79
+ delete next.title;
80
+ }
81
+ } else {
82
+ delete next.title;
83
+ }
84
+ return Object.keys(next).length > 0 ? next : undefined;
85
+ }
86
+
87
+ function metadataWithResolvedTitle(input: {
88
+ metadata?: Record<string, unknown> | null;
89
+ title?: string | null;
90
+ prompt?: string | null;
91
+ }): Record<string, unknown> | undefined {
92
+ const next = { ...(normalizeMetadataForStorage(input.metadata) ?? {}) };
93
+ const resolvedTitle =
94
+ input.title !== undefined
95
+ ? normalizeSessionTitle(input.title)
96
+ : deriveSessionTitleFromPrompt(input.prompt);
97
+ if (resolvedTitle) {
98
+ next.title = resolvedTitle;
99
+ }
100
+ return Object.keys(next).length > 0 ? next : undefined;
101
+ }
102
+
51
103
  export interface PersistedSessionUpdateInput {
52
104
  sessionId: string;
53
105
  expectedStatusLock?: number;
@@ -56,6 +108,7 @@ export interface PersistedSessionUpdateInput {
56
108
  exitCode?: number | null;
57
109
  prompt?: string | null;
58
110
  metadataJson?: string | null;
111
+ title?: string | null;
59
112
  parentSessionId?: string | null;
60
113
  parentAgentId?: string | null;
61
114
  agentId?: string | null;
@@ -173,6 +226,63 @@ export class UnifiedSessionPersistenceService {
173
226
  );
174
227
  }
175
228
 
229
+ private readSessionManifestFile(sessionId: string): {
230
+ path: string;
231
+ manifest?: SessionManifest;
232
+ } {
233
+ const manifestPath = this.sessionManifestPath(sessionId, false);
234
+ if (!existsSync(manifestPath)) {
235
+ return { path: manifestPath };
236
+ }
237
+ try {
238
+ const manifest = SessionManifestSchema.parse(
239
+ JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest,
240
+ );
241
+ return { path: manifestPath, manifest };
242
+ } catch {
243
+ return { path: manifestPath };
244
+ }
245
+ }
246
+
247
+ private applyResolvedTitleToRow(row: SessionRowShape): SessionRowShape {
248
+ const existingMetadata =
249
+ typeof row.metadata_json === "string" &&
250
+ row.metadata_json.trim().length > 0
251
+ ? (() => {
252
+ try {
253
+ const parsed = JSON.parse(row.metadata_json) as unknown;
254
+ if (
255
+ parsed &&
256
+ typeof parsed === "object" &&
257
+ !Array.isArray(parsed)
258
+ ) {
259
+ return parsed as Record<string, unknown>;
260
+ }
261
+ } catch {
262
+ // Ignore malformed metadata payloads.
263
+ }
264
+ return undefined;
265
+ })()
266
+ : undefined;
267
+ const sanitizedMetadata = normalizeMetadataForStorage(existingMetadata);
268
+ const { manifest } = this.readSessionManifestFile(row.session_id);
269
+ const manifestTitle = normalizeSessionTitle(
270
+ typeof manifest?.metadata?.title === "string"
271
+ ? (manifest.metadata.title as string)
272
+ : undefined,
273
+ );
274
+ const resolvedMetadata = manifestTitle
275
+ ? {
276
+ ...(sanitizedMetadata ?? {}),
277
+ title: manifestTitle,
278
+ }
279
+ : sanitizedMetadata;
280
+ return {
281
+ ...row,
282
+ metadata_json: stringifyMetadataJson(resolvedMetadata),
283
+ };
284
+ }
285
+
176
286
  private createRootSessionId(): string {
177
287
  return `${Date.now()}_${nanoid(5)}`;
178
288
  }
@@ -207,9 +317,13 @@ export class UnifiedSessionPersistenceService {
207
317
  enable_spawn: input.enableSpawn,
208
318
  enable_teams: input.enableTeams,
209
319
  prompt: input.prompt?.trim() || undefined,
210
- metadata: input.metadata,
320
+ metadata: metadataWithResolvedTitle({
321
+ metadata: input.metadata,
322
+ prompt: input.prompt,
323
+ }),
211
324
  messages_path: messagesPath,
212
325
  });
326
+ const storedMetadata = normalizeMetadataForStorage(manifest.metadata);
213
327
 
214
328
  await this.adapter.upsertSession({
215
329
  session_id: sessionId,
@@ -235,7 +349,7 @@ export class UnifiedSessionPersistenceService {
235
349
  conversation_id: null,
236
350
  is_subagent: 0,
237
351
  prompt: manifest.prompt ?? null,
238
- metadata_json: stringifyMetadataJson(manifest.metadata),
352
+ metadata_json: stringifyMetadataJson(storedMetadata),
239
353
  transcript_path: transcriptPath,
240
354
  hook_path: hookPath,
241
355
  messages_path: messagesPath,
@@ -293,40 +407,86 @@ export class UnifiedSessionPersistenceService {
293
407
  sessionId: string;
294
408
  prompt?: string | null;
295
409
  metadata?: Record<string, unknown> | null;
410
+ title?: string | null;
296
411
  }): Promise<{ updated: boolean }> {
297
412
  for (let attempt = 0; attempt < 4; attempt++) {
298
413
  const row = await this.adapter.getSession(input.sessionId);
299
414
  if (!row || typeof row.status_lock !== "number") {
300
415
  return { updated: false };
301
416
  }
417
+ const sanitizedMetadata =
418
+ input.metadata === undefined
419
+ ? undefined
420
+ : normalizeMetadataForStorage(input.metadata);
421
+ const existingMetadata = (() => {
422
+ const raw = row.metadata_json?.trim();
423
+ if (!raw) {
424
+ return undefined;
425
+ }
426
+ try {
427
+ const parsed = JSON.parse(raw) as unknown;
428
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
429
+ return normalizeMetadataForStorage(
430
+ parsed as Record<string, unknown>,
431
+ );
432
+ }
433
+ } catch {
434
+ // Ignore malformed metadata payloads.
435
+ }
436
+ return undefined;
437
+ })();
438
+ const existingTitle = normalizeSessionTitle(
439
+ typeof existingMetadata?.title === "string"
440
+ ? (existingMetadata.title as string)
441
+ : undefined,
442
+ );
443
+ const nextTitle =
444
+ input.title !== undefined
445
+ ? normalizeSessionTitle(input.title)
446
+ : input.prompt !== undefined
447
+ ? deriveSessionTitleFromPrompt(input.prompt)
448
+ : existingTitle;
449
+ const nextMetadata =
450
+ input.metadata !== undefined
451
+ ? { ...(sanitizedMetadata ?? {}) }
452
+ : { ...(existingMetadata ?? {}) };
453
+ if (nextTitle) {
454
+ nextMetadata.title = nextTitle;
455
+ } else {
456
+ delete nextMetadata.title;
457
+ }
302
458
  const changed = await this.adapter.updateSession({
303
459
  sessionId: input.sessionId,
304
460
  prompt: input.prompt,
305
461
  metadataJson:
306
- input.metadata === undefined
462
+ input.metadata === undefined &&
463
+ input.prompt === undefined &&
464
+ input.title === undefined
307
465
  ? undefined
308
- : stringifyMetadataJson(input.metadata),
466
+ : stringifyMetadataJson(nextMetadata),
467
+ title: nextTitle,
309
468
  expectedStatusLock: row.status_lock,
310
469
  });
311
470
  if (!changed.updated) {
312
471
  continue;
313
472
  }
314
- const manifestPath = this.sessionManifestPath(input.sessionId, false);
315
- if (existsSync(manifestPath)) {
316
- try {
317
- const manifest = SessionManifestSchema.parse(
318
- JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest,
319
- );
320
- if (input.prompt !== undefined) {
321
- manifest.prompt = input.prompt ?? undefined;
322
- }
323
- if (input.metadata !== undefined) {
324
- manifest.metadata = input.metadata ?? undefined;
325
- }
326
- this.writeSessionManifestFile(manifestPath, manifest);
327
- } catch {
328
- // Ignore malformed manifests and keep backend session state as source of truth.
473
+ const { path: manifestPath, manifest } = this.readSessionManifestFile(
474
+ input.sessionId,
475
+ );
476
+ if (manifest) {
477
+ if (input.prompt !== undefined) {
478
+ manifest.prompt = input.prompt ?? undefined;
479
+ }
480
+ const nextMetadata =
481
+ input.metadata !== undefined
482
+ ? { ...(normalizeMetadataForStorage(input.metadata) ?? {}) }
483
+ : { ...(normalizeMetadataForStorage(manifest.metadata) ?? {}) };
484
+ if (nextTitle) {
485
+ nextMetadata.title = nextTitle;
329
486
  }
487
+ manifest.metadata =
488
+ Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined;
489
+ this.writeSessionManifestFile(manifestPath, manifest);
330
490
  }
331
491
  return { updated: true };
332
492
  }
@@ -422,7 +582,9 @@ export class UnifiedSessionPersistenceService {
422
582
  conversation_id: input.conversationId,
423
583
  is_subagent: 1,
424
584
  prompt,
425
- metadata_json: null,
585
+ metadata_json: stringifyMetadataJson(
586
+ metadataWithResolvedTitle({ prompt }),
587
+ ),
426
588
  transcript_path: artifactPaths.transcriptPath,
427
589
  hook_path: artifactPaths.hookPath,
428
590
  messages_path: artifactPaths.messagesPath,
@@ -444,6 +606,30 @@ export class UnifiedSessionPersistenceService {
444
606
  agentId: input.agentId,
445
607
  conversationId: input.conversationId,
446
608
  prompt: existing.prompt ?? prompt ?? null,
609
+ metadataJson: stringifyMetadataJson(
610
+ metadataWithResolvedTitle({
611
+ metadata: (() => {
612
+ const raw = existing.metadata_json?.trim();
613
+ if (!raw) {
614
+ return undefined;
615
+ }
616
+ try {
617
+ const parsed = JSON.parse(raw) as unknown;
618
+ if (
619
+ parsed &&
620
+ typeof parsed === "object" &&
621
+ !Array.isArray(parsed)
622
+ ) {
623
+ return parsed as Record<string, unknown>;
624
+ }
625
+ } catch {
626
+ // Ignore malformed metadata payloads.
627
+ }
628
+ return undefined;
629
+ })(),
630
+ prompt: existing.prompt ?? prompt ?? null,
631
+ }),
632
+ ),
447
633
  expectedStatusLock: existing.status_lock,
448
634
  });
449
635
  return sessionId;
@@ -601,7 +787,9 @@ export class UnifiedSessionPersistenceService {
601
787
  conversation_id: null,
602
788
  is_subagent: 1,
603
789
  prompt: message || `Team task for ${agentId}`,
604
- metadata_json: null,
790
+ metadata_json: stringifyMetadataJson(
791
+ metadataWithResolvedTitle({ prompt: message }),
792
+ ),
605
793
  transcript_path: transcriptPath,
606
794
  hook_path: hookPath,
607
795
  messages_path: messagesPath,
@@ -750,7 +938,9 @@ export class UnifiedSessionPersistenceService {
750
938
  }
751
939
  rows = await this.adapter.listSessions({ limit: scanLimit });
752
940
  }
753
- return rows.slice(0, requestedLimit);
941
+ return rows
942
+ .slice(0, requestedLimit)
943
+ .map((row) => this.applyResolvedTitleToRow(row));
754
944
  }
755
945
 
756
946
  async deleteSession(sessionId: string): Promise<{ deleted: boolean }> {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Telemetry adapter interface for the @clinebot/core SDK.
3
+ *
4
+ * This is the SDK-side counterpart to the extension's ITelemetryProvider.
5
+ * It is intentionally free of VS Code / host-provider dependencies so that
6
+ * any consumer (CLI, tests, third-party integrations) can plug in their own
7
+ * backend without pulling in the full extension runtime.
8
+ */
9
+
10
+ import type { TelemetryProperties } from "@clinebot/shared";
11
+
12
+ export type {
13
+ TelemetryArray,
14
+ TelemetryMetadata,
15
+ TelemetryObject,
16
+ TelemetryPrimitive,
17
+ TelemetryProperties,
18
+ TelemetryValue,
19
+ } from "@clinebot/shared";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Adapter interface
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Telemetry adapter that an SDK consumer implements (or uses via the
27
+ * provided {@link OpenTelemetryAdapter}) to receive Cline telemetry events.
28
+ *
29
+ * The interface intentionally mirrors ITelemetryProvider from the extension
30
+ * so that shared logic can be re-used or compared easily.
31
+ */
32
+ export interface ITelemetryAdapter {
33
+ /** Human-readable adapter name used for logging / diagnostics. */
34
+ readonly name: string;
35
+
36
+ /**
37
+ * Emit a standard telemetry event.
38
+ * Implementations may silently drop events when telemetry is disabled.
39
+ */
40
+ emit(event: string, properties?: TelemetryProperties): void;
41
+
42
+ /**
43
+ * Emit a *required* telemetry event that must not be suppressed by
44
+ * user opt-out settings (e.g. final opt-out confirmation events).
45
+ */
46
+ emitRequired(event: string, properties?: TelemetryProperties): void;
47
+
48
+ /**
49
+ * Record a monotonically-increasing counter metric.
50
+ * Implementations that do not support metrics may treat this as a no-op.
51
+ */
52
+ recordCounter(
53
+ name: string,
54
+ value: number,
55
+ attributes?: TelemetryProperties,
56
+ description?: string,
57
+ required?: boolean,
58
+ ): void;
59
+
60
+ /**
61
+ * Record a histogram (distribution) metric.
62
+ * Implementations that do not support metrics may treat this as a no-op.
63
+ */
64
+ recordHistogram(
65
+ name: string,
66
+ value: number,
67
+ attributes?: TelemetryProperties,
68
+ description?: string,
69
+ required?: boolean,
70
+ ): void;
71
+
72
+ /**
73
+ * Record a gauge (point-in-time) metric.
74
+ * Pass `null` as `value` to retire the series identified by
75
+ * `name + attributes` and prevent stale gauge entries.
76
+ * Implementations that do not support metrics may treat this as a no-op.
77
+ */
78
+ recordGauge(
79
+ name: string,
80
+ value: number | null,
81
+ attributes?: TelemetryProperties,
82
+ description?: string,
83
+ required?: boolean,
84
+ ): void;
85
+
86
+ /** Returns whether the adapter is currently accepting events. */
87
+ isEnabled(): boolean;
88
+
89
+ /** Flush any buffered events/metrics to the backend. */
90
+ flush(): Promise<void>;
91
+
92
+ /** Release all resources held by the adapter. */
93
+ dispose(): Promise<void>;
94
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { LoggerTelemetryAdapter } from "./LoggerTelemetryAdapter";
3
+
4
+ describe("LoggerTelemetryAdapter", () => {
5
+ it("logs events and metrics through the provided logger", async () => {
6
+ const logger = {
7
+ debug: vi.fn(),
8
+ info: vi.fn(),
9
+ warn: vi.fn(),
10
+ };
11
+ const adapter = new LoggerTelemetryAdapter({ logger });
12
+
13
+ adapter.emit("session.started", { sessionId: "s1" });
14
+ adapter.emitRequired("user.opt_out", { reason: "manual" });
15
+ adapter.recordCounter("cline.session.starts.total", 1, {
16
+ sessionId: "s1",
17
+ });
18
+
19
+ expect(logger.info).toHaveBeenCalledWith("telemetry.event", {
20
+ adapter: "LoggerTelemetryAdapter",
21
+ event: "session.started",
22
+ properties: { sessionId: "s1" },
23
+ });
24
+ expect(logger.warn).toHaveBeenCalledWith("telemetry.required_event", {
25
+ adapter: "LoggerTelemetryAdapter",
26
+ event: "user.opt_out",
27
+ properties: { reason: "manual" },
28
+ });
29
+ expect(logger.debug).toHaveBeenCalledWith("telemetry.metric", {
30
+ adapter: "LoggerTelemetryAdapter",
31
+ instrument: "counter",
32
+ name: "cline.session.starts.total",
33
+ value: 1,
34
+ attributes: { sessionId: "s1" },
35
+ description: undefined,
36
+ required: false,
37
+ });
38
+
39
+ await adapter.flush();
40
+ await adapter.dispose();
41
+ });
42
+ });