lancedb-opencode-pro 0.2.3 → 0.2.5

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
@@ -240,6 +240,17 @@ Supported environment variables:
240
240
  - `LANCEDB_OPENCODE_PRO_UNUSED_DAYS_THRESHOLD`
241
241
  - `LANCEDB_OPENCODE_PRO_MIN_CAPTURE_CHARS`
242
242
  - `LANCEDB_OPENCODE_PRO_MAX_ENTRIES_PER_SCOPE`
243
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MODE`
244
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MAX_MEMORIES`
245
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MIN_MEMORIES`
246
+ - `LANCEDB_OPENCODE_PRO_INJECTION_BUDGET_TOKENS`
247
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MAX_CHARS_PER_MEMORY`
248
+ - `LANCEDB_OPENCODE_PRO_INJECTION_SUMMARIZATION`
249
+ - `LANCEDB_OPENCODE_PRO_INJECTION_SUMMARY_TARGET_CHARS`
250
+ - `LANCEDB_OPENCODE_PRO_INJECTION_SCORE_DROP_TOLERANCE`
251
+ - `LANCEDB_OPENCODE_PRO_INJECTION_INJECTION_FLOOR`
252
+ - `LANCEDB_OPENCODE_PRO_INJECTION_CODE_SUMMARIZATION_MODE`
253
+ - `LANCEDB_OPENCODE_PRO_INJECTION_CODE_SUMMARIZATION_PRESERVE_STRUCTURE`
243
254
 
244
255
  ## What It Provides
245
256
 
@@ -357,6 +368,158 @@ Recommended review order in low-feedback environments:
357
368
  3. Check whether users still needed manual rescue through `memory_search` or issued correction-like responses.
358
369
  4. Run a bounded audit of recalled memories or skipped captures before concluding the system is helping.
359
370
 
371
+ ## Injection Control
372
+
373
+ This provider supports configurable memory injection behavior, allowing you to control how recalled memories are processed before being injected into the LLM prompt.
374
+
375
+ ### Configuration
376
+
377
+ Add an `injection` block to your sidecar config:
378
+
379
+ ```json
380
+ {
381
+ "provider": "lancedb-opencode-pro",
382
+ "injection": {
383
+ "mode": "fixed",
384
+ "maxMemories": 3,
385
+ "minMemories": 1,
386
+ "budgetTokens": 4096,
387
+ "maxCharsPerMemory": 1200,
388
+ "summarization": "none",
389
+ "summaryTargetChars": 300,
390
+ "scoreDropTolerance": 0.15,
391
+ "injectionFloor": 0.2,
392
+ "codeSummarization": {
393
+ "mode": "smart",
394
+ "preserveStructure": true
395
+ }
396
+ }
397
+ }
398
+ ```
399
+
400
+ ### Injection Modes
401
+
402
+ - **`fixed`** (default) — Always inject up to `maxMemories` memories regardless of content size. This preserves backward-compatible behavior.
403
+ - **`budget`** — Limit total injected tokens to `budgetTokens`. The provider accumulates memories until the token budget is exhausted.
404
+ - **`adaptive`** — Dynamically adjust injection count based on score drops. Stop injection when scores drop below `scoreDropTolerance` relative to the highest-scored memory.
405
+
406
+ ### Summarization Modes
407
+
408
+ When `summarization` is set to `truncate` or `extract`, memories are summarized before injection:
409
+
410
+ - **`none`** (default) — No summarization; inject full text.
411
+ - **`truncate`** — Simple truncation to `summaryTargetChars` with ellipsis.
412
+ - **`extract`** — Key sentence extraction for text, structure-preserving truncation for code.
413
+ - **`auto`** — Content-aware summarization (truncate for text, preserve structure for code).
414
+
415
+ ### Code Handling
416
+
417
+ The `codeSummarization` config controls how code snippets are processed:
418
+
419
+ - **`mode`**: `"smart"` | `"truncate"` | `"preserve"` (default: `"smart"`)
420
+ - **`preserveStructure`**: When `true`, code truncation attempts to balance brackets and preserve syntactic validity.
421
+
422
+ ### Environment Variables
423
+
424
+ All injection options can be overridden via environment variables:
425
+
426
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MODE`
427
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MAX_MEMORIES`
428
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MIN_MEMORIES`
429
+ - `LANCEDB_OPENCODE_PRO_INJECTION_BUDGET_TOKENS`
430
+ - `LANCEDB_OPENCODE_PRO_INJECTION_MAX_CHARS_PER_MEMORY`
431
+ - `LANCEDB_OPENCODE_PRO_INJECTION_SUMMARIZATION`
432
+ - `LANCEDB_OPENCODE_PRO_INJECTION_SUMMARY_TARGET_CHARS`
433
+ - `LANCEDB_OPENCODE_PRO_INJECTION_SCORE_DROP_TOLERANCE`
434
+ - `LANCEDB_OPENCODE_PRO_INJECTION_INJECTION_FLOOR`
435
+ - `LANCEDB_OPENCODE_PRO_INJECTION_CODE_SUMMARIZATION_MODE`
436
+ - `LANCEDB_OPENCODE_PRO_INJECTION_CODE_SUMMARIZATION_PRESERVE_STRUCTURE`
437
+
438
+ ### Default Behavior
439
+
440
+ The default configuration preserves backward compatibility:
441
+
442
+ - `mode`: `"fixed"`
443
+ - `maxMemories`: `3`
444
+ - `summarization`: `"none"`
445
+
446
+ This means without any `injection` configuration, the provider behaves identically to previous versions: always inject up to 3 memories with full text.
447
+
448
+ ### Example: Token Budget Mode
449
+
450
+ For token-sensitive deployments, use budget mode to limit context size:
451
+
452
+ ```json
453
+ {
454
+ "injection": {
455
+ "mode": "budget",
456
+ "budgetTokens": 1500,
457
+ "summarization": "truncate",
458
+ "summaryTargetChars": 400
459
+ }
460
+ }
461
+ ```
462
+
463
+ This configuration:
464
+ 1. Accumulates memories until total estimated tokens reach ~1500
465
+ 2. Truncates each memory to ~400 characters before injection
466
+ 3. Guarantees at least 1 memory is always included
467
+
468
+ ### Example: Adaptive Mode
469
+
470
+ For quality-sensitive scenarios where you want to avoid low-relevance memories:
471
+
472
+ ```json
473
+ {
474
+ "injection": {
475
+ "mode": "adaptive",
476
+ "maxMemories": 5,
477
+ "minMemories": 2,
478
+ "scoreDropTolerance": 0.15,
479
+ "injectionFloor": 0.2
480
+ }
481
+ }
482
+ ```
483
+
484
+ This configuration:
485
+ 1. Starts with up to 5 candidate memories
486
+ 2. Stops adding memories when score drops >15% from the top
487
+ 3. Ensures minimum score threshold (floor) prevents low-quality injection
488
+ 4. Always includes at least 2 memories
489
+
490
+ ### Example: Adaptive Mode with Auto Summarization
491
+
492
+ Recommended for users who want intelligent memory injection with content-aware summarization:
493
+
494
+ ```json
495
+ {
496
+ "injection": {
497
+ "mode": "adaptive",
498
+ "maxMemories": 5,
499
+ "minMemories": 2,
500
+ "budgetTokens": 4096,
501
+ "maxCharsPerMemory": 1200,
502
+ "summarization": "auto",
503
+ "summaryTargetChars": 400,
504
+ "scoreDropTolerance": 0.15,
505
+ "injectionFloor": 0.2,
506
+ "codeSummarization": {
507
+ "mode": "smart",
508
+ "preserveStructure": true
509
+ }
510
+ }
511
+ }
512
+ ```
513
+
514
+ This configuration:
515
+ 1. Dynamically adjusts injection count based on relevance scores
516
+ 2. Uses content-aware summarization (key sentences for text, smart truncation for code)
517
+ 3. Guarantees at least 2 memories are injected
518
+ 4. Preserves code structure when truncating
519
+ 5. Prevents injection of memories below 0.2 score threshold
520
+
521
+ ---
522
+
360
523
  ## OpenAI Embedding Configuration
361
524
 
362
525
  Default behavior stays on Ollama. To use OpenAI embeddings, set `embedding.provider` to `openai` and provide API key + model.
@@ -500,7 +663,7 @@ The project provides layered validation workflows that can run locally or inside
500
663
  | `npm run verify` | Typecheck + build + effectiveness workflow + retrieval (quick release check) |
501
664
  | `npm run verify:full` | All of the above + benchmark + `npm pack` (full release gate) |
502
665
 
503
- Threshold policy and benchmark profiles are documented in `docs/benchmark-thresholds.md`.
666
+ Threshold policy and benchmark profiles are documented in `docs/memory-validation-checklist.md` (Phase 4.4).
504
667
  Acceptance evidence mapping and archive/ship gate policy are documented in `docs/release-readiness.md`.
505
668
 
506
669
  ## Maintainer Release SOP
@@ -512,7 +675,7 @@ Use this flow when publishing a new version to npm.
512
675
 
513
676
  ```bash
