@loreai/core 0.11.1 → 0.13.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.
Files changed (94) hide show
  1. package/dist/bun/agents-file.d.ts +29 -8
  2. package/dist/bun/agents-file.d.ts.map +1 -1
  3. package/dist/bun/config.d.ts +1 -0
  4. package/dist/bun/config.d.ts.map +1 -1
  5. package/dist/bun/db.d.ts.map +1 -1
  6. package/dist/bun/distillation.d.ts +55 -0
  7. package/dist/bun/distillation.d.ts.map +1 -1
  8. package/dist/bun/embedding.d.ts +15 -1
  9. package/dist/bun/embedding.d.ts.map +1 -1
  10. package/dist/bun/gradient.d.ts +53 -5
  11. package/dist/bun/gradient.d.ts.map +1 -1
  12. package/dist/bun/index.d.ts +4 -4
  13. package/dist/bun/index.d.ts.map +1 -1
  14. package/dist/bun/index.js +799 -256
  15. package/dist/bun/index.js.map +4 -4
  16. package/dist/bun/pattern-extract.d.ts +36 -0
  17. package/dist/bun/pattern-extract.d.ts.map +1 -0
  18. package/dist/bun/recall.d.ts +1 -0
  19. package/dist/bun/recall.d.ts.map +1 -1
  20. package/dist/bun/search.d.ts +13 -1
  21. package/dist/bun/search.d.ts.map +1 -1
  22. package/dist/bun/temporal.d.ts +15 -0
  23. package/dist/bun/temporal.d.ts.map +1 -1
  24. package/dist/bun/types.d.ts +41 -1
  25. package/dist/bun/types.d.ts.map +1 -1
  26. package/dist/bun/worker-model.d.ts +22 -0
  27. package/dist/bun/worker-model.d.ts.map +1 -1
  28. package/dist/node/agents-file.d.ts +29 -8
  29. package/dist/node/agents-file.d.ts.map +1 -1
  30. package/dist/node/config.d.ts +1 -0
  31. package/dist/node/config.d.ts.map +1 -1
  32. package/dist/node/db.d.ts.map +1 -1
  33. package/dist/node/distillation.d.ts +55 -0
  34. package/dist/node/distillation.d.ts.map +1 -1
  35. package/dist/node/embedding.d.ts +15 -1
  36. package/dist/node/embedding.d.ts.map +1 -1
  37. package/dist/node/gradient.d.ts +53 -5
  38. package/dist/node/gradient.d.ts.map +1 -1
  39. package/dist/node/index.d.ts +4 -4
  40. package/dist/node/index.d.ts.map +1 -1
  41. package/dist/node/index.js +799 -256
  42. package/dist/node/index.js.map +4 -4
  43. package/dist/node/pattern-extract.d.ts +36 -0
  44. package/dist/node/pattern-extract.d.ts.map +1 -0
  45. package/dist/node/recall.d.ts +1 -0
  46. package/dist/node/recall.d.ts.map +1 -1
  47. package/dist/node/search.d.ts +13 -1
  48. package/dist/node/search.d.ts.map +1 -1
  49. package/dist/node/temporal.d.ts +15 -0
  50. package/dist/node/temporal.d.ts.map +1 -1
  51. package/dist/node/types.d.ts +41 -1
  52. package/dist/node/types.d.ts.map +1 -1
  53. package/dist/node/worker-model.d.ts +22 -0
  54. package/dist/node/worker-model.d.ts.map +1 -1
  55. package/dist/types/agents-file.d.ts +29 -8
  56. package/dist/types/agents-file.d.ts.map +1 -1
  57. package/dist/types/config.d.ts +1 -0
  58. package/dist/types/config.d.ts.map +1 -1
  59. package/dist/types/db.d.ts.map +1 -1
  60. package/dist/types/distillation.d.ts +55 -0
  61. package/dist/types/distillation.d.ts.map +1 -1
  62. package/dist/types/embedding.d.ts +15 -1
  63. package/dist/types/embedding.d.ts.map +1 -1
  64. package/dist/types/gradient.d.ts +53 -5
  65. package/dist/types/gradient.d.ts.map +1 -1
  66. package/dist/types/index.d.ts +4 -4
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/dist/types/pattern-extract.d.ts +36 -0
  69. package/dist/types/pattern-extract.d.ts.map +1 -0
  70. package/dist/types/recall.d.ts +1 -0
  71. package/dist/types/recall.d.ts.map +1 -1
  72. package/dist/types/search.d.ts +13 -1
  73. package/dist/types/search.d.ts.map +1 -1
  74. package/dist/types/temporal.d.ts +15 -0
  75. package/dist/types/temporal.d.ts.map +1 -1
  76. package/dist/types/types.d.ts +41 -1
  77. package/dist/types/types.d.ts.map +1 -1
  78. package/dist/types/worker-model.d.ts +22 -0
  79. package/dist/types/worker-model.d.ts.map +1 -1
  80. package/package.json +3 -2
  81. package/src/agents-file.ts +111 -28
  82. package/src/config.ts +25 -18
  83. package/src/curator.ts +2 -2
  84. package/src/db.ts +83 -4
  85. package/src/distillation.ts +270 -27
  86. package/src/embedding.ts +158 -14
  87. package/src/gradient.ts +398 -227
  88. package/src/index.ts +13 -5
  89. package/src/pattern-extract.ts +108 -0
  90. package/src/recall.ts +142 -6
  91. package/src/search.ts +37 -1
  92. package/src/temporal.ts +39 -0
  93. package/src/types.ts +41 -1
  94. package/src/worker-model.ts +142 -5
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export * as distillation from "./distillation";
15
15
  export * as curator from "./curator";
