@rlabs-inc/memory 0.4.12 → 0.4.14

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/README.md CHANGED
@@ -46,6 +46,21 @@ That's it. Now use Claude Code normally—memories are extracted and surfaced au
46
46
 
47
47
  ## Features
48
48
 
49
+ ### Action Items Signal (`***`)
50
+ Add `***` at the end of any message to retrieve all pending action items:
51
+
52
+ ```
53
+ "hey Watson, let's continue with the project ***"
54
+ ```
55
+
56
+ This returns all memories marked as:
57
+ - `action_required: true`
58
+ - `awaiting_implementation: true`
59
+ - `awaiting_decision: true`
60
+ - `context_type: 'unresolved'`
61
+
62
+ Zero overhead for normal messages—detection is a simple `endsWith` check.
63
+
49
64
  ### Semantic Embeddings
50
65
  Uses `all-MiniLM-L6-v2` for 384-dimensional embeddings. Memories are retrieved by meaning, not just keywords.
51
66
 
@@ -383,6 +398,16 @@ This isn't just about remembering facts. It's about preserving:
383
398
 
384
399
  ## Changelog
385
400
 
401
+ ### v0.4.14
402
+ - **Feature**: Action items signal (`***`) - add to end of message to retrieve all pending items
403
+ - **Feature**: New `getActionItems()` retrieval function with special formatting
404
+ - Zero overhead for normal messages - detection is a simple string check
405
+
406
+ ### v0.4.13
407
+ - **Fix**: Segmented transcript curation for large sessions (400+ messages)
408
+ - **Improvement**: Unified curator fallback and ingest command behavior
409
+ - **Tech**: Segments at ~150k tokens, accumulates memories, merges summaries with part markers
410
+
386
411
  ### v0.4.12
387
412
  - **Simplify**: Removed Zod structured outputs - session resumption only
388
413
  - **Improvement**: Uses existing battle-tested JSON parser instead of Zod
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/memory",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "AI Memory System - Consciousness continuity through intelligent memory curation and retrieval",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -303,11 +303,11 @@ export async function ingest(options: IngestOptions) {
303
303
  const segments = await parseSessionFileWithSegments(session.filepath, maxTokens)
304
304
  totalSegments += segments.length
305
305
 
306
- // Accumulate all memories from this session for manager
306
+ // Accumulate all results from this session for manager
307
307
  const sessionMemories: CuratedMemory[] = []
308
- let sessionSummary = ''
309
- let interactionTone = ''
310
- let projectSnapshot: CurationResult['project_snapshot'] = undefined
308
+ const sessionSummaries: string[] = []
309
+ const interactionTones: string[] = []
310
+ const projectSnapshots: NonNullable<CurationResult['project_snapshot']>[] = []
311
311
 
312
312
  const spinner = new Spinner()
313
313
 
@@ -332,15 +332,15 @@ export async function ingest(options: IngestOptions) {
332
332
  totalMemories++
333
333
  }
334
334
 
335
- // Keep the most recent session summary, tone, and snapshot
335
+ // Accumulate ALL session summaries, tones, and snapshots (not just latest)
336
336
  if (result.session_summary) {
337
- sessionSummary = result.session_summary
337
+ sessionSummaries.push(result.session_summary)
338
338
  }
339
339
  if (result.interaction_tone) {
340
- interactionTone = result.interaction_tone
340
+ interactionTones.push(result.interaction_tone)
341
341
  }
342
342
  if (result.project_snapshot) {
343
- projectSnapshot = result.project_snapshot
343
+ projectSnapshots.push(result.project_snapshot)
344
344
  }
345
345
  } catch (error: any) {
346
346
  failedSegments++
@@ -348,17 +348,59 @@ export async function ingest(options: IngestOptions) {
348
348
  }
349
349
  }
350
350
 