514
677
  docker compose build --no-cache && docker compose up -d
515
- docker compose exec app npm run release:check
678
+ docker compose exec opencode-dev npm run release:check
516
679
  ```
517
680
 
518
681
  3. Confirm npm authentication:
@@ -568,8 +731,8 @@ ls -l dist dist-test/src 2>/dev/null
568
731
 
569
732
  ```bash
570
733
  docker compose build --no-cache && docker compose up -d
571
- docker compose exec app npm run typecheck
572
- docker compose exec app npm run build
734
+ docker compose exec opencode-dev npm run typecheck
735
+ docker compose exec opencode-dev npm run build
573
736
  ```
574
737
 
575
738
  ### Running validation inside Docker
@@ -578,16 +741,16 @@ docker compose exec app npm run build
578
741
  docker compose build --no-cache && docker compose up -d
579
742
 
580
743
  # Quick release check
581
- docker compose exec app npm run verify
744
+ docker compose exec opencode-dev npm run verify
582
745
 
583
746
  # Full release gate (includes benchmark + pack)
584
- docker compose exec app npm run verify:full
747
+ docker compose exec opencode-dev npm run verify:full
585
748
 
586
749
  # Individual workflows
587
- docker compose exec app npm run test:foundation
588
- docker compose exec app npm run test:regression
589
- docker compose exec app npm run test:retrieval
590
- docker compose exec app npm run benchmark:latency
750
+ docker compose exec opencode-dev npm run test:foundation
751
+ docker compose exec opencode-dev npm run test:regression
752
+ docker compose exec opencode-dev npm run test:retrieval
753
+ docker compose exec opencode-dev npm run benchmark:latency
591
754
  ```
592
755
 
593
756
  ### Operator verification
@@ -596,15 +759,15 @@ After running `npm run verify:full`, operators can inspect the following:
596
759
 
597
760
  ```bash
598
761
  # Confirm the packaged build is installable
599
- docker compose exec app ls -la lancedb-opencode-pro-*.tgz
762
+ docker compose exec opencode-dev ls -la lancedb-opencode-pro-*.tgz
600
763
 
601
764
  # Confirm typecheck and build succeeded
602
- docker compose exec app npm run typecheck
603
- docker compose exec app npm run build
765
+ docker compose exec opencode-dev npm run typecheck
766
+ docker compose exec opencode-dev npm run build
604
767
 
605
768
  # Check resolved default storage path
606
- docker compose exec app node -e "import('./dist/index.js').then(() => console.log('plugin loaded'))"
607
- docker compose exec app sh -lc 'ls -la ~/.opencode/memory/lancedb 2>/dev/null || echo "No data yet (expected before first use)"'
769
+ docker compose exec opencode-dev node -e "import('./dist/index.js').then(() => console.log('plugin loaded'))"
770
+ docker compose exec opencode-dev sh -lc 'ls -la ~/.opencode/memory/lancedb 2>/dev/null || echo "No data yet (expected before first use)"'
608
771
  ```
609
772
 
610
773
  ## Long Memory Verification
@@ -622,14 +785,14 @@ docker compose build --no-cache && docker compose up -d
622
785
  The E2E script loads `dist/index.js`, so build artifacts must exist first.
623
786
 
624
787
  ```bash
625
- docker compose exec app npm install
626
- docker compose exec app npm run build
788
+ docker compose exec opencode-dev npm install
789
+ docker compose exec opencode-dev npm run build
627
790
  ```
628
791
 
629
792
  ### 3. Run the built-in end-to-end memory test
630
793
 
631
794
  ```bash
632
- docker compose exec app npm run test:e2e
795
+ docker compose exec opencode-dev npm run test:e2e
633
796
  ```
634
797
 
635
798
  Expected success output:
@@ -651,7 +814,7 @@ This verifies all of the following in one run:
651
814
  The E2E script uses `/tmp/opencode-memory-e2e` as its test database path.
652
815
 
653
816
  ```bash
654
- docker compose exec app ls -la /tmp/opencode-memory-e2e
817
+ docker compose exec opencode-dev ls -la /tmp/opencode-memory-e2e
655
818
  ```
656
819
 
657
820
  If files appear in that directory after the E2E run, memory was written to disk instead of only being kept in process memory.
@@ -667,7 +830,7 @@ When running through the normal plugin config, the default durable storage path
667
830
  Check it inside the container with:
668
831
 
669
832
  ```bash
670
- docker compose exec app sh -lc 'ls -la ~/.opencode/memory/lancedb'
833
+ docker compose exec opencode-dev sh -lc 'ls -la ~/.opencode/memory/lancedb'
671
834
  ```
672
835
 
673
836
  ### 6. Stronger proof: verify retrieval still works after restart
@@ -676,8 +839,8 @@ Long memory is only convincing if retrieval still works after the runtime is res
676
839
 
677
840
  ```bash
678
841
  docker compose restart app
679
- docker compose exec app npm run test:e2e
680
- docker compose exec app ls -la /tmp/opencode-memory-e2e
842
+ docker compose exec opencode-dev npm run test:e2e
843
+ docker compose exec opencode-dev ls -la /tmp/opencode-memory-e2e
681
844
  ```
682
845
 
683
846
  If the search step still succeeds after restart and the database files remain present, that is strong evidence that the memory is durable.
@@ -686,7 +849,7 @@ If the search step still succeeds after restart and the database files remain pr
686
849
 
687
850
  Treat the feature as verified only when all of these are true:
688
851
 
689
- - `docker compose exec app npm run test:e2e` passes
852
+ - `docker compose exec opencode-dev npm run test:e2e` passes
690
853
  - `/tmp/opencode-memory-e2e` contains LanceDB files after the run
691
854
  - the memory retrieval step still succeeds after container restart
692
855
  - the configured OpenCode storage path exists when running real plugin integration
