@remnic/core 9.3.645 → 9.3.646

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 (95) hide show
  1. package/dist/access-cli.js +18 -17
  2. package/dist/access-cli.js.map +1 -1
  3. package/dist/access-http.js +13 -13
  4. package/dist/access-mcp.js +10 -10
  5. package/dist/access-schema.js +3 -3
  6. package/dist/access-service.js +8 -8
  7. package/dist/adapters/index.js +4 -4
  8. package/dist/adapters/registry.js +2 -2
  9. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-GWVG7LGC.js} +2 -2
  10. package/dist/{chunk-6YLJZ2EU.js → chunk-3JSWINVD.js} +92 -29
  11. package/dist/chunk-3JSWINVD.js.map +1 -0
  12. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  13. package/dist/{chunk-HRXCPKN4.js → chunk-APWJRJFW.js} +2 -2
  14. package/dist/{chunk-VDX2J7OX.js → chunk-BUKK5SWA.js} +2 -2
  15. package/dist/{chunk-T22TZJWJ.js → chunk-DC66QVL2.js} +2 -2
  16. package/dist/chunk-EVWIEEKZ.js +315 -0
  17. package/dist/chunk-EVWIEEKZ.js.map +1 -0
  18. package/dist/{chunk-IZXOO6NP.js → chunk-FAYDM5WD.js} +2 -2
  19. package/dist/{chunk-MBT3ASYV.js → chunk-H67ZTMTL.js} +12 -12
  20. package/dist/{chunk-3SF7QNKF.js → chunk-I3IWTRYB.js} +2 -2
  21. package/dist/{chunk-Y2XKJJEI.js → chunk-IMWFHBG2.js} +10 -33
  22. package/dist/chunk-IMWFHBG2.js.map +1 -0
  23. package/dist/{chunk-IHG27THY.js → chunk-JLOJ5RJ7.js} +68 -59
  24. package/dist/chunk-JLOJ5RJ7.js.map +1 -0
  25. package/dist/{chunk-W5ISYSIL.js → chunk-L7W5YW6Y.js} +4 -4
  26. package/dist/{chunk-DGNQRNLL.js → chunk-NT5TINK5.js} +2 -2
  27. package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
  28. package/dist/{chunk-CDHRUNBX.js → chunk-QKE4LHNR.js} +2 -2
  29. package/dist/{chunk-BTRL6GG7.js → chunk-RAELB5NX.js} +5 -5
  30. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  31. package/dist/chunk-S4DDLTPX.js +140 -0
  32. package/dist/chunk-S4DDLTPX.js.map +1 -0
  33. package/dist/{chunk-KVUG7FF7.js → chunk-U55D5UD5.js} +7 -7
  34. package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
  35. package/dist/{chunk-D2B22JDF.js → chunk-WEPMT6SC.js} +7 -7
  36. package/dist/{chunk-YOXCJP3A.js → chunk-ZPPFKVSD.js} +3 -3
  37. package/dist/cli.js +30 -28
  38. package/dist/{first-start-migration-FF7YFGRP.js → first-start-migration-PG5HBC3K.js} +4 -4
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +77 -63
  41. package/dist/index.js.map +1 -1
  42. package/dist/lcm/engine.js +3 -3
  43. package/dist/lcm/index.js +11 -11
  44. package/dist/namespaces/migrate.js +5 -5
  45. package/dist/namespaces/search.js +4 -4
  46. package/dist/operator-toolkit.js +6 -6
  47. package/dist/orchestrator.js +14 -13
  48. package/dist/resume-bundles.js +3 -2
  49. package/dist/search/factory.js +3 -3
  50. package/dist/search/index.js +7 -7
  51. package/dist/session-identity.d.ts +118 -0
  52. package/dist/session-identity.js +21 -0
  53. package/dist/session-identity.js.map +1 -0
  54. package/dist/session-transcript-migration.d.ts +65 -0
  55. package/dist/session-transcript-migration.js +16 -0
  56. package/dist/session-transcript-migration.js.map +1 -0
  57. package/dist/summarizer.js +2 -1
  58. package/dist/transcript.d.ts +12 -6
  59. package/dist/transcript.js +2 -1
  60. package/dist/transfer/backup.js +2 -2
  61. package/dist/transfer/capsule-export.js +2 -2
  62. package/dist/transfer/capsule-import.js +2 -2
  63. package/dist/transfer/import-sqlite.js +2 -2
  64. package/package.json +1 -1
  65. package/src/cli.ts +79 -0
  66. package/src/index.ts +18 -0
  67. package/src/session-identity.test.ts +130 -0
  68. package/src/session-identity.ts +281 -0
  69. package/src/session-transcript-migration.test.ts +350 -0
  70. package/src/session-transcript-migration.ts +527 -0
  71. package/src/summarizer.ts +15 -35
  72. package/src/transcript-session-identity.test.ts +424 -0
  73. package/src/transcript.ts +100 -70
  74. package/dist/chunk-6YLJZ2EU.js.map +0 -1
  75. package/dist/chunk-IHG27THY.js.map +0 -1
  76. package/dist/chunk-Y2XKJJEI.js.map +0 -1
  77. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  78. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  79. /package/dist/{chunk-HRXCPKN4.js.map → chunk-APWJRJFW.js.map} +0 -0
  80. /package/dist/{chunk-VDX2J7OX.js.map → chunk-BUKK5SWA.js.map} +0 -0
  81. /package/dist/{chunk-T22TZJWJ.js.map → chunk-DC66QVL2.js.map} +0 -0
  82. /package/dist/{chunk-IZXOO6NP.js.map → chunk-FAYDM5WD.js.map} +0 -0
  83. /package/dist/{chunk-MBT3ASYV.js.map → chunk-H67ZTMTL.js.map} +0 -0
  84. /package/dist/{chunk-3SF7QNKF.js.map → chunk-I3IWTRYB.js.map} +0 -0
  85. /package/dist/{chunk-W5ISYSIL.js.map → chunk-L7W5YW6Y.js.map} +0 -0
  86. /package/dist/{chunk-DGNQRNLL.js.map → chunk-NT5TINK5.js.map} +0 -0
  87. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
  88. /package/dist/{chunk-CDHRUNBX.js.map → chunk-QKE4LHNR.js.map} +0 -0
  89. /package/dist/{chunk-BTRL6GG7.js.map → chunk-RAELB5NX.js.map} +0 -0
  90. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  91. /package/dist/{chunk-KVUG7FF7.js.map → chunk-U55D5UD5.js.map} +0 -0
  92. /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
  93. /package/dist/{chunk-D2B22JDF.js.map → chunk-WEPMT6SC.js.map} +0 -0
  94. /package/dist/{chunk-YOXCJP3A.js.map → chunk-ZPPFKVSD.js.map} +0 -0
  95. /package/dist/{first-start-migration-FF7YFGRP.js.map → first-start-migration-PG5HBC3K.js.map} +0 -0
