@joshuaswarren/openclaw-engram 9.0.77 → 9.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2177,6 +2177,14 @@ var FallbackLlmClient = class {
2177
2177
  * Returns parsed JSON or null on failure.
2178
2178
  */
2179
2179
  async parseWithSchema(messages, schema, options = {}) {
2180
+ const detailed = await this.parseWithSchemaDetailed(messages, schema, options);
2181
+ return detailed?.result ?? null;
2182
+ }
2183
+ /**
2184
+ * Like parseWithSchema but also returns the model that was used,
2185
+ * so callers can emit accurate trace events.
2186
+ */
2187
+ async parseWithSchemaDetailed(messages, schema, options = {}) {
2180
2188
  const response = await this.chatCompletion(messages, options);
2181
2189
  if (!response?.content) return null;
2182
2190
  try {
@@ -2184,7 +2192,7 @@ var FallbackLlmClient = class {
2184
2192
  for (const c of candidates) {
2185
2193
  try {
2186
2194
  const parsed = JSON.parse(c);
2187
- return schema.parse(parsed);
2195
+ return { result: schema.parse(parsed), modelUsed: response.modelUsed };
2188
2196
  } catch {
2189
2197
  }
2190
2198
  }
@@ -3234,18 +3242,18 @@ var ExtractionEngine = class {
3234
3242
  };
3235
3243
  }
3236
3244
  async parseWithGatewayFallback(traceId, operation, startedAtMs, schema, messages, options = {}) {
3237
- const result = await this.fallbackLlm.parseWithSchema(messages, schema, options);
3238
- if (result) {
3245
+ const detailed = await this.fallbackLlm.parseWithSchemaDetailed(messages, schema, options);
3246
+ if (detailed?.result) {
3239
3247
  const durationMs = Date.now() - startedAtMs;
3240
3248
  this.emit({
3241
3249
  kind: "llm_end",
3242
3250
  traceId,
3243
- model: "fallback",
3251
+ model: detailed.modelUsed,
3244
3252
  operation,
3245
3253
  durationMs,
3246
- output: JSON.stringify(result).slice(0, 2e3)
3254
+ output: JSON.stringify(detailed.result).slice(0, 2e3)
3247
3255
  });
3248
- return result;
3256
+ return detailed.result;
3249
3257
  }
3250
3258
  return null;
3251
3259
  }
@@ -3267,7 +3275,11 @@ var ExtractionEngine = class {
3267
3275
  const lastTurnTs = boundedTurns.length > 0 ? new Date(boundedTurns[boundedTurns.length - 1].timestamp) : void 0;
3268
3276
  const messageTimestamp = lastTurnTs && !isNaN(lastTurnTs.getTime()) ? lastTurnTs : void 0;
3269
3277
  const traceId = crypto.randomUUID();
3270
- this.emit({ kind: "llm_start", traceId, model: this.config.model, operation: "extraction", input: conversation });
3278
+ const emittedDirectStart = !!(this.client || this.config.localLlmEnabled);
3279
+ if (emittedDirectStart) {
3280
+ this.emit({ kind: "llm_start", traceId, model: this.config.model, operation: "extraction", input: conversation });
3281
+ }
3282
+ let closedDirectTrace = false;
3271
3283
  const startTime = Date.now();
3272
3284
  if (this.config.localLlmEnabled) {
3273
3285
  try {
@@ -3302,34 +3314,75 @@ var ExtractionEngine = class {
3302
3314
  const sanitized = this.sanitizeExtractionResult(directResult, messageTimestamp);
3303
3315
  return await this.applyProactiveQuestionPass(conversation, sanitized);
3304
3316
  }
3317
+ try {
3318
+ this.emit({
3319
+ kind: "llm_error",
3320
+ traceId,
3321
+ model: this.config.model,
3322
+ operation: "extraction",
3323
+ durationMs: Date.now() - startTime,
3324
+ error: "direct client returned no result"
3325
+ });
3326
+ } catch {
3327
+ }
3328
+ closedDirectTrace = true;
3305
3329
  log.info("extraction: direct client returned no result, falling back to gateway AI");
3306
3330
  } catch (err) {
3331
+ try {
3332
+ this.emit({
3333
+ kind: "llm_error",
3334
+ traceId,
3335
+ model: this.config.model,
3336
+ operation: "extraction",
3337
+ durationMs: Date.now() - startTime,
3338
+ error: String(err)
3339
+ });
3340
+ } catch {
3341
+ }
3342
+ closedDirectTrace = true;
3307
3343
  log.info("extraction: direct client failed, falling back to gateway AI:", err);
3308
3344
  }
3309
3345
  }
3346
+ if (emittedDirectStart && !closedDirectTrace) {
3347
+ try {
3348
+ this.emit({
3349
+ kind: "llm_error",
3350
+ traceId,
3351
+ model: this.config.model,
3352
+ operation: "extraction",
3353
+ durationMs: Date.now() - startTime,
3354
+ error: "local LLM failed, handing off to gateway fallback"
3355
+ });
3356
+ } catch {
3357
+ }
3358
+ }
3359
+ const fallbackTraceId = crypto.randomUUID();
3360
+ const fallbackStartTime = Date.now();
3310
3361
  log.info("extraction: falling back to gateway default AI");
3311
3362
  try {
3312
3363
  const messages = [
3313
3364
  { role: "system", content: this.buildExtractionInstructions(existingEntities) },
3314
3365
  { role: "user", content: conversation }
3315
3366
  ];
3316
- const result = await this.fallbackLlm.parseWithSchema(
3367
+ this.emit({ kind: "llm_start", traceId: fallbackTraceId, model: "fallback", operation: "extraction", input: conversation });
3368
+ const detailed = await this.fallbackLlm.parseWithSchemaDetailed(
3317
3369
  messages,
3318
3370
  ExtractionResultSchema,
3319
3371
  { temperature: 0.3, maxTokens: 4096 }
3320
3372
  );
3321
- const durationMs = Date.now() - startTime;
3322
- if (result && Array.isArray(result.facts)) {
3373
+ const fallbackDurationMs = Date.now() - fallbackStartTime;
3374
+ if (detailed?.result && Array.isArray(detailed.result.facts)) {
3375
+ const result = detailed.result;
3323
3376
  this.emit({
3324
3377
  kind: "llm_end",
3325
- traceId,
3326
- model: "fallback",
3378
+ traceId: fallbackTraceId,
3379
+ model: detailed.modelUsed,
3327
3380
  operation: "extraction",
3328
- durationMs,
3381
+ durationMs: fallbackDurationMs,
3329
3382
  output: JSON.stringify(result).slice(0, 2e3)
3330
3383
  });
3331
3384
  log.debug(
3332
- `extracted ${result.facts.length} facts, ${result.entities.length} entities, ${(result.questions ?? []).length} questions via fallback`
3385
+ `extracted ${result.facts.length} facts, ${result.entities.length} entities, ${(result.questions ?? []).length} questions via fallback (${detailed.modelUsed})`
3333
3386
  );
3334
3387
  const sanitized = this.sanitizeExtractionResult({
3335
3388
  ...result,
@@ -3338,15 +3391,23 @@ var ExtractionEngine = class {
3338
3391
  }, messageTimestamp);
3339
3392
  return await this.applyProactiveQuestionPass(conversation, sanitized);
3340
3393
  }
3394
+ this.emit({
3395
+ kind: "llm_error",
3396
+ traceId: fallbackTraceId,
3397
+ model: "fallback",
3398
+ operation: "extraction",
3399
+ durationMs: fallbackDurationMs,
3400
+ error: "fallback returned no parsed output"
3401
+ });
3341
3402
  log.warn("extraction fallback returned no parsed output");
3342
3403
  return { facts: [], profileUpdates: [], entities: [], questions: [] };
3343
3404
  } catch (err) {
3344
3405
  this.emit({
3345
3406
  kind: "llm_error",
3346
- traceId,
3407
+ traceId: fallbackTraceId,
3347
3408
  model: "fallback",
3348
3409
  operation: "extraction",
3349
- durationMs: Date.now() - startTime,
3410
+ durationMs: Date.now() - fallbackStartTime,
3350
3411
  error: String(err)
3351
3412
  });
3352
3413
  log.error("extraction fallback failed", err);
@@ -40009,11 +40070,34 @@ async function postSpanBatch(cfg, spans, log2) {
40009
40070
  log2.debug?.(`[opik-exporter] span batch error: ${err}`);
40010
40071
  }
40011
40072
  }
40073
+ async function postTraceBatch(cfg, traces, log2) {
40074
+ const url = `${cfg.apiUrl.replace(/\/$/, "")}/v1/private/traces/batch`;
40075
+ try {
40076
+ const res = await fetch(url, {
40077
+ method: "POST",
40078
+ headers: buildHeaders(cfg),
40079
+ body: JSON.stringify({ traces })
40080
+ });
40081
+ if (!res.ok) {
40082
+ const text = await res.text().catch(() => "");
40083
+ log2.debug?.(`[opik-exporter] trace batch failed ${res.status}: ${text}`);
40084
+ return false;
40085
+ }
40086
+ return true;
40087
+ } catch (err) {
40088
+ log2.debug?.(`[opik-exporter] trace batch error: ${err}`);
40089
+ return false;
40090
+ }
40091
+ }
40012
40092
  var OpikExporter = class _OpikExporter {
40013
40093
  cfg;
40014
40094
  log;
40015
40095
  _handler;
40016
40096
  inFlight = /* @__PURE__ */ new Map();
40097
+ /** Track which trace_ids we have already created parent trace objects for. */
40098
+ createdTraces = /* @__PURE__ */ new Set();
40099
+ /** In-flight ensureTrace promises keyed by traceId — concurrent callers await the same promise. */
40100
+ pendingTraces = /* @__PURE__ */ new Map();
40017
40101
  /** TTL for in-flight LLM entries (ms). Entries older than this are discarded. */
40018
40102
  static IN_FLIGHT_TTL_MS = 5 * 60 * 1e3;
40019
40103
  // 5 minutes
@@ -40116,9 +40200,11 @@ var OpikExporter = class _OpikExporter {
40116
40200
  metadata.identityInjectedChars = evt.identityInjectedChars;
40117
40201
  }
40118
40202
  if (evt.timings) metadata.timings = evt.timings;
40203
+ const traceId = evt.sessionKey ? this.sessionToTraceId(evt.sessionKey) : uuidV7();
40204
+ await this.ensureTrace(traceId, evt.sessionKey ?? "engram:recall", startTime, endTime);
40119
40205
  const span = {
40120
40206
  id: uuidV7(),
40121
- trace_id: evt.sessionKey ? this.sessionToTraceId(evt.sessionKey) : uuidV7(),
40207
+ trace_id: traceId,
40122
40208
  project_name: this.cfg.projectName,
40123
40209
  name: "engram:recall",
40124
40210
  type: "general",
@@ -40162,9 +40248,11 @@ var OpikExporter = class _OpikExporter {
40162
40248
  if (evt.tokenUsage?.input != null) usage.prompt_tokens = evt.tokenUsage.input;
40163
40249
  if (evt.tokenUsage?.output != null) usage.completion_tokens = evt.tokenUsage.output;
40164
40250
  if (evt.tokenUsage?.total != null) usage.total_tokens = evt.tokenUsage.total;
40251
+ const traceId = state?.traceId ?? uuidV7();
40252
+ await this.ensureTrace(traceId, `engram:${evt.operation}`, startTime, endTime);
40165
40253
  const span = {
40166
40254
  id: state?.spanId ?? uuidV7(),
40167
- trace_id: state?.traceId ?? uuidV7(),
40255
+ trace_id: traceId,
40168
40256
  project_name: this.cfg.projectName,
40169
40257
  name: `engram:${evt.operation}`,
40170
40258
  type: "llm",
@@ -40186,6 +40274,47 @@ var OpikExporter = class _OpikExporter {
40186
40274
  // -------------------------------------------------------------------------
40187
40275
  // Helpers
40188
40276
  // -------------------------------------------------------------------------
40277
+ /**
40278
+ * Create a parent trace object in Opik if we haven't already.
40279
+ * Opik does NOT auto-create traces from spans, so without this
40280
+ * the spans are orphaned and don't show up in the trace list UI.
40281
+ */
40282
+ async ensureTrace(traceId, name, startTime, endTime) {
40283
+ if (this.createdTraces.has(traceId)) return;
40284
+ const existing = this.pendingTraces.get(traceId);
40285
+ if (existing) {
40286
+ await existing;
40287
+ if (this.createdTraces.has(traceId)) return;
40288
+ const retrying = this.pendingTraces.get(traceId);
40289
+ if (retrying) {
40290
+ await retrying;
40291
+ if (this.createdTraces.has(traceId)) return;
40292
+ }
40293
+ }
40294
+ const work = (async () => {
40295
+ const trace = {
40296
+ id: traceId,
40297
+ project_name: this.cfg.projectName,
40298
+ name,
40299
+ start_time: startTime,
40300
+ end_time: endTime,
40301
+ tags: ["engram"]
40302
+ };
40303
+ const ok = await postTraceBatch(this.cfg, [trace], this.log);
40304
+ if (!ok) {
40305
+ this.pendingTraces.delete(traceId);
40306
+ return;
40307
+ }
40308
+ this.createdTraces.add(traceId);
40309
+ this.pendingTraces.delete(traceId);
40310
+ if (this.createdTraces.size > 1e4) {
40311
+ const first = this.createdTraces.values().next().value;
40312
+ if (first) this.createdTraces.delete(first);
40313
+ }
40314
+ })();
40315
+ this.pendingTraces.set(traceId, work);
40316
+ await work;
40317
+ }
40189
40318
  /**
40190
40319
  * Convert a sessionKey to a stable, deterministic UUID v7-shaped ID so that
40191
40320
  * spans for the same session share a trace_id and are threaded in Opik.