package/dist/config.js CHANGED
@@ -38,6 +38,8 @@ export function resolveMemoryConfig(config, worktree) {
38
38
  ? process.env.LANCEDB_OPENCODE_PRO_OPENAI_TIMEOUT_MS ?? process.env.LANCEDB_OPENCODE_PRO_EMBEDDING_TIMEOUT_MS
39
39
  : process.env.LANCEDB_OPENCODE_PRO_EMBEDDING_TIMEOUT_MS;
40
40
  const timeoutRaw = timeoutEnv ?? embeddingRaw.timeoutMs;
41
+ const injection = resolveInjectionConfig(raw, process.env);
42
+ const dedup = resolveDedupConfig(raw, process.env);
41
43
  const resolvedConfig = {
42
44
  provider,
43
45
  dbPath,
@@ -58,6 +60,8 @@ export function resolveMemoryConfig(config, worktree) {
58
60
  recencyHalfLifeHours,
59
61
  importanceWeight,
60
62
  },
63
+ injection,
64
+ dedup,
61
65
  includeGlobalScope: toBoolean(process.env.LANCEDB_OPENCODE_PRO_INCLUDE_GLOBAL_SCOPE ?? raw.includeGlobalScope, true),
62
66
  globalDetectionThreshold: Math.max(1, Math.floor(toNumber(process.env.LANCEDB_OPENCODE_PRO_GLOBAL_DETECTION_THRESHOLD ?? raw.globalDetectionThreshold, 2))),
63
67
  globalDiscountFactor: clamp(toNumber(process.env.LANCEDB_OPENCODE_PRO_GLOBAL_DISCOUNT_FACTOR ?? raw.globalDiscountFactor, 0.7), 0, 1),
@@ -75,6 +79,51 @@ function resolveEmbeddingProvider(raw) {
75
79
  return "openai";
76
80
  throw new Error(`[lancedb-opencode-pro] Invalid embedding provider "${raw}". Expected "ollama" or "openai".`);
77
81
  }
82
+ function resolveInjectionMode(raw) {
83
+ if (raw === "fixed" || raw === "budget" || raw === "adaptive")
84
+ return raw;
85
+ return "fixed";
86
+ }
87
+ function resolveSummarizationMode(raw) {
88
+ if (raw === "none" || raw === "truncate" || raw === "extract" || raw === "auto")
89
+ return raw;
90
+ return "none";
91
+ }
92
+ function resolveCodeTruncationMode(raw) {
93
+ if (raw === "smart" || raw === "signature" || raw === "preserve")
94
+ return raw;
95
+ return "smart";
96
+ }
97
+ function resolveDedupConfig(raw, env) {
98
+ const dedupRaw = (raw.dedup ?? {});
99
+ const enabled = toBoolean(env.LANCEDB_OPENCODE_PRO_DEDUP_ENABLED ?? dedupRaw.enabled, true);
100
+ const writeThreshold = clamp(toNumber(env.LANCEDB_OPENCODE_PRO_DEDUP_WRITE_THRESHOLD ?? dedupRaw.writeThreshold, 0.92), 0.0, 1.0);
101
+ const consolidateThreshold = clamp(toNumber(env.LANCEDB_OPENCODE_PRO_DEDUP_CONSOLIDATE_THRESHOLD ?? dedupRaw.consolidateThreshold, 0.95), 0.0, 1.0);
102
+ return { enabled, writeThreshold, consolidateThreshold };
103
+ }
104
+ function resolveInjectionConfig(raw, env) {
105
+ const injectionRaw = (raw.injection ?? {});
106
+ const codeSummarizationRaw = (injectionRaw.codeSummarization ?? {});
107
+ return {
108
+ mode: resolveInjectionMode(env.LANCEDB_OPENCODE_PRO_INJECTION_MODE ?? injectionRaw.mode),
109
+ maxMemories: Math.max(1, Math.floor(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_MAX_MEMORIES ?? injectionRaw.maxMemories, 3))),
110
+ minMemories: Math.max(1, Math.floor(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_MIN_MEMORIES ?? injectionRaw.minMemories, 1))),
111
+ budgetTokens: Math.max(256, Math.floor(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_BUDGET_TOKENS ?? injectionRaw.budgetTokens, 4096))),
112
+ maxCharsPerMemory: Math.max(100, Math.floor(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_MAX_CHARS ?? injectionRaw.maxCharsPerMemory, 1200))),
113
+ summarization: resolveSummarizationMode(env.LANCEDB_OPENCODE_PRO_INJECTION_SUMMARIZATION ?? injectionRaw.summarization),
114
+ summaryTargetChars: Math.max(50, Math.floor(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_SUMMARY_TARGET_CHARS ?? injectionRaw.summaryTargetChars, 300))),
115
+ scoreDropTolerance: clamp(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_SCORE_DROP_TOLERANCE ?? injectionRaw.scoreDropTolerance, 0.15), 0, 1),
116
+ injectionFloor: clamp(toNumber(env.LANCEDB_OPENCODE_PRO_INJECTION_FLOOR ?? injectionRaw.injectionFloor, 0.2), 0, 1),
117
+ codeSummarization: {
118
+ enabled: toBoolean(env.LANCEDB_OPENCODE_PRO_CODE_SUMMARIZATION_ENABLED ?? codeSummarizationRaw.enabled, true),
119
+ pureCodeThreshold: Math.max(100, Math.floor(toNumber(codeSummarizationRaw.pureCodeThreshold, 500))),
120
+ maxCodeLines: Math.max(5, Math.floor(toNumber(codeSummarizationRaw.maxCodeLines, 15))),
121
+ codeTruncationMode: resolveCodeTruncationMode(codeSummarizationRaw.codeTruncationMode),
122
+ preserveComments: toBoolean(codeSummarizationRaw.preserveComments, true),
123
+ preserveImports: toBoolean(codeSummarizationRaw.preserveImports, false),
124
+ },
125
+ };
126
+ }
78
127
  function validateEmbeddingConfig(embedding) {
79
128
  if (embedding.provider !== "openai")
80
129
  return;
@@ -130,6 +179,18 @@ function mergeMemoryConfig(base, override) {
130
179
  ...(base.retrieval ?? {}),
131
180
  ...(override.retrieval ?? {}),
132
181
  },
182
+ injection: {
183
+ ...(base.injection ?? {}),
184
+ ...(override.injection ?? {}),
185
+ codeSummarization: {
186
+ ...((base.injection ?? {}).codeSummarization ?? {}),
187
+ ...((override.injection ?? {}).codeSummarization ?? {}),
188
+ },
189
+ },
190
+ dedup: {
191
+ ...(base.dedup ?? {}),
192
+ ...(override.dedup ?? {}),
193
+ },
133
194
  };
134
195
  }