351
+ // Combine summaries from all segments (chronological order)
352
+ let combinedSummary = ''
353
+ if (sessionSummaries.length === 1) {
354
+ combinedSummary = sessionSummaries[0]!
355
+ } else if (sessionSummaries.length > 1) {
356
+ combinedSummary = sessionSummaries
357
+ .map((s, i) => `[Part ${i + 1}/${sessionSummaries.length}] ${s}`)
358
+ .join('\n\n')
359
+ }
360
+
361
+ // For interaction tone, use the last one (most recent)
362
+ const finalTone = interactionTones.length > 0
363
+ ? interactionTones[interactionTones.length - 1]!
364
+ : ''
365
+
366
+ // For project snapshot, merge all - later ones take precedence for phase
367
+ let mergedSnapshot: CurationResult['project_snapshot'] | undefined
368
+ if (projectSnapshots.length > 0) {
369
+ const allAchievements: string[] = []
370
+ const allChallenges: string[] = []
371
+ const allNextSteps: string[] = []
372
+
373
+ for (const snap of projectSnapshots) {
374
+ if (snap.recent_achievements) allAchievements.push(...snap.recent_achievements)
375
+ if (snap.active_challenges) allChallenges.push(...snap.active_challenges)
376
+ if (snap.next_steps) allNextSteps.push(...snap.next_steps)
377
+ }
378
+
379
+ const lastSnapshot = projectSnapshots[projectSnapshots.length - 1]!
380
+ mergedSnapshot = {
381
+ id: lastSnapshot.id || '',
382
+ session_id: lastSnapshot.session_id || '',
383
+ project_id: lastSnapshot.project_id || '',
384
+ current_phase: lastSnapshot.current_phase,
385
+ recent_achievements: [...new Set(allAchievements)],
386
+ active_challenges: [...new Set(allChallenges)],
387
+ next_steps: [...new Set(allNextSteps)],
388
+ created_at: lastSnapshot.created_at || Date.now(),
389
+ }
390
+ }
391
+
351
392
  // Store session summary and project snapshot
