@hir4ta/mneme 0.24.2 → 0.25.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mneme",
3
3
  "description": "A plugin that provides long-term memory for Claude Code. It automatically saves context lost during auto-compact, offering features for session restoration, recording technical decisions, and learning developer patterns.",
4
- "version": "0.24.2",
4
+ "version": "0.25.0",
5
5
  "author": {
6
6
  "name": "hir4ta"
7
7
  },
package/README.ja.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mneme
2
2
 
3
- ![Version](https://img.shields.io/badge/version-0.24.2-blue)
3
+ ![Version](https://img.shields.io/badge/version-0.25.0-blue)
4
4
  ![Node.js](https://img.shields.io/badge/node-%3E%3D22.5.0-brightgreen)
5
5
  [![NPM Version](https://img.shields.io/npm/v/%40hir4ta%2Fmneme)](https://www.npmjs.com/package/@hir4ta/mneme)
6
6
  [![MIT License](https://img.shields.io/npm/l/%40hir4ta%2Fmneme)](https://github.com/hir4ta/mneme/blob/main/LICENSE)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mneme
2
2
 
3
- ![Version](https://img.shields.io/badge/version-0.24.2-blue)
3
+ ![Version](https://img.shields.io/badge/version-0.25.0-blue)
4
4
  ![Node.js](https://img.shields.io/badge/node-%3E%3D22.5.0-brightgreen)
5
5
  [![NPM Version](https://img.shields.io/npm/v/%40hir4ta%2Fmneme)](https://www.npmjs.com/package/@hir4ta/mneme)
6
6
  [![MIT License](https://img.shields.io/npm/l/%40hir4ta%2Fmneme)](https://github.com/hir4ta/mneme/blob/main/LICENSE)
package/bin/mneme.js CHANGED
@@ -100,11 +100,14 @@ function initMneme() {
100
100
  );
101
101
  fs.writeFileSync(path.join(rulesDir, "dev-rules.json"), rulesTemplate);
102
102
 
103
- // Create .gitignore for local.db
103
+ // Create .gitignore for local.db and temporary files
104
104
  const gitignoreContent = `# Local SQLite database (private interactions)
105
105
  local.db
106
106
  local.db-wal
107
107
  local.db-shm
108
+
109
+ # Temporary files
110
+ .pending-compact.json
108
111
  `;
109
112
  fs.writeFileSync(gitignorePath, gitignoreContent);
110
113
 
@@ -417,6 +417,14 @@ function updateSaveState(db, claudeSessionId, lastSavedTimestamp, lastSavedLine)
417
417
  `);
418
418
  stmt.run(lastSavedTimestamp, lastSavedLine, claudeSessionId);
419
419
  }
420
+ function updateSaveStateMnemeSessionId(db, claudeSessionId, mnemeSessionId) {
421
+ const stmt = db.prepare(`
422
+ UPDATE session_save_state
423
+ SET mneme_session_id = ?, updated_at = datetime('now')
424
+ WHERE claude_session_id = ?
425
+ `);
426
+ stmt.run(mnemeSessionId, claudeSessionId);
427
+ }
420
428
 
421
429
  // lib/save/parser.ts
422
430
  import * as fs4 from "node:fs";
@@ -523,7 +531,7 @@ async function parseTranscriptIncremental(transcriptPath, lastSavedLine) {
523
531
  return {
524
532
  timestamp: e.timestamp,
525
533
  content,
526
- isCompactSummary: e.isCompactSummary || false,
534
+ isCompactSummary: e.isCompactSummary || !!e.planContent || false,
527
535
  slashCommand: extractSlashCommand(content)
528
536
  };
529
537
  });
@@ -569,6 +577,35 @@ async function parseTranscriptIncremental(transcriptPath, lastSavedLine) {
569
577
  return { timestamp: e.timestamp, thinking, text, toolDetails };
570
578
  }).filter((m) => m !== null);
571
579
  const interactions = [];
580
+ const firstUserTs = userMessages.length > 0 ? userMessages[0].timestamp : "9999-12-31T23:59:59Z";
581
+ const orphanedResponses = assistantMessages.filter(
582
+ (a) => a.timestamp < firstUserTs
583
+ );
584
+ if (orphanedResponses.length > 0) {
585
+ const allToolDetails = orphanedResponses.flatMap((r) => r.toolDetails);
586
+ const orphanedTimeKeys = new Set(
587
+ orphanedResponses.map((r) => r.timestamp.slice(0, 16))
588
+ );
589
+ const allToolResults = [...orphanedTimeKeys].flatMap(
590
+ (k) => toolResultsByTimestamp.get(k) || []
591
+ );
592
+ const allProgressEvents = [...orphanedTimeKeys].flatMap(
593
+ (k) => progressEvents.get(k) || []
594
+ );
595
+ interactions.push({
596
+ timestamp: orphanedResponses[0].timestamp,
597
+ user: "",
598
+ thinking: orphanedResponses.filter((r) => r.thinking).map((r) => r.thinking).join("\n"),
599
+ assistant: orphanedResponses.filter((r) => r.text).map((r) => r.text).join("\n"),
600
+ isCompactSummary: false,
601
+ isContinuation: true,
602
+ toolsUsed: [...new Set(allToolDetails.map((t) => t.name))],
603
+ toolDetails: allToolDetails,
604
+ inPlanMode: isInPlanMode(orphanedResponses[0].timestamp) || void 0,
605
+ toolResults: allToolResults.length > 0 ? allToolResults : void 0,
606
+ progressEvents: allProgressEvents.length > 0 ? allProgressEvents : void 0
607
+ });
608
+ }
572
609
  for (let i = 0; i < userMessages.length; i++) {
573
610
  const user = userMessages[i];
574
611
  const nextUserTs = i + 1 < userMessages.length ? userMessages[i + 1].timestamp : "9999-12-31T23:59:59Z";
@@ -610,6 +647,61 @@ var IGNORED_PREFIXES = [
610
647
  ".claude/"
611
648
  ];
612
649
  var IGNORED_FILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
650
+ function detectCompactContinuation(interactions, projectPath) {
651
+ const compactInteraction = interactions.find((i) => i.isCompactSummary);
652
+ if (!compactInteraction?.user) return null;
653
+ const match = compactInteraction.user.match(
654
+ /read the full transcript at:.*?\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl/
655
+ );
656
+ if (!match) return null;
657
+ const oldClaudeSessionId = match[1];
658
+ return resolveMnemeSessionId(projectPath, oldClaudeSessionId);
659
+ }
660
+ function linkToMasterSession(projectPath, claudeSessionId, masterMnemeSessionId) {
661
+ const sessionLinksDir = path4.join(projectPath, ".mneme", "session-links");
662
+ if (!fs5.existsSync(sessionLinksDir)) {
663
+ fs5.mkdirSync(sessionLinksDir, { recursive: true });
664
+ }
665
+ const linkFile = path4.join(sessionLinksDir, `${claudeSessionId}.json`);
666
+ if (!fs5.existsSync(linkFile)) {
667
+ fs5.writeFileSync(
668
+ linkFile,
669
+ JSON.stringify(
670
+ {
671
+ masterSessionId: masterMnemeSessionId,
672
+ claudeSessionId,
673
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
674
+ },
675
+ null,
676
+ 2
677
+ )
678
+ );
679
+ console.error(
680
+ `[mneme] Compact continuation linked: ${claudeSessionId} \u2192 ${masterMnemeSessionId}`
681
+ );
682
+ }
683
+ const masterFile = findSessionFileById(projectPath, masterMnemeSessionId);
684
+ if (masterFile && fs5.existsSync(masterFile)) {
685
+ try {
686
+ const master = JSON.parse(fs5.readFileSync(masterFile, "utf8"));
687
+ const workPeriods = master.workPeriods || [];
688
+ const alreadyLinked = workPeriods.some(
689
+ (wp) => wp.claudeSessionId === claudeSessionId
690
+ );
691
+ if (!alreadyLinked) {
692
+ workPeriods.push({
693
+ claudeSessionId,
694
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
695
+ endedAt: null
696
+ });
697
+ master.workPeriods = workPeriods;
698
+ master.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
699
+ fs5.writeFileSync(masterFile, JSON.stringify(master, null, 2));
700
+ }
701
+ } catch {
702
+ }
703
+ }
704
+ }
613
705
  function isIgnoredPath(relativePath) {
614
706
  return IGNORED_PREFIXES.some((p) => relativePath.startsWith(p)) || IGNORED_FILES.includes(relativePath);
615
707
  }
@@ -663,7 +755,7 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
663
755
  }
664
756
  const dbPath = path4.join(projectPath, ".mneme", "local.db");
665
757
  const db = initDatabase(dbPath);
666
- const mnemeSessionId = resolveMnemeSessionId(projectPath, claudeSessionId);
758
+ let mnemeSessionId = resolveMnemeSessionId(projectPath, claudeSessionId);
667
759
  const saveState = getSaveState(
668
760
  db,
669
761
  claudeSessionId,
@@ -674,7 +766,25 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
674
766
  transcriptPath,
675
767
  saveState.lastSavedLine
676
768
  );
769
+ if (saveState.lastSavedLine === 0 && interactions.length > 0) {
770
+ const masterSessionId = detectCompactContinuation(
771
+ interactions,
772
+ projectPath
773
+ );
774
+ if (masterSessionId && masterSessionId !== mnemeSessionId) {
775
+ linkToMasterSession(projectPath, claudeSessionId, masterSessionId);
776
+ mnemeSessionId = masterSessionId;
777
+ updateSaveStateMnemeSessionId(db, claudeSessionId, masterSessionId);
778
+ }
779
+ }
677
780
  if (interactions.length === 0) {
781
+ updateSaveState(
782
+ db,
783
+ claudeSessionId,
784
+ saveState.lastSavedTimestamp || "",
785
+ totalLines
786
+ );
787
+ db.close();
678
788
  return {
679
789
  success: true,
680
790
  savedCount: 0,
@@ -701,6 +811,7 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
701
811
  toolsUsed: interaction.toolsUsed,
702
812
  toolDetails: interaction.toolDetails,
703
813
  ...interaction.inPlanMode && { inPlanMode: true },
814
+ ...interaction.isContinuation && { isContinuation: true },
704
815
  ...interaction.slashCommand && {
705
816
  slashCommand: interaction.slashCommand
706
817
  },
@@ -711,27 +822,30 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
711
822
  progressEvents: interaction.progressEvents
712
823
  }
713
824
  });
714
- insertStmt.run(
715
- mnemeSessionId,
716
- claudeSessionId,
717
- projectPath,
718
- repository,
719
- repositoryUrl,
720
- repositoryRoot,
721
- owner,
722
- "user",
723
- interaction.user,
724
- null,
725
- metadata,
726
- interaction.timestamp,
727
- interaction.isCompactSummary ? 1 : 0
728
- );
729
- insertedCount++;
730
- if (interaction.assistant) {
825
+ if (!interaction.isContinuation) {
826
+ insertStmt.run(
827
+ mnemeSessionId,
828
+ claudeSessionId,
829
+ projectPath,
830
+ repository,
831
+ repositoryUrl,
832
+ repositoryRoot,
833
+ owner,
834
+ "user",
835
+ interaction.user,
836
+ null,
837
+ metadata,
838
+ interaction.timestamp,
839
+ interaction.isCompactSummary ? 1 : 0
840
+ );
841
+ insertedCount++;
842
+ }
843
+ if (interaction.assistant || interaction.thinking) {
731
844
  const assistantMetadata = JSON.stringify({
732
845
  toolsUsed: interaction.toolsUsed,
733
846
  toolDetails: interaction.toolDetails,
734
847
  ...interaction.inPlanMode && { inPlanMode: true },
848
+ ...interaction.isContinuation && { isContinuation: true },
735
849
  ...interaction.toolResults?.length && {
736
850
  toolResults: interaction.toolResults
737
851
  },
@@ -748,7 +862,7 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
748
862
  repositoryRoot,
749
863
  owner,
750
864
  "assistant",
751
- interaction.assistant,
865
+ interaction.assistant || "",
752
866
  interaction.thinking || null,
753
867
  assistantMetadata,
754
868
  interaction.timestamp,
@@ -421,6 +421,14 @@ function updateSaveState(db, claudeSessionId, lastSavedTimestamp, lastSavedLine)
421
421
  `);
422
422
  stmt.run(lastSavedTimestamp, lastSavedLine, claudeSessionId);
423
423
  }
424
+ function updateSaveStateMnemeSessionId(db, claudeSessionId, mnemeSessionId) {
425
+ const stmt = db.prepare(`
426
+ UPDATE session_save_state
427
+ SET mneme_session_id = ?, updated_at = datetime('now')
428
+ WHERE claude_session_id = ?
429
+ `);
430
+ stmt.run(mnemeSessionId, claudeSessionId);
431
+ }
424
432
 
425
433
  // lib/save/parser.ts
426
434
  import * as fs4 from "node:fs";
@@ -527,7 +535,7 @@ async function parseTranscriptIncremental(transcriptPath, lastSavedLine) {
527
535
  return {
528
536
  timestamp: e.timestamp,
529
537
  content,
530
- isCompactSummary: e.isCompactSummary || false,
538
+ isCompactSummary: e.isCompactSummary || !!e.planContent || false,
531
539
  slashCommand: extractSlashCommand(content)
532
540
  };
533
541
  });
@@ -573,6 +581,35 @@ async function parseTranscriptIncremental(transcriptPath, lastSavedLine) {
573
581
  return { timestamp: e.timestamp, thinking, text, toolDetails };
574
582
  }).filter((m) => m !== null);
575
583
  const interactions = [];
584
+ const firstUserTs = userMessages.length > 0 ? userMessages[0].timestamp : "9999-12-31T23:59:59Z";
585
+ const orphanedResponses = assistantMessages.filter(
586
+ (a) => a.timestamp < firstUserTs
587
+ );
588
+ if (orphanedResponses.length > 0) {
589
+ const allToolDetails = orphanedResponses.flatMap((r) => r.toolDetails);
590
+ const orphanedTimeKeys = new Set(
591
+ orphanedResponses.map((r) => r.timestamp.slice(0, 16))
592
+ );
593
+ const allToolResults = [...orphanedTimeKeys].flatMap(
594
+ (k) => toolResultsByTimestamp.get(k) || []
595
+ );
596
+ const allProgressEvents = [...orphanedTimeKeys].flatMap(
597
+ (k) => progressEvents.get(k) || []
598
+ );
599
+ interactions.push({
600
+ timestamp: orphanedResponses[0].timestamp,
601
+ user: "",
602
+ thinking: orphanedResponses.filter((r) => r.thinking).map((r) => r.thinking).join("\n"),
603
+ assistant: orphanedResponses.filter((r) => r.text).map((r) => r.text).join("\n"),
604
+ isCompactSummary: false,
605
+ isContinuation: true,
606
+ toolsUsed: [...new Set(allToolDetails.map((t) => t.name))],
607
+ toolDetails: allToolDetails,
608
+ inPlanMode: isInPlanMode(orphanedResponses[0].timestamp) || void 0,
609
+ toolResults: allToolResults.length > 0 ? allToolResults : void 0,
610
+ progressEvents: allProgressEvents.length > 0 ? allProgressEvents : void 0
611
+ });
612
+ }
576
613
  for (let i = 0; i < userMessages.length; i++) {
577
614
  const user = userMessages[i];
578
615
  const nextUserTs = i + 1 < userMessages.length ? userMessages[i + 1].timestamp : "9999-12-31T23:59:59Z";
@@ -614,6 +651,61 @@ var IGNORED_PREFIXES = [
614
651
  ".claude/"
615
652
  ];
616
653
  var IGNORED_FILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
654
+ function detectCompactContinuation(interactions, projectPath) {
655
+ const compactInteraction = interactions.find((i) => i.isCompactSummary);
656
+ if (!compactInteraction?.user) return null;
657
+ const match = compactInteraction.user.match(
658
+ /read the full transcript at:.*?\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl/
659
+ );
660
+ if (!match) return null;
661
+ const oldClaudeSessionId = match[1];
662
+ return resolveMnemeSessionId(projectPath, oldClaudeSessionId);
663
+ }
664
+ function linkToMasterSession(projectPath, claudeSessionId, masterMnemeSessionId) {
665
+ const sessionLinksDir = path4.join(projectPath, ".mneme", "session-links");
666
+ if (!fs5.existsSync(sessionLinksDir)) {
667
+ fs5.mkdirSync(sessionLinksDir, { recursive: true });
668
+ }
669
+ const linkFile = path4.join(sessionLinksDir, `${claudeSessionId}.json`);
670
+ if (!fs5.existsSync(linkFile)) {
671
+ fs5.writeFileSync(
672
+ linkFile,
673
+ JSON.stringify(
674
+ {
675
+ masterSessionId: masterMnemeSessionId,
676
+ claudeSessionId,
677
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
678
+ },
679
+ null,
680
+ 2
681
+ )
682
+ );
683
+ console.error(
684
+ `[mneme] Compact continuation linked: ${claudeSessionId} \u2192 ${masterMnemeSessionId}`
685
+ );
686
+ }
687
+ const masterFile = findSessionFileById(projectPath, masterMnemeSessionId);
688
+ if (masterFile && fs5.existsSync(masterFile)) {
689
+ try {
690
+ const master = JSON.parse(fs5.readFileSync(masterFile, "utf8"));
691
+ const workPeriods = master.workPeriods || [];
692
+ const alreadyLinked = workPeriods.some(
693
+ (wp) => wp.claudeSessionId === claudeSessionId
694
+ );
695
+ if (!alreadyLinked) {
696
+ workPeriods.push({
697
+ claudeSessionId,
698
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
699
+ endedAt: null
700
+ });
701
+ master.workPeriods = workPeriods;
702
+ master.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
703
+ fs5.writeFileSync(masterFile, JSON.stringify(master, null, 2));
704
+ }
705
+ } catch {
706
+ }
707
+ }
708
+ }
617
709
  function isIgnoredPath(relativePath) {
618
710
  return IGNORED_PREFIXES.some((p) => relativePath.startsWith(p)) || IGNORED_FILES.includes(relativePath);
619
711
  }
@@ -667,7 +759,7 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
667
759
  }
668
760
  const dbPath = path4.join(projectPath, ".mneme", "local.db");
669
761
  const db = initDatabase(dbPath);
670
- const mnemeSessionId = resolveMnemeSessionId(projectPath, claudeSessionId);
762
+ let mnemeSessionId = resolveMnemeSessionId(projectPath, claudeSessionId);
671
763
  const saveState = getSaveState(
672
764
  db,
673
765
  claudeSessionId,
@@ -678,7 +770,25 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
678
770
  transcriptPath,
679
771
  saveState.lastSavedLine
680
772
  );
773
+ if (saveState.lastSavedLine === 0 && interactions.length > 0) {
774
+ const masterSessionId = detectCompactContinuation(
775
+ interactions,
776
+ projectPath
777
+ );
778
+ if (masterSessionId && masterSessionId !== mnemeSessionId) {
779
+ linkToMasterSession(projectPath, claudeSessionId, masterSessionId);
780
+ mnemeSessionId = masterSessionId;
781
+ updateSaveStateMnemeSessionId(db, claudeSessionId, masterSessionId);
782
+ }
783
+ }
681
784
  if (interactions.length === 0) {
785
+ updateSaveState(
786
+ db,
787
+ claudeSessionId,
788
+ saveState.lastSavedTimestamp || "",
789
+ totalLines
790
+ );
791
+ db.close();
682
792
  return {
683
793
  success: true,
684
794
  savedCount: 0,
@@ -705,6 +815,7 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
705
815
  toolsUsed: interaction.toolsUsed,
706
816
  toolDetails: interaction.toolDetails,
707
817
  ...interaction.inPlanMode && { inPlanMode: true },
818
+ ...interaction.isContinuation && { isContinuation: true },
708
819
  ...interaction.slashCommand && {
709
820
  slashCommand: interaction.slashCommand
710
821
  },
@@ -715,27 +826,30 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
715
826
  progressEvents: interaction.progressEvents
716
827
  }
717
828
  });
718
- insertStmt.run(
719
- mnemeSessionId,
720
- claudeSessionId,
721
- projectPath,
722
- repository,
723
- repositoryUrl,
724
- repositoryRoot,
725
- owner,
726
- "user",
727
- interaction.user,
728
- null,
729
- metadata,
730
- interaction.timestamp,
731
- interaction.isCompactSummary ? 1 : 0
732
- );
733
- insertedCount++;
734
- if (interaction.assistant) {
829
+ if (!interaction.isContinuation) {
830
+ insertStmt.run(
831
+ mnemeSessionId,
832
+ claudeSessionId,
833
+ projectPath,
834
+ repository,
835
+ repositoryUrl,
836
+ repositoryRoot,
837
+ owner,
838
+ "user",
839
+ interaction.user,
840
+ null,
841
+ metadata,
842
+ interaction.timestamp,
843
+ interaction.isCompactSummary ? 1 : 0
844
+ );
845
+ insertedCount++;
846
+ }
847
+ if (interaction.assistant || interaction.thinking) {
735
848
  const assistantMetadata = JSON.stringify({
736
849
  toolsUsed: interaction.toolsUsed,
737
850
  toolDetails: interaction.toolDetails,
738
851
  ...interaction.inPlanMode && { inPlanMode: true },
852
+ ...interaction.isContinuation && { isContinuation: true },
739
853
  ...interaction.toolResults?.length && {
740
854
  toolResults: interaction.toolResults
741
855
  },
@@ -752,7 +866,7 @@ async function incrementalSave(claudeSessionId, transcriptPath, projectPath) {
752
866
  repositoryRoot,
753
867
  owner,
754
868
  "assistant",
755
- interaction.assistant,
869
+ interaction.assistant || "",
756
870
  interaction.thinking || null,
757
871
  assistantMetadata,
758
872
  interaction.timestamp,
@@ -209,6 +209,69 @@ function initTags(mnemeDir, pluginRoot) {
209
209
  }
210
210
 
211
211
  // lib/session/init.ts
212
+ function resolveSessionLink(sessionLinksDir, claudeSessionId) {
213
+ const fullPath = path3.join(sessionLinksDir, `${claudeSessionId}.json`);
214
+ const shortPath = claudeSessionId.length > 8 ? path3.join(sessionLinksDir, `${claudeSessionId.slice(0, 8)}.json`) : fullPath;
215
+ const linkPath = fs3.existsSync(fullPath) ? fullPath : shortPath;
216
+ if (fs3.existsSync(linkPath)) {
217
+ try {
218
+ const link = JSON.parse(fs3.readFileSync(linkPath, "utf8"));
219
+ if (link.masterSessionId) {
220
+ return link.masterSessionId;
221
+ }
222
+ } catch {
223
+ }
224
+ }
225
+ return claudeSessionId;
226
+ }
227
+ function handlePendingCompact(mnemeDir, sessionLinksDir, currentClaudeSessionId) {
228
+ const pendingFile = path3.join(mnemeDir, ".pending-compact.json");
229
+ if (!fs3.existsSync(pendingFile)) return;
230
+ try {
231
+ const pending = JSON.parse(fs3.readFileSync(pendingFile, "utf8"));
232
+ const oldClaudeSessionId = pending.claudeSessionId || "";
233
+ const timestamp = pending.timestamp || "";
234
+ if (timestamp) {
235
+ const age = Date.now() - new Date(timestamp).getTime();
236
+ if (age > 5 * 60 * 1e3) {
237
+ fs3.unlinkSync(pendingFile);
238
+ console.error("[mneme] Stale pending-compact removed");
239
+ return;
240
+ }
241
+ }
242
+ if (!oldClaudeSessionId || oldClaudeSessionId === currentClaudeSessionId) {
243
+ fs3.unlinkSync(pendingFile);
244
+ return;
245
+ }
246
+ const masterSessionId = resolveSessionLink(
247
+ sessionLinksDir,
248
+ oldClaudeSessionId
249
+ );
250
+ ensureDir(sessionLinksDir);
251
+ const linkFile = path3.join(
252
+ sessionLinksDir,
253
+ `${currentClaudeSessionId}.json`
254
+ );
255
+ if (!fs3.existsSync(linkFile)) {
256
+ const linkData = {
257
+ masterSessionId,
258
+ claudeSessionId: currentClaudeSessionId,
259
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
260
+ };
261
+ fs3.writeFileSync(linkFile, JSON.stringify(linkData, null, 2));
262
+ console.error(
263
+ `[mneme] Compact continuation linked: ${currentClaudeSessionId} \u2192 ${masterSessionId}`
264
+ );
265
+ }
266
+ fs3.unlinkSync(pendingFile);
267
+ } catch (e) {
268
+ console.error(`[mneme] Error handling pending-compact: ${e}`);
269
+ try {
270
+ fs3.unlinkSync(path3.join(mnemeDir, ".pending-compact.json"));
271
+ } catch {
272
+ }
273
+ }
274
+ }
212
275
  function sessionInit(sessionId, cwd) {
213
276
  const pluginRoot = path3.resolve(__dirname, "..", "..");
214
277
  const mnemeDir = path3.join(cwd, ".mneme");
@@ -223,6 +286,7 @@ function sessionInit(sessionId, cwd) {
223
286
  }
224
287
  const now = nowISO();
225
288
  const fileId = sessionId || "";
289
+ handlePendingCompact(mnemeDir, sessionLinksDir, fileId);
226
290
  const git = getGitInfo(cwd);
227
291
  const repoInfo = getRepositoryInfo(cwd);
228
292
  const projectName = path3.basename(cwd);
package/dist/server.js CHANGED
@@ -4493,7 +4493,7 @@ misc.get("/project", (c) => {
4493
4493
  }
4494
4494
  } catch {
4495
4495
  }
4496
- const version = "0.24.2";
4496
+ const version = "0.25.0";
4497
4497
  return c.json({
4498
4498
  name: projectName,
4499
4499
  path: projectRoot,
@@ -70,4 +70,11 @@ else
70
70
  echo "[mneme:pre-compact] Save result: ${result}" >&2
71
71
  fi
72
72
 
73
+ # Write pending-compact breadcrumb for session linking on next SessionStart
74
+ # The new session (post-compact) will read this to link back to the current mneme session
75
+ pending_compact_file="${cwd}/.mneme/.pending-compact.json"
76
+ printf '{"claudeSessionId":"%s","timestamp":"%s"}\n' \
77
+ "$session_id" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$pending_compact_file"
78
+ echo "[mneme:pre-compact] Wrote pending-compact breadcrumb for session linking" >&2
79
+
73
80
  exit 0
@@ -4,8 +4,9 @@
4
4
  #
5
5
  # Purpose: Search mneme for relevant context and approved rules,
6
6
  # inject as additionalContext using shared search-core logic.
7
+ # Also injects Claude Session ID (workaround for anthropics/claude-code#16538).
7
8
  #
8
- # Input (stdin): JSON with prompt, cwd
9
+ # Input (stdin): JSON with prompt, session_id, cwd
9
10
  # Output (stdout): JSON with hookSpecificOutput.additionalContext (if matches found)
10
11
  #
11
12
 
@@ -23,13 +24,16 @@ fi
23
24
 
24
25
  prompt=$(echo "$input_json" | jq -r ".prompt // empty" 2>/dev/null || echo "")
25
26
  cwd=$(echo "$input_json" | jq -r ".cwd // empty" 2>/dev/null || echo "")
27
+ session_id=$(echo "$input_json" | jq -r ".session_id // empty" 2>/dev/null || echo "")
26
28
 
29
+ # Determine if search should run (session ID is always injected regardless)
30
+ run_search=true
27
31
  if [ -z "$prompt" ] || [ ${#prompt} -lt 10 ]; then
28
- exit 0
32
+ run_search=false
29
33
  fi
30
34
 
31
35
  if [[ "$prompt" == /mneme* ]]; then
32
- exit 0
36
+ run_search=false
33
37
  fi
34
38
 
35
39
  if [ -z "$cwd" ]; then
@@ -37,104 +41,114 @@ if [ -z "$cwd" ]; then
37
41
  fi
38
42
  cwd=$(cd "$cwd" 2>/dev/null && pwd || echo "$cwd")
39
43
 
40
- # Cap very large prompts to avoid overly expensive search
41
- if [ ${#prompt} -gt 4000 ]; then
42
- prompt="${prompt:0:4000}"
43
- fi
44
-
45
44
  if ! validate_mneme "$cwd"; then
46
45
  exit 0
47
46
  fi
48
47
 
49
- PLUGIN_ROOT="$(get_plugin_root)"
50
-
51
- search_script=$(find_script "$PLUGIN_ROOT" "prompt-search")
52
- if [ -z "$search_script" ]; then
53
- exit 0
54
- fi
55
-
56
- # Detect changed files for file-based session recommendation
57
- changed_files=""
58
- if command -v git >/dev/null 2>&1 && git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
59
- changed_files=$(cd "$cwd" && {
60
- git diff --name-only HEAD 2>/dev/null
61
- git diff --name-only --cached 2>/dev/null
62
- } | sort -u | head -20 | paste -sd "," - 2>/dev/null || echo "")
63
- fi
64
-
65
- search_output=$(invoke_node "$search_script" \
66
- --query "$prompt" --project "$cwd" --limit 5 \
67
- ${changed_files:+--files "$changed_files"} 2>/dev/null || echo "")
48
+ # --- Search (skipped for short prompts and /mneme commands) ---
49
+ context_message=""
50
+ rules_message=""
68
51
 
69
- if [ -z "$search_output" ]; then
70
- exit 0
71
- fi
52
+ if [ "$run_search" = true ]; then
53
+ # Cap very large prompts to avoid overly expensive search
54
+ if [ ${#prompt} -gt 4000 ]; then
55
+ prompt="${prompt:0:4000}"
56
+ fi
72
57
 
73
- success=$(echo "$search_output" | jq -r ".success // false" 2>/dev/null || echo "false")
74
- if [ "$success" != "true" ]; then
75
- exit 0
76
- fi
58
+ PLUGIN_ROOT="$(get_plugin_root)"
59
+
60
+ search_script=$(find_script "$PLUGIN_ROOT" "prompt-search")
61
+ if [ -n "$search_script" ]; then
62
+ # Detect changed files for file-based session recommendation
63
+ changed_files=""
64
+ if command -v git >/dev/null 2>&1 && git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
65
+ changed_files=$(cd "$cwd" && {
66
+ git diff --name-only HEAD 2>/dev/null
67
+ git diff --name-only --cached 2>/dev/null
68
+ } | sort -u | head -20 | paste -sd "," - 2>/dev/null || echo "")
69
+ fi
77
70
 
78
- # Format session/interaction context (existing behavior)
79
- context_message=""
80
- context_lines=$(echo "$search_output" | jq -r '
81
- .results
82
- | map(select(.score >= 3 and (.type == "session" or .type == "unit")))
83
- | .[:3]
84
- | map("[\(.type):\(.id)] \(.title) | match: \((.matchedFields // []) | join(","))")
85
- | join("\n")
86
- ')
87
-
88
- # Format file-based session recommendations
89
- file_rec_lines=$(echo "$search_output" | jq -r '
90
- .fileRecommendations // []
91
- | .[:3]
92
- | map("[session:\(.sessionId)] \(.title) | files: \(.matchedFiles | join(", "))")
93
- | join("\n")
94
- ')
95
-
96
- if [ -n "$context_lines" ] && [ "$context_lines" != "null" ] || \
97
- [ -n "$file_rec_lines" ] && [ "$file_rec_lines" != "null" ]; then
98
- context_parts=""
99
- if [ -n "$context_lines" ] && [ "$context_lines" != "null" ]; then
100
- context_parts="Related context found (sessions/units):
71
+ search_output=$(invoke_node "$search_script" \
72
+ --query "$prompt" --project "$cwd" --limit 5 \
73
+ ${changed_files:+--files "$changed_files"} 2>/dev/null || echo "")
74
+
75
+ if [ -n "$search_output" ]; then
76
+ success=$(echo "$search_output" | jq -r ".success // false" 2>/dev/null || echo "false")
77
+ if [ "$success" = "true" ]; then
78
+ # Format session/interaction context
79
+ context_lines=$(echo "$search_output" | jq -r '
80
+ .results
81
+ | map(select(.score >= 3 and (.type == "session" or .type == "unit")))
82
+ | .[:3]
83
+ | map("[\(.type):\(.id)] \(.title) | match: \((.matchedFields // []) | join(","))")
84
+ | join("\n")
85
+ ')
86
+
87
+ # Format file-based session recommendations
88
+ file_rec_lines=$(echo "$search_output" | jq -r '
89
+ .fileRecommendations // []
90
+ | .[:3]
91
+ | map("[session:\(.sessionId)] \(.title) | files: \(.matchedFiles | join(", "))")
92
+ | join("\n")
93
+ ')
94
+
95
+ if [ -n "$context_lines" ] && [ "$context_lines" != "null" ] || \
96
+ [ -n "$file_rec_lines" ] && [ "$file_rec_lines" != "null" ]; then
97
+ context_parts=""
98
+ if [ -n "$context_lines" ] && [ "$context_lines" != "null" ]; then
99
+ context_parts="Related context found (sessions/units):
101
100
  ${context_lines}"
102
- fi
103
- if [ -n "$file_rec_lines" ] && [ "$file_rec_lines" != "null" ]; then
104
- if [ -n "$context_parts" ]; then
105
- context_parts="${context_parts}
101
+ fi
102
+ if [ -n "$file_rec_lines" ] && [ "$file_rec_lines" != "null" ]; then
103
+ if [ -n "$context_parts" ]; then
104
+ context_parts="${context_parts}
106
105
  "
107
- fi
108
- context_parts="${context_parts}Related sessions (editing same files):
106
+ fi
107
+ context_parts="${context_parts}Related sessions (editing same files):
109
108
  ${file_rec_lines}"
110
- fi
111
- context_message="<mneme-context>
109
+ fi
110
+ context_message="<mneme-context>
112
111
  ${context_parts}
113
112
  To explore deeper: use /mneme:search with specific technical terms, error messages, or file paths.
114
113
  </mneme-context>"
115
- fi
116
-
117
- # Format approved rules
118
- rules_message=""
119
- rules_lines=$(echo "$search_output" | jq -r '
120
- .rules // []
121
- | map(select(.score >= 2))
122
- | .[:5]
123
- | map("[\(.sourceType):\(.id)] (\(.priority // "—")) \(.text)")
124
- | join("\n")
125
- ')
126
-
127
- if [ -n "$rules_lines" ] && [ "$rules_lines" != "null" ]; then
128
- rules_message="<mneme-rules>
114
+ fi
115
+
116
+ # Format approved rules
117
+ rules_lines=$(echo "$search_output" | jq -r '
118
+ .rules // []
119
+ | map(select(.score >= 2))
120
+ | .[:5]
121
+ | map("[\(.sourceType):\(.id)] (\(.priority // "—")) \(.text)")
122
+ | join("\n")
123
+ ')
124
+
125
+ if [ -n "$rules_lines" ] && [ "$rules_lines" != "null" ]; then
126
+ rules_message="<mneme-rules>
129
127
  Approved development rules (apply during this response):
130
128
  ${rules_lines}
131
129
  </mneme-rules>"
130
+ fi
131
+ fi
132
+ fi
133
+ fi
132
134
  fi
133
135
 
134
- # Combine both sections
136
+ # --- Assemble full context ---
135
137
  full_context=""
138
+
139
+ # Session ID injection (workaround for anthropics/claude-code#16538:
140
+ # Plugin SessionStart hooks don't surface additionalContext to Claude)
141
+ if [ -n "$session_id" ]; then
142
+ full_context="**Claude Session ID:** ${session_id}"
143
+ fi
144
+
136
145
  if [ -n "$context_message" ]; then
137
- full_context="$context_message"
146
+ if [ -n "$full_context" ]; then
147
+ full_context="${full_context}
148
+ ${context_message}"
149
+ else
150
+ full_context="$context_message"
151
+ fi
138
152
  fi
139
153
  if [ -n "$rules_message" ]; then
140
154
  if [ -n "$full_context" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hir4ta/mneme",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Long-term memory plugin for Claude Code - automated session saving, recording technical decisions, and web dashboard",
5
5
  "keywords": [
6
6
  "claude",
@@ -45,7 +45,7 @@ Always render missing required fields as blocking errors before write.
45
45
 
46
46
  **STOP. Before doing ANYTHING else, find the Claude Session ID.**
47
47
 
48
- Scan upward in this conversation for the SessionStart context block. It contains a line matching this exact format:
48
+ The UserPromptSubmit hook injects the session ID into every prompt's context. Look for this exact line in the system-reminder messages above:
49
49
 
50
50
  ```
51
51
  **Claude Session ID:** <UUID>
@@ -54,8 +54,8 @@ Scan upward in this conversation for the SessionStart context block. It contains
54
54
  Copy the full UUID value (36 characters, e.g. `a1b2c3d4-e5f6-7890-abcd-ef1234567890`).
55
55
 
56
56
  <required>
57
- - The Claude Session ID is ALREADY in this conversation — injected by the session-start hook at the very beginning
58
- - Search for the literal text `**Claude Session ID:**` in the messages above
57
+ - The Claude Session ID is ALREADY in this conversation — injected by the UserPromptSubmit hook on every prompt
58
+ - Search for the literal text `**Claude Session ID:**` in system-reminder messages above
59
59
  - Copy the full 36-character UUID that follows it
60
60
  - Do NOT run Bash, Glob, Grep, or any other tool to discover the session ID
61
61
  - Do NOT call `mneme_list_sessions` to find it (that is only for the fallback below)