135
196
  function firstString(...values) {
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { isTcpPortAvailable, parsePortReservations, planPorts, reservationKey }
6
6
  import { buildScopeFilter, deriveProjectScope } from "./scope.js";
7
7
  import { MemoryStore } from "./store.js";
8
8
  import { generateId } from "./utils.js";
9
+ import { calculateInjectionLimit, createSummarizationConfig, summarizeContent } from "./summarize.js";
9
10
  const SCHEMA_VERSION = 1;
10
11
  const plugin = async (input) => {
11
12
  const state = await createRuntimeState(input);
@@ -22,6 +23,10 @@ const plugin = async (input) => {
22
23
  if (event.type === "session.idle" || event.type === "session.compacted") {
23
24
  const sessionID = event.properties.sessionID;
24
25
  await flushAutoCapture(sessionID, state, input.client);
26
+ if (event.type === "session.compacted" && state.config.dedup.enabled) {
27
+ const activeScope = deriveProjectScope(input.worktree);
28
+ state.store.consolidateDuplicates(activeScope, state.config.dedup.consolidateThreshold).catch(() => { });
29
+ }
25
30
  }
26
31
  },
27
32
  "experimental.text.complete": async (eventInput, eventOutput) => {
@@ -52,16 +57,19 @@ const plugin = async (input) => {
52
57
  query,
53
58
  queryVector,
54
59
  scopes,
55
- limit: 3,
60
+ limit: state.config.injection.maxMemories * 2, // Fetch more than needed for filtering
56
61
  vectorWeight: state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight,
57
62
  bm25Weight: state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.bm25Weight,
58
- minScore: state.config.retrieval.minScore,
63
+ minScore: Math.max(state.config.retrieval.minScore, state.config.injection.injectionFloor),
59
64
  rrfK: state.config.retrieval.rrfK,
60
65
  recencyBoost: state.config.retrieval.recencyBoost,
61
66
  recencyHalfLifeHours: state.config.retrieval.recencyHalfLifeHours,
62
67
  importanceWeight: state.config.retrieval.importanceWeight,
63
68
  globalDiscountFactor: state.config.globalDiscountFactor,
64
69
  });
70
+ // Apply injection control
71
+ const injectionLimit = calculateInjectionLimit(results, state.config.injection);
72
+ const limitedResults = results.slice(0, injectionLimit);
65
73
  await state.store.putEvent({
66
74
  id: generateId(),
67
75
  type: "recall",
@@ -69,21 +77,32 @@ const plugin = async (input) => {
69
77
  scope: activeScope,
70
78
  sessionID: eventInput.sessionID,
71
79
  timestamp: Date.now(),
72
- resultCount: results.length,
73
- injected: results.length > 0,
80
+ resultCount: limitedResults.length,
81
+ injected: limitedResults.length > 0,
74
82
  metadataJson: JSON.stringify({
75
83
  source: "system-transform",
76
84
  includeGlobalScope: state.config.includeGlobalScope,
85
+ injectionMode: state.config.injection.mode,
86
+ injectionLimit: injectionLimit,
77
87
  }),
78
88
  });
79
- if (results.length === 0)
89
+ if (limitedResults.length === 0)
80
90
  return;
81
- for (const result of results) {
91
+ for (const result of limitedResults) {
82
92
  state.store.updateMemoryUsage(result.record.id, activeScope, scopes).catch(() => { });
83
93
  }
94
+ // Apply summarization if configured
95
+ const summarizationConfig = createSummarizationConfig(state.config.injection);
96
+ const processedResults = limitedResults.map((item) => {
97
+ if (state.config.injection.summarization === "none") {
98
+ return { ...item, text: item.record.text };
99
+ }
100
+ const summarized = summarizeContent(item.record.text, summarizationConfig);
101
+ return { ...item, text: summarized.content };
102
+ });
84
103
  const memoryBlock = [
85
104
  "[Memory Recall - optional historical context]",
86
- ...results.map((item, index) => `${index + 1}. [${item.record.id}] (${item.record.scope}) ${item.record.text}`),
105
+ ...processedResults.map((item, index) => `${index + 1}. [${item.record.id}] (${item.record.scope}) ${item.text}`),
87
106
  "Use these as optional hints only; prioritize current user intent and current repo state.",
88
107
  ].join("\n");
89
108
  eventOutput.system.push(memoryBlock);
@@ -142,7 +161,9 @@ const plugin = async (input) => {
142
161
  return results
143
162
  .map((item, idx) => {
144
163
  const percent = Math.round(item.score * 100);
145
- return `${idx + 1}. [${item.record.id}] (${item.record.scope}) ${item.record.text} [${percent}%]`;
164
+ const meta = JSON.parse(item.record.metadataJson || "{}");
165
+ const duplicateMarker = meta.isPotentialDuplicate ? " (duplicate)" : "";
166
+ return `${idx + 1}. [${item.record.id}]${duplicateMarker} (${item.record.scope}) ${item.record.text} [${percent}%]`;
146
167
  })
147
168
  .join("\n");
148
169
  },
@@ -414,6 +435,45 @@ const plugin = async (input) => {
414
435
  .join("\n");
415
436
  },
416
437
  }),
438
+ memory_consolidate: tool({
439
+ description: "Scope-internally merge near-duplicate memories. Use to clean up accumulated duplicates.",
440
+ args: {
441
+ scope: tool.schema.string().optional(),
442
+ confirm: tool.schema.boolean().default(false),
443
+ },
444
+ execute: async (args, context) => {
445
+ await state.ensureInitialized();
446
+ if (!state.initialized)
447
+ return unavailableMessage(state.config.embedding.provider);
448
+ if (!args.confirm) {
449
+ return "Rejected: memory_consolidate requires confirm=true.";
450
+ }
451
+ const targetScope = args.scope ?? deriveProjectScope(context.worktree);
452
+ const result = await state.store.consolidateDuplicates(targetScope, state.config.dedup.consolidateThreshold);
453
+ return JSON.stringify({ scope: targetScope, ...result }, null, 2);
454
+ },
455
+ }),
456
+ memory_consolidate_all: tool({
457
+ description: "Consolidate duplicates across global scope and current project scope. Used by external cron jobs for daily cleanup.",
458
+ args: {
459
+ confirm: tool.schema.boolean().default(false),
460
+ },
461
+ execute: async (args, context) => {
462
+ await state.ensureInitialized();
463
+ if (!state.initialized)
464
+ return unavailableMessage(state.config.embedding.provider);
465
+ if (!args.confirm) {
466
+ return "Rejected: memory_consolidate_all requires confirm=true.";
467
+ }
468
+ const projectScope = deriveProjectScope(context.worktree);
469
+ const globalResult = await state.store.consolidateDuplicates("global", state.config.dedup.consolidateThreshold);
470
+ const projectResult = await state.store.consolidateDuplicates(projectScope, state.config.dedup.consolidateThreshold);
471
+ return JSON.stringify({
472
+ global: { scope: "global", ...globalResult },
473
+ project: { scope: projectScope, ...projectResult },
474
+ }, null, 2);
475
+ },
476
+ }),
417
477
  memory_port_plan: tool({
418
478
  description: "Plan non-conflicting host ports for compose services and optionally persist reservations",
419
479
  args: {
@@ -623,6 +683,26 @@ async function flushAutoCapture(sessionID, state, client) {
623
683
  });
624
684
  return;
625
685
  }
686
+ let isPotentialDuplicate = false;
687
+ let duplicateOf = null;
688
+ if (state.config.dedup.enabled) {
689
+ const similar = await state.store.search({
690
+ query: result.candidate.text,
691
+ queryVector: vector,
692
+ scopes: [activeScope],
693
+ limit: 1,
694
+ vectorWeight: 1.0,
695
+ bm25Weight: 0.0,
696
+ minScore: 0.0,
697
+ rrfK: 60,
698
+ recencyBoost: false,
699
+ globalDiscountFactor: 1.0,
700
+ });
701
+ if (similar.length > 0 && similar[0].score >= state.config.dedup.writeThreshold) {
702
+ isPotentialDuplicate = true;
703
+ duplicateOf = similar[0].record.id;
704
+ }
705
+ }
626
706
  const memoryId = generateId();
627
707
  await state.store.put({
628
708
  id: memoryId,
@@ -641,6 +721,8 @@ async function flushAutoCapture(sessionID, state, client) {
641
721
  metadataJson: JSON.stringify({
642
722
  source: "auto-capture",
643
723
  sessionID,
724
+ isPotentialDuplicate,
725
+ duplicateOf,
644
726
  }),
645
727
  });
646
728
  await recordCaptureEvent(state, {
package/dist/store.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { EffectivenessSummary, MemoryEffectivenessEvent, MemoryRecord, SearchResult } from "./types.js";
2
+ export declare function storeFastCosine(a: number[], b: number[], normA: number, normB: number): number;
2
3
  export declare class MemoryStore {
3
4
  private readonly dbPath;
4
5
  private lancedb;
@@ -32,6 +33,11 @@ export declare class MemoryStore {
32
33
  clearScope(scope: string): Promise<number>;
33
34
  list(scope: string, limit: number): Promise<MemoryRecord[]>;
34
35
  pruneScope(scope: string, maxEntries: number): Promise<number>;
36
+ consolidateDuplicates(scope: string, threshold: number): Promise<{
37
+ mergedPairs: number;
38
+ updatedRecords: number;
39
+ skippedRecords: number;
40
+ }>;
35
41
  countIncompatibleVectors(scopes: string[], expectedDim: number): Promise<number>;
36
42
  private matchesId;
37
43
  hasMemory(id: string, scopes: string[]): Promise<boolean>;
@@ -48,6 +54,7 @@ export declare class MemoryStore {
48
54
  private requireTable;
49
55
  private requireEventTable;
50
56
  private readEventsByScopes;
57
+ private readByScopesIncludingMerged;
51
58
  private readByScopes;
52
59
  private ensureIndexes;
53
60
  private ensureMemoriesTableCompatibility;
package/dist/store.js CHANGED
@@ -4,6 +4,19 @@ import { tokenize } from "./utils.js";
4
4
  const TABLE_NAME = "memories";
5
5
  const EVENTS_TABLE_NAME = "effectiveness_events";
6
6
  const EVENTS_SOURCE_COLUMN = "source";
7
+ // Exported for use by consolidateDuplicates
8
+ export function storeFastCosine(a, b, normA, normB) {
9
+ if (a.length === 0 || b.length === 0 || a.length !== b.length)
10
+ return 0;
11
+ const denom = normA * normB;
12
+ if (denom === 0)
13
+ return 0;
14
+ let dot = 0;
15
+ for (let i = 0; i < a.length; i += 1) {
16
+ dot += a[i] * b[i];
17
+ }
18
+ return dot / denom;
19
+ }
7
20
  export class MemoryStore {
8
21
  dbPath;
9
22
  lancedb = null;
@@ -209,13 +222,83 @@ export class MemoryStore {
209
222
  const rows = await this.list(scope, 100000);
210
223
  if (rows.length <= maxEntries)
211
224
  return 0;
212
- const toDelete = rows.slice(maxEntries);
225
+ const flagged = rows.filter((r) => {
226
+ const meta = parseMetadata(r.metadataJson);
227
+ return meta.isPotentialDuplicate === true;
228
+ });
229
+ const unflagged = rows.filter((r) => {
230
+ const meta = parseMetadata(r.metadataJson);
231
+ return meta.isPotentialDuplicate !== true;
232
+ });
233
+ const sortedFlagged = flagged.sort((a, b) => a.timestamp - b.timestamp);
234
+ const sortedUnflagged = unflagged.sort((a, b) => a.timestamp - b.timestamp);
235
+ const toDeleteCount = rows.length - maxEntries;
236
+ const deleteFromFlagged = Math.min(sortedFlagged.length, toDeleteCount);
237
+ const toDelete = [
238
+ ...sortedFlagged.slice(0, deleteFromFlagged),
239
+ ...sortedUnflagged.slice(0, toDeleteCount - deleteFromFlagged),
240
+ ];
213
241
  for (const row of toDelete) {
214
242
  await this.requireTable().delete(`id = '${escapeSql(row.id)}'`);
215
243
  }
216
244
  this.invalidateScope(scope);
217
245
  return toDelete.length;
218
246
  }
247
+ async consolidateDuplicates(scope, threshold) {
248
+ const rows = await this.readByScopesIncludingMerged([scope]);
249
+ if (rows.length === 0) {
250
+ return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
251
+ }
252
+ let mergedPairs = 0;
253
+ let updatedRecords = 0;
254
+ let skippedRecords = 0;
255
+ const now = Date.now();
256
+ const FIVE_MINUTES_MS = 5 * 60 * 1000;
257
+ const rowsWithNorms = rows.map((row) => ({
258
+ row,
259
+ norm: this.scopeCache.get(scope)?.norms.get(row.id) ?? vecNorm(row.vector),
260
+ }));
261
+ for (let i = 0; i < rowsWithNorms.length; i += 1) {
262
+ const a = rowsWithNorms[i];
263
+ for (let j = i + 1; j < rowsWithNorms.length; j += 1) {
264
+ const b = rowsWithNorms[j];
265
+ const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
266
+ if (sim < threshold)
267
+ continue;
268
+ const aMeta = parseMetadata(a.row.metadataJson);
269
+ if (aMeta.status === "merged") {
270
+ skippedRecords += 1;
271
+ continue;
272
+ }
273
+ if (a.row.lastRecalled > 0 && now - a.row.lastRecalled < FIVE_MINUTES_MS) {
274
+ skippedRecords += 1;
275
+ continue;
276
+ }
277
+ const older = a.row.timestamp <= b.row.timestamp ? a.row : b.row;
278
+ const newer = a.row.timestamp <= b.row.timestamp ? b.row : a.row;
279
+ const newerMeta = parseMetadata(newer.metadataJson);
280
+ const mergedIntoId = newer.id;
281
+ const updatedOlderMeta = { status: "merged", mergedInto: mergedIntoId };
282
+ await this.requireTable().delete(`id = '${escapeSql(older.id)}'`);
283
+ await this.requireTable().add([{
284
+ ...older,
285
+ metadataJson: JSON.stringify({ ...parseMetadata(older.metadataJson), ...updatedOlderMeta }),
286
+ }]);
287
+ const updatedNewerMeta = { ...newerMeta, mergedFrom: older.id };
288
+ await this.requireTable().delete(`id = '${escapeSql(newer.id)}'`);
289
+ await this.requireTable().add([{
290
+ ...newer,
291
+ metadataJson: JSON.stringify(updatedNewerMeta),
292
+ }]);
293
+ mergedPairs += 1;
294
+ updatedRecords += 2;
295
+ }
296
+ }
297
+ if (mergedPairs > 0) {
298
+ this.invalidateScope(scope);
299
+ }
300
+ return { mergedPairs, updatedRecords, skippedRecords };
301
+ }
219
302
  async countIncompatibleVectors(scopes, expectedDim) {
220
303
  const rows = await this.readByScopes(scopes);
221
304
  return rows.filter((row) => row.vectorDim !== expectedDim).length;
@@ -279,6 +362,8 @@ export class MemoryStore {
279
362
  async summarizeEvents(scope, includeGlobalScope) {
280
363
  const scopes = includeGlobalScope && scope !== "global" ? [scope, "global"] : [scope];
281
364
  const events = await this.readEventsByScopes(scopes);
365
+ // Read all memories including merged for duplicate counts
366
+ const memories = await this.readByScopesIncludingMerged(scopes);
282
367
  const captureSkipReasons = {};
283
368
  let captureConsidered = 0;
284
369
  let captureStored = 0;
@@ -343,6 +428,15 @@ export class MemoryStore {
343
428
  }
344
429
  const totalCaptureAttempts = captureStored + captureSkipped;
345
430
  const totalUsefulFeedback = feedbackUsefulPositive + feedbackUsefulNegative;
431
+ // Count flagged (isPotentialDuplicate) and consolidated (status=merged) from memories table
432
+ const flaggedCount = memories.filter((r) => {
433
+ const meta = parseMetadata(r.metadataJson);
434
+ return meta.isPotentialDuplicate === true;
435
+ }).length;
436
+ const consolidatedCount = memories.filter((r) => {
437
+ const meta = parseMetadata(r.metadataJson);
438
+ return meta.status === "merged";
439
+ }).length;
346
440
  return {
347
441
  scope,
348
442
  totalEvents: events.length,
@@ -384,6 +478,10 @@ export class MemoryStore {
384
478
  falsePositiveRate: captureStored === 0 ? 0 : feedbackWrong / captureStored,
385
479
  falseNegativeRate: totalCaptureAttempts === 0 ? 0 : feedbackMissing / totalCaptureAttempts,
386
480
  },
481
+ duplicates: {
482
+ flaggedCount,
483
+ consolidatedCount,
484
+ },
387
485
  };
388
486
  }
389
487
  getIndexHealth() {
@@ -469,7 +567,7 @@ export class MemoryStore {
469
567
  .map((row) => normalizeEventRow(row))
470
568
  .filter((row) => row !== null);
471
569
  }
472
- async readByScopes(scopes) {
570
+ async readByScopesIncludingMerged(scopes) {
473
571
  const table = this.requireTable();
474
572
  if (scopes.length === 0)
475
573
  return [];
@@ -499,6 +597,36 @@ export class MemoryStore {
499
597
  .map((row) => normalizeRow(row))
500
598
  .filter((row) => row !== null);
501
599
  }
600
+ async readByScopes(scopes) {
601
+ const table = this.requireTable();
602
+ if (scopes.length === 0)
603
+ return [];
604
+ const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
605
+ const rows = await table
606
+ .query()
607
+ .where(`(${whereExpr}) AND metadataJson NOT LIKE '%"status":"merged"%'`)
608
+ .select([
609
+ "id",
610
+ "text",
611
+ "vector",
612
+ "category",
613
+ "scope",
614
+ "importance",
615
+ "timestamp",
616
+ "lastRecalled",
617
+ "recallCount",
618
+ "projectCount",
619
+ "schemaVersion",
620
+ "embeddingModel",
621
+ "vectorDim",
622
+ "metadataJson",
623
+ ])
624
+ .limit(100000)
625
+ .toArray();
626
+ return rows
627
+ .map((row) => normalizeRow(row))
628
+ .filter((row) => row !== null);
629
+ }
502
630
  async ensureIndexes() {
503
631
  const table = this.requireTable();
504
632
  try {
@@ -747,3 +875,11 @@ function extractRecalledProjects(metadataJson) {
747
875
  }
748
876
  return new Set();
749
877
  }
878
+ function parseMetadata(metadataJson) {
879
+ try {
880
+ return JSON.parse(metadataJson);
881
+ }
882
+ catch {
883
+ return {};
884
+ }
885
+ }
@@ -0,0 +1,52 @@
1
+ import type { ContentType, ContentDetection, SummarizedContent, SummarizationConfig, InjectionConfig, SearchResult } from "./types.js";
2
+ /**
3
+ * Detects whether content contains code and its type
4
+ */
5
+ export declare function detectContentType(text: string): ContentDetection;
6
+ /**
7
+ * Calculates bracket balance for code detection
8
+ */
9
+ export declare function calculateBracketBalance(text: string): number;
10
+ /**
11
+ * Counts code-related keywords
12
+ */
13
+ export declare function countCodeKeywords(text: string): number;
14
+ /**
15
+ * Calculates ratio of indented lines
16
+ */
17
+ export declare function calculateIndentationRatio(text: string): number;
18
+ /**
19
+ * Estimates token count for content
20
+ */
21
+ export declare function estimateTokens(text: string, contentType: ContentType): number;
22
+ /**
23
+ * Truncates text to max characters
24
+ */
25
+ export declare function truncateText(text: string, maxChars: number): string;
26
+ /**
27
+ * Smart truncation for code - finds complete statement boundaries
28
+ */
29
+ export declare function smartTruncateCode(code: string, maxLines: number, config?: {
30
+ preserveComments?: boolean;
31
+ preserveImports?: boolean;
32
+ }): string;
33
+ /**
34
+ * Extracts key sentences from text
35
+ */
36
+ export declare function extractKeySentences(text: string, targetChars: number): string;
37
+ export declare function splitCodeAndText(text: string): Array<{
38
+ type: "code" | "text";
39
+ content: string;
40
+ }>;
41
+ /**
42
+ * Main summarization function
43
+ */
44
+ export declare function summarizeContent(text: string, config: SummarizationConfig): SummarizedContent;
45
+ /**
46
+ * Calculates injection limit based on mode
47
+ */
48
+ export declare function calculateInjectionLimit(results: SearchResult[], config: InjectionConfig): number;
49
+ /**
50
+ * Creates default summarization config from injection config
51
+ */
52
+ export declare function createSummarizationConfig(injection: InjectionConfig): SummarizationConfig;
@@ -0,0 +1,350 @@
1
+ // Code keywords used for content detection
2
+ const CODE_KEYWORDS = [
3
+ "function", "async", "await", "const", "let", "var", "return", "class", "interface", "type",
4
+ "import", "export", "from", "default", "extends", "implements", "new", "this", "super",
5
+ "def ", "async def", "func ", "fn ", "pub fn", "impl ", "struct ", "enum ",
6
+ "=>", "->", "::", "if (", "for (", "while (", "try {", "catch (", "throw ",
7
+ ];
8
+ // Keywords for key sentence extraction
9
+ const KEY_SENTENCE_PATTERNS = [
10
+ /(?:fixed|resolved|works?\s+now|successful|done|完成|已解決|修復|成功)/i,
11
+ /(?:probleme|issue|bug|error|fail|錯誤|問題|失敗)/i,
12
+ /(?:solution|fix|resolve|解決方案|修正)/i,
13
+ /(?:because|root\s+cause|原因|由於)/i,
14
+ /(?:decide|decision|tradeoff|architecture|決定|架構|採用)/i,
15
+ /(?:prefer|preference|偏好|習慣)/i,
16
+ ];
17
+ /**
18
+ * Detects whether content contains code and its type
19
+ */
20
+ export function detectContentType(text) {
21
+ const hasMarkdownCode = /```[\s\S]*?```/.test(text);
22
+ const bracketBalance = calculateBracketBalance(text);
23
+ const codeKeywords = countCodeKeywords(text);
24
+ const indentationRatio = calculateIndentationRatio(text);
25
+ const codeScore = (hasMarkdownCode ? 2 : 0) +
26
+ (bracketBalance > 3 ? 1 : 0) +
27
+ (codeKeywords > 5 ? 1 : 0) +
28
+ (indentationRatio > 0.3 ? 1 : 0);
29
+ if (codeScore >= 5) {
30
+ return { hasCode: true, isPureCode: true };
31
+ }
32
+ if (codeScore >= 3) {
33
+ return { hasCode: true, isPureCode: false };
34
+ }
35
+ if (hasMarkdownCode || codeKeywords > 10) {
36
+ return { hasCode: true, isPureCode: false };
37
+ }
38
+ return { hasCode: false, isPureCode: false };
39
+ }
40
+ /**
41
+ * Calculates bracket balance for code detection
42
+ */
43
+ export function calculateBracketBalance(text) {
44
+ const openBrackets = (text.match(/[{([]/g) || []).length;
45
+ const closeBrackets = (text.match(/[})\]]/g) || []).length;
46
+ return Math.abs(openBrackets - closeBrackets) + Math.min(openBrackets, closeBrackets);
47
+ }
48
+ /**
49
+ * Counts code-related keywords
50
+ */
51
+ export function countCodeKeywords(text) {
52
+ const lower = text.toLowerCase();
53
+ let count = 0;
54
+ for (const keyword of CODE_KEYWORDS) {
55
+ if (lower.includes(keyword.toLowerCase())) {
56
+ count += 1;
57
+ }
58
+ }
59
+ return count;
60
+ }
61
+ /**
62
+ * Calculates ratio of indented lines
63
+ */
64
+ export function calculateIndentationRatio(text) {
65
+ const lines = text.split("\n");
66
+ if (lines.length === 0)
67
+ return 0;
68
+ const indentedLines = lines.filter((line) => /^\s{2,}/.test(line));
69
+ return indentedLines.length / lines.length;
70
+ }
71
+ /**
72
+ * Estimates token count for content
73
+ */
74
+ export function estimateTokens(text, contentType) {
75
+ // Count Chinese characters
76
+ const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
77
+ const nonChineseChars = text.length - chineseChars;
78
+ // Chinese ~2 chars/token, English/other ~4 chars/token
79
+ const baseTokens = Math.ceil(chineseChars / 2 + nonChineseChars / 4);
80
+ // Code has higher token density
81
+ if (contentType === "code") {
82
+ return Math.ceil(baseTokens * 1.2);
83
+ }
84
+ return baseTokens;
85
+ }
86
+ /**
87
+ * Truncates text to max characters
88
+ */
89
+ export function truncateText(text, maxChars) {
90
+ if (text.length <= maxChars)
91
+ return text;
92
+ return `${text.slice(0, maxChars - 3)}...`;
93
+ }
94
+ /**
95
+ * Smart truncation for code - finds complete statement boundaries
96
+ */
97
+ export function smartTruncateCode(code, maxLines, config) {
98
+ const lines = code.split("\n");
99
+ if (lines.length <= maxLines)
100
+ return code;
101
+ let braceBalance = 0;
102
+ let lastCompleteIndex = maxLines;
103
+ let foundComplete = false;
104
+ // Calculate brace balance and find last complete statement
105
+ for (let i = 0; i < Math.min(lines.length, maxLines + 10); i++) {
106
+ const line = lines[i];
107
+ braceBalance += (line.match(/{/g) || []).length;
108
+ braceBalance -= (line.match(/}/g) || []).length;
109
+ if (i >= maxLines - 5 && braceBalance === 0 && i < lines.length - 1) {
110
+ lastCompleteIndex = i + 1;
111
+ foundComplete = true;
112
+ break;
113
+ }
114
+ }
115
+ // If no complete boundary found, use maxLines
116
+ if (!foundComplete) {
117
+ lastCompleteIndex = maxLines;
118
+ }
119
+ // Build truncated code
120
+ let result = lines.slice(0, lastCompleteIndex).join("\n");
121
+ // Add truncation indicator
122
+ result += "\n// ... (truncated)";
123
+ return result;
124
+ }
125
+ /**
126
+ * Extracts key sentences from text
127
+ */
128
+ export function extractKeySentences(text, targetChars) {
129
+ const sentences = text.split(/[。.!?\n]+/).filter((s) => s.trim().length > 0);
130
+ const keySentences = [];
131
+ let currentLength = 0;
132
+ // First pass: sentences matching key patterns
133
+ for (const sentence of sentences) {
134
+ const trimmed = sentence.trim();
135
+ if (KEY_SENTENCE_PATTERNS.some((pattern) => pattern.test(trimmed))) {
136
+ if (currentLength + trimmed.length > targetChars && keySentences.length > 0) {
137
+ break;
138
+ }
139
+ keySentences.push(trimmed);
140
+ currentLength += trimmed.length + 1;
141
+ }
142
+ }
143
+ // Second pass: fill remaining with first sentences if needed
144
+ if (currentLength < targetChars * 0.5) {
145
+ for (const sentence of sentences) {
146
+ const trimmed = sentence.trim();
147
+ if (!keySentences.includes(trimmed)) {
148
+ if (currentLength + trimmed.length > targetChars) {
149
+ break;
150
+ }
151
+ keySentences.push(trimmed);
152
+ currentLength += trimmed.length + 1;
153
+ }
154
+ }
155
+ }
156
+ return keySentences.join(" → ");
157
+ }
158
+ export function splitCodeAndText(text) {
159
+ const parts = [];
160
+ const codeBlockRegex = /```[\s\S]*?```/g;
161
+ let lastIndex = 0;
162
+ let match = codeBlockRegex.exec(text);
163
+ while (match !== null) {
164
+ if (match.index > lastIndex) {
165
+ const textPart = text.slice(lastIndex, match.index).trim();
166
+ if (textPart) {
167
+ parts.push({ type: "text", content: textPart });
168
+ }
169
+ }
170
+ parts.push({ type: "code", content: match[0] });
171
+ lastIndex = match.index + match[0].length;
172
+ match = codeBlockRegex.exec(text);
173
+ }
174
+ if (lastIndex < text.length) {
175
+ const remaining = text.slice(lastIndex).trim();
176
+ if (remaining) {
177
+ parts.push({ type: "text", content: remaining });
178
+ }
179
+ }
180
+ return parts;
181
+ }
182
+ /**
183
+ * Main summarization function
184
+ */
185
+ export function summarizeContent(text, config) {
186
+ const detection = detectContentType(text);
187
+ const originalLength = text.length;
188
+ // Determine content type
189
+ const contentType = detection.isPureCode
190
+ ? "code"
191
+ : detection.hasCode
192
+ ? "mixed"
193
+ : "text";
194
+ // No summarization
195
+ if (config.mode === "none") {
196
+ return {
197
+ type: "kept",
198
+ content: truncateText(text, config.textThreshold * 4), // Max chars limit
199
+ originalLength,
200
+ estimatedTokens: estimateTokens(text, contentType),
201
+ };
202
+ }
203
+ // Pure text
204
+ if (contentType === "text") {
205
+ if (text.length <= config.textThreshold) {
206
+ return {
207
+ type: "kept",
208
+ content: text,
209
+ originalLength,
210
+ estimatedTokens: estimateTokens(text, contentType),
211
+ };
212
+ }
213
+ if (config.mode === "truncate") {
214
+ const truncated = truncateText(text, config.summaryTargetChars);
215
+ return {
216
+ type: "truncated",
217
+ content: truncated,
218
+ originalLength,
219
+ estimatedTokens: estimateTokens(truncated, contentType),
220
+ };
221
+ }
222
+ const extracted = extractKeySentences(text, config.summaryTargetChars);
223
+ return {
224
+ type: "summarized",
225
+ content: extracted,
226
+ originalLength,
227
+ estimatedTokens: estimateTokens(extracted, contentType),
228
+ };
229
+ }
230
+ // Pure code
231
+ if (contentType === "code") {
232
+ if (text.length <= config.codeThreshold) {
233
+ return {
234
+ type: "kept",
235
+ content: text,
236
+ originalLength,
237
+ estimatedTokens: estimateTokens(text, contentType),
238
+ };
239
+ }
240
+ const truncated = smartTruncateCode(text, config.maxCodeLines, {
241
+ preserveComments: config.preserveComments,
242
+ preserveImports: config.preserveImports,
243
+ });
244
+ return {
245
+ type: "truncated",
246
+ content: truncated,
247
+ originalLength,
248
+ estimatedTokens: estimateTokens(truncated, contentType),
249
+ };
250
+ }
251
+ // Mixed content
252
+ if (config.mode === "auto" || config.mode === "extract") {
253
+ const parts = splitCodeAndText(text);
254
+ const summarizedParts = [];
255
+ for (const part of parts) {
256
+ if (part.type === "text") {
257
+ if (part.content.length <= config.textThreshold) {
258
+ summarizedParts.push(part.content);
259
+ }
260
+ else {
261
+ summarizedParts.push(extractKeySentences(part.content, config.summaryTargetChars / 2));
262
+ }
263
+ }
264
+ else {
265
+ if (part.content.length <= config.codeThreshold) {
266
+ summarizedParts.push(part.content);
267
+ }
268
+ else {
269
+ summarizedParts.push(smartTruncateCode(part.content, config.maxCodeLines));
270
+ }
271
+ }
272
+ }
273
+ return {
274
+ type: "mixed",
275
+ content: summarizedParts.join("\n\n"),
276
+ originalLength,
277
+ estimatedTokens: estimateTokens(summarizedParts.join("\n\n"), contentType),
278
+ };
279
+ }
280
+ // Fallback: truncate
281
+ return {
282
+ type: "truncated",
283
+ content: truncateText(text, config.summaryTargetChars),
284
+ originalLength,
285
+ estimatedTokens: estimateTokens(truncateText(text, config.summaryTargetChars), contentType),
286
+ };
287
+ }
288
+ /**
289
+ * Calculates injection limit based on mode
290
+ */
291
+ export function calculateInjectionLimit(results, config) {
292
+ // Filter by injection floor
293
+ const filteredResults = results.filter((r) => r.score >= config.injectionFloor);
294
+ // Fixed mode: simple limit
295
+ if (config.mode === "fixed") {
296
+ return Math.min(config.maxMemories, filteredResults.length);
297
+ }
298
+ // Budget mode: accumulate until budget exhausted
299
+ if (config.mode === "budget") {
300
+ let accumulatedTokens = 0;
301
+ let count = 0;
302
+ for (const result of filteredResults) {
303
+ const tokens = estimateTokens(result.record.text, detectContentType(result.record.text).isPureCode ? "code" : "text");
304
+ if (accumulatedTokens + tokens > config.budgetTokens && count >= config.minMemories) {
305
+ break;
306
+ }
307
+ accumulatedTokens += tokens;
308
+ count += 1;
309
+ if (count >= config.maxMemories) {
310
+ break;
311
+ }
312
+ }
313
+ return Math.max(config.minMemories, Math.min(count, config.maxMemories));
314
+ }
315
+ // Adaptive mode: stop on score drop
316
+ if (config.mode === "adaptive") {
317
+ let count = 0;
318
+ let prevScore = filteredResults[0]?.score ?? 0;
319
+ for (const result of filteredResults) {
320
+ const scoreDrop = prevScore - result.score;
321
+ // Stop if score drops below tolerance (but respect minimum)
322
+ if (scoreDrop > config.scoreDropTolerance && count >= config.minMemories) {
323
+ break;
324
+ }
325
+ count += 1;
326
+ prevScore = result.score;
327
+ if (count >= config.maxMemories) {
328
+ break;
329
+ }
330
+ }
331
+ return Math.max(config.minMemories, Math.min(count, filteredResults.length));
332
+ }
333
+ // Fallback
334
+ return Math.min(config.maxMemories, filteredResults.length);
335
+ }
336
+ /**
337
+ * Creates default summarization config from injection config
338
+ */
339
+ export function createSummarizationConfig(injection) {
340
+ return {
341
+ mode: injection.summarization,
342
+ textThreshold: 300,
343
+ codeThreshold: injection.codeSummarization.pureCodeThreshold,
344
+ summaryTargetChars: injection.summaryTargetChars,
345
+ maxCodeLines: injection.codeSummarization.maxCodeLines,
346
+ codeTruncationMode: injection.codeSummarization.codeTruncationMode,
347
+ preserveComments: injection.codeSummarization.preserveComments,
348
+ preserveImports: injection.codeSummarization.preserveImports,
349
+ };
350
+ }
package/dist/types.d.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  export type EmbeddingProvider = "ollama" | "openai";
2
2
  export type RetrievalMode = "hybrid" | "vector";
3
+ export type InjectionMode = "fixed" | "budget" | "adaptive";
4
+ export type SummarizationMode = "none" | "truncate" | "extract" | "auto";
5
+ export type CodeTruncationMode = "smart" | "signature" | "preserve";
6
+ export type ContentType = "text" | "code" | "mixed";
7
+ export interface ContentDetection {
8
+ hasCode: boolean;
9
+ isPureCode: boolean;
10
+ }
11
+ export interface SummarizedContent {
12
+ type: "kept" | "truncated" | "summarized" | "mixed";
13
+ content: string;
14
+ originalLength: number;
15
+ estimatedTokens: number;
16
+ }
3
17
  export type MemoryCategory = "preference" | "fact" | "decision" | "entity" | "other";
4
18
  export type CaptureOutcome = "considered" | "skipped" | "stored";
5
- export type CaptureSkipReason = "empty-buffer" | "below-min-chars" | "no-positive-signal" | "initialization-unavailable" | "embedding-unavailable" | "empty-embedding";
19
+ export type CaptureSkipReason = "empty-buffer" | "below-min-chars" | "no-positive-signal" | "initialization-unavailable" | "embedding-unavailable" | "empty-embedding" | "duplicate-similarity" | "duplicate-exact";
6
20
  export type FeedbackType = "missing" | "wrong" | "useful";
7
21
  export type RecallSource = "system-transform" | "manual-search";
8
22
  export type MemoryScope = "project" | "global";
@@ -23,11 +37,48 @@ export interface RetrievalConfig {
23
37
  recencyHalfLifeHours: number;
24
38
  importanceWeight: number;
25
39
  }
40
+ export interface CodeSummarizationConfig {
41
+ enabled: boolean;
42
+ pureCodeThreshold: number;
43
+ maxCodeLines: number;
44
+ codeTruncationMode: CodeTruncationMode;
45
+ preserveComments: boolean;
46
+ preserveImports: boolean;
47
+ }
48
+ export interface InjectionConfig {
49
+ mode: InjectionMode;
50
+ maxMemories: number;
51
+ minMemories: number;
52
+ budgetTokens: number;
53
+ maxCharsPerMemory: number;
54
+ summarization: SummarizationMode;
55
+ summaryTargetChars: number;
56
+ scoreDropTolerance: number;
57
+ injectionFloor: number;
58
+ codeSummarization: CodeSummarizationConfig;
59
+ }
60
+ export interface SummarizationConfig {
61
+ mode: SummarizationMode;
62
+ textThreshold: number;
63
+ codeThreshold: number;
64
+ summaryTargetChars: number;
65
+ maxCodeLines: number;
66
+ codeTruncationMode: CodeTruncationMode;
67
+ preserveComments: boolean;
68
+ preserveImports: boolean;
69
+ }
70
+ export interface DedupConfig {
71
+ enabled: boolean;
72
+ writeThreshold: number;
73
+ consolidateThreshold: number;
74
+ }
26
75
  export interface MemoryRuntimeConfig {
27
76
  provider: string;
28
77
  dbPath: string;
29
78
  embedding: EmbeddingConfig;
30
79
  retrieval: RetrievalConfig;
80
+ injection: InjectionConfig;
81
+ dedup: DedupConfig;
31
82
  includeGlobalScope: boolean;
32
83
  globalDetectionThreshold: number;
33
84
  globalDiscountFactor: number;
@@ -135,5 +186,9 @@ export interface EffectivenessSummary {
135
186
  falsePositiveRate: number;
136
187
  falseNegativeRate: number;
137
188
  };
189
+ duplicates: {
190
+ flaggedCount: number;
191
+ consolidatedCount: number;
192
+ };
138
193
  }
139
194
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lancedb-opencode-pro",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "LanceDB-backed long-term memory provider for OpenCode",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",