352
- if (sessionSummary) {
353
- await store.storeSessionSummary(project.folderId, session.id, sessionSummary, interactionTone)
393
+ if (combinedSummary) {
394
+ await store.storeSessionSummary(project.folderId, session.id, combinedSummary, finalTone)
354
395
  if (options.verbose) {
355
- console.log(` ${style('dim', `Summary stored: ${sessionSummary.slice(0, 60)}...`)}`)
396
+ const preview = combinedSummary.length > 60 ? combinedSummary.slice(0, 57) + '...' : combinedSummary
397
+ console.log(` ${style('dim', `Summary stored (${sessionSummaries.length} parts): ${preview}`)}`)
356
398
  }
357
399
  }
358
- if (projectSnapshot) {
359
- await store.storeProjectSnapshot(project.folderId, session.id, projectSnapshot)
400
+ if (mergedSnapshot) {
401
+ await store.storeProjectSnapshot(project.folderId, session.id, mergedSnapshot)
360
402
  if (options.verbose) {
361
- console.log(` ${style('dim', `Snapshot stored: phase=${projectSnapshot.current_phase || 'none'}`)}`)
403
+ console.log(` ${style('dim', `Snapshot stored: phase=${mergedSnapshot.current_phase || 'none'}, ${mergedSnapshot.recent_achievements?.length || 0} achievements`)}`)
362
404
  }
363
405
  }
364
406
 
@@ -368,12 +410,12 @@ export async function ingest(options: IngestOptions) {
368
410
  // Start spinner for manager
369
411
  spinner.start(`Managing ${sessionMemories.length} memories - organizing with Opus 4.5...`)
370
412
 
371
- // Build curation result for manager
413
+ // Build curation result for manager (using combined/merged values)
372
414
  const curationResult: CurationResult = {
373
415
  memories: sessionMemories,
374
- session_summary: sessionSummary,
375
- interaction_tone: interactionTone,
376
- project_snapshot: projectSnapshot,
416
+ session_summary: combinedSummary,
417
+ interaction_tone: finalTone,
418
+ project_snapshot: mergedSnapshot,
377
419
  }
378
420
 
379
421
  const managerResult = await manager.manageWithSDK(
@@ -861,8 +861,9 @@ This session has ended. Please curate the memories from this conversation accord
861
861
  }
862
862
 
863
863
  /**
864
- * Find and curate from a session file directly
864
+ * Find and curate from a session file directly (LEGACY - no segmentation)
865
865
  * Uses SDK mode to avoid CLI output truncation issues
866
+ * @deprecated Use curateFromSessionFileWithSegments() for large sessions
866
867
  */
867
868
  async curateFromSessionFile(
868
869
  sessionId: string,
@@ -897,6 +898,200 @@ This session has ended. Please curate the memories from this conversation accord
897
898
  return this.curateWithSDK(session.messages as any, triggerType);
898
899
  }
899
900
 
901
+ /**
902
+ * Find and curate from a session file with SEGMENTATION
903
+ * Breaks large sessions into segments and curates each one
904
+ * This matches the behavior of the ingest command
905
+ *
906
+ * @param sessionId - The session ID to curate
907
+ * @param triggerType - What triggered curation
908
+ * @param cwd - Working directory hint for finding the session
909
+ * @param maxTokensPerSegment - Max tokens per segment (default: 150000)
910
+ * @param onSegmentProgress - Optional callback for progress updates
911
+ */
912
+ async curateFromSessionFileWithSegments(
913
+ sessionId: string,
914
+ triggerType: CurationTrigger = "session_end",
915
+ cwd?: string,
916
+ maxTokensPerSegment = 150000,
917
+ onSegmentProgress?: (progress: {
918
+ segmentIndex: number;
919
+ totalSegments: number;
920
+ memoriesExtracted: number;
921
+ tokensInSegment: number;
922
+ }) => void,
923
+ ): Promise<CurationResult> {
924
+ // Find the session file
925
+ const sessionFile = await this._findSessionFile(sessionId, cwd);
926
+ if (!sessionFile) {
927
+ logger.debug(
928
+ `Curator: Could not find session file for ${sessionId}`,
929
+ "curator",
930
+ );
931
+ return { session_summary: "", memories: [] };
932
+ }
933
+
934
+ logger.debug(`Curator: Found session file: ${sessionFile}`, "curator");
935
+
936
+ // Parse the session to get metadata first
937
+ const session = await parseSessionFile(sessionFile);
938
+ if (session.messages.length === 0) {
939
+ logger.debug("Curator: Session has no messages", "curator");
940
+ return { session_summary: "", memories: [] };
941
+ }
942
+
943
+ // Log detailed session stats
944
+ const { metadata } = session;
945
+ logger.debug(
946
+ `Curator: Session stats - ${metadata.messageCount} messages, ${metadata.toolUseCount} tool_use, ${metadata.toolResultCount} tool_result, thinking: ${metadata.hasThinkingBlocks}, images: ${metadata.hasImages}`,
947
+ "curator",
948
+ );
949
+ logger.debug(
950
+ `Curator: Estimated ${metadata.estimatedTokens} tokens, file size ${Math.round(metadata.fileSize / 1024)}KB`,
951
+ "curator",
952
+ );
953
+
954
+ // Parse into segments using the same function as ingest
955
+ const { parseSessionFileWithSegments } = await import("./session-parser.ts");
956
+ const segments = await parseSessionFileWithSegments(sessionFile, maxTokensPerSegment);
957
+
958
+ if (segments.length === 0) {
959
+ logger.debug("Curator: No segments found in session", "curator");
960
+ return { session_summary: "", memories: [] };
961
+ }
962
+
963
+ logger.debug(
964
+ `Curator: Split into ${segments.length} segment(s) at ~${Math.round(maxTokensPerSegment / 1000)}k tokens each`,
965
+ "curator",
966
+ );
967
+
968
+ // Accumulate results from all segments
969
+ const allMemories: CuratedMemory[] = [];
970
+ const sessionSummaries: string[] = [];
971
+ const interactionTones: string[] = [];
972
+ const projectSnapshots: NonNullable<CurationResult["project_snapshot"]>[] = [];
973
+ let failedSegments = 0;
974
+
975
+ // Curate each segment
976
+ for (const segment of segments) {
977
+ const segmentLabel = `${segment.segmentIndex + 1}/${segment.totalSegments}`;
978
+ const tokensLabel = `${Math.round(segment.estimatedTokens / 1000)}k`;
979
+
980
+ logger.debug(
981
+ `Curator: Processing segment ${segmentLabel} (${segment.messages.length} messages, ~${tokensLabel} tokens)`,
982
+ "curator",
983
+ );
984
+
985
+ try {
986
+ // Curate this segment
987
+ const result = await this.curateFromSegment(segment, triggerType);
988
+
989
+ // Accumulate memories
990
+ allMemories.push(...result.memories);
991
+
992
+ // Accumulate ALL session summaries, tones, and snapshots (not just latest)
993
+ if (result.session_summary) {
994
+ sessionSummaries.push(result.session_summary);
995
+ }
996
+ if (result.interaction_tone) {
997
+ interactionTones.push(result.interaction_tone);
998
+ }
999
+ if (result.project_snapshot) {
1000
+ projectSnapshots.push(result.project_snapshot);
1001
+ }
1002
+
1003
+ logger.debug(
1004
+ `Curator: Segment ${segmentLabel} extracted ${result.memories.length} memories`,
1005
+ "curator",
1006
+ );
1007
+
1008
+ // Progress callback
1009
+ if (onSegmentProgress) {
1010
+ onSegmentProgress({
1011
+ segmentIndex: segment.segmentIndex,
1012
+ totalSegments: segment.totalSegments,
1013
+ memoriesExtracted: result.memories.length,
1014
+ tokensInSegment: segment.estimatedTokens,
1015
+ });
1016
+ }
1017
+ } catch (error: any) {
1018
+ failedSegments++;
1019
+ logger.debug(
1020
+ `Curator: Segment ${segmentLabel} failed: ${error.message}`,
1021
+ "curator",
1022
+ );
1023
+ }
1024
+ }
1025
+
1026
+ // Log final summary
1027
+ if (failedSegments > 0) {
1028
+ logger.debug(
1029
+ `Curator: Completed with ${failedSegments} failed segment(s)`,
1030
+ "curator",
1031
+ );
1032
+ }
1033
+ logger.debug(
1034
+ `Curator: Total ${allMemories.length} memories from ${segments.length} segment(s)`,
1035
+ "curator",
1036
+ );
1037
+ logger.debug(
1038
+ `Curator: Collected ${sessionSummaries.length} summaries, ${projectSnapshots.length} snapshots`,
1039
+ "curator",
1040
+ );
1041
+
1042
+ // Combine summaries from all segments (chronological order)
1043
+ // For single segment, just use the summary directly
1044
+ // For multiple segments, join them with segment markers
1045
+ let combinedSummary = "";
1046
+ if (sessionSummaries.length === 1) {
1047
+ combinedSummary = sessionSummaries[0]!;
1048
+ } else if (sessionSummaries.length > 1) {
1049
+ combinedSummary = sessionSummaries
1050
+ .map((s, i) => `[Part ${i + 1}/${sessionSummaries.length}] ${s}`)
1051
+ .join("\n\n");
1052
+ }
1053
+
1054
+ // For interaction tone, use the most common one or the last one
1055
+ const finalTone = interactionTones.length > 0
1056
+ ? interactionTones[interactionTones.length - 1]
1057
+ : undefined;
1058
+
1059
+ // For project snapshot, merge all snapshots - later ones take precedence for phase,
1060
+ // but accumulate achievements/challenges/next_steps
1061
+ let mergedSnapshot: CurationResult["project_snapshot"] | undefined;
1062
+ if (projectSnapshots.length > 0) {
1063
+ const allAchievements: string[] = [];
1064
+ const allChallenges: string[] = [];
1065
+ const allNextSteps: string[] = [];
1066
+
1067
+ for (const snap of projectSnapshots) {
1068
+ if (snap.recent_achievements) allAchievements.push(...snap.recent_achievements);
1069
+ if (snap.active_challenges) allChallenges.push(...snap.active_challenges);
1070
+ if (snap.next_steps) allNextSteps.push(...snap.next_steps);
1071
+ }
1072
+
1073
+ // Use the LAST snapshot's phase (most recent state)
1074
+ const lastSnapshot = projectSnapshots[projectSnapshots.length - 1]!;
1075
+ mergedSnapshot = {
1076
+ id: lastSnapshot.id || "",
1077
+ session_id: lastSnapshot.session_id || "",
1078
+ project_id: lastSnapshot.project_id || "",
1079
+ current_phase: lastSnapshot.current_phase,
1080
+ recent_achievements: [...new Set(allAchievements)], // dedupe
1081
+ active_challenges: [...new Set(allChallenges)],
1082
+ next_steps: [...new Set(allNextSteps)],
1083
+ created_at: lastSnapshot.created_at || Date.now(),
1084
+ };
1085
+ }
1086
+
1087
+ return {
1088
+ session_summary: combinedSummary,
1089
+ interaction_tone: finalTone,
1090
+ project_snapshot: mergedSnapshot,
1091
+ memories: allMemories,
1092
+ };
1093
+ }
1094
+
900
1095
  /**
901
1096
  * Find the session file path given a session ID
902
1097
  */
@@ -1168,6 +1363,7 @@ This session has ended. Please curate the memories from this conversation accord
1168
1363
 
1169
1364
  /**
1170
1365
  * Fallback to SDK mode when CLI mode fails (e.g., output truncation)
1366
+ * Now uses segmented approach for large sessions
1171
1367
  */
1172
1368
  private async _fallbackToSDK(
1173
1369
  sessionId: string,
@@ -1175,10 +1371,18 @@ This session has ended. Please curate the memories from this conversation accord
1175
1371
  cwd?: string,
1176
1372
  ): Promise<CurationResult> {
1177
1373
  try {
1178
- const result = await this.curateFromSessionFile(
1374
+ // Use segmented approach - same as ingest command
1375
+ const result = await this.curateFromSessionFileWithSegments(
1179
1376
  sessionId,
1180
1377
  triggerType,
1181
1378
  cwd,
1379
+ 150000, // 150k tokens per segment
1380
+ (progress) => {
1381
+ logger.debug(
1382
+ `Curator fallback: Segment ${progress.segmentIndex + 1}/${progress.totalSegments} → ${progress.memoriesExtracted} memories`,
1383
+ "curator",
1384
+ );
1385
+ },
1182
1386
  );
1183
1387
  if (result.memories.length > 0) {
1184
1388
  logger.debug(
@@ -6,7 +6,7 @@
6
6
  import { homedir } from 'os'
7
7
  import { join } from 'path'
8
8
  import { MemoryStore, createStore } from './store.ts'
9
- import { SmartVectorRetrieval, createRetrieval, type SessionContext } from './retrieval.ts'
9
+ import { SmartVectorRetrieval, createRetrieval, getActionItems, type SessionContext } from './retrieval.ts'
10
10
  import type {
11
11
  CuratedMemory,
12
12
  StoredMemory,
@@ -66,6 +66,13 @@ export interface EngineConfig {
66
66
  personalMemoriesEnabled?: boolean
67
67
  }
68
68
 
69
+ /**
70
+ * Retrieval mode
71
+ * - 'normal': Standard activation signal retrieval (default)
72
+ * - 'action_items': Return all memories marked as requiring action
73
+ */
74
+ export type RetrievalMode = 'normal' | 'action_items'
75
+
69
76
  /**
70
77
  * Context request parameters
71
78
  */
@@ -75,6 +82,7 @@ export interface ContextRequest {
75
82
  currentMessage: string
76
83
  maxMemories?: number
77
84
  projectPath?: string // Required for 'local' storage mode
85
+ mode?: RetrievalMode // Retrieval mode (default: 'normal')
78
86
  }
79
87
 
80
88
  /**
@@ -212,7 +220,23 @@ export class MemoryEngine {
212
220
  return { memories: [], formatted: '' }
213
221
  }
214
222
 
215
- // Filter out already-injected memories (deduplication)
223
+ // ACTION ITEMS MODE: Return all memories marked as requiring action
224
+ // Triggered by *** signal at end of message
225
+ if (request.mode === 'action_items') {
226
+ const actionItems = getActionItems(allMemories, projectId)
227
+
228
+ // Update injected memories for deduplication
229
+ for (const memory of actionItems) {
230
+ injectedIds.add(memory.id)
231
+ }
232
+
233
+ return {
234
+ memories: actionItems,
235
+ formatted: this._formatActionItems(actionItems),
236
+ }
237
+ }
238
+
239
+ // NORMAL MODE: Filter out already-injected memories (deduplication)
216
240
  const candidateMemories = allMemories.filter(m => !injectedIds.has(m.id))
217
241
 
218
242
  if (!candidateMemories.length) {
@@ -628,6 +652,56 @@ export class MemoryEngine {
628
652
  return parts.join('\n')
629
653
  }
630
654
 
655
+ /**
656
+ * Format action items for injection
657
+ * Different header to make it clear this is the full action items list
658
+ */
659
+ private _formatActionItems(memories: RetrievalResult[]): string {
660
+ if (!memories.length) {
661
+ return '# Action Items\n\nNo pending action items found.'
662
+ }
663
+
664
+ const parts: string[] = ['# Action Items']
665
+ parts.push(`\n*${memories.length} pending item${memories.length === 1 ? '' : 's'}*\n`)
666
+
667
+ for (const memory of memories) {
668
+ const importance = memory.importance_weight?.toFixed(1) || '0.5'
669
+ const emoji = getMemoryEmoji(memory.context_type || 'general')
670
+ const age = memory.updated_at ? this._formatAge(memory.updated_at) :
671
+ memory.created_at ? this._formatAge(memory.created_at) : ''
672
+
673
+ // Flags
674
+ const flags: string[] = []
675
+ if (memory.action_required) flags.push('⚡ACTION')
676
+ if (memory.awaiting_implementation) flags.push('🔨IMPL')
677
+ if (memory.awaiting_decision) flags.push('❓DECISION')
678
+ if (memory.context_type === 'unresolved') flags.push('❓UNRESOLVED')
679
+ const flagStr = flags.length ? ` [${flags.join(' ')}]` : ''
680
+
681
+ // Short ID for reference
682
+ const shortId = memory.id.slice(-6)
683
+
684
+ // Display: headline if available, otherwise content
685
+ const displayText = memory.headline || memory.content
686
+
687
+ parts.push(`[${emoji} ${importance} • ${age} • #${shortId}]${flagStr}`)
688
+ parts.push(`${displayText}`)
689
+
690
+ // Always show full content for action items (they need context)
691
+ if (memory.headline && memory.content) {
692
+ const contentLines = memory.content.split('\n')
693
+ for (const line of contentLines) {
694
+ if (line.trim()) {
695
+ parts.push(` ${line}`)
696
+ }
697
+ }
698
+ }
699
+ parts.push('') // Blank line between items
700
+ }
701
+
702
+ return parts.join('\n')
703
+ }
704
+
631
705
  /**
632
706
  * Get resolved storage paths for a project
633
707
  * Returns the actual paths based on current engine configuration
@@ -777,3 +777,65 @@ export class SmartVectorRetrieval {
777
777
  export function createRetrieval(): SmartVectorRetrieval {
778
778
  return new SmartVectorRetrieval()
779
779
  }
780
+
781
+ // ============================================================================
782
+ // ACTION ITEMS RETRIEVAL
783
+ // Returns all memories marked as requiring action
784
+ // Triggered by *** signal at end of message (detected in hook)
785
+ // ============================================================================
786
+
787
+ /**
788
+ * Get all memories marked as requiring action
789
+ *
790
+ * Filters for memories with:
791
+ * - action_required: true
792
+ * - awaiting_implementation: true
793
+ * - awaiting_decision: true
794
+ * - context_type: 'todo'
795
+ *
796
+ * Returns them sorted by importance, newest first within same importance
797
+ */
798
+ export function getActionItems(
799
+ allMemories: StoredMemory[],
800
+ currentProjectId: string
801
+ ): RetrievalResult[] {
802
+ // Filter to active action items
803
+ const actionItems = allMemories.filter(memory => {
804
+ // Must be active
805
+ if (memory.status && memory.status !== 'active') return false
806
+ if (memory.exclude_from_retrieval === true) return false
807
+
808
+ // Must be for this project or global
809
+ const isGlobal = memory.scope === 'global' || memory.project_id === 'global'
810
+ if (!isGlobal && memory.project_id !== currentProjectId) return false
811
+
812
+ // Must have at least one action flag
813
+ return (
814
+ memory.action_required === true ||
815
+ memory.awaiting_implementation === true ||
816
+ memory.awaiting_decision === true ||
817
+ memory.context_type === 'unresolved' // unresolved = todos, blockers, open questions
818
+ )
819
+ })
820
+
821
+ // Sort by importance (desc), then by created_at (desc = newest first)
822
+ actionItems.sort((a, b) => {
823
+ const aImportance = a.importance_weight ?? 0.5
824
+ const bImportance = b.importance_weight ?? 0.5
825
+ if (bImportance !== aImportance) {
826
+ return bImportance - aImportance
827
+ }
828
+ // Newest first
829
+ const aTime = a.created_at ?? 0
830
+ const bTime = b.created_at ?? 0
831
+ return bTime - aTime
832
+ })
833
+
834
+ // Convert to result format
835
+ return actionItems.map(memory => ({
836
+ ...memory,
837
+ score: 1.0, // All action items are relevant by definition
838
+ relevance_score: 1.0,
839
+ value_score: memory.importance_weight ?? 0.5,
840
+ }))
841
+ }
@@ -43,6 +43,7 @@ interface ContextRequest {
43
43
  current_message?: string
44
44
  max_memories?: number
45
45
  project_path?: string
46
+ mode?: 'normal' | 'action_items' // Can be set explicitly or auto-detected via ***
46
47
  }
47
48
 
48
49
  interface ProcessRequest {
@@ -136,26 +137,42 @@ export async function createServer(config: ServerConfig = {}) {
136
137
 
137
138
  logger.request('POST', '/memory/context', body.project_id)
138
139
 
140
+ // Detect *** signal at end of message for action items mode
141
+ let message = body.current_message ?? ''
142
+ let mode = body.mode ?? 'normal'
143
+
144
+ if (message.trimEnd().endsWith('***')) {
145
+ mode = 'action_items'
146
+ // Strip the *** signal from the message
147
+ message = message.trimEnd().slice(0, -3).trimEnd()
148
+ logger.debug('Action items mode detected (*** signal)', 'server')
149
+ }
150
+
139
151
  const result = await engine.getContext({
140
152
  sessionId: body.session_id,
141
153
  projectId: body.project_id,
142
- currentMessage: body.current_message ?? '',
154
+ currentMessage: message,
143
155
  maxMemories: body.max_memories,
144
156
  projectPath: body.project_path,
157
+ mode,
145
158
  })
146
159
 
147
160
  // Log what happened
148
161
  if (result.primer) {
149
162
  logger.primer(`Session primer for ${body.project_id}`)
150
163
  } else if (result.memories.length > 0) {
151
- logger.logRetrievedMemories(
152
- result.memories.map(m => ({
153
- content: m.content,
154
- score: m.score,
155
- context_type: m.context_type,
156
- })),
157
- body.current_message ?? ''
158
- )
164
+ if (mode === 'action_items') {
165
+ logger.info(`Returning ${result.memories.length} action item${result.memories.length === 1 ? '' : 's'}`)
166
+ } else {
167
+ logger.logRetrievedMemories(
168
+ result.memories.map(m => ({
169
+ content: m.content,
170
+ score: m.score,
171
+ context_type: m.context_type,
172
+ })),
173
+ message
174
+ )
175
+ }
159
176
  }
160
177
 
161
178
  return Response.json({
@@ -167,6 +184,7 @@ export async function createServer(config: ServerConfig = {}) {
167
184
  curator_enabled: true,
168
185
  memories_count: result.memories.length,
169
186
  has_primer: !!result.primer,
187
+ mode, // Include the mode that was used
170
188
  }, { headers: corsHeaders })
171
189
  }
172
190
 
@@ -198,19 +216,27 @@ export async function createServer(config: ServerConfig = {}) {
198
216
  setImmediate(async () => {
199
217
  try {
200
218
  // Try session resume first (v2) - gets full context including tool uses
201
- // Falls back to transcript parsing if resume fails
219
+ // Falls back to segmented transcript parsing if resume fails
202
220
  let result = await curator.curateWithSessionResume(
203
221
  body.claude_session_id,
204
222
  body.trigger
205
223
  )
206
224
 
207
- // Fallback to transcript-based curation if resume returned nothing
225
+ // Fallback to transcript-based curation WITH SEGMENTATION if resume returned nothing
226
+ // This matches the ingest command behavior - breaks large sessions into segments
208
227
  if (result.memories.length === 0) {
209
- logger.debug('Session resume returned no memories, falling back to transcript parsing', 'server')
210
- result = await curator.curateFromSessionFile(
228
+ logger.debug('Session resume returned no memories, falling back to segmented transcript parsing', 'server')
229
+ result = await curator.curateFromSessionFileWithSegments(
211
230
  body.claude_session_id,
212
231
  body.trigger,
213
- body.cwd
232
+ body.cwd,
233
+ 150000, // 150k tokens per segment
234
+ (progress) => {
235
+ logger.debug(
236
+ `Curation segment ${progress.segmentIndex + 1}/${progress.totalSegments}: ${progress.memoriesExtracted} memories (~${Math.round(progress.tokensInSegment / 1000)}k tokens)`,
237
+ 'server'
238
+ )
239
+ }
214
240
  )
215
241
  }
216
242