context-mode 1.0.160 → 1.0.162
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/session/analytics.d.ts +7 -7
- package/build/session/analytics.js +13 -12
- package/build/session/db.d.ts +1 -0
- package/build/session/db.js +14 -1
- package/build/session/extract.d.ts +46 -0
- package/build/session/extract.js +756 -13
- package/build/session/project-attribution.js +14 -0
- package/cli.bundle.mjs +10 -6
- package/hooks/session-db.bundle.mjs +11 -7
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-loaders.mjs +8 -5
- package/hooks/sessionstart.mjs +16 -2
- package/hooks/userpromptsubmit.mjs +9 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +9 -5
package/build/session/extract.js
CHANGED
|
@@ -267,15 +267,273 @@ function extractGit(input) {
|
|
|
267
267
|
if (input.tool_name !== "Bash")
|
|
268
268
|
return [];
|
|
269
269
|
const cmd = String(input.tool_input["command"] ?? "");
|
|
270
|
-
|
|
270
|
+
// Bug 8 (v1.0.162) — parse the git invocation algorithmically so flags
|
|
271
|
+
// between `git` and the operation token are tolerated (`git -C /path
|
|
272
|
+
// status`, `git --no-pager log`, etc.). Falls back to the legacy regex
|
|
273
|
+
// pattern scan when the algorithmic parse cannot locate a `git` token —
|
|
274
|
+
// preserves backward compat for commands like `cd /repo && git status`
|
|
275
|
+
// where the algorithmic parse sees `cd` as the first token instead.
|
|
276
|
+
const parsed = parseGitInvocation(cmd);
|
|
277
|
+
let match;
|
|
278
|
+
if (parsed && parsed.operation) {
|
|
279
|
+
match = GIT_PATTERNS.find(p => p.operation === parsed.operation);
|
|
280
|
+
}
|
|
281
|
+
if (!match) {
|
|
282
|
+
match = GIT_PATTERNS.find(p => p.pattern.test(cmd));
|
|
283
|
+
}
|
|
271
284
|
if (!match)
|
|
272
285
|
return [];
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
286
|
+
// Bug 1 (v1.0.161) — for `git commit` operations, parse -m / -am / --message=
|
|
287
|
+
// from the Bash command via shell-like argv tokenization so downstream
|
|
288
|
+
// consumers receive the actual commit subject in `data`. Falls back to the
|
|
289
|
+
// operation name when no message argument is present (--amend / --no-edit /
|
|
290
|
+
// -F file / interactive editor flow). Tokenizer is hand-rolled char-by-char
|
|
291
|
+
// (no regex) to mirror real shell quoting/cluster-flag semantics.
|
|
292
|
+
//
|
|
293
|
+
// When a message is captured, the event surfaces as type='git_commit' so the
|
|
294
|
+
// rollup aggregator can distinguish ACTUAL commits from other git operations
|
|
295
|
+
// (status/diff/log were inflating has_commit on every event — see
|
|
296
|
+
// session-loaders.mjs rollup stamp + Bug 2).
|
|
297
|
+
// Bug 8 cwd hint — when `-C <dir>` is present in the git invocation, emit
|
|
298
|
+
// a leading cwd event so the attribution carry-forward (LAST_SEEN source)
|
|
299
|
+
// routes downstream events in the same batch to the scoped directory's
|
|
300
|
+
// project. Without the hint, `git -C /projB status` while cwd=/projA
|
|
301
|
+
// misattributes to /projA.
|
|
302
|
+
const out = [];
|
|
303
|
+
if (parsed?.scopedDir) {
|
|
304
|
+
out.push({
|
|
305
|
+
type: "cwd",
|
|
306
|
+
category: "cwd",
|
|
307
|
+
data: safeString(parsed.scopedDir),
|
|
277
308
|
priority: 2,
|
|
278
|
-
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (match.operation === "commit") {
|
|
312
|
+
const msg = extractCommitMessageFromCommand(cmd);
|
|
313
|
+
if (msg) {
|
|
314
|
+
out.push({
|
|
315
|
+
type: "git_commit",
|
|
316
|
+
category: "git",
|
|
317
|
+
data: safeString(msg),
|
|
318
|
+
priority: 2,
|
|
319
|
+
});
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
out.push({
|
|
324
|
+
type: "git",
|
|
325
|
+
category: "git",
|
|
326
|
+
data: safeString(match.operation),
|
|
327
|
+
priority: 2,
|
|
328
|
+
});
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Gap #2 (16-oss-verify-gap-prd) — expand leading `~` / `~/` to homedir.
|
|
333
|
+
* Does NOT support `~user/path` (no current-user resolution at bridge
|
|
334
|
+
* layer; that requires a passwd lookup). Returns input unchanged when
|
|
335
|
+
* there is no tilde or the path starts with `~<otheruser>`.
|
|
336
|
+
*/
|
|
337
|
+
function expandHomeTilde(path) {
|
|
338
|
+
if (typeof path !== "string" || path.length === 0)
|
|
339
|
+
return path;
|
|
340
|
+
if (path === "~")
|
|
341
|
+
return getHomedirSafe();
|
|
342
|
+
if (path.startsWith("~/"))
|
|
343
|
+
return getHomedirSafe() + path.slice(1);
|
|
344
|
+
return path;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Lazily-resolved homedir — avoids a require/import at module init time.
|
|
348
|
+
* Falls back to "~" (no-op expansion) when the environment is sandboxed
|
|
349
|
+
* without HOME / USERPROFILE.
|
|
350
|
+
*/
|
|
351
|
+
function getHomedirSafe() {
|
|
352
|
+
try {
|
|
353
|
+
const home = process.env.HOME
|
|
354
|
+
|| process.env.USERPROFILE
|
|
355
|
+
|| (process.env.HOMEDRIVE && process.env.HOMEPATH
|
|
356
|
+
? process.env.HOMEDRIVE + process.env.HOMEPATH
|
|
357
|
+
: "");
|
|
358
|
+
return home || "~";
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return "~";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function parseGitInvocation(cmd) {
|
|
365
|
+
const tokens = tokenizeCommand(cmd);
|
|
366
|
+
let i = 0;
|
|
367
|
+
// Skip env-style assignments at the head (FOO=bar git ...)
|
|
368
|
+
while (i < tokens.length && isEnvAssignment(tokens[i]))
|
|
369
|
+
i++;
|
|
370
|
+
// Locate the `git` token (allow common runners like `sudo git ...`)
|
|
371
|
+
while (i < tokens.length && tokens[i] !== "git" && !tokens[i].endsWith("/git")) {
|
|
372
|
+
// Stop runner-skipping at the first non-assignment, non-runner token
|
|
373
|
+
if (!isCommonRunner(tokens[i]))
|
|
374
|
+
break;
|
|
375
|
+
i++;
|
|
376
|
+
}
|
|
377
|
+
if (i >= tokens.length)
|
|
378
|
+
return null;
|
|
379
|
+
if (tokens[i] !== "git" && !tokens[i].endsWith("/git"))
|
|
380
|
+
return null;
|
|
381
|
+
i++; // consume `git`
|
|
382
|
+
let scopedDir = null;
|
|
383
|
+
let operation = null;
|
|
384
|
+
while (i < tokens.length) {
|
|
385
|
+
const t = tokens[i];
|
|
386
|
+
if (t === "-C" || t === "--directory") {
|
|
387
|
+
scopedDir = tokens[i + 1] ?? null;
|
|
388
|
+
i += 2;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// Gap #2 — `--directory=/path` equals-form (tokenizer keeps it as one)
|
|
392
|
+
if (t.startsWith("--directory=")) {
|
|
393
|
+
scopedDir = t.slice("--directory=".length);
|
|
394
|
+
i++;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (t.length > 0 && t[0] === "-") {
|
|
398
|
+
// Generic flag — skip the flag itself. We do NOT consume the next
|
|
399
|
+
// token as its value generically because git's per-flag arg shape
|
|
400
|
+
// varies; the dedicated extractCommitMessageFromCommand handles -m
|
|
401
|
+
// separately.
|
|
402
|
+
i++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// First bare (non-flag) token after `git` = operation
|
|
406
|
+
operation = t;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
if (scopedDir)
|
|
410
|
+
scopedDir = expandHomeTilde(scopedDir);
|
|
411
|
+
return { scopedDir, operation };
|
|
412
|
+
}
|
|
413
|
+
function isEnvAssignment(token) {
|
|
414
|
+
if (token.length === 0)
|
|
415
|
+
return false;
|
|
416
|
+
// FOO=bar shape: starts with an uppercase letter, contains an `=`
|
|
417
|
+
let sawEq = false;
|
|
418
|
+
for (let j = 0; j < token.length; j++) {
|
|
419
|
+
const c = token.charCodeAt(j);
|
|
420
|
+
if (j === 0) {
|
|
421
|
+
// First char must be A-Z or underscore
|
|
422
|
+
if (!((c >= 65 && c <= 90) || c === 95))
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
else if (c === 61 /* = */) {
|
|
426
|
+
sawEq = true;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
else if (!((c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c === 95)) {
|
|
430
|
+
// Body chars must be A-Z, 0-9, or _
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return sawEq;
|
|
435
|
+
}
|
|
436
|
+
function isCommonRunner(token) {
|
|
437
|
+
// Runners that wrap real commands. We skip them when locating `git`
|
|
438
|
+
// so `sudo git status` works the same as `git status`.
|
|
439
|
+
switch (token) {
|
|
440
|
+
case "sudo":
|
|
441
|
+
case "doas":
|
|
442
|
+
case "env":
|
|
443
|
+
case "exec":
|
|
444
|
+
case "time":
|
|
445
|
+
return true;
|
|
446
|
+
default:
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Shell-like argv tokenizer — handles single/double quotes, backslash escapes,
|
|
451
|
+
// and merges adjacent quoted/unquoted segments per POSIX shell behavior
|
|
452
|
+
// (`echo a"b c"d` → ["ab cd"]). Pure char loop; no regex.
|
|
453
|
+
function tokenizeCommand(cmd) {
|
|
454
|
+
const tokens = [];
|
|
455
|
+
const n = cmd.length;
|
|
456
|
+
let i = 0;
|
|
457
|
+
while (i < n) {
|
|
458
|
+
while (i < n && (cmd[i] === " " || cmd[i] === "\t"))
|
|
459
|
+
i++;
|
|
460
|
+
if (i >= n)
|
|
461
|
+
break;
|
|
462
|
+
let buf = "";
|
|
463
|
+
while (i < n && cmd[i] !== " " && cmd[i] !== "\t") {
|
|
464
|
+
const ch = cmd[i];
|
|
465
|
+
if (ch === '"' || ch === "'") {
|
|
466
|
+
const quote = ch;
|
|
467
|
+
i++;
|
|
468
|
+
while (i < n && cmd[i] !== quote) {
|
|
469
|
+
if (cmd[i] === "\\" && i + 1 < n) {
|
|
470
|
+
buf += cmd[i + 1];
|
|
471
|
+
i += 2;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
buf += cmd[i];
|
|
475
|
+
i++;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (i < n)
|
|
479
|
+
i++; // consume closing quote
|
|
480
|
+
}
|
|
481
|
+
else if (ch === "\\" && i + 1 < n) {
|
|
482
|
+
buf += cmd[i + 1];
|
|
483
|
+
i += 2;
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
buf += ch;
|
|
487
|
+
i++;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
tokens.push(buf);
|
|
491
|
+
}
|
|
492
|
+
return tokens;
|
|
493
|
+
}
|
|
494
|
+
// Linear scan over argv looking for a commit-message-bearing flag:
|
|
495
|
+
// --message=<value> long form, attached value
|
|
496
|
+
// --message <value> long form, separate token
|
|
497
|
+
// -m / -am / -cm ... short cluster ending in 'm', value in next token
|
|
498
|
+
// Returns null when no message arg is present — caller falls back to
|
|
499
|
+
// operation name. Pure char checks; no regex.
|
|
500
|
+
function extractCommitMessageFromCommand(cmd) {
|
|
501
|
+
const argv = tokenizeCommand(cmd);
|
|
502
|
+
const longPrefix = "--message=";
|
|
503
|
+
for (let i = 0; i < argv.length; i++) {
|
|
504
|
+
const arg = argv[i];
|
|
505
|
+
// Long form: --message=VALUE
|
|
506
|
+
if (arg.length > longPrefix.length && arg.startsWith(longPrefix)) {
|
|
507
|
+
const v = arg.slice(longPrefix.length);
|
|
508
|
+
return v.length > 0 ? v : null;
|
|
509
|
+
}
|
|
510
|
+
// Long form: --message VALUE
|
|
511
|
+
if (arg === "--message") {
|
|
512
|
+
const v = argv[i + 1];
|
|
513
|
+
return v && v.length > 0 ? v : null;
|
|
514
|
+
}
|
|
515
|
+
// Short cluster ending in 'm' (e.g. -m, -am, -cm). Cluster must be
|
|
516
|
+
// single-dash followed by only lowercase letters, last letter 'm'.
|
|
517
|
+
if (arg.length >= 2 &&
|
|
518
|
+
arg[0] === "-" &&
|
|
519
|
+
arg[1] !== "-" &&
|
|
520
|
+
arg[arg.length - 1] === "m" &&
|
|
521
|
+
isLowerAlphaRun(arg, 1)) {
|
|
522
|
+
const v = argv[i + 1];
|
|
523
|
+
return v && v.length > 0 ? v : null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
function isLowerAlphaRun(s, start) {
|
|
529
|
+
if (start >= s.length)
|
|
530
|
+
return false;
|
|
531
|
+
for (let i = start; i < s.length; i++) {
|
|
532
|
+
const c = s.charCodeAt(i);
|
|
533
|
+
if (c < 97 || c > 122)
|
|
534
|
+
return false; // not a-z
|
|
535
|
+
}
|
|
536
|
+
return true;
|
|
279
537
|
}
|
|
280
538
|
/**
|
|
281
539
|
* Category 3: task
|
|
@@ -307,6 +565,39 @@ function extractTask(input) {
|
|
|
307
565
|
* Note: Shift+Tab and /plan command do NOT fire PostToolUse hooks
|
|
308
566
|
* (Claude Code bug #15660). Only programmatic EnterPlanMode is tracked.
|
|
309
567
|
*/
|
|
568
|
+
/**
|
|
569
|
+
* FNV-1a 32-bit hash → 8-char lowercase hex. Stable across runs/platforms.
|
|
570
|
+
* Used for plan_hash so identical plans dedupe at the platform side.
|
|
571
|
+
*/
|
|
572
|
+
function fnv1a32Hex(s) {
|
|
573
|
+
let hash = 0x811c9dc5;
|
|
574
|
+
for (let i = 0; i < s.length; i++) {
|
|
575
|
+
hash ^= s.charCodeAt(i);
|
|
576
|
+
hash = Math.imul(hash, 0x01000193);
|
|
577
|
+
}
|
|
578
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Read the plan text from the ExitPlanMode envelope. SDK carries it on
|
|
582
|
+
* the OUTPUT (ExitPlanModeOutput @ :2222), but the PRD body cites input.
|
|
583
|
+
* Try both so we are spec-flexible.
|
|
584
|
+
*/
|
|
585
|
+
function extractExitPlanText(input) {
|
|
586
|
+
const inputPlan = input.tool_input["plan"];
|
|
587
|
+
if (typeof inputPlan === "string" && inputPlan.length > 0)
|
|
588
|
+
return inputPlan;
|
|
589
|
+
const resp = input.tool_response;
|
|
590
|
+
if (typeof resp === "string" && resp.length > 0) {
|
|
591
|
+
try {
|
|
592
|
+
const parsed = JSON.parse(resp);
|
|
593
|
+
if (parsed && typeof parsed === "object" && typeof parsed.plan === "string") {
|
|
594
|
+
return parsed.plan;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch { /* fall through */ }
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
310
601
|
function extractPlan(input) {
|
|
311
602
|
if (input.tool_name === "EnterPlanMode") {
|
|
312
603
|
return [{
|
|
@@ -320,13 +611,22 @@ function extractPlan(input) {
|
|
|
320
611
|
const events = [];
|
|
321
612
|
// Plan exit event with allowedPrompts detail
|
|
322
613
|
const prompts = input.tool_input["allowedPrompts"];
|
|
323
|
-
|
|
614
|
+
let detail = Array.isArray(prompts) && prompts.length > 0
|
|
324
615
|
? `exited plan mode (allowed: ${safeStringAny(prompts.map((p) => {
|
|
325
616
|
if (typeof p === "object" && p !== null && "prompt" in p)
|
|
326
617
|
return String(p.prompt);
|
|
327
618
|
return String(p);
|
|
328
619
|
}).join(", "))})`
|
|
329
620
|
: "exited plan mode";
|
|
621
|
+
// §11 / PRD #6 — append plan_bytes + plan_hash so the platform can
|
|
622
|
+
// dedupe identical plans across sessions and JOIN plan_mode_authorized
|
|
623
|
+
// writes against a stable plan id. Plan source: tool_input.plan first
|
|
624
|
+
// (per PRD), fall back to tool_response.plan (SDK actually carries it
|
|
625
|
+
// there per ExitPlanModeOutput @ sdk-tools.d.ts:2222).
|
|
626
|
+
const plan = extractExitPlanText(input);
|
|
627
|
+
if (typeof plan === "string" && plan.length > 0) {
|
|
628
|
+
detail += ` plan_bytes:${plan.length} plan_hash:${fnv1a32Hex(plan)}`;
|
|
629
|
+
}
|
|
330
630
|
events.push({
|
|
331
631
|
type: "plan_exit",
|
|
332
632
|
category: "plan",
|
|
@@ -757,16 +1057,310 @@ function extractExternalRef(input) {
|
|
|
757
1057
|
}
|
|
758
1058
|
/**
|
|
759
1059
|
* Category 8: env (worktree)
|
|
760
|
-
* EnterWorktree
|
|
1060
|
+
* EnterWorktree + ExitWorktree tools — tracks worktree lifecycle.
|
|
761
1061
|
*/
|
|
762
1062
|
function extractWorktree(input) {
|
|
763
|
-
if (input.tool_name
|
|
1063
|
+
if (input.tool_name === "EnterWorktree") {
|
|
1064
|
+
const name = String(input.tool_input["name"] ?? "unnamed");
|
|
1065
|
+
return [{
|
|
1066
|
+
type: "worktree",
|
|
1067
|
+
category: "env",
|
|
1068
|
+
data: safeString(`entered worktree: ${name}`),
|
|
1069
|
+
priority: 2,
|
|
1070
|
+
}];
|
|
1071
|
+
}
|
|
1072
|
+
if (input.tool_name === "ExitWorktree") {
|
|
1073
|
+
const discard = Boolean(input.tool_input["discard_changes"]);
|
|
1074
|
+
return [{
|
|
1075
|
+
type: "worktree_exit",
|
|
1076
|
+
category: "env",
|
|
1077
|
+
data: safeString(`exited worktree (discard_changes:${discard})`),
|
|
1078
|
+
priority: 2,
|
|
1079
|
+
}];
|
|
1080
|
+
}
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Algorithmic URL host extraction — no regex.
|
|
1085
|
+
* Skips scheme, returns everything up to the first path/query/fragment marker.
|
|
1086
|
+
* Port is preserved as part of the host signature.
|
|
1087
|
+
*/
|
|
1088
|
+
function extractHostFromUrl(url) {
|
|
1089
|
+
if (typeof url !== "string" || url.length === 0)
|
|
1090
|
+
return null;
|
|
1091
|
+
const protoEnd = url.indexOf("://");
|
|
1092
|
+
if (protoEnd < 0)
|
|
1093
|
+
return null;
|
|
1094
|
+
const start = protoEnd + 3;
|
|
1095
|
+
if (start >= url.length)
|
|
1096
|
+
return null;
|
|
1097
|
+
let end = url.length;
|
|
1098
|
+
for (let i = start; i < url.length; i++) {
|
|
1099
|
+
const c = url.charCodeAt(i);
|
|
1100
|
+
if (c === 47 || c === 63 || c === 35) {
|
|
1101
|
+
end = i;
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const host = url.slice(start, end);
|
|
1106
|
+
return host.length > 0 ? host : null;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* WebFetch response metadata — captures bytes/code/durationMs and host
|
|
1110
|
+
* (privacy: never the full URL or query string). Redirect-loop detection
|
|
1111
|
+
* is temporal, not single-field — SDK has no redirect_url.
|
|
1112
|
+
*/
|
|
1113
|
+
function extractWebFetchMetadata(input) {
|
|
1114
|
+
if (input.tool_name !== "WebFetch")
|
|
1115
|
+
return [];
|
|
1116
|
+
const resp = input.tool_response;
|
|
1117
|
+
if (typeof resp !== "string" || resp.length === 0)
|
|
1118
|
+
return [];
|
|
1119
|
+
let parsed;
|
|
1120
|
+
try {
|
|
1121
|
+
parsed = JSON.parse(resp);
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
return [];
|
|
1125
|
+
}
|
|
1126
|
+
if (!parsed || typeof parsed !== "object")
|
|
1127
|
+
return [];
|
|
1128
|
+
const obj = parsed;
|
|
1129
|
+
const parts = [];
|
|
1130
|
+
if (typeof obj.code === "number")
|
|
1131
|
+
parts.push(`code:${obj.code}`);
|
|
1132
|
+
if (typeof obj.bytes === "number")
|
|
1133
|
+
parts.push(`bytes:${obj.bytes}`);
|
|
1134
|
+
if (typeof obj.durationMs === "number")
|
|
1135
|
+
parts.push(`durMs:${obj.durationMs}`);
|
|
1136
|
+
if (typeof obj.url === "string") {
|
|
1137
|
+
const host = extractHostFromUrl(obj.url);
|
|
1138
|
+
if (host)
|
|
1139
|
+
parts.push(`host:${host}`);
|
|
1140
|
+
}
|
|
1141
|
+
if (parts.length === 0)
|
|
764
1142
|
return [];
|
|
765
|
-
const name = String(input.tool_input["name"] ?? "unnamed");
|
|
766
1143
|
return [{
|
|
767
|
-
type: "
|
|
768
|
-
category: "
|
|
769
|
-
data: safeString(
|
|
1144
|
+
type: "webfetch_metadata",
|
|
1145
|
+
category: "data",
|
|
1146
|
+
data: safeString(parts.join(" ")),
|
|
1147
|
+
priority: 3,
|
|
1148
|
+
}];
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Bash outcome signals — captures the three fields that DO exist on
|
|
1152
|
+
* BashOutput (SDK :2160-2200): interrupted (boolean), stderr (length-only
|
|
1153
|
+
* for privacy), returnCodeInterpretation (semantic non-zero exit hint).
|
|
1154
|
+
* NO exit_code field exists in the SDK.
|
|
1155
|
+
*/
|
|
1156
|
+
function extractBashOutcome(input) {
|
|
1157
|
+
if (input.tool_name !== "Bash")
|
|
1158
|
+
return [];
|
|
1159
|
+
const resp = input.tool_response;
|
|
1160
|
+
if (typeof resp !== "string" || resp.length === 0)
|
|
1161
|
+
return [];
|
|
1162
|
+
let parsed;
|
|
1163
|
+
try {
|
|
1164
|
+
parsed = JSON.parse(resp);
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
return [];
|
|
1168
|
+
}
|
|
1169
|
+
if (!parsed || typeof parsed !== "object")
|
|
1170
|
+
return [];
|
|
1171
|
+
const obj = parsed;
|
|
1172
|
+
const hasSignal = typeof obj.interrupted === "boolean" ||
|
|
1173
|
+
typeof obj.stderr === "string" ||
|
|
1174
|
+
typeof obj.returnCodeInterpretation === "string";
|
|
1175
|
+
if (!hasSignal)
|
|
1176
|
+
return [];
|
|
1177
|
+
const parts = [];
|
|
1178
|
+
if (typeof obj.interrupted === "boolean") {
|
|
1179
|
+
parts.push(`interrupted:${obj.interrupted}`);
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof obj.returnCodeInterpretation === "string") {
|
|
1182
|
+
parts.push(`rcInterp:${obj.returnCodeInterpretation.slice(0, 80)}`);
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof obj.stderr === "string") {
|
|
1185
|
+
parts.push(`stderrBytes:${obj.stderr.length}`);
|
|
1186
|
+
}
|
|
1187
|
+
return [{
|
|
1188
|
+
type: "bash_outcome",
|
|
1189
|
+
category: "data",
|
|
1190
|
+
data: safeString(parts.join(" ")),
|
|
1191
|
+
priority: 3,
|
|
1192
|
+
}];
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* FileReadOutput size metadata — branches on the text/image variant.
|
|
1196
|
+
* Captures sizes/line counts only; never file content. Image dimensions
|
|
1197
|
+
* are formatted as "WxH" when both width/height are numeric.
|
|
1198
|
+
*/
|
|
1199
|
+
function extractFileReadMetadata(input) {
|
|
1200
|
+
if (input.tool_name !== "Read")
|
|
1201
|
+
return [];
|
|
1202
|
+
const resp = input.tool_response;
|
|
1203
|
+
if (typeof resp !== "string" || resp.length === 0)
|
|
1204
|
+
return [];
|
|
1205
|
+
let parsed;
|
|
1206
|
+
try {
|
|
1207
|
+
parsed = JSON.parse(resp);
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
return [];
|
|
1211
|
+
}
|
|
1212
|
+
if (!parsed || typeof parsed !== "object")
|
|
1213
|
+
return [];
|
|
1214
|
+
const obj = parsed;
|
|
1215
|
+
const variant = obj.type;
|
|
1216
|
+
if (variant !== "text" && variant !== "image")
|
|
1217
|
+
return [];
|
|
1218
|
+
const parts = [`type:${variant}`];
|
|
1219
|
+
if (variant === "text") {
|
|
1220
|
+
if (typeof obj.numLines === "number")
|
|
1221
|
+
parts.push(`lines:${obj.numLines}`);
|
|
1222
|
+
if (typeof obj.totalLines === "number")
|
|
1223
|
+
parts.push(`totalLines:${obj.totalLines}`);
|
|
1224
|
+
if (typeof obj.startLine === "number")
|
|
1225
|
+
parts.push(`start:${obj.startLine}`);
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
if (typeof obj.originalSize === "number")
|
|
1229
|
+
parts.push(`origSize:${obj.originalSize}`);
|
|
1230
|
+
const dims = obj.dimensions;
|
|
1231
|
+
if (dims && typeof dims === "object") {
|
|
1232
|
+
const d = dims;
|
|
1233
|
+
if (typeof d.width === "number" && typeof d.height === "number") {
|
|
1234
|
+
parts.push(`dims:${d.width}x${d.height}`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return [{
|
|
1239
|
+
type: "file_read_metadata",
|
|
1240
|
+
category: "data",
|
|
1241
|
+
data: safeString(parts.join(" ")),
|
|
1242
|
+
priority: 3,
|
|
1243
|
+
}];
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Per-model USD price table — Anthropic public list pricing, $/MTok.
|
|
1247
|
+
* Verified against platform.claude.com/docs/en/about-claude/pricing,
|
|
1248
|
+
* cloudzero.com, finout.io 2026-06 (cache: 5-min cache_write = 1.25× input,
|
|
1249
|
+
* cache_read = 0.10× input). Fast-mode variants (e.g. opus-4-8-fast at
|
|
1250
|
+
* $10/$50) are intentionally NOT mapped — they ship as separate model
|
|
1251
|
+
* ids and would dilute the standard-tier dashboards if blended here.
|
|
1252
|
+
*
|
|
1253
|
+
* NOTE: 16-oss-verify-gap-prd Gap #1 quoted Opus at $15/$75 — that is
|
|
1254
|
+
* the prior Opus 4 (non-4.7) rate. Opus 4.7 and 4.8 ship at $5/$25.
|
|
1255
|
+
*/
|
|
1256
|
+
const MODEL_PRICING_USD_PER_MTOK = {
|
|
1257
|
+
"claude-opus-4-8": { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 },
|
|
1258
|
+
"claude-opus-4-7": { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 },
|
|
1259
|
+
"claude-sonnet-4-6": { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
1260
|
+
"claude-haiku-4-5": { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 },
|
|
1261
|
+
default: { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
1262
|
+
};
|
|
1263
|
+
function resolveModelKey(input, parsedResp) {
|
|
1264
|
+
const candidates = [
|
|
1265
|
+
input.tool_input?.model,
|
|
1266
|
+
input.model,
|
|
1267
|
+
parsedResp.model,
|
|
1268
|
+
];
|
|
1269
|
+
const keys = Object.keys(MODEL_PRICING_USD_PER_MTOK).filter((k) => k !== "default");
|
|
1270
|
+
for (const c of candidates) {
|
|
1271
|
+
if (typeof c !== "string" || c.length === 0)
|
|
1272
|
+
continue;
|
|
1273
|
+
if (c in MODEL_PRICING_USD_PER_MTOK)
|
|
1274
|
+
return c;
|
|
1275
|
+
// Prefix match for date-suffixed model ids
|
|
1276
|
+
// (e.g. claude-haiku-4-5-20251001 → claude-haiku-4-5)
|
|
1277
|
+
for (const key of keys) {
|
|
1278
|
+
if (c.startsWith(key))
|
|
1279
|
+
return key;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return "default";
|
|
1283
|
+
}
|
|
1284
|
+
function computeCostUsd(modelKey, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
|
|
1285
|
+
const price = MODEL_PRICING_USD_PER_MTOK[modelKey] ?? MODEL_PRICING_USD_PER_MTOK.default;
|
|
1286
|
+
const totalMicroDollars = inputTokens * price.input +
|
|
1287
|
+
outputTokens * price.output +
|
|
1288
|
+
cacheCreationTokens * price.cache_write +
|
|
1289
|
+
cacheReadTokens * price.cache_read;
|
|
1290
|
+
return totalMicroDollars / 1_000_000;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* AgentOutput.usage capture — fires on the Task sub-agent dispatcher.
|
|
1294
|
+
* Captures the 7 cost/perf fields from sdk-tools.d.ts:64-75. Derives
|
|
1295
|
+
* cost_usd from per-model pricing (Gap #1 fix). The platform persists
|
|
1296
|
+
* these as typed columns post-release; the bridge emits them as
|
|
1297
|
+
* structured tokens in event.data for forward-compatible ingestion.
|
|
1298
|
+
*/
|
|
1299
|
+
function extractAgentUsage(input) {
|
|
1300
|
+
if (input.tool_name !== "Task")
|
|
1301
|
+
return [];
|
|
1302
|
+
const resp = input.tool_response;
|
|
1303
|
+
if (typeof resp !== "string" || resp.length === 0)
|
|
1304
|
+
return [];
|
|
1305
|
+
let parsed;
|
|
1306
|
+
try {
|
|
1307
|
+
parsed = JSON.parse(resp);
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
return [];
|
|
1311
|
+
}
|
|
1312
|
+
if (!parsed || typeof parsed !== "object")
|
|
1313
|
+
return [];
|
|
1314
|
+
const out = parsed;
|
|
1315
|
+
const usage = (out.usage && typeof out.usage === "object")
|
|
1316
|
+
? out.usage
|
|
1317
|
+
: {};
|
|
1318
|
+
const hasSignal = typeof out.totalTokens === "number" ||
|
|
1319
|
+
typeof out.totalDurationMs === "number" ||
|
|
1320
|
+
typeof usage.input_tokens === "number" ||
|
|
1321
|
+
typeof usage.output_tokens === "number" ||
|
|
1322
|
+
typeof usage.service_tier === "string";
|
|
1323
|
+
if (!hasSignal)
|
|
1324
|
+
return [];
|
|
1325
|
+
const parts = [];
|
|
1326
|
+
if (typeof out.totalTokens === "number")
|
|
1327
|
+
parts.push(`totalTokens:${out.totalTokens}`);
|
|
1328
|
+
if (typeof out.totalDurationMs === "number")
|
|
1329
|
+
parts.push(`totalDurMs:${out.totalDurationMs}`);
|
|
1330
|
+
if (typeof usage.input_tokens === "number")
|
|
1331
|
+
parts.push(`tokens_in:${usage.input_tokens}`);
|
|
1332
|
+
if (typeof usage.output_tokens === "number")
|
|
1333
|
+
parts.push(`tokens_out:${usage.output_tokens}`);
|
|
1334
|
+
if (typeof usage.cache_creation_input_tokens === "number") {
|
|
1335
|
+
parts.push(`cache_create:${usage.cache_creation_input_tokens}`);
|
|
1336
|
+
}
|
|
1337
|
+
if (typeof usage.cache_read_input_tokens === "number") {
|
|
1338
|
+
parts.push(`cache_read:${usage.cache_read_input_tokens}`);
|
|
1339
|
+
}
|
|
1340
|
+
if (typeof usage.service_tier === "string") {
|
|
1341
|
+
parts.push(`tier:${usage.service_tier.slice(0, 32)}`);
|
|
1342
|
+
}
|
|
1343
|
+
// Gap #1 (16-oss-verify-gap-prd) — derive cost_usd from per-model pricing
|
|
1344
|
+
// when at least one token count is present. Zero-token case skips cost
|
|
1345
|
+
// so dashboard never shows misleading "$0.00 for nothing" rows.
|
|
1346
|
+
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
1347
|
+
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
1348
|
+
const cacheCreate = typeof usage.cache_creation_input_tokens === "number"
|
|
1349
|
+
? usage.cache_creation_input_tokens
|
|
1350
|
+
: 0;
|
|
1351
|
+
const cacheRead = typeof usage.cache_read_input_tokens === "number"
|
|
1352
|
+
? usage.cache_read_input_tokens
|
|
1353
|
+
: 0;
|
|
1354
|
+
const anyTokens = inputTokens > 0 || outputTokens > 0 || cacheCreate > 0 || cacheRead > 0;
|
|
1355
|
+
if (anyTokens) {
|
|
1356
|
+
const modelKey = resolveModelKey(input, out);
|
|
1357
|
+
const cost = computeCostUsd(modelKey, inputTokens, outputTokens, cacheCreate, cacheRead);
|
|
1358
|
+
parts.push(`cost_usd:${cost.toFixed(6).replace(/0+$/, "").replace(/\.$/, ".0")}`);
|
|
1359
|
+
}
|
|
1360
|
+
return [{
|
|
1361
|
+
type: "agent_usage",
|
|
1362
|
+
category: "cost",
|
|
1363
|
+
data: safeString(parts.join(" ")),
|
|
770
1364
|
priority: 2,
|
|
771
1365
|
}];
|
|
772
1366
|
}
|
|
@@ -1189,6 +1783,10 @@ export function extractEvents(rawInput) {
|
|
|
1189
1783
|
events.push(...extractDecision(input));
|
|
1190
1784
|
events.push(...extractConstraint(input));
|
|
1191
1785
|
events.push(...extractWorktree(input));
|
|
1786
|
+
events.push(...extractWebFetchMetadata(input));
|
|
1787
|
+
events.push(...extractBashOutcome(input));
|
|
1788
|
+
events.push(...extractFileReadMetadata(input));
|
|
1789
|
+
events.push(...extractAgentUsage(input));
|
|
1192
1790
|
events.push(...extractAgentFinding(input));
|
|
1193
1791
|
events.push(...extractExternalRef(input));
|
|
1194
1792
|
// Cross-event stateful extractors
|
|
@@ -1210,6 +1808,7 @@ export function extractEvents(rawInput) {
|
|
|
1210
1808
|
export function extractUserEvents(message) {
|
|
1211
1809
|
try {
|
|
1212
1810
|
const events = [];
|
|
1811
|
+
events.push(...extractUserPlan(message));
|
|
1213
1812
|
events.push(...extractUserDecision(message));
|
|
1214
1813
|
events.push(...extractRole(message));
|
|
1215
1814
|
events.push(...extractIntent(message));
|
|
@@ -1222,3 +1821,147 @@ export function extractUserEvents(message) {
|
|
|
1222
1821
|
return [];
|
|
1223
1822
|
}
|
|
1224
1823
|
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Issue #4 (new PRD) — SessionStart settings + MCP servers snapshot.
|
|
1826
|
+
*
|
|
1827
|
+
* Emits ONE session_settings_snapshot event when ≥1 setting is available
|
|
1828
|
+
* on the SessionStart input. The data field carries key:value tokens
|
|
1829
|
+
* (mcp_count, mcp_servers, model, permission_mode) so the platform can
|
|
1830
|
+
* compute MCP integration counts and primary-model adoption per org.
|
|
1831
|
+
* mcp_servers list is truncated to first 8 names.
|
|
1832
|
+
*/
|
|
1833
|
+
export function extractSessionSettings(input) {
|
|
1834
|
+
if (!input || typeof input !== "object")
|
|
1835
|
+
return [];
|
|
1836
|
+
const obj = input;
|
|
1837
|
+
const parts = [];
|
|
1838
|
+
const mcpServers = obj.mcp_servers;
|
|
1839
|
+
let mcpKeys = null;
|
|
1840
|
+
if (mcpServers && typeof mcpServers === "object" && !Array.isArray(mcpServers)) {
|
|
1841
|
+
mcpKeys = Object.keys(mcpServers);
|
|
1842
|
+
parts.push(`mcp_count:${mcpKeys.length}`);
|
|
1843
|
+
if (mcpKeys.length > 0) {
|
|
1844
|
+
parts.push(`mcp_servers:${mcpKeys.slice(0, 8).join(",")}`);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (typeof obj.model === "string") {
|
|
1848
|
+
parts.push(`model:${obj.model.slice(0, 64)}`);
|
|
1849
|
+
}
|
|
1850
|
+
if (typeof obj.permission_mode === "string") {
|
|
1851
|
+
parts.push(`permission_mode:${obj.permission_mode.slice(0, 32)}`);
|
|
1852
|
+
}
|
|
1853
|
+
if (parts.length === 0)
|
|
1854
|
+
return [];
|
|
1855
|
+
return [{
|
|
1856
|
+
type: "session_settings_snapshot",
|
|
1857
|
+
category: "env",
|
|
1858
|
+
data: safeString(parts.join(" ")),
|
|
1859
|
+
priority: 2,
|
|
1860
|
+
}];
|
|
1861
|
+
}
|
|
1862
|
+
const PROMPT_SCRIPT_NAMES = [
|
|
1863
|
+
"Latin", "Cyrillic", "Arabic", "Han", "Hangul",
|
|
1864
|
+
"Hiragana", "Katakana", "Devanagari", "Hebrew", "Thai", "Greek",
|
|
1865
|
+
];
|
|
1866
|
+
const EMPTY_PROMPT_FEATURES = {
|
|
1867
|
+
prompt_length: 0,
|
|
1868
|
+
prompt_word_count: 0,
|
|
1869
|
+
prompt_uppercase_ratio: 0,
|
|
1870
|
+
prompt_file_ref_count: 0,
|
|
1871
|
+
prompt_path_ref_count: 0,
|
|
1872
|
+
prompt_script_primary: null,
|
|
1873
|
+
prompt_script_count: 0,
|
|
1874
|
+
prompt_question_glyph_count: 0,
|
|
1875
|
+
prompt_code_block_count: 0,
|
|
1876
|
+
prompt_url_count: 0,
|
|
1877
|
+
prompt_word_tokens: [],
|
|
1878
|
+
};
|
|
1879
|
+
/**
|
|
1880
|
+
* Verbatim mirror of §11 Layer 1 reference implementation + Layer 3
|
|
1881
|
+
* token extraction. Uses Unicode property regex per the spec — the
|
|
1882
|
+
* "no regex" project default does NOT apply here because the spec
|
|
1883
|
+
* explicitly mandates `\p{Script=X}` for script-agnostic classification.
|
|
1884
|
+
*/
|
|
1885
|
+
export function extractUserPromptFeatures(prompt) {
|
|
1886
|
+
if (typeof prompt !== "string" || prompt.length === 0) {
|
|
1887
|
+
return { ...EMPTY_PROMPT_FEATURES, prompt_word_tokens: [] };
|
|
1888
|
+
}
|
|
1889
|
+
const letters = prompt.match(/\p{L}+/gu) ?? [];
|
|
1890
|
+
const upperCount = (prompt.match(/\p{Lu}/gu) ?? []).length;
|
|
1891
|
+
const totalLetters = letters.join("").length;
|
|
1892
|
+
const fences = (prompt.match(/```/g) ?? []).length;
|
|
1893
|
+
const scripts = {};
|
|
1894
|
+
for (const name of PROMPT_SCRIPT_NAMES) {
|
|
1895
|
+
const re = new RegExp(`\\p{Script=${name}}`, "gu");
|
|
1896
|
+
const n = (prompt.match(re) ?? []).length;
|
|
1897
|
+
if (n > 0)
|
|
1898
|
+
scripts[name] = n;
|
|
1899
|
+
}
|
|
1900
|
+
const primary = Object.entries(scripts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
1901
|
+
const seen = new Set();
|
|
1902
|
+
const tokens = [];
|
|
1903
|
+
for (const word of letters) {
|
|
1904
|
+
if (word.length < 3)
|
|
1905
|
+
continue;
|
|
1906
|
+
const lower = word.toLowerCase();
|
|
1907
|
+
if (seen.has(lower))
|
|
1908
|
+
continue;
|
|
1909
|
+
seen.add(lower);
|
|
1910
|
+
tokens.push(lower);
|
|
1911
|
+
}
|
|
1912
|
+
return {
|
|
1913
|
+
prompt_length: prompt.length,
|
|
1914
|
+
prompt_word_count: letters.length,
|
|
1915
|
+
prompt_uppercase_ratio: totalLetters === 0 ? 0 : upperCount / totalLetters,
|
|
1916
|
+
prompt_file_ref_count: (prompt.match(/(\w+\/)+\w+\.\w+/g) ?? []).length,
|
|
1917
|
+
prompt_path_ref_count: (prompt.match(/\.{0,2}\/[\w\/.-]+/g) ?? []).length,
|
|
1918
|
+
prompt_script_primary: primary,
|
|
1919
|
+
prompt_script_count: Object.keys(scripts).length,
|
|
1920
|
+
prompt_question_glyph_count: (prompt.match(/[??؟]/gu) ?? []).length,
|
|
1921
|
+
prompt_code_block_count: Math.floor(fences / 2),
|
|
1922
|
+
prompt_url_count: (prompt.match(/https?:\/\/[^\s]+/gu) ?? []).length,
|
|
1923
|
+
prompt_word_tokens: tokens,
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* UserPromptSubmit-driven `/plan` slash detector.
|
|
1928
|
+
*
|
|
1929
|
+
* Compensates for Claude Code Bug #15660: programmatic EnterPlanMode tool
|
|
1930
|
+
* calls fire PostToolUse, but the `/plan` slash command and Shift+Tab do
|
|
1931
|
+
* NOT. Shift+Tab is unrecoverable from the OSS bridge without an upstream
|
|
1932
|
+
* SDK change; this detector handles the slash case.
|
|
1933
|
+
*
|
|
1934
|
+
* Algorithmic (no regex): tolerate leading whitespace, require lowercase
|
|
1935
|
+
* "/plan", reject longer slashes like "/plans" via the next-char check.
|
|
1936
|
+
*/
|
|
1937
|
+
function extractUserPlan(message) {
|
|
1938
|
+
if (typeof message !== "string" || message.length === 0)
|
|
1939
|
+
return [];
|
|
1940
|
+
let i = 0;
|
|
1941
|
+
while (i < message.length) {
|
|
1942
|
+
const c = message.charCodeAt(i);
|
|
1943
|
+
if (c !== 32 && c !== 9)
|
|
1944
|
+
break;
|
|
1945
|
+
i++;
|
|
1946
|
+
}
|
|
1947
|
+
if (i + 5 > message.length)
|
|
1948
|
+
return [];
|
|
1949
|
+
if (message.slice(i, i + 5) !== "/plan")
|
|
1950
|
+
return [];
|
|
1951
|
+
if (i + 5 < message.length) {
|
|
1952
|
+
const next = message.charCodeAt(i + 5);
|
|
1953
|
+
const isWordBoundary = next === 32 || next === 9 || next === 10 || next === 13;
|
|
1954
|
+
if (!isWordBoundary)
|
|
1955
|
+
return [];
|
|
1956
|
+
}
|
|
1957
|
+
const arg = message.slice(i + 5).trim();
|
|
1958
|
+
const detail = arg.length > 0
|
|
1959
|
+
? `plan via /plan slash: ${arg.slice(0, 120)}`
|
|
1960
|
+
: "plan via /plan slash";
|
|
1961
|
+
return [{
|
|
1962
|
+
type: "plan_enter",
|
|
1963
|
+
category: "plan",
|
|
1964
|
+
data: safeString(detail),
|
|
1965
|
+
priority: 2,
|
|
1966
|
+
}];
|
|
1967
|
+
}
|