agent-trajectories 0.5.5 → 0.5.7

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/cli/index.js CHANGED
@@ -404,8 +404,16 @@ function abandonTrajectory(trajectory, reason) {
404
404
  }
405
405
 
406
406
  // src/storage/file.ts
407
+ import { randomUUID } from "crypto";
407
408
  import { existsSync } from "fs";
408
- import { mkdir, readFile, readdir, unlink, writeFile } from "fs/promises";
409
+ import {
410
+ mkdir,
411
+ readFile,
412
+ readdir,
413
+ rename,
414
+ unlink,
415
+ writeFile
416
+ } from "fs/promises";
409
417
  import { join } from "path";
410
418
 
411
419
  // src/export/markdown.ts
@@ -586,6 +594,16 @@ function getSearchPaths() {
586
594
  }
587
595
  return [join(process.cwd(), ".trajectories")];
588
596
  }
597
+ var indexLocks = /* @__PURE__ */ new Map();
598
+ function withIndexLock(path2, task) {
599
+ const prev = indexLocks.get(path2) ?? Promise.resolve();
600
+ const next = prev.then(task, task);
601
+ indexLocks.set(
602
+ path2,
603
+ next.catch(() => void 0)
604
+ );
605
+ return next;
606
+ }
589
607
  var FileStorage = class {
590
608
  baseDir;
591
609
  trajectoriesDir;
@@ -612,10 +630,10 @@ var FileStorage = class {
612
630
  await mkdir(this.activeDir, { recursive: true });
613
631
  await mkdir(this.completedDir, { recursive: true });
614
632
  if (!existsSync(this.indexPath)) {
615
- await this.saveIndex({
616
- version: 1,
617
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
618
- trajectories: {}
633
+ await withIndexLock(this.indexPath, async () => {
634
+ if (!existsSync(this.indexPath)) {
635
+ await this.saveIndex(this.emptyIndex());
636
+ }
619
637
  });
620
638
  }
621
639
  await this.reconcileIndex();
@@ -643,49 +661,51 @@ var FileStorage = class {
643
661
  skippedSchemaViolation: 0,
644
662
  skippedIoError: 0
645
663
  };
646
- const index = await this.loadIndex();
647
- const before = Object.keys(index.trajectories).length;
648
- const discovered = [];
649
- try {
650
- const activeFiles = await readdir(this.activeDir);
651
- for (const file of activeFiles) {
652
- if (!file.endsWith(".json")) continue;
653
- discovered.push(join(this.activeDir, file));
664
+ await withIndexLock(this.indexPath, async () => {
665
+ const index = await this.loadIndex();
666
+ const before = Object.keys(index.trajectories).length;
667
+ const discovered = [];
668
+ try {
669
+ const activeFiles = await readdir(this.activeDir);
670
+ for (const file of activeFiles) {
671
+ if (!file.endsWith(".json")) continue;
672
+ discovered.push(join(this.activeDir, file));
673
+ }
674
+ } catch (error) {
675
+ if (error.code !== "ENOENT") throw error;
654
676
  }
655
- } catch (error) {
656
- if (error.code !== "ENOENT") throw error;
657
- }
658
- await this.walkJsonFilesInto(this.completedDir, discovered);
659
- for (const filePath of discovered) {
660
- summary.scanned += 1;
661
- const result = await this.readTrajectoryFile(filePath);
662
- if (!result.ok) {
663
- if (result.reason === "malformed_json") {
664
- summary.skippedMalformedJson += 1;
665
- } else if (result.reason === "schema_violation") {
666
- summary.skippedSchemaViolation += 1;
667
- } else {
668
- summary.skippedIoError += 1;
677
+ await this.walkJsonFilesInto(this.completedDir, discovered);
678
+ for (const filePath of discovered) {
679
+ summary.scanned += 1;
680
+ const result = await this.readTrajectoryFile(filePath);
681
+ if (!result.ok) {
682
+ if (result.reason === "malformed_json") {
683
+ summary.skippedMalformedJson += 1;
684
+ } else if (result.reason === "schema_violation") {
685
+ summary.skippedSchemaViolation += 1;
686
+ } else {
687
+ summary.skippedIoError += 1;
688
+ }
689
+ continue;
669
690
  }
670
- continue;
691
+ const trajectory = result.trajectory;
692
+ if (index.trajectories[trajectory.id]) {
693
+ summary.alreadyIndexed += 1;
694
+ continue;
695
+ }
696
+ index.trajectories[trajectory.id] = {
697
+ title: trajectory.task.title,
698
+ status: trajectory.status,
699
+ startedAt: trajectory.startedAt,
700
+ completedAt: trajectory.completedAt,
701
+ path: filePath
702
+ };
703
+ summary.added += 1;
671
704
  }
672
- const trajectory = result.trajectory;
673
- if (index.trajectories[trajectory.id]) {
674
- summary.alreadyIndexed += 1;
675
- continue;
705
+ if (Object.keys(index.trajectories).length !== before) {
706
+ await this.saveIndex(index);
676
707
  }
677
- index.trajectories[trajectory.id] = {
678
- title: trajectory.task.title,
679
- status: trajectory.status,
680
- startedAt: trajectory.startedAt,
681
- completedAt: trajectory.completedAt,
682
- path: filePath
683
- };
684
- summary.added += 1;
685
- }
686
- if (Object.keys(index.trajectories).length !== before) {
687
- await this.saveIndex(index);
688
- }
708
+ });
689
709
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
690
710
  if (summary.added > 0 || hadSkips) {
691
711
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
@@ -888,17 +908,19 @@ var FileStorage = class {
888
908
  if (existsSync(activePath)) {
889
909
  await unlink(activePath);
890
910
  }
891
- const index = await this.loadIndex();
892
- const entry = index.trajectories[id];
893
- if (entry?.path && existsSync(entry.path)) {
894
- await unlink(entry.path);
895
- const mdPath = entry.path.replace(".json", ".md");
896
- if (existsSync(mdPath)) {
897
- await unlink(mdPath);
911
+ await withIndexLock(this.indexPath, async () => {
912
+ const index = await this.loadIndex();
913
+ const entry = index.trajectories[id];
914
+ if (entry?.path && existsSync(entry.path)) {
915
+ await unlink(entry.path);
916
+ const mdPath = entry.path.replace(".json", ".md");
917
+ if (existsSync(mdPath)) {
918
+ await unlink(mdPath);
919
+ }
898
920
  }
899
- }
900
- delete index.trajectories[id];
901
- await this.saveIndex(index);
921
+ delete index.trajectories[id];
922
+ await this.saveIndex(index);
923
+ });
902
924
  }
903
925
  /**
904
926
  * Search trajectories by text
@@ -976,10 +998,23 @@ var FileStorage = class {
976
998
  const result = await this.readTrajectoryFile(path2);
977
999
  return result.ok ? result.trajectory : null;
978
1000
  }
1001
+ /**
1002
+ * Read and parse the on-disk index.
1003
+ *
1004
+ * Tolerances (belt-and-braces against the read/write race):
1005
+ * - ENOENT: first-run, return an empty index silently.
1006
+ * - Empty file: a concurrent writer truncated index.json in "w" mode
1007
+ * right before we read. Return an empty index silently — this is
1008
+ * not a real corruption, just an interleaving the mutex + atomic
1009
+ * rename should already prevent. Logging here would be noise.
1010
+ * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1011
+ * edit, disk error, etc). Log it and return an empty index so the
1012
+ * caller can recover, but keep the log so the problem is visible.
1013
+ */
979
1014
  async loadIndex() {
1015
+ let content;
980
1016
  try {
981
- const content = await readFile(this.indexPath, "utf-8");
982
- return JSON.parse(content);
1017
+ content = await readFile(this.indexPath, "utf-8");
983
1018
  } catch (error) {
984
1019
  if (error.code !== "ENOENT") {
985
1020
  console.error(
@@ -987,27 +1022,56 @@ var FileStorage = class {
987
1022
  error
988
1023
  );
989
1024
  }
990
- return {
991
- version: 1,
992
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
993
- trajectories: {}
994
- };
1025
+ return this.emptyIndex();
1026
+ }
1027
+ if (content.length === 0) {
1028
+ return this.emptyIndex();
1029
+ }
1030
+ try {
1031
+ return JSON.parse(content);
1032
+ } catch (error) {
1033
+ console.error(
1034
+ "Error loading trajectory index, using empty index:",
1035
+ error
1036
+ );
1037
+ return this.emptyIndex();
995
1038
  }
996
1039
  }
1040
+ emptyIndex() {
1041
+ return {
1042
+ version: 1,
1043
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1044
+ trajectories: {}
1045
+ };
1046
+ }
1047
+ /**
1048
+ * Atomic write: stage into a process-unique temp path in the same directory
1049
+ * and then rename over the live file. `rename` is atomic on POSIX, so
1050
+ * concurrent readers in any process either see the old complete file or
1051
+ * the new complete file — never a half-written / zero-byte state.
1052
+ *
1053
+ * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1054
+ * read-modify-write cycle stays serialized; the unique temp name also keeps
1055
+ * parallel writers in other processes from colliding on a shared tmp path.
1056
+ */
997
1057
  async saveIndex(index) {
998
1058
  index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
999
- await writeFile(this.indexPath, JSON.stringify(index, null, 2), "utf-8");
1059
+ const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1060
+ await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1061
+ await rename(tmpPath, this.indexPath);
1000
1062
  }
1001
1063
  async updateIndex(trajectory, filePath) {
1002
- const index = await this.loadIndex();
1003
- index.trajectories[trajectory.id] = {
1004
- title: trajectory.task.title,
1005
- status: trajectory.status,
1006
- startedAt: trajectory.startedAt,
1007
- completedAt: trajectory.completedAt,
1008
- path: filePath
1009
- };
1010
- await this.saveIndex(index);
1064
+ await withIndexLock(this.indexPath, async () => {
1065
+ const index = await this.loadIndex();
1066
+ index.trajectories[trajectory.id] = {
1067
+ title: trajectory.task.title,
1068
+ status: trajectory.status,
1069
+ startedAt: trajectory.startedAt,
1070
+ completedAt: trajectory.completedAt,
1071
+ path: filePath
1072
+ };
1073
+ await this.saveIndex(index);
1074
+ });
1011
1075
  }
1012
1076
  };
1013
1077
 
@@ -1032,7 +1096,13 @@ function registerAbandonCommand(program2) {
1032
1096
 
1033
1097
  // src/cli/commands/compact.ts
1034
1098
  import { execFileSync } from "child_process";
1035
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
1099
+ import {
1100
+ existsSync as existsSync3,
1101
+ mkdirSync,
1102
+ readFileSync as readFileSync2,
1103
+ unlinkSync,
1104
+ writeFileSync
1105
+ } from "fs";
1036
1106
  import { dirname, join as join4 } from "path";
1037
1107
 
1038
1108
  // src/compact/config.ts
@@ -2178,7 +2248,10 @@ function registerCompactCommand(program2) {
2178
2248
  ).option("--all", "Include all trajectories, even previously compacted ones").option("--llm", "Use LLM-based compaction when a provider is available").option("--no-llm", "Disable LLM-based compaction").option("--mechanical", "Force the original mechanical compaction flow").option(
2179
2249
  "--focus <areas>",
2180
2250
  "Comma-separated focus areas to emphasize in LLM compaction"
2181
- ).option("--markdown", "Also write a Markdown companion file").option("--no-markdown", "Skip writing a Markdown companion file").option("--dry-run", "Preview what would be compacted without saving").option("--output <path>", "Output path for compacted trajectory").action(async (options) => {
2251
+ ).option("--markdown", "Also write a Markdown companion file").option("--no-markdown", "Skip writing a Markdown companion file").option(
2252
+ "--discard-sources",
2253
+ "After saving the compaction, delete source trajectory JSON/MD/trace files and remove their index entries"
2254
+ ).option("--dry-run", "Preview what would be compacted without saving").option("--output <path>", "Output path for compacted trajectory").action(async (options) => {
2182
2255
  const trajectories = await loadTrajectories(options);
2183
2256
  if (trajectories.length === 0) {
2184
2257
  if (options.all || options.since || options.ids || options.workflow || options.pr || options.branch || options.commits) {
@@ -2217,7 +2290,15 @@ function registerCompactCommand(program2) {
2217
2290
  outputPath2,
2218
2291
  markdownEnabled
2219
2292
  );
2220
- await markTrajectoriesAsCompacted(trajectories, mechanicalCompacted.id);
2293
+ if (options.discardSources) {
2294
+ const discardSummary = discardSourceTrajectories(trajectories);
2295
+ printDiscardSummary(discardSummary);
2296
+ } else {
2297
+ await markTrajectoriesAsCompacted(
2298
+ trajectories,
2299
+ mechanicalCompacted.id
2300
+ );
2301
+ }
2221
2302
  console.log(`
2222
2303
  Compacted trajectory saved to: ${outputPath2}`);
2223
2304
  if (markdownEnabled) {
@@ -2270,7 +2351,12 @@ Compacted trajectory saved to: ${outputPath2}`);
2270
2351
  };
2271
2352
  const outputPath = options.output || getDefaultOutputPath(compacted, options.workflow);
2272
2353
  saveCompactionArtifacts(compacted, outputPath, markdownEnabled);
2273
- await markTrajectoriesAsCompacted(trajectories, compacted.id);
2354
+ if (options.discardSources) {
2355
+ const discardSummary = discardSourceTrajectories(trajectories);
2356
+ printDiscardSummary(discardSummary);
2357
+ } else {
2358
+ await markTrajectoriesAsCompacted(trajectories, compacted.id);
2359
+ }
2274
2360
  console.log(`
2275
2361
  Compacted trajectory saved to: ${outputPath}`);
2276
2362
  if (markdownEnabled) {
@@ -2418,6 +2504,74 @@ async function markTrajectoriesAsCompacted(trajectories, compactedIntoId) {
2418
2504
  }
2419
2505
  }
2420
2506
  }
2507
+ function discardSourceTrajectories(trajectories) {
2508
+ const sourceIds = new Set(trajectories.map((trajectory) => trajectory.id));
2509
+ const summary = {
2510
+ removedIndexEntries: 0,
2511
+ deletedJsonFiles: 0,
2512
+ deletedMarkdownFiles: 0,
2513
+ deletedTraceFiles: 0
2514
+ };
2515
+ for (const searchPath of getSearchPaths()) {
2516
+ const indexPath = join4(searchPath, "index.json");
2517
+ if (!existsSync3(indexPath)) continue;
2518
+ let index;
2519
+ try {
2520
+ const indexContent = readFileSync2(indexPath, "utf-8");
2521
+ const parsedIndex = JSON.parse(indexContent);
2522
+ if (!isTrajectoryIndex(parsedIndex)) {
2523
+ continue;
2524
+ }
2525
+ index = parsedIndex;
2526
+ } catch {
2527
+ continue;
2528
+ }
2529
+ let updated = false;
2530
+ for (const id of sourceIds) {
2531
+ const entry = index.trajectories[id];
2532
+ if (!entry) continue;
2533
+ if (deleteFileIfExists(entry.path)) {
2534
+ summary.deletedJsonFiles += 1;
2535
+ }
2536
+ if (deleteFileIfExists(getMarkdownOutputPath(entry.path))) {
2537
+ summary.deletedMarkdownFiles += 1;
2538
+ }
2539
+ if (deleteFileIfExists(getTraceOutputPath(entry.path))) {
2540
+ summary.deletedTraceFiles += 1;
2541
+ }
2542
+ delete index.trajectories[id];
2543
+ summary.removedIndexEntries += 1;
2544
+ updated = true;
2545
+ }
2546
+ if (updated) {
2547
+ index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
2548
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
2549
+ }
2550
+ }
2551
+ return summary;
2552
+ }
2553
+ function deleteFileIfExists(path2) {
2554
+ if (!existsSync3(path2)) {
2555
+ return false;
2556
+ }
2557
+ unlinkSync(path2);
2558
+ return true;
2559
+ }
2560
+ function isTrajectoryIndex(value) {
2561
+ if (value === null || typeof value !== "object") {
2562
+ return false;
2563
+ }
2564
+ const candidate = value;
2565
+ return candidate.trajectories !== null && typeof candidate.trajectories === "object" && !Array.isArray(candidate.trajectories);
2566
+ }
2567
+ function getTraceOutputPath(outputPath) {
2568
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".trace.json") : `${outputPath}.trace.json`;
2569
+ }
2570
+ function printDiscardSummary(summary) {
2571
+ console.log(
2572
+ `Discarded source trajectories: ${summary.removedIndexEntries} index entries, ${summary.deletedJsonFiles} JSON files, ${summary.deletedMarkdownFiles} Markdown files, ${summary.deletedTraceFiles} trace files`
2573
+ );
2574
+ }
2421
2575
  function parseRelativeDate(input) {
2422
2576
  const match = input.match(/^(\d+)([dwmh])$/);
2423
2577
  if (match) {