linkshell-cli 0.3.8 → 0.3.10

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.
@@ -2,7 +2,7 @@ import { closeSync, existsSync, openSync, readFileSync, readdirSync, readSync, s
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
 
5
- const SAMPLE_BYTES = 64 * 1024;
5
+ const SAMPLE_BYTES = 512 * 1024;
6
6
  const HISTORY_BYTES = 2 * 1024 * 1024;
7
7
  const MAX_SESSIONS = 200;
8
8
  const MAX_HISTORY_ITEMS = 200;
@@ -19,10 +19,38 @@ export interface CodexStoredSession {
19
19
  export interface StoredAgentTimelineItem {
20
20
  id: string;
21
21
  conversationId: string;
22
- type: "message";
23
- role: "user" | "assistant" | "system";
24
- content: Array<{ type: "text"; text: string }>;
25
- text: string;
22
+ type: "message" | "tool_call";
23
+ kind?: "tool_activity" | "command_execution" | "file_change";
24
+ itemId?: string;
25
+ role?: "user" | "assistant" | "system";
26
+ content?: Array<{ type: "text"; text: string }>;
27
+ text?: string;
28
+ toolCall?: {
29
+ id: string;
30
+ name: string;
31
+ input?: string;
32
+ output?: string;
33
+ createdAt?: number;
34
+ status: "pending" | "running" | "completed" | "failed";
35
+ };
36
+ commandExecution?: {
37
+ command?: string;
38
+ cwd?: string;
39
+ output?: string;
40
+ exitCode?: number | null;
41
+ status?: "pending" | "running" | "completed" | "failed";
42
+ };
43
+ fileChange?: {
44
+ entries: Array<{
45
+ path: string;
46
+ kind?: string;
47
+ added?: number;
48
+ removed?: number;
49
+ }>;
50
+ diff?: string;
51
+ summary?: string;
52
+ status?: "pending" | "running" | "completed" | "failed";
53
+ };
26
54
  createdAt: number;
27
55
  updatedAt?: number;
28
56
  metadata?: Record<string, unknown>;
@@ -169,6 +197,7 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
169
197
 
170
198
  let id = sessionIdFromRolloutFile(filePath);
171
199
  let cwd: string | undefined;
200
+ let title: string | undefined;
172
201
  let createdAt: number | undefined;
173
202
  let lastActivityAt: number | undefined;
174
203
  for (const line of readSample(filePath, statSize).split(/\r?\n/)) {
@@ -182,6 +211,9 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
182
211
  if (typeof payload.cwd === "string" && payload.cwd.trim()) cwd = payload.cwd;
183
212
  createdAt ??= parseTimestamp(payload.timestamp);
184
213
  }
214
+ if (entry?.type === "event_msg" && payload?.type === "user_message") {
215
+ title ??= normalizeTitle(normalizeHistoryText(payload.message));
216
+ }
185
217
  const timestamp = parseTimestamp(entry?.timestamp);
186
218
  createdAt ??= timestamp;
187
219
  if (timestamp) lastActivityAt = timestamp;
@@ -194,6 +226,7 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
194
226
  return {
195
227
  id,
196
228
  cwd: cwd ?? resolve(fallbackCwd),
229
+ title,
197
230
  createdAt,
198
231
  lastModified: lastActivityAt ?? statMtime,
199
232
  };
@@ -271,6 +304,40 @@ function normalizeHistoryText(value: unknown): string | undefined {
271
304
  return normalizeHistoryText(record.content ?? record.text ?? record.message);
272
305
  }
273
306
 
307
+ function stringifyHistoryValue(value: unknown): string | undefined {
308
+ if (value === undefined || value === null || value === "") return undefined;
309
+ if (typeof value === "string") return value;
310
+ try {
311
+ return JSON.stringify(value, null, 2);
312
+ } catch {
313
+ return String(value);
314
+ }
315
+ }
316
+
317
+ function visibleCodexRole(value: unknown): "user" | "assistant" | undefined {
318
+ return value === "user" || value === "assistant" ? value : undefined;
319
+ }
320
+
321
+ function isInjectedCodexContext(text: string): boolean {
322
+ const trimmed = text.trimStart();
323
+ return (
324
+ trimmed.startsWith("<permissions instructions>") ||
325
+ trimmed.startsWith("<app-context>") ||
326
+ trimmed.startsWith("<environment_context>") ||
327
+ trimmed.startsWith("<skill>") ||
328
+ trimmed.startsWith("<turn_aborted>") ||
329
+ trimmed.startsWith("<developer") ||
330
+ trimmed.startsWith("# AGENTS.md instructions") ||
331
+ trimmed.startsWith("AGENTS.md instructions for ") ||
332
+ trimmed.includes("\n# AGENTS.md\n") ||
333
+ trimmed.includes("\n<INSTRUCTIONS>")
334
+ );
335
+ }
336
+
337
+ function messageDedupeKey(role: "user" | "assistant" | "system", text: string): string {
338
+ return `${role}:${text.replace(/\s+/g, " ").trim().slice(0, 400)}`;
339
+ }
340
+
274
341
  function historyMessage(
275
342
  conversationId: string,
276
343
  index: number,
@@ -292,6 +359,207 @@ function historyMessage(
292
359
  };
293
360
  }
294
361
 
362
+ function historyToolName(name: string | undefined): string {
363
+ if (!name) return "工具";
364
+ if (name.endsWith("exec_command") || name.endsWith("write_stdin")) return "命令";
365
+ if (name === "apply_patch") return "文件修改";
366
+ return name;
367
+ }
368
+
369
+ function historyToolKind(name: string | undefined): "tool_activity" | "command_execution" {
370
+ return name?.endsWith("exec_command") || name?.endsWith("write_stdin") ? "command_execution" : "tool_activity";
371
+ }
372
+
373
+ function commandFromCodexTool(name: string | undefined, rawInput: unknown, output?: string): StoredAgentTimelineItem["commandExecution"] {
374
+ if (!name?.endsWith("exec_command") && !name?.endsWith("write_stdin")) return undefined;
375
+ let input = asRecord(rawInput);
376
+ if (!input && typeof rawInput === "string" && rawInput.trim().startsWith("{")) {
377
+ try {
378
+ input = asRecord(JSON.parse(rawInput));
379
+ } catch {
380
+ input = undefined;
381
+ }
382
+ }
383
+ const command = typeof input?.cmd === "string"
384
+ ? input.cmd
385
+ : typeof input?.chars === "string"
386
+ ? input.chars
387
+ : undefined;
388
+ const cwd = typeof input?.workdir === "string" ? input.workdir : undefined;
389
+ if (!command && !cwd && !output) return undefined;
390
+ return { command, cwd, output, status: output === undefined ? "running" : "completed" };
391
+ }
392
+
393
+ function upsertHistoryTool(
394
+ itemsById: Map<string, StoredAgentTimelineItem>,
395
+ conversationId: string,
396
+ callId: string,
397
+ name: string | undefined,
398
+ rawInput: unknown,
399
+ input: string | undefined,
400
+ createdAt: number,
401
+ ): void {
402
+ const id = `history-tool:${callId}`;
403
+ const existing = itemsById.get(id);
404
+ const commandExecution = commandFromCodexTool(name, rawInput, existing?.commandExecution?.output);
405
+ itemsById.set(id, {
406
+ id,
407
+ conversationId,
408
+ type: "tool_call",
409
+ kind: historyToolKind(name),
410
+ itemId: callId,
411
+ toolCall: {
412
+ id: callId,
413
+ name: historyToolName(name),
414
+ input: input ?? existing?.toolCall?.input,
415
+ output: existing?.toolCall?.output,
416
+ createdAt: existing?.toolCall?.createdAt ?? createdAt,
417
+ status: existing?.toolCall?.status ?? "running",
418
+ },
419
+ commandExecution: commandExecution ?? existing?.commandExecution,
420
+ createdAt: existing?.createdAt ?? createdAt,
421
+ updatedAt: createdAt,
422
+ metadata: { source: "device-history", provider: "codex" },
423
+ });
424
+ }
425
+
426
+ function completeHistoryTool(
427
+ itemsById: Map<string, StoredAgentTimelineItem>,
428
+ conversationId: string,
429
+ callId: string,
430
+ output: string | undefined,
431
+ createdAt: number,
432
+ ): void {
433
+ const id = `history-tool:${callId}`;
434
+ const existing = itemsById.get(id);
435
+ const commandExecution = existing?.commandExecution
436
+ ? { ...existing.commandExecution, output, status: "completed" as const }
437
+ : undefined;
438
+ itemsById.set(id, {
439
+ id,
440
+ conversationId,
441
+ type: "tool_call",
442
+ kind: existing?.kind ?? "tool_activity",
443
+ itemId: callId,
444
+ toolCall: {
445
+ id: callId,
446
+ name: existing?.toolCall?.name ?? "工具",
447
+ input: existing?.toolCall?.input,
448
+ output,
449
+ createdAt: existing?.toolCall?.createdAt ?? createdAt,
450
+ status: "completed",
451
+ },
452
+ commandExecution,
453
+ createdAt: existing?.createdAt ?? createdAt,
454
+ updatedAt: createdAt,
455
+ metadata: { source: "device-history", provider: "codex" },
456
+ });
457
+ }
458
+
459
+ function relativeHistoryPath(path: string, cwd: string): string {
460
+ const normalized = path.replace(/\\/g, "/").replace(/^["']|["']$/g, "");
461
+ const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
462
+ return normalized.startsWith(`${normalizedCwd}/`)
463
+ ? normalized.slice(normalizedCwd.length + 1)
464
+ : normalized;
465
+ }
466
+
467
+ function countDiffLines(diff: string | undefined): { added: number; removed: number } {
468
+ if (!diff) return { added: 0, removed: 0 };
469
+ let added = 0;
470
+ let removed = 0;
471
+ for (const line of diff.split("\n")) {
472
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
473
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
474
+ }
475
+ return { added, removed };
476
+ }
477
+
478
+ function countContentLines(content: unknown): number {
479
+ if (typeof content !== "string") return 0;
480
+ if (!content) return 0;
481
+ return content.split(/\r?\n/).filter((line) => line.length > 0).length;
482
+ }
483
+
484
+ function changeKind(value: string | undefined): string | undefined {
485
+ if (value === "add") return "create";
486
+ if (value === "delete") return "delete";
487
+ if (value === "update" || value === "move") return "update";
488
+ return value;
489
+ }
490
+
491
+ function patchApplyFileChange(
492
+ conversationId: string,
493
+ payload: Record<string, unknown>,
494
+ cwd: string,
495
+ createdAt: number,
496
+ ): StoredAgentTimelineItem | undefined {
497
+ const changes = asRecord(payload.changes);
498
+ if (!changes) return undefined;
499
+ const entries: NonNullable<StoredAgentTimelineItem["fileChange"]>["entries"] = [];
500
+ const diffParts: string[] = [];
501
+ for (const [absolutePath, rawChange] of Object.entries(changes)) {
502
+ const change = asRecord(rawChange);
503
+ if (!change) continue;
504
+ const path = relativeHistoryPath(absolutePath, cwd);
505
+ const kind = changeKind(typeof change.type === "string" ? change.type : undefined);
506
+ const diff = typeof change.unified_diff === "string" ? change.unified_diff : undefined;
507
+ const stats = countDiffLines(diff);
508
+ const added = stats.added || (kind === "create" ? countContentLines(change.content) : 0);
509
+ const removed = stats.removed;
510
+ const entry: NonNullable<StoredAgentTimelineItem["fileChange"]>["entries"][number] = { path };
511
+ if (kind) entry.kind = kind;
512
+ if (added > 0) entry.added = added;
513
+ if (removed > 0) entry.removed = removed;
514
+ entries.push(entry);
515
+ if (diff) {
516
+ diffParts.push([
517
+ `Path: ${path}`,
518
+ kind ? `Kind: ${kind}` : undefined,
519
+ `Totals: +${added} -${removed}`,
520
+ "",
521
+ "```diff",
522
+ diff.trimEnd(),
523
+ "```",
524
+ ].filter((line): line is string => line !== undefined).join("\n"));
525
+ }
526
+ }
527
+ if (entries.length === 0) return undefined;
528
+ const callId = typeof payload.call_id === "string" ? payload.call_id : `patch-${createdAt}`;
529
+ const status = payload.success === false ? "failed" : "completed";
530
+ const totalAdded = entries.reduce((sum, entry) => sum + (entry.added ?? 0), 0);
531
+ const totalRemoved = entries.reduce((sum, entry) => sum + (entry.removed ?? 0), 0);
532
+ const summary = entries
533
+ .map((entry) => [entry.kind, entry.path].filter(Boolean).join(" "))
534
+ .join("\n");
535
+ const diff = diffParts.length > 0 ? diffParts.join("\n\n---\n\n") : undefined;
536
+ return {
537
+ id: `history-file-change:${callId}`,
538
+ conversationId,
539
+ type: "tool_call",
540
+ kind: "file_change",
541
+ itemId: callId,
542
+ toolCall: {
543
+ id: callId,
544
+ name: "文件修改",
545
+ input: summary,
546
+ output: diff ?? (typeof payload.stdout === "string" ? payload.stdout : undefined),
547
+ createdAt,
548
+ status,
549
+ },
550
+ fileChange: {
551
+ entries,
552
+ diff,
553
+ summary,
554
+ status,
555
+ },
556
+ text: `已编辑 ${entries.length} 个文件 +${totalAdded} -${totalRemoved}`,
557
+ createdAt,
558
+ updatedAt: createdAt,
559
+ metadata: { source: "device-history", provider: "codex" },
560
+ };
561
+ }
562
+
295
563
  export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStoredSession[] } {
296
564
  const root = join(homedir(), ".codex");
297
565
  if (!existsSync(root)) return { sessions: [] };
@@ -309,7 +577,7 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
309
577
  const session: CodexStoredSession = {
310
578
  id: metadata.id,
311
579
  cwd: metadata.cwd,
312
- title: indexed?.title,
580
+ title: indexed?.title ?? metadata.title,
313
581
  createdAt: metadata.createdAt,
314
582
  lastModified: indexed?.updatedAt ?? metadata.lastModified ?? Date.now(),
315
583
  archived: file.archived,
@@ -355,7 +623,8 @@ export function loadCodexStoredTimeline(
355
623
  return { items: [] };
356
624
  }
357
625
 
358
- const items: StoredAgentTimelineItem[] = [];
626
+ const itemsById = new Map<string, StoredAgentTimelineItem>();
627
+ const seenMessages = new Set<string>();
359
628
  let index = 0;
360
629
  for (const line of readHistorySample(file.path, statSize).split(/\r?\n/)) {
361
630
  const trimmed = line.trim();
@@ -363,21 +632,76 @@ export function loadCodexStoredTimeline(
363
632
  try {
364
633
  const entry = asRecord(JSON.parse(trimmed));
365
634
  const payload = asRecord(entry?.payload);
366
- if (entry?.type !== "event_msg" || !payload) continue;
367
- const eventType = typeof payload.type === "string" ? payload.type : undefined;
368
635
  const createdAt =
369
- parseTimestamp(entry.timestamp ?? payload.created_at ?? payload.started_at ?? payload.completed_at) ??
636
+ parseTimestamp(entry?.timestamp ?? payload?.created_at ?? payload?.started_at ?? payload?.completed_at) ??
370
637
  statMtime + index;
371
- if (eventType === "user_message") {
372
- const text = normalizeHistoryText(payload.message);
373
- if (!text || text.startsWith("<turn_aborted>")) continue;
374
- items.push(historyMessage(conversationId, index, "user", text, createdAt, sessionId));
375
- index += 1;
376
- } else if (eventType === "agent_message") {
638
+ if (entry?.type === "event_msg" && payload) {
639
+ const eventType = typeof payload.type === "string" ? payload.type : undefined;
640
+ if (eventType === "user_message") {
641
+ const text = normalizeHistoryText(payload.message);
642
+ if (!text || isInjectedCodexContext(text)) continue;
643
+ const dedupeKey = messageDedupeKey("user", text);
644
+ if (seenMessages.has(dedupeKey)) continue;
645
+ seenMessages.add(dedupeKey);
646
+ itemsById.set(`history:${sessionId}:${index}`, historyMessage(conversationId, index, "user", text, createdAt, sessionId));
647
+ index += 1;
648
+ } else if (eventType === "agent_message") {
649
+ const text = normalizeHistoryText(payload.message);
650
+ if (!text || isInjectedCodexContext(text)) continue;
651
+ const dedupeKey = messageDedupeKey("assistant", text);
652
+ if (seenMessages.has(dedupeKey)) continue;
653
+ seenMessages.add(dedupeKey);
654
+ itemsById.set(`history:${sessionId}:${index}`, historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
655
+ index += 1;
656
+ } else if (eventType === "patch_apply_end") {
657
+ const item = patchApplyFileChange(conversationId, payload, inputCwd, createdAt);
658
+ if (item) itemsById.set(item.id, item);
659
+ }
660
+ continue;
661
+ }
662
+
663
+ if (entry?.type !== "response_item" || !payload) continue;
664
+ const responseType = typeof payload.type === "string" ? payload.type : undefined;
665
+ if (responseType === "message") {
666
+ const role = visibleCodexRole(payload.role);
667
+ if (!role) continue;
377
668
  const text = normalizeHistoryText(payload.message);
378
- if (!text) continue;
379
- items.push(historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
669
+ const contentText = text ?? normalizeHistoryText(payload.content);
670
+ if (!contentText || isInjectedCodexContext(contentText)) continue;
671
+ const dedupeKey = messageDedupeKey(role, contentText);
672
+ if (seenMessages.has(dedupeKey)) continue;
673
+ seenMessages.add(dedupeKey);
674
+ itemsById.set(`history:${sessionId}:${index}`, historyMessage(conversationId, index, role, contentText, createdAt, sessionId));
380
675
  index += 1;
676
+ continue;
677
+ }
678
+ if (responseType === "function_call") {
679
+ const callId = typeof payload.call_id === "string"
680
+ ? payload.call_id
681
+ : typeof payload.id === "string"
682
+ ? payload.id
683
+ : undefined;
684
+ if (!callId) continue;
685
+ const name = typeof payload.name === "string" ? payload.name : undefined;
686
+ upsertHistoryTool(
687
+ itemsById,
688
+ conversationId,
689
+ callId,
690
+ name,
691
+ payload.arguments,
692
+ stringifyHistoryValue(payload.arguments),
693
+ createdAt,
694
+ );
695
+ continue;
696
+ }
697
+ if (responseType === "function_call_output") {
698
+ const callId = typeof payload.call_id === "string"
699
+ ? payload.call_id
700
+ : typeof payload.id === "string"
701
+ ? payload.id
702
+ : undefined;
703
+ if (!callId) continue;
704
+ completeHistoryTool(itemsById, conversationId, callId, stringifyHistoryValue(payload.output), createdAt);
381
705
  }
382
706
  } catch {
383
707
  // Ignore malformed or partial JSONL lines in the history window.
@@ -385,7 +709,7 @@ export function loadCodexStoredTimeline(
385
709
  }
386
710
 
387
711
  return {
388
- items: items
712
+ items: [...itemsById.values()]
389
713
  .sort((a, b) => a.createdAt - b.createdAt)
390
714
  .slice(-MAX_HISTORY_ITEMS),
391
715
  };