package/src/transcript.ts CHANGED
@@ -3,13 +3,8 @@ import path from "node:path";
3
3
  import { log } from "./logger.js";
4
4
  import type { TranscriptEntry, Checkpoint, PluginConfig } from "./types.js";
5
5
  import { analyzeSessionIntegrity, type SessionIntegrityReport } from "./session-integrity.js";
6
- import {
7
- encodeStoragePathSegment,
8
- encodeStoragePathSegmentWithHash,
9
- isSafeLegacyPathSegment,
10
- resolveSafeStoragePath,
11
- storagePathHash,
12
- } from "./storage-paths.js";
6
+ import { resolveSafeStoragePath } from "./storage-paths.js";
7
+ import { sessionStoragePaths } from "./session-identity.js";
13
8
 
14
9
  type DirectorySessionStatus = "missing" | "empty" | "matches" | "occupied";
15
10
  type DirectoryOwnershipCacheEntry = {
@@ -17,16 +12,25 @@ type DirectoryOwnershipCacheEntry = {
17
12
  fileSizes: Map<string, number>;
18
13
  };
19
14
 
20
- function legacyTranscriptDirFor(
21
- channelType: string,
22
- channelId: string,
23
- encodedDir: string,
24
- ): string | undefined {
25
- if (!isSafeLegacyPathSegment(channelType) || !isSafeLegacyPathSegment(channelId)) {
26
- return undefined;
27
- }
28
- const legacyDir = path.join(channelType, channelId);
29
- return legacyDir === encodedDir ? undefined : legacyDir;
15
+ /**
16
+ * De-duplicate JSONL lines collected across multiple read-back candidate
17
+ * directories. A partially-applied migration (issue #1496) can leave the SAME
18
+ * raw row in both the primary `session/<hash>` tree and a legacy read-back dir
19
+ * (`other/default` or an old `parts.length >= 3` directory) when the source was
20
+ * copied but not yet trimmed. Without this guard, `readRecent`, `readToolUse`,
21
+ * footprint estimation, and the summarizer fetch would return that row twice
22
+ * (cursor review on PR #1504). The exact raw JSONL line is the stable identity:
23
+ * the migration preserves byte content per session, so a copied-but-not-trimmed
24
+ * row is byte-identical in both locations. Returns `true` the first time a line
25
+ * is seen, `false` for every subsequent duplicate.
26
+ */
27
+ function makeRawLineDeduper(): (rawLine: string) => boolean {
28
+ const seen = new Set<string>();
29
+ return (rawLine: string): boolean => {
30
+ if (seen.has(rawLine)) return false;
31
+ seen.add(rawLine);
32
+ return true;
33
+ };
30
34
  }
31
35
 
32
36
  /**
@@ -64,13 +68,17 @@ export class TranscriptManager {
64
68
  }
65
69
 
66
70
  /**
67
- * Parse a sessionKey to extract channel type and ID.
71
+ * Resolve the storage path pieces for a sessionKey via the shared
72
+ * {@link sessionStoragePaths} helper (issue #1496, rule #22).
68
73
  *
69
- * SessionKey patterns:
70
- * - agent:<agent-id>:main type="main", id="default"
71
- * - agent:<agent-id>:discord:channel:<channel-id> type="discord", id="<channel-id>"
72
- * - agent:<agent-id>:cron:<job-id> type="cron", id="<job-id>"
73
- * - agent:<agent-id>:slack:channel:<channel-id> type="slack", id="<channel-id>"
74
+ * Legacy `agent:<id>:...` shapes keep their existing readable channel paths.
75
+ * Arbitrary / non-legacy keys (e.g. `pi-geek:abc123`) route to
76
+ * `transcripts/session/<hash>/YYYY-MM-DD.jsonl` from the FIRST write — they
77
+ * never start life in `other/default`. For those keys a list of
78
+ * read-back-only `readbackDirs` is returned so data written by older builds
79
+ * (under `other/default`, or under an old `parts.length >= 3` directory such
80
+ * as `baz/default`) stays discoverable until migrated. New writes never
81
+ * target read-back dirs. See `session-identity.ts`.
74
82
  *
75
83
  * @returns Object with raw channel identifiers and encoded storage path pieces.
76
84
  */
@@ -81,49 +89,21 @@ export class TranscriptManager {
81
89
  channelId: string;
82
90
  alternateDir: string;
83
91
  legacyDir?: string;
92
+ readbackDirs: string[];
84
93
  } {
85
- const parts = sessionKey.split(":");
86
-
87
- // Default fallback
88
- let channelType = "other";
89
- let channelId = "default";
90
-
91
- if (parts.length >= 3) {
92
- // parts[0] = "agent", parts[1] = agent name, parts[2] = channel type
93
- channelType = parts[2];
94
-
95
- // Extract channel ID based on pattern
96
- if (channelType === "main") {
97
- channelId = "default";
98
- } else if (channelType === "discord" && parts.length >= 5 && parts[3] === "channel") {
99
- channelId = parts[4];
100
- } else if (channelType === "slack" && parts.length >= 5 && parts[3] === "channel") {
101
- channelId = parts[4];
102
- } else if (channelType === "cron" && parts.length >= 4) {
103
- channelId = parts[3];
104
- } else if (parts.length >= 4) {
105
- // For other types, use the 4th part as ID if available
106
- channelId = parts[3];
107
- }
108
- }
94
+ const paths = sessionStoragePaths(sessionKey);
109
95
 
110
96
  // Daily rotation: transcripts/{channelType}/{channelId}/YYYY-MM-DD.jsonl
111
97
  const today = new Date().toISOString().slice(0, 10);
112
- const dir = path.join(
113
- encodeStoragePathSegment(channelType),
114
- encodeStoragePathSegment(channelId),
115
- );
116
- const alternateDir = path.join(
117
- encodeStoragePathSegmentWithHash(channelType),
118
- `${encodeStoragePathSegmentWithHash(channelId)}--session-${storagePathHash(sessionKey)}`,
119
- );
98
+
120
99
  return {
121
- dir,
100
+ dir: paths.dir,
122
101
  file: `${today}.jsonl`,
123
- channelType,
124
- channelId,
125
- alternateDir,
126
- legacyDir: legacyTranscriptDirFor(channelType, channelId, dir),
102
+ channelType: paths.channelType,
103
+ channelId: paths.channelId,
104
+ alternateDir: paths.alternateDir,
105
+ legacyDir: paths.legacyDir,
106
+ readbackDirs: paths.readbackDirs,
127
107
  };
128
108
  }
129
109
 
@@ -183,9 +163,16 @@ export class TranscriptManager {
183
163
  file: string;
184
164
  alternateDir: string;
185
165
  legacyDir?: string;
166
+ readbackDirs: string[];
186
167
  } {
187
168
  const p = this.getTranscriptPath(sessionKey);
188
- return { dir: p.dir, file: p.file, alternateDir: p.alternateDir, legacyDir: p.legacyDir };
169
+ return {
170
+ dir: p.dir,
171
+ file: p.file,
172
+ alternateDir: p.alternateDir,
173
+ legacyDir: p.legacyDir,
174
+ readbackDirs: p.readbackDirs,
175
+ };
189
176
  }
190
177
 
191
178
  private async selectStorageDirForWrite(
@@ -344,11 +331,12 @@ export class TranscriptManager {
344
331
  dir: string,
345
332
  legacyDir?: string,
346
333
  alternateDir?: string,
334
+ readbackDirs: string[] = [],
347
335
  ): Promise<Array<{ cacheKey: string; name: string; path: string }>> {
348
336
  const files: Array<{ cacheKey: string; name: string; path: string }> = [];
349
337
  const seenDirs = new Set<string>();
350
338
 
351
- for (const candidateDir of [dir, alternateDir, legacyDir]) {
339
+ for (const candidateDir of [dir, alternateDir, legacyDir, ...readbackDirs]) {
352
340
  if (!candidateDir || seenDirs.has(candidateDir)) continue;
353
341
  seenDirs.add(candidateDir);
354
342
 
@@ -400,10 +388,19 @@ export class TranscriptManager {
400
388
  startTime: Date,
401
389
  endTime: Date,
402
390
  ): Promise<Array<{ timestamp: string; sessionKey: string; tool: string }>> {
403
- const { dir, alternateDir, legacyDir } = this.getToolUsagePath(sessionKey);
391
+ const { dir, alternateDir, legacyDir, readbackDirs } = this.getToolUsagePath(sessionKey);
404
392
  try {
405
- const files = await this.getSessionStorageFiles(this.toolUsageDir, dir, legacyDir, alternateDir);
393
+ const files = await this.getSessionStorageFiles(
394
+ this.toolUsageDir,
395
+ dir,
396
+ legacyDir,
397
+ alternateDir,
398
+ readbackDirs,
399
+ );
406
400
  const out: Array<{ timestamp: string; sessionKey: string; tool: string }> = [];
401
+ // Dedup identical raw rows that a partially-applied migration may have left
402
+ // in both the primary dir and a read-back dir (issue #1496, cursor review).
403
+ const keepRawLine = makeRawLineDeduper();
407
404
  for (const file of files) {
408
405
  const raw = await readFile(file.path, "utf-8");
409
406
  for (const line of raw.split("\n")) {
@@ -414,7 +411,7 @@ export class TranscriptManager {
414
411
  if (!Number.isFinite(ts)) continue;
415
412
  if (ts >= startTime.getTime() && ts < endTime.getTime()) {
416
413
  if (typeof obj.tool === "string" && typeof obj.sessionKey === "string") {
417
- if (obj.sessionKey === sessionKey) {
414
+ if (obj.sessionKey === sessionKey && keepRawLine(line)) {
418
415
  out.push({ timestamp: obj.timestamp, sessionKey: obj.sessionKey, tool: obj.tool });
419
416
  }
420
417
  }
@@ -431,11 +428,24 @@ export class TranscriptManager {
431
428
  }
432
429
 
433
430
  async estimateSessionFootprint(sessionKey: string): Promise<{ bytes: number; tokens: number }> {
434
- const { dir, alternateDir, legacyDir } = this.getTranscriptPath(sessionKey);
431
+ const { dir, alternateDir, legacyDir, readbackDirs } = this.getTranscriptPath(sessionKey);
435
432
  let bytes = 0;
436
433
 
434
+ // NOTE: this is a best-effort byte ESTIMATE for compaction sizing, not an
435
+ // exact row count, and is maintained via an incremental per-file cache. A
436
+ // copied-but-not-trimmed migration window (issue #1496) can transiently
437
+ // over-estimate by counting a duplicated row in both the primary and a
438
+ // read-back dir; that self-heals once the migration trims the source. The
439
+ // exact-once guarantee that matters for callers lives in readRecent /
440
+ // readToolUse / the summarizer fetch, which dedup by raw line.
437
441
  try {
438
- const files = await this.getSessionStorageFiles(this.transcriptsDir, dir, legacyDir, alternateDir);
442
+ const files = await this.getSessionStorageFiles(
443
+ this.transcriptsDir,
444
+ dir,
445
+ legacyDir,
446
+ alternateDir,
447
+ readbackDirs,
448
+ );
439
449
  const cached = this.sessionFootprintCache.get(sessionKey);
440
450
  if (!cached) {
441
451
  const fileBytes = new Map<string, number>();
@@ -642,6 +652,16 @@ export class TranscriptManager {
642
652
  const end = new Date(endTime);
643
653
  const entries: TranscriptEntry[] = [];
644
654
 
655
+ // When a sessionKey is given, a partially-applied #1496 migration can leave
656
+ // the SAME raw row in both the primary `session/<hash>` dir and a read-back
657
+ // dir (`other/default` or an old `parts.length >= 3` dir). `readRange` scans
658
+ // EVERY transcript file, so it would append that row once per directory.
659
+ // Dedup by exact raw line to give the same exact-once guarantee `readRecent`
660
+ // / `readToolUse` / the summarizer fetch already provide (cursor review on
661
+ // PR #1504). Only applied for the session-scoped path so unfiltered range
662
+ // scans (each file already enumerated once) keep their existing behavior.
663
+ const keepRawLine = sessionKey ? makeRawLineDeduper() : undefined;
664
+
645
665
  try {
646
666
  // Get all transcript files from the hierarchical structure
647
667
  const transcriptFiles = await this.getAllTranscriptFiles();
@@ -662,6 +682,7 @@ export class TranscriptManager {
662
682
  if (entryTime >= start && entryTime < end) {
663
683
  // Filter by sessionKey if provided
664
684
  if (!sessionKey || entry.sessionKey === sessionKey) {
685
+ if (keepRawLine && !keepRawLine(line)) continue;
665
686
  entries.push(entry);
666
687
  }
667
688
  }
@@ -711,7 +732,7 @@ export class TranscriptManager {
711
732
  end: Date,
712
733
  sessionKey: string,
713
734
  ): Promise<TranscriptEntry[]> {
714
- const { dir, alternateDir, legacyDir } = this.getTranscriptPath(sessionKey);
735
+ const { dir, alternateDir, legacyDir, readbackDirs } = this.getTranscriptPath(sessionKey);
715
736
 
716
737
  // Build set of date strings that overlap with [start, end].
717
738
  // Always include end's date to handle midnight-crossing lookbacks
@@ -725,8 +746,17 @@ export class TranscriptManager {
725
746
  dateStrings.add(end.toISOString().slice(0, 10));
726
747
 
727
748
  const entries: TranscriptEntry[] = [];
728
- const files = await this.getSessionStorageFiles(this.transcriptsDir, dir, legacyDir, alternateDir);
749
+ const files = await this.getSessionStorageFiles(
750
+ this.transcriptsDir,
751
+ dir,
752
+ legacyDir,
753
+ alternateDir,
754
+ readbackDirs,
755
+ );
729
756
 
757
+ // Dedup identical raw rows that a partially-applied migration may have left
758
+ // in both the primary dir and a read-back dir (issue #1496, cursor review).
759
+ const keepRawLine = makeRawLineDeduper();
730
760
  for (const file of files) {
731
761
  // Only read files whose date is within the window
732
762
  const dateStr = file.name.slice(0, 10);
@@ -739,7 +769,7 @@ export class TranscriptManager {
739
769
  try {
740
770
  const entry = JSON.parse(line) as TranscriptEntry;
741
771
  const ts = new Date(entry.timestamp);
742
- if (ts >= start && ts < end && entry.sessionKey === sessionKey) {
772
+ if (ts >= start && ts < end && entry.sessionKey === sessionKey && keepRawLine(line)) {
743
773
  entries.push(entry);
744
774
  }
745
775
  } catch {