16
16
  export * as embedding from "./embedding";
17
17
  export * as latReader from "./lat-reader";
18
+ export * as patternExtract from "./pattern-extract";
18
19
  export * as log from "./log";
19
20
 
20
21
  export {
@@ -72,6 +73,7 @@ export {
72
73
  getLastTransformEstimate,
73
74
  toolStripAnnotation,
74
75
  onIdleResume,
76
+ getLastTurnAt,
75
77
  consumeCameOutOfIdle,
76
78
  // Test-only — exposed at the barrel so host-package tests can simulate idle
77
79
  // gaps without sleeping. Not part of the public API.
@@ -93,13 +95,18 @@ export {
93
95
  COMPACT_SUMMARY_TEMPLATE,
94
96
  buildCompactPrompt,
95
97
  } from "./prompt";
96
- export { shouldImport, importFromFile, exportToFile } from "./agents-file";
98
+ export {
99
+ shouldImport,
100
+ importFromFile,
101
+ exportToFile,
102
+ exportLoreFile,
103
+ importLoreFile,
104
+ shouldImportLoreFile,
105
+ loreFileExists,
106
+ LORE_FILE,
107
+ } from "./agents-file";
97
108
  export { workerSessionIDs, isWorkerSession } from "./worker";
98
109
  export * as workerModel from "./worker-model";
99
- export {
100
- WORKER_JUDGE_SYSTEM,
101
- workerJudgeUser,
102
- } from "./worker-model";
103
110
  export {
104
111
  ftsQuery,
105
112
  ftsQueryOr,
@@ -107,6 +114,7 @@ export {
107
114
  reciprocalRankFusion,
108
115
  expandQuery,
109
116
  extractTopTerms,
117
+ exactTermMatchRank,
110
118
  } from "./search";
111
119
  export {
112
120
  serialize,
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Lightweight regex-based pattern extraction from distillation observations.
3
+ *
4
+ * Scans for decision/preference/choice patterns and returns structured
5
+ * extractions that can be stored as knowledge entries. No LLM required.
6
+ *
7
+ * Patterns target how decisions and preferences are typically expressed
8
+ * in distilled engineering context:
9
+ * - "decided to use X"
10
+ * - "chose X over Y"
11
+ * - "switched from X to Y"
12
+ * - "prefers X for Y"
13
+ * - "going with X because Y"
14
+ *
15
+ * Extracted entries participate in the normal curator cycle — the curator
16
+ * can consolidate or remove them based on actual value. The extraction is
17
+ * a cheap seed, not a permanent fixture.
18
+ */
19
+
20
+ export type ExtractedPattern = {
21
+ category: "decision" | "preference";
22
+ /** Short descriptive title, e.g. "Chose PostgreSQL over MySQL". */
23
+ title: string;
24
+ /** Full matched text for context. */
25
+ content: string;
26
+ };
27
+
28
+ type PatternDef = {
29
+ regex: RegExp;
30
+ category: "decision" | "preference";
31
+ titleFn: (match: RegExpMatchArray) => string;
32
+ };
33
+
34
+ const PATTERNS: PatternDef[] = [
35
+ // Decision patterns
36
+ {
37
+ regex: /decided to (?:use |switch to |go with |adopt )(.+?)(?:\.|,|$)/gi,
38
+ category: "decision",
39
+ titleFn: (m) => `Decided to use ${m[1].trim()}`,
40
+ },
41
+ {
42
+ regex: /chose (.+?) over (.+?)(?:\.|,|$)/gi,
43
+ category: "decision",
44
+ titleFn: (m) => `Chose ${m[1].trim()} over ${m[2].trim()}`,
45
+ },
46
+ {
47
+ regex: /switched from (.+?) to (.+?)(?:\.|,|$)/gi,
48
+ category: "decision",
49
+ titleFn: (m) => `Switched from ${m[1].trim()} to ${m[2].trim()}`,
50
+ },
51
+ {
52
+ regex: /going with (.+?) (?:because|for|due to)(.+?)(?:\.|,|$)/gi,
53
+ category: "decision",
54
+ titleFn: (m) => `Going with ${m[1].trim()}`,
55
+ },
56
+ {
57
+ regex: /migrat(?:ed|ing) (?:from .+? )?to (.+?)(?:\.|,|$)/gi,
58
+ category: "decision",
59
+ titleFn: (m) => `Migrated to ${m[1].trim()}`,
60
+ },
61
+ {
62
+ regex: /adopted (.+?) (?:for|as|instead)(.+?)(?:\.|,|$)/gi,
63
+ category: "decision",
64
+ titleFn: (m) => `Adopted ${m[1].trim()}`,
65
+ },
66
+
67
+ // Preference patterns
68
+ {
69
+ regex: /prefers? (.+?) (?:over|to|instead of|rather than) (.+?)(?:\.|,|$)/gi,
70
+ category: "preference",
71
+ titleFn: (m) => `Prefers ${m[1].trim()} over ${m[2].trim()}`,
72
+ },
73
+ {
74
+ regex:
75
+ /(?:user |team |we )(?:always |usually |typically )(?:use|prefer|go with) (.+?)(?:\.|,|$)/gi,
76
+ category: "preference",
77
+ titleFn: (m) => `Typically uses ${m[1].trim()}`,
78
+ },
79
+ ];
80
+
81
+ /**
82
+ * Extract decision/preference patterns from distillation observations text.
83
+ *
84
+ * Returns structured entries suitable for `ltm.create()`. Deduplicates by
85
+ * lowercased title within a single call.
86
+ *
87
+ * @param observations The distilled observations text to scan.
88
+ * @returns Array of extracted patterns (may be empty).
89
+ */
90
+ export function extractPatterns(observations: string): ExtractedPattern[] {
91
+ const results: ExtractedPattern[] = [];
92
+ const seen = new Set<string>();
93
+
94
+ for (const { regex, category, titleFn } of PATTERNS) {
95
+ // Reset lastIndex for global regexes reused across calls
96
+ regex.lastIndex = 0;
97
+ let match: RegExpMatchArray | null;
98
+ while ((match = regex.exec(observations)) !== null) {
99
+ const title = titleFn(match);
100
+ const key = title.toLowerCase();
101
+ if (seen.has(key)) continue;
102
+ seen.add(key);
103
+ results.push({ category, title, content: match[0].trim() });
104
+ }
105
+ }
106
+
107
+ return results;
108
+ }
package/src/recall.ts CHANGED
@@ -19,7 +19,9 @@ import type { LoreConfig } from "./config";
19
19
  import type { LLMClient } from "./types";
20
20
  import {
21
21
  EMPTY_QUERY,
22
+ exactTermMatchRank,
22
23
  expandQuery,
24
+ filterTerms,
23
25
  ftsQuery,
24
26
  ftsQueryOr,
25
27
  reciprocalRankFusion,
@@ -36,6 +38,7 @@ type Distillation = {
36
38
  generation: number;
37
39
  created_at: number;
38
40
  session_id: string;
41
+ c_norm: number | null;
39
42
  };
40
43
 
41
44
  export type ScoredDistillation = Distillation & { rank: number };
@@ -72,6 +75,41 @@ type TaggedResult =
72
75
  | { source: "temporal"; item: temporal.ScoredTemporalMessage }
73
76
  | { source: "lat-section"; item: latReader.ScoredLatSection };
74
77
 
78
+ // ---------------------------------------------------------------------------
79
+ // Tagged result helpers (used by exact-match boost + formatting)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /** Extract searchable text from any TaggedResult variant. */
83
+ function getTaggedText(tagged: TaggedResult): string {
84
+ switch (tagged.source) {
85
+ case "knowledge":
86
+ case "cross-knowledge":
87
+ return `${tagged.item.title} ${tagged.item.content}`;
88
+ case "distillation":
89
+ return tagged.item.observations;
90
+ case "temporal":
91
+ return tagged.item.content;
92
+ case "lat-section":
93
+ return `${tagged.item.heading} ${tagged.item.content}`;
94
+ }
95
+ }
96
+
97
+ /** Unified key function for TaggedResult — source-prefixed ID for RRF dedup. */
98
+ function taggedResultKey(r: TaggedResult): string {
99
+ switch (r.source) {
100
+ case "knowledge":
101
+ return `k:${r.item.id}`;
102
+ case "cross-knowledge":
103
+ return `xk:${r.item.id}`;
104
+ case "distillation":
105
+ return `d:${r.item.id}`;
106
+ case "temporal":
107
+ return `t:${r.item.id}`;
108
+ case "lat-section":
109
+ return `lat:${r.item.id}`;
110
+ }
111
+ }
112
+
75
113
  // ---------------------------------------------------------------------------
76
114
  // Distillation search
77
115
  // ---------------------------------------------------------------------------
@@ -93,8 +131,8 @@ function searchDistillationsLike(input: {
93
131
  .join(" AND ");
94
132
  const likeParams = terms.map((term) => `%${term}%`);
95
133
  const sql = input.sessionID
96
- ? `SELECT id, observations, generation, created_at, session_id FROM distillations WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
97
- : `SELECT id, observations, generation, created_at, session_id FROM distillations WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
134
+ ? `SELECT id, observations, generation, created_at, session_id, c_norm FROM distillations WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
135
+ : `SELECT id, observations, generation, created_at, session_id, c_norm FROM distillations WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
98
136
  const allParams = input.sessionID
99
137
  ? [input.pid, input.sessionID, ...likeParams, input.limit]
100
138
  : [input.pid, ...likeParams, input.limit];
@@ -115,13 +153,13 @@ function searchDistillationsScored(input: {
115
153
  if (q === EMPTY_QUERY) return [];
116
154
 
117
155
  const ftsSQL = input.sessionID
118
- ? `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, rank
156
+ ? `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, d.c_norm, rank
119
157
  FROM distillation_fts f
120
158
  CROSS JOIN distillations d ON d.rowid = f.rowid
121
159
  WHERE distillation_fts MATCH ?
122
160
  AND d.project_id = ? AND d.session_id = ?
123
161
  ORDER BY rank LIMIT ?`
124
- : `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, rank
162
+ : `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, d.c_norm, rank
125
163
  FROM distillation_fts f
126
164
  CROSS JOIN distillations d ON d.rowid = f.rowid
127
165
  WHERE distillation_fts MATCH ?
@@ -241,7 +279,7 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
241
279
  let queries = [query];
242
280
  if (searchConfig?.queryExpansion && llm) {
243
281
  try {
244
- queries = await expandQuery(llm, query);
282
+ queries = await expandQuery(llm, query, undefined, sessionID);
245
283
  } catch (err) {
246
284
  log.info("recall: query expansion failed, using original:", err);
247
285
  }
@@ -322,6 +360,24 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
322
360
  key: (r) => `t:${r.item.id}`,
323
361
  },
324
362
  );
363
+
364
+ // Recency-biased list for temporal results: same candidates re-ranked
365
+ // by created_at (newest first). RRF naturally boosts messages that
366
+ // appear in both the BM25 and recency lists — i.e. results that are
367
+ // both semantically relevant AND recent. Uses the same `t:` key prefix
368
+ // so RRF merges rather than duplicates.
369
+ if (temporalResults.length > 0) {
370
+ const recencySorted = [...temporalResults].sort(
371
+ (a, b) => b.created_at - a.created_at,
372
+ );
373
+ allRrfLists.push({
374
+ items: recencySorted.map((item) => ({
375
+ source: "temporal" as const,
376
+ item,
377
+ })),
378
+ key: (r) => `t:${r.item.id}`,
379
+ });
380
+ }
325
381
  }
326
382
 
327
383
  // Vector search on the original query (not expansions — avoid redundant embeds).
@@ -358,7 +414,7 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
358
414
  .map((hit): TaggedResult | null => {
359
415
  const row = db()
360
416
  .query(
361
- "SELECT id, observations, generation, created_at, session_id FROM distillations WHERE id = ?",
417
+ "SELECT id, observations, generation, created_at, session_id, c_norm FROM distillations WHERE id = ?",
362
418
  )
363
419
  .get(hit.id) as Distillation | null;
364
420
  if (!row) return null;
@@ -430,6 +486,86 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
430
486
  }
431
487
  }
432
488
 
489
+ // Distillation quality list: rank distillation candidates by a quality score
490
+ // that combines temporal clustering (c_norm) and age. Segments with low c_norm
491
+ // (uniformly distributed timestamps) are considered higher quality than bursty
492
+ // segments (high c_norm). Among high-c_norm segments, recent ones are more
493
+ // likely relevant. This adds a mild signal — RRF naturally blends it with the
494
+ // BM25 and vector signals without overriding them.
495
+ {
496
+ const distillationCandidates: Array<{
497
+ tagged: TaggedResult;
498
+ key: string;
499
+ qualityScore: number;
500
+ }> = [];
501
+
502
+ for (const list of allRrfLists) {
503
+ for (const item of list.items) {
504
+ if (item.source !== "distillation") continue;
505
+ const key = `d:${item.item.id}`;
506
+ const d = item.item as ScoredDistillation;
507
+ const cNorm = d.c_norm ?? 0; // NULL → treat as uniform (best case)
508
+ // Quality score: lower c_norm is better. For high c_norm, recency
509
+ // partially compensates. Age is normalized to days (capped at 90).
510
+ const ageDays = Math.min(
511
+ (Date.now() - d.created_at) / 86_400_000,
512
+ 90,
513
+ );
514
+ // score ∈ [0, ~1]: 0 = best quality (uniform + recent)
515
+ // c_norm dominates (0–1), age adds a mild 0–0.1 penalty
516
+ const score = cNorm + (ageDays / 90) * 0.1;
517
+ distillationCandidates.push({ tagged: item, key, qualityScore: score });
518
+ }
519
+ }
520
+
521
+ if (distillationCandidates.length > 1) {
522
+ // De-duplicate by key (same distillation may appear in BM25 + vector lists)
523
+ const seen = new Set<string>();
524
+ const unique = distillationCandidates.filter((c) => {
525
+ if (seen.has(c.key)) return false;
526
+ seen.add(c.key);
527
+ return true;
528
+ });
529
+
530
+ // Sort by quality: lowest score first (best quality)
531
+ unique.sort((a, b) => a.qualityScore - b.qualityScore);
532
+
533
+ allRrfLists.push({
534
+ items: unique.map((c) => c.tagged),
535
+ key: (r) => `d:${r.item.id}`,
536
+ });
537
+ }
538
+ }
539
+
540
+ // Exact-match boost: add an additional RRF list that ranks candidates by
541
+ // the number of exact query term matches. This boosts proper nouns, file
542
+ // names, and technical terms that BM25's prefix/stem matching may dilute.
543
+ // Only runs when there are meaningful terms and existing candidates.
544
+ if (filterTerms(query).length > 0 && allRrfLists.length > 0) {
545
+ // Collect unique candidates across all lists
546
+ const allCandidates = new Map<string, TaggedResult>();
547
+ for (const list of allRrfLists) {
548
+ for (const item of list.items) {
549
+ const key = list.key(item);
550
+ if (!allCandidates.has(key)) allCandidates.set(key, item);
551
+ }
552
+ }
553
+
554
+ const candidateEntries = [...allCandidates.entries()];
555
+ const exactRanked = exactTermMatchRank(
556
+ candidateEntries,
557
+ ([, tagged]) => getTaggedText(tagged),
558
+ query,
559
+ );
560
+
561
+ if (exactRanked.length) {
562
+ allRrfLists.push({
563
+ items: exactRanked.map(([, item]) => item),
564
+ key: taggedResultKey,
565
+ });
566
+ }
567
+ }
568
+
433
569
  const fused = reciprocalRankFusion<TaggedResult>(allRrfLists);
434
570
  return formatFusedResults(fused, 20);
435
571
  }
package/src/search.ts CHANGED
@@ -267,6 +267,41 @@ export function reciprocalRankFusion<T>(
267
267
  return [...scores.values()].sort((a, b) => b.score - a.score);
268
268
  }
269
269
 
270
+ // ---------------------------------------------------------------------------
271
+ // Exact term match ranking (Phase 5 — MemPalace-inspired keyword boost)
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Score candidates by exact query term overlap.
276
+ *
277
+ * Returns items sorted by number of exact term matches (descending).
278
+ * Used as an additional RRF list to boost results that contain query terms
279
+ * verbatim — important for proper nouns, file names, and technical terms
280
+ * that BM25's prefix matching + Porter stemming can miss or dilute.
281
+ *
282
+ * Terms are filtered through the standard stopword + single-char filter
283
+ * (same as `ftsQuery`), then matched case-insensitively via `includes()`.
284
+ */
285
+ export function exactTermMatchRank<T>(
286
+ items: T[],
287
+ getText: (item: T) => string,
288
+ query: string,
289
+ ): T[] {
290
+ const terms = filterTerms(query).map((t) => t.toLowerCase());
291
+ if (!terms.length) return [];
292
+
293
+ const scored = items
294
+ .map((item) => {
295
+ const text = getText(item).toLowerCase();
296
+ const matches = terms.filter((t) => text.includes(t)).length;
297
+ return { item, matches };
298
+ })
299
+ .filter((s) => s.matches > 0)
300
+ .sort((a, b) => b.matches - a.matches);
301
+
302
+ return scored.map((s) => s.item);
303
+ }
304
+
270
305
  // ---------------------------------------------------------------------------
271
306
  // LLM query expansion (Phase 4)
272
307
  // ---------------------------------------------------------------------------
@@ -290,6 +325,7 @@ export async function expandQuery(
290
325
  llm: LLMClient,
291
326
  query: string,
292
327
  model?: { providerID: string; modelID: string },
328
+ sessionID?: string,
293
329
  ): Promise<string[]> {
294
330
  const TIMEOUT_MS = 3000;
295
331
 
@@ -299,7 +335,7 @@ export async function expandQuery(
299
335
  llm.prompt(
300
336
  QUERY_EXPANSION_SYSTEM,
301
337
  `Input: "${query}"`,
302
- { model, workerID: "lore-query-expand" },
338
+ { model, workerID: "lore-query-expand", thinking: false, urgent: true, sessionID },
303
339
  ),
304
340
  new Promise<null>((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
305
341
  ]);
package/src/temporal.ts CHANGED
@@ -280,6 +280,45 @@ export function searchScored(input: {
280
280
  }
281
281
  }
282
282
 
283
+ /**
284
+ * Normalized variance of relative-existence weights over message timestamps.
285
+ *
286
+ * Measures temporal attention imbalance: 0 means timestamps are evenly
287
+ * distributed (uniform attention), 1 means a single distant timestamp
288
+ * dominates (attention stuck in the past). Useful as a lightweight
289
+ * signal for distillation segmentation, recall time-biasing, and
290
+ * idle-resume awareness.
291
+ *
292
+ * Only meaningful for n ≥ 2. Returns 0 for 0 or 1 timestamps.
293
+ *
294
+ * Based on the "Temporal Clustering via Relative Existence" heuristic
295
+ * from D7x7z49/llm-context-idea.
296
+ */
297
+ export function temporalCnorm(
298
+ timestamps: number[],
299
+ now: number = Date.now(),
300
+ ): number {
301
+ const n = timestamps.length;
302
+ if (n < 2) return 0;
303
+
304
+ // Existence durations: how long each piece has existed
305
+ const durations = timestamps.map((t) => now - t);
306
+ const totalDuration = durations.reduce((a, b) => a + b, 0);
307
+ if (totalDuration <= 0) return 0;
308
+
309
+ // Relative existence weights (positive, sum to 1)
310
+ const weights = durations.map((d) => d / totalDuration);
311
+
312
+ // Normalized variance: Var(w) / Var_max
313
+ // Var(w) = (1/n) * Σ(w_i - 1/n)²
314
+ // Var_max = (n-1) / n² (when one weight = 1, rest = 0)
315
+ const uniform = 1 / n;
316
+ const variance =
317
+ weights.reduce((sum, w) => sum + (w - uniform) ** 2, 0) / n;
318
+ const maxVariance = (n - 1) / (n * n);
319
+ return maxVariance === 0 ? 0 : variance / maxVariance;
320
+ }
321
+
283
322
  export function count(projectPath: string, sessionID?: string): number {
284
323
  const pid = ensureProject(projectPath);
285
324
  const query = sessionID
package/src/types.ts CHANGED
@@ -189,7 +189,7 @@ export interface LLMClient {
189
189
  *
190
190
  * @param system System prompt text
191
191
  * @param user User message text
192
- * @param opts Optional model selection and worker identification
192
+ * @param opts Optional model selection, worker identification, and thinking control
193
193
  * @returns The assistant's text response, or null on failure
194
194
  */
195
195
  prompt(
@@ -203,6 +203,46 @@ export interface LLMClient {
203
203
  * (e.g. OpenCode uses this as the session agent name).
204
204
  */
205
205
  workerID?: string;
206
+ /**
207
+ * Disable extended thinking/reasoning for this call.
208
+ *
209
+ * Background workers discard thinking tokens — they only extract the
210
+ * text response. Setting `thinking: false` tells the adapter to avoid
211
+ * producing (and billing for) thinking tokens when possible.
212
+ *
213
+ * Adapter behavior:
214
+ * - Gateway: no-op (bare API call never triggers thinking)
215
+ * - Pi: passes `thinkingEnabled: false` to `complete()`
216
+ * - OpenCode: cannot honor — SDK has no thinking toggle on session.prompt();
217
+ * relies on Part A (non-reasoning model selection) instead
218
+ */
219
+ thinking?: boolean;
220
+ /**
221
+ * When true, the request must be processed immediately and the result
222
+ * returned before the next user turn. When false or absent, the request
223
+ * may be deferred to a batch queue for cost savings (50% discount via
224
+ * Anthropic's Message Batches API).
225
+ *
226
+ * Callers that `await` the result for a blocking operation (compaction,
227
+ * overflow recovery, query expansion) should set `urgent: true`.
228
+ * Fire-and-forget background work (incremental distillation, idle
229
+ * curation) should leave it unset or set `false`.
230
+ *
231
+ * Only the gateway's BatchLLMClient honors this flag; other adapters
232
+ * (OpenCode, Pi) ignore it and always process immediately.
233
+ */
234
+ urgent?: boolean;
235
+ /**
236
+ * Session identifier for per-session auth credential lookup.
237
+ *
238
+ * The gateway uses this to resolve the correct API key or OAuth
239
+ * token for the session that triggered the work, preventing
240
+ * cross-session key mixups when multiple clients are connected.
241
+ *
242
+ * Other adapters (OpenCode, Pi) ignore this field — they resolve
243
+ * auth through their own mechanisms.
244
+ */
245
+ sessionID?: string;
206
246
  },
207
247
  ): Promise<string | null>;
208
248
  }