@memory-river/core 0.2.0 → 0.2.3

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.
@@ -7,6 +7,7 @@
7
7
  * 3. 雙軌並行提煉:短期前情提要 (大膠囊, health: 30) + 長期精確記憶 (小紙條)
8
8
  * 4. 完美回注與幽靈清理:注入 Context 頂端,並自動拔除斷片的 Tool Result
9
9
  */
10
+ import { type ChildProcess } from 'node:child_process';
10
11
  import { ContextMessage } from '../types.js';
11
12
  import type { RawTranscriptEntry, TranscriptArchive } from '../transcript/transcript-archive.js';
12
13
  import type { SessionIdentity } from '../util/session-identity.js';
@@ -87,7 +88,23 @@ export declare function findProbeTextMismatchDetail(summarizePairs: ComparableTr
87
88
  export declare function logProbeTextMismatchDetail(detail: ProbeTextMismatchDetail): void;
88
89
  export declare function buildDualTrackPrompt(conversationLog: string, capsuleLanguage?: string): string;
89
90
  export declare function buildGeneralConversationPrompt(conversationLog: string, capsuleLanguage?: string): string;
91
+ export declare function buildImportanceScoringPrompt(noteTexts: string[]): string;
90
92
  export declare function buildSimplePrompt(conversationLog: string, capsuleLanguage?: string): string;
93
+ export type CodexReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
94
+ export interface CodexCliConfig {
95
+ model: string;
96
+ reasoningEffort: CodexReasoningEffort;
97
+ workdir: string;
98
+ timeoutMs: number;
99
+ }
100
+ type ExecFileCallback = (error: NodeJS.ErrnoException | null, stdout: string | Buffer, stderr: string | Buffer) => void;
101
+ type ExecFileInvoker = (file: string, args: string[], options: {
102
+ cwd: string;
103
+ timeout: number;
104
+ }, callback: ExecFileCallback) => Pick<ChildProcess, 'kill' | 'stdin'> | undefined;
105
+ export declare function buildCodexExecArgs(prompt: string, config: CodexCliConfig): string[];
106
+ export declare function parseCodexOutput(stdout: string): unknown;
107
+ export declare function runCodexCli(prompt: string, config: CodexCliConfig, execFileImpl?: ExecFileInvoker): Promise<string>;
91
108
  export interface ConcentratorConfig {
92
109
  apiKey: string;
93
110
  model: string;
@@ -101,7 +118,11 @@ export interface ConcentratorConfig {
101
118
  */
102
119
  capsuleLanguage?: string;
103
120
  concentrationTarget?: number;
104
- provider?: 'gemini' | 'deepseek';
121
+ provider?: 'codex' | 'gemini' | 'deepseek';
122
+ codexModel?: string;
123
+ codexReasoningEffort?: CodexReasoningEffort;
124
+ codexWorkdir?: string;
125
+ codexTimeoutMs?: number;
105
126
  maxTokens?: number;
106
127
  deepseekApiKey?: string;
107
128
  deepseekModel?: string;
@@ -109,6 +130,7 @@ export interface ConcentratorConfig {
109
130
  transcriptArchive: TranscriptArchive;
110
131
  sessionSummaryDir: string;
111
132
  llm?: LlmClient;
133
+ importanceScorer?: (prompt: string) => Promise<string>;
112
134
  }
113
135
  export interface CapsuleOutput {
114
136
  messages: ContextMessage[];
@@ -122,6 +144,7 @@ export declare class ConcentratorAdapter implements LlmClient {
122
144
  private statsStore?;
123
145
  private transcriptArchive;
124
146
  private llm?;
147
+ private importanceScorer?;
125
148
  private readonly MAX_CONTEXT_WINDOW;
126
149
  private static readonly WATERLINE_CODE;
127
150
  private static readonly WATERLINE_DEFAULT;
@@ -133,6 +156,7 @@ export declare class ConcentratorAdapter implements LlmClient {
133
156
  */
134
157
  private detectConversationMode;
135
158
  buildFallbackCapsule(messages: ContextMessage[]): string;
159
+ private scoreNoteImportance;
136
160
  concentrate(rawMessages: ContextMessage[], dryRun?: boolean, force?: boolean, context?: ConcentrateContext): Promise<CapsuleOutput>;
137
161
  estimateTokens(messages: ContextMessage[]): number;
138
162
  estimateTokenBreakdown(messages: ContextMessage[]): {
@@ -142,7 +166,7 @@ export declare class ConcentratorAdapter implements LlmClient {
142
166
  };
143
167
  /**
144
168
  * Provider 輪替 fallback 核心方法
145
- * 依序嘗試 gemini → deepseek,任一成功即返回
169
+ * 依序嘗試 codex → gemini → deepseek,任一成功即返回
146
170
  * Gemini 若連續 3 次 503,冷卻 90 秒內直接跳過
147
171
  */
148
172
  private callWithFallback;
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import * as path from 'node:path';
11
11
  import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import { execFile } from 'node:child_process';
12
14
  import { CapsuleBridge } from '../pipeline/capsule-bridge.js';
13
15
  import { sharedLLMRateLimiter } from '../util/rate-limiter.js';
14
16
  const CONCRETE_FACT_NOTE_CATEGORIES = new Set([
@@ -794,7 +796,7 @@ ${conversationLog}
794
796
  【時間正規化】對話每個 turn 前綴有 [at=<ISO>] 絕對時間戳。當事實含「昨天/今天/明天/上週/下個月/剛才/之後」等相對時間,必須以該事實所在 turn 的 at 為錨,在 text 內寫出絕對日期(例:「在 2023-05-07(原文稱昨天)參加了…」),並填 when 欄位。若 at=unknown 或語意不足以唯一解析,保留原文相對詞、不可猜,when.precision 與 when.source 設為 "unknown"。明確日期、月份、年份、區間、期限、事件先後順序都要保留,不要只寫「最近」「之前」。
795
797
 
796
798
  每筆格式:
797
- { "text": "...", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "importance": 0.0-1.0, "tags": ["..."], "subject": "主體(選配)", "predicate": "關係/屬性/動作(選配)", "value": "值,字串/數字/布林(選配)", "unit": "數值單位(選配)", "when": { "start": "YYYY-MM-DD 或 ISO(選配)", "end": "(選配)", "precision": "datetime|date|month|year|range|unknown", "sourceText": "原文時間詞", "source": "explicit|relative_anchored|contextual|unknown", "anchor": "錨定用的 turn ISO 時間" } }
799
+ { "text": "...", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "tags": ["..."], "subject": "主體(選配)", "predicate": "關係/屬性/動作(選配)", "value": "值,字串/數字/布林(選配)", "unit": "數值單位(選配)", "when": { "start": "YYYY-MM-DD 或 ISO(選配)", "end": "(選配)", "precision": "datetime|date|month|year|range|unknown", "sourceText": "原文時間詞", "source": "explicit|relative_anchored|contextual|unknown", "anchor": "錨定用的 turn ISO 時間" } }
798
800
  (subject/predicate/value/unit/entities/when 全為選配:有對應資訊才填,沒有就整個省略,不可填空字串或編造。text 仍是主要可讀、可檢索內容;日期、數值、人名、地名、檔案名、產品名、事件名也必須同時寫進 text,能正規化的日期同時寫入 text 與 when。)
799
801
 
800
802
  收錄:
@@ -814,7 +816,6 @@ ${conversationLog}
814
816
  - 一筆只表達一個可獨立更新的主張,必須自足:寫明主體、內容、作用域,以及必要的時間、狀態、條件或理由;不得使用「這個」「上述方案」「已處理」等脫離原文便無法理解的指涉。
815
817
  - 保留足以語意檢索與回查原始 transcript 的專案、元件、人物或事件名稱,但不要複製整段對話、長篇推理、日誌或操作過程(原文可由 rehydrate 取回)。
816
818
  - category 選最能代表該主張長期用途者;tags 用少量具辨識力的實體/專案/領域/狀態詞。
817
- - importance 依「跨時間耐久性 × 未來決策效用」評分,不依篇幅、情緒強度或主題是否熱門;較低分代表耐久性或決策效用較低,但不得因此省略可獨立檢索的具體事實。
818
819
 
819
820
  CRITICAL INSTRUCTION: Output ONLY valid, raw JSON. Do NOT wrap in markdown code blocks. Start with '{' and end with '}'.
820
821
 
@@ -841,7 +842,6 @@ confidence 評分標準(0.0–1.0):
841
842
  {
842
843
  "text": "精確的顆粒化記憶(含足夠上下文,獨立可理解;日期/數值/人名要寫進 text)",
843
844
  "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other",
844
- "importance": 0.0-1.0,
845
845
  "tags": [],
846
846
  "subject": "主體(選配,省略則整個不要出現)",
847
847
  "predicate": "關係/屬性/動作(選配)",
@@ -896,7 +896,7 @@ ${capsuleLanguageInstruction}寫一段「前情提要」(600–900 字),
896
896
  【時間正規化】對話每個 turn 前綴有 [at=<ISO>] 絕對時間戳。當事實含「昨天/今天/明天/上週/下個月/剛才/之後」等相對時間,必須以該事實所在 turn 的 at 為錨,在 text 內寫出絕對日期(例:「在 2023-05-07(原文稱昨天)參加了…」),並填 when 欄位。若 at=unknown 或語意不足以唯一解析,保留原文相對詞、不可猜,when.precision 與 when.source 設為 "unknown"。明確日期、月份、年份、區間、期限、事件先後順序都要保留,不要只寫「最近」「之前」。
897
897
 
898
898
  每筆格式:
899
- { "text": "...", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "importance": 0.0-1.0, "tags": ["..."], "subject": "主體(選配)", "predicate": "關係/屬性/動作(選配)", "value": "值,字串/數字/布林(選配)", "unit": "數值單位(選配)", "when": { "start": "YYYY-MM-DD 或 ISO(選配)", "end": "(選配)", "precision": "datetime|date|month|year|range|unknown", "sourceText": "原文時間詞", "source": "explicit|relative_anchored|contextual|unknown", "anchor": "錨定用的 turn ISO 時間" } }
899
+ { "text": "...", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "tags": ["..."], "subject": "主體(選配)", "predicate": "關係/屬性/動作(選配)", "value": "值,字串/數字/布林(選配)", "unit": "數值單位(選配)", "when": { "start": "YYYY-MM-DD 或 ISO(選配)", "end": "(選配)", "precision": "datetime|date|month|year|range|unknown", "sourceText": "原文時間詞", "source": "explicit|relative_anchored|contextual|unknown", "anchor": "錨定用的 turn ISO 時間" } }
900
900
  (subject/predicate/value/unit/entities/when 全為選配:有對應資訊才填,沒有就整個省略,不可填空字串或編造。text 仍是主要可讀、可檢索內容;日期、數值、人名、地名、檔案名、產品名、事件名也必須同時寫進 text,能正規化的日期同時寫入 text 與 when。)
901
901
 
902
902
  收錄:
@@ -916,7 +916,6 @@ ${capsuleLanguageInstruction}寫一段「前情提要」(600–900 字),
916
916
  - 一筆只表達一個可獨立更新的主張,必須自足:寫明主體、內容、作用域,以及必要的時間、狀態、條件或理由;不得使用「這個」「上述方案」「已處理」等脫離原文便無法理解的指涉。
917
917
  - 保留足以語意檢索與回查原始 transcript 的專案、元件、人物或事件名稱,但不要複製整段對話、長篇推理、日誌或操作過程(原文可由 rehydrate 取回)。
918
918
  - category 選最能代表該主張長期用途者;tags 用少量具辨識力的實體/專案/領域/狀態詞。
919
- - importance 依「跨時間耐久性 × 未來決策效用」評分,不依篇幅、情緒強度或主題是否熱門;較低分代表耐久性或決策效用較低,但不得因此省略可獨立檢索的具體事實。
920
919
 
921
920
  CRITICAL INSTRUCTION: Output ONLY valid, raw JSON. Do NOT wrap in markdown code blocks. Start with '{' and end with '}'.
922
921
 
@@ -930,7 +929,7 @@ confidence 評分標準(0.0–1.0):
930
929
  "confidence": 0.85,
931
930
  "capsule": "任務B的自然語言前情提要...",
932
931
  "notes": [
933
- { "text": "精確的顆粒化記憶(含足夠上下文,獨立可理解;日期/數值/人名要寫進 text)", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "importance": 0.0, "tags": [], "subject": "(選配,省略則不要出現)", "predicate": "(選配)", "value": "(選配)", "unit": "(選配)", "when": { "start": "YYYY-MM-DD(選配)", "precision": "date", "sourceText": "原文時間詞", "source": "relative_anchored", "anchor": "turn ISO 時間" } }
932
+ { "text": "精確的顆粒化記憶(含足夠上下文,獨立可理解;日期/數值/人名要寫進 text)", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "tags": [], "subject": "(選配,省略則不要出現)", "predicate": "(選配)", "value": "(選配)", "unit": "(選配)", "when": { "start": "YYYY-MM-DD(選配)", "precision": "date", "sourceText": "原文時間詞", "source": "relative_anchored", "anchor": "turn ISO 時間" } }
934
933
  ]
935
934
  }
936
935
 
@@ -940,6 +939,70 @@ confidence 評分標準(0.0–1.0):
940
939
  - 必須回傳可完整 JSON.parse 的有效 JSON,不可輸出半截 JSON`;
941
940
  return isSourceLanguage ? `${sourceLanguageRequirementTop}\n\n${prompt}\n\n${sourceLanguageRequirementBottom}` : prompt;
942
941
  }
942
+ export function buildImportanceScoringPrompt(noteTexts) {
943
+ const numberedNotes = noteTexts
944
+ .map((text, index) => `${index + 1}. ${text}`)
945
+ .join('\n');
946
+ return `非互動模式,立即回答,不要提問、不要使用任何工具、不要讀檔。
947
+
948
+ 下面是即將存進 AI 長期記憶庫的記憶。請為每一則評 importance(0.0-1.0)。
949
+
950
+ 評分標準(照這個定義,不要自行更改):
951
+ importance 依「跨時間耐久性 × 未來決策效用」評分,不依篇幅、情緒強度或主題是否熱門;
952
+ 較低分代表耐久性或決策效用較低。
953
+
954
+ 只輸出 JSON,不要任何其他文字、不要 markdown 圍欄。必須包含全部 <${noteTexts.length}> 筆:
955
+ {"r":[{"n":<編號>,"i":<0.0-1.0>}]}
956
+
957
+ ${numberedNotes}`;
958
+ }
959
+ class ImportanceScoreParseError extends Error {
960
+ scores;
961
+ constructor(message, scores) {
962
+ super(message);
963
+ this.scores = scores;
964
+ this.name = 'ImportanceScoreParseError';
965
+ }
966
+ }
967
+ function parseImportanceScores(raw, expectedCount) {
968
+ const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
969
+ let response = null;
970
+ for (let index = lines.length - 1; index >= 0; index--) {
971
+ try {
972
+ const candidate = JSON.parse(lines[index]);
973
+ if (candidate && typeof candidate === 'object' && Array.isArray(candidate.r)) {
974
+ response = candidate;
975
+ break;
976
+ }
977
+ }
978
+ catch {
979
+ // Codex progress lines and other non-JSON output are expected here.
980
+ }
981
+ }
982
+ if (!response) {
983
+ throw new ImportanceScoreParseError('importance score response parse failed', new Map());
984
+ }
985
+ const scores = new Map();
986
+ for (const entry of response.r) {
987
+ if (!entry || typeof entry !== 'object')
988
+ continue;
989
+ const item = entry;
990
+ if (typeof item.n === 'number'
991
+ && Number.isInteger(item.n)
992
+ && item.n >= 1
993
+ && item.n <= expectedCount
994
+ && typeof item.i === 'number'
995
+ && Number.isFinite(item.i)) {
996
+ scores.set(item.n, Math.max(0, Math.min(1, item.i)));
997
+ }
998
+ }
999
+ const missing = Array.from({ length: expectedCount }, (_, index) => index + 1)
1000
+ .filter((number) => !scores.has(number));
1001
+ if (missing.length > 0) {
1002
+ throw new ImportanceScoreParseError(`importance score response missing notes: ${missing.join(',')}`, scores);
1003
+ }
1004
+ return scores;
1005
+ }
943
1006
  // ─── 降級版 Prompt (專供本地小模型使用) ───
944
1007
  export function buildSimplePrompt(conversationLog, capsuleLanguage = '繁體中文') {
945
1008
  if (capsuleLanguage === 'source') {
@@ -1076,14 +1139,123 @@ function classifyConcentratorFailure(err) {
1076
1139
  return 'quota';
1077
1140
  return 'other';
1078
1141
  }
1142
+ const CODEX_FAILURE_BREAKER_THRESHOLD = 3;
1143
+ const CODEX_FAILURE_COOLDOWN_MS = 90_000;
1079
1144
  const GEMINI_503_BREAKER_THRESHOLD = 3;
1080
1145
  const GEMINI_503_COOLDOWN_MS = 90_000;
1146
+ let codexConsecutiveFailureCount = 0;
1147
+ let codexCooldownUntil = 0;
1081
1148
  let geminiConsecutive503Count = 0;
1082
1149
  let geminiCooldownUntil = 0;
1083
1150
  function isGemini503Error(err) {
1084
1151
  const message = String(err?.message ?? err ?? '').toLowerCase();
1085
1152
  return message.includes('gemini api error: 503');
1086
1153
  }
1154
+ export function buildCodexExecArgs(prompt, config) {
1155
+ return [
1156
+ 'exec',
1157
+ '-C', config.workdir,
1158
+ '-c', `model=${config.model}`,
1159
+ '-c', `model_reasoning_effort=${config.reasoningEffort}`,
1160
+ prompt,
1161
+ ];
1162
+ }
1163
+ function findBalancedJsonCandidates(text) {
1164
+ const stack = [];
1165
+ const candidates = [];
1166
+ let inString = false;
1167
+ let escaped = false;
1168
+ for (let i = 0; i < text.length; i++) {
1169
+ const ch = text[i];
1170
+ if (escaped) {
1171
+ escaped = false;
1172
+ continue;
1173
+ }
1174
+ if (inString && ch === '\\') {
1175
+ escaped = true;
1176
+ continue;
1177
+ }
1178
+ if (ch === '"') {
1179
+ inString = !inString;
1180
+ continue;
1181
+ }
1182
+ if (inString)
1183
+ continue;
1184
+ if (ch === '[' || ch === '{') {
1185
+ stack.push({ opener: ch, start: i });
1186
+ continue;
1187
+ }
1188
+ if (ch !== ']' && ch !== '}')
1189
+ continue;
1190
+ const expected = ch === ']' ? '[' : '{';
1191
+ const top = stack[stack.length - 1];
1192
+ if (!top || top.opener !== expected)
1193
+ continue;
1194
+ stack.pop();
1195
+ candidates.push(text.slice(top.start, i + 1));
1196
+ }
1197
+ return candidates;
1198
+ }
1199
+ export function parseCodexOutput(stdout) {
1200
+ const trimmed = stdout.trim();
1201
+ if (!trimmed)
1202
+ throw new Error('Codex CLI returned empty stdout');
1203
+ try {
1204
+ const parsed = JSON.parse(trimmed);
1205
+ if (parsed && typeof parsed === 'object')
1206
+ return parsed;
1207
+ }
1208
+ catch { }
1209
+ const candidates = findBalancedJsonCandidates(stdout);
1210
+ for (let i = candidates.length - 1; i >= 0; i--) {
1211
+ try {
1212
+ const parsed = JSON.parse(candidates[i]);
1213
+ if (parsed && typeof parsed === 'object')
1214
+ return parsed;
1215
+ }
1216
+ catch { }
1217
+ }
1218
+ throw new Error('Codex CLI stdout did not contain parseable JSON');
1219
+ }
1220
+ export function runCodexCli(prompt, config, execFileImpl = execFile) {
1221
+ const args = buildCodexExecArgs(prompt, config);
1222
+ return new Promise((resolve, reject) => {
1223
+ let settled = false;
1224
+ let timedOut = false;
1225
+ let child;
1226
+ const timer = setTimeout(() => {
1227
+ timedOut = true;
1228
+ child?.kill('SIGTERM');
1229
+ reject(new Error(`Codex CLI timed out after ${config.timeoutMs}ms`));
1230
+ }, config.timeoutMs);
1231
+ const finish = (error, stdout, stderr) => {
1232
+ if (settled)
1233
+ return;
1234
+ settled = true;
1235
+ clearTimeout(timer);
1236
+ if (timedOut)
1237
+ return;
1238
+ if (error) {
1239
+ const detail = String(stderr || error.message || error);
1240
+ reject(new Error(`Codex CLI failed: ${detail}`));
1241
+ return;
1242
+ }
1243
+ try {
1244
+ resolve(JSON.stringify(parseCodexOutput(String(stdout))));
1245
+ }
1246
+ catch (parseError) {
1247
+ reject(parseError);
1248
+ }
1249
+ };
1250
+ child = execFileImpl('codex', args, {
1251
+ cwd: config.workdir,
1252
+ timeout: config.timeoutMs,
1253
+ }, finish);
1254
+ // execFile 不吃 stdio 選項,child 的 stdin 會是一個開著的 pipe,
1255
+ // codex CLI 因此停在 "Reading additional input from stdin" 直到 timeout。
1256
+ child?.stdin?.end();
1257
+ });
1258
+ }
1087
1259
  function extractBalancedObjectForKey(text, key) {
1088
1260
  const keyIndex = text.indexOf(`"${key}"`);
1089
1261
  if (keyIndex === -1)
@@ -1285,6 +1457,7 @@ export class ConcentratorAdapter {
1285
1457
  statsStore;
1286
1458
  transcriptArchive;
1287
1459
  llm;
1460
+ importanceScorer;
1288
1461
  MAX_CONTEXT_WINDOW = 200000;
1289
1462
  // ── 動態水位線常數 ──────────────────────────────────────
1290
1463
  static WATERLINE_CODE = 0.40; // 代碼/技術密集:40%
@@ -1298,6 +1471,10 @@ export class ConcentratorAdapter {
1298
1471
  capsuleLanguage: config.capsuleLanguage ?? '繁體中文',
1299
1472
  concentrationTarget: config.concentrationTarget ?? 0,
1300
1473
  provider: config.provider ?? 'gemini',
1474
+ codexModel: config.codexModel ?? 'gpt-5.6-luna',
1475
+ codexReasoningEffort: config.codexReasoningEffort ?? 'low',
1476
+ codexWorkdir: config.codexWorkdir || os.homedir(),
1477
+ codexTimeoutMs: config.codexTimeoutMs ?? 120000,
1301
1478
  maxTokens: config.maxTokens ?? 8192,
1302
1479
  deepseekApiKey: config.deepseekApiKey || '',
1303
1480
  deepseekModel: config.deepseekModel ?? 'deepseek-v4-flash',
@@ -1307,6 +1484,7 @@ export class ConcentratorAdapter {
1307
1484
  this.statsStore = config.statsStore;
1308
1485
  this.transcriptArchive = config.transcriptArchive;
1309
1486
  this.llm = config.llm;
1487
+ this.importanceScorer = config.importanceScorer;
1310
1488
  this.capsuleBridge = new CapsuleBridge(this.config.inboxPath);
1311
1489
  }
1312
1490
  /**
@@ -1376,6 +1554,41 @@ export class ConcentratorAdapter {
1376
1554
  `## 9. Optional Next Step\n${latestAssistant === '無' ? '無' : latestAssistant}`,
1377
1555
  ].join('\n\n');
1378
1556
  }
1557
+ async scoreNoteImportance(notes, metricContext) {
1558
+ if (notes.length === 0)
1559
+ return;
1560
+ const prompt = buildImportanceScoringPrompt(notes.map((note) => typeof note.text === 'string' ? note.text : ''));
1561
+ let scores = new Map();
1562
+ try {
1563
+ const raw = this.importanceScorer
1564
+ ? await this.importanceScorer(prompt)
1565
+ : await this.callWithFallback(prompt, 'scoreImportance', undefined, { sessionIdentity: metricContext?.sessionIdentity, inputTokens: estimatePromptTokens(prompt) }, 2048);
1566
+ scores = parseImportanceScores(raw, notes.length);
1567
+ }
1568
+ catch (error) {
1569
+ if (error instanceof ImportanceScoreParseError) {
1570
+ scores = error.scores;
1571
+ }
1572
+ const message = String(error?.message ?? error).toLowerCase();
1573
+ const reason = error instanceof ImportanceScoreParseError
1574
+ ? (message.includes('missing') ? 'partial missing' : 'parse failed')
1575
+ : (message.includes('timeout') || message.includes('timed out') || message.includes('abort')
1576
+ ? 'timeout'
1577
+ : 'call failed');
1578
+ console.warn(`[ConcentratorAdapter] Importance scoring fallback: ${reason}; missing scores use 0.5`);
1579
+ }
1580
+ notes.forEach((note, index) => {
1581
+ const importance = scores.get(index + 1);
1582
+ if (importance === undefined) {
1583
+ note.importance = 0.5;
1584
+ note.importanceFallback = true;
1585
+ }
1586
+ else {
1587
+ note.importance = importance;
1588
+ delete note.importanceFallback;
1589
+ }
1590
+ });
1591
+ }
1379
1592
  async concentrate(rawMessages, dryRun = false, force = false, context = {}) {
1380
1593
  const messages = rawMessages.map(msg => truncateGiantStrings(msg));
1381
1594
  const currentTokens = estimateTotalTokens(messages);
@@ -1388,7 +1601,7 @@ export class ConcentratorAdapter {
1388
1601
  if (!needsCut) {
1389
1602
  return { messages, wasConcentrated: false, processedThroughIndex: 0 };
1390
1603
  }
1391
- if (!this.llm && !this.config.apiKey && !this.config.deepseekApiKey) {
1604
+ if (!this.llm && this.config.provider !== 'codex' && !this.config.apiKey && !this.config.deepseekApiKey) {
1392
1605
  console.warn('[ConcentratorAdapter] Concentration skipped: no LLM API key configured; raw transcripts and recall remain available.');
1393
1606
  return { messages, wasConcentrated: false, processedThroughIndex: 0 };
1394
1607
  }
@@ -1577,7 +1790,9 @@ export class ConcentratorAdapter {
1577
1790
  const originalTokens = estimatePromptTokens(conversationLog);
1578
1791
  const summaryTokens = estimatePromptTokens(capsuleText);
1579
1792
  const compressionRatio = originalTokens / Math.max(1, summaryTokens);
1580
- const notes = parsedData.notes || [];
1793
+ const notes = Array.isArray(parsedData.notes)
1794
+ ? parsedData.notes.map((item) => item && typeof item === 'object' ? item : {})
1795
+ : [];
1581
1796
  const sourceEntryIds = sourceEntryIdsProbe?.matched ? sourceEntryIdsProbe.sourceEntryIds : [];
1582
1797
  const sourceEntryRange = sourceEntryIds.length > 0
1583
1798
  ? {
@@ -1605,6 +1820,9 @@ export class ConcentratorAdapter {
1605
1820
  console.log(`[ConcentratorAdapter] sourceEntryIds metadata: length=${sourceEntryIds.length} firstEntryId=${sourceEntryRange?.firstEntryId ?? 'none'} lastEntryId=${sourceEntryRange?.lastEntryId ?? 'none'}`);
1606
1821
  console.log(`[ConcentratorAdapter] Short-term capsule written (health: 30, confidence: ${confidence.toFixed(2)})`);
1607
1822
  }
1823
+ await this.scoreNoteImportance(notes, {
1824
+ sessionIdentity: context.sessionIdentity,
1825
+ });
1608
1826
  const acceptedNoteTexts = [];
1609
1827
  for (const item of notes) {
1610
1828
  const text = typeof item.text === 'string' ? item.text.trim() : '';
@@ -1629,6 +1847,7 @@ export class ConcentratorAdapter {
1629
1847
  confidence,
1630
1848
  firstTimestamp,
1631
1849
  lastTimestamp,
1850
+ ...(item.importanceFallback === true ? { importanceFallback: true } : {}),
1632
1851
  ...(sourceEntryIds.length > 0 ? { sourceEntryIds } : {}),
1633
1852
  ...(sourceEntryRange ? { sourceEntryRange } : {}),
1634
1853
  // Optional structured enrichment — additive, stored as-is (no schema change),
@@ -1655,7 +1874,7 @@ export class ConcentratorAdapter {
1655
1874
  || rawMessages[0]?.sessionId
1656
1875
  || 'unknown';
1657
1876
  const concentratedAt = Date.now();
1658
- await this.writeSessionSummary({ sessionId, concentratedAt, capsule: capsuleText, notes: parsedData.notes || [], primaryRequest: summary?.primaryRequest || '', pendingTasks: summary?.pendingTasks || '', nextStep: summary?.nextStep || '' });
1877
+ await this.writeSessionSummary({ sessionId, concentratedAt, capsule: capsuleText, notes: notes, primaryRequest: summary?.primaryRequest || '', pendingTasks: summary?.pendingTasks || '', nextStep: summary?.nextStep || '' });
1659
1878
  }
1660
1879
  catch (err) {
1661
1880
  console.error("[ConcentratorAdapter] Compaction failed:", err);
@@ -1683,7 +1902,7 @@ export class ConcentratorAdapter {
1683
1902
  }
1684
1903
  /**
1685
1904
  * Provider 輪替 fallback 核心方法
1686
- * 依序嘗試 gemini → deepseek,任一成功即返回
1905
+ * 依序嘗試 codex → gemini → deepseek,任一成功即返回
1687
1906
  * Gemini 若連續 3 次 503,冷卻 90 秒內直接跳過
1688
1907
  */
1689
1908
  async callWithFallback(prompt, fnName = 'generate', fallbackPrompt, // concentrate 失敗時由呼叫端接 deterministic capsule;此處不處理
@@ -1691,22 +1910,44 @@ export class ConcentratorAdapter {
1691
1910
  if (this.llm) {
1692
1911
  return this.llm.generate(prompt, { purpose: fnName, maxTokens });
1693
1912
  }
1694
- const providers = [];
1913
+ const providers = this.config.provider === 'codex'
1914
+ ? ['codex', 'gemini', 'deepseek']
1915
+ : ['gemini', 'deepseek'];
1695
1916
  const now = Date.now();
1917
+ if (providers.includes('codex')) {
1918
+ if (now < codexCooldownUntil) {
1919
+ console.warn(`[${fnName}] codex skipped: circuit breaker cooling down for ${Math.ceil((codexCooldownUntil - now) / 1000)}s`);
1920
+ }
1921
+ else {
1922
+ // codex has no API key; authentication belongs to the local CLI.
1923
+ }
1924
+ }
1925
+ const eligibleProviders = providers.filter((provider) => provider !== 'codex' || now >= codexCooldownUntil);
1696
1926
  if (now < geminiCooldownUntil) {
1697
1927
  console.warn(`[${fnName}] gemini skipped: circuit breaker cooling down for ${Math.ceil((geminiCooldownUntil - now) / 1000)}s`);
1698
1928
  }
1699
- else {
1700
- providers.push('gemini');
1701
- }
1702
- providers.push('deepseek');
1929
+ const orderedProviders = eligibleProviders.filter((provider) => provider !== 'gemini' || now >= geminiCooldownUntil);
1703
1930
  const attemptedProviders = [];
1704
1931
  const startedAt = Date.now();
1705
1932
  const shouldRecordMetric = fnName === 'concentrate';
1706
1933
  let lastError = null;
1707
- for (const provider of providers) {
1934
+ for (const provider of orderedProviders) {
1708
1935
  attemptedProviders.push(provider);
1709
1936
  try {
1937
+ if (provider === 'codex') {
1938
+ const result = await this.callProvider(provider, prompt, maxTokens);
1939
+ codexConsecutiveFailureCount = 0;
1940
+ await this.recordConcentratorAttemptMetric({
1941
+ metricContext,
1942
+ provider,
1943
+ outcome: 'success',
1944
+ attemptedProviders,
1945
+ inputTokens: metricContext?.inputTokens ?? estimatePromptTokens(prompt),
1946
+ outputTokens: estimatePromptTokens(result),
1947
+ durationMs: Date.now() - startedAt,
1948
+ }, shouldRecordMetric);
1949
+ return result;
1950
+ }
1710
1951
  if (provider === 'gemini') {
1711
1952
  if (this.config.apiKey) {
1712
1953
  const result = await this.callProvider(provider, prompt, maxTokens);
@@ -1745,7 +1986,14 @@ export class ConcentratorAdapter {
1745
1986
  }
1746
1987
  catch (err) {
1747
1988
  console.warn(`[${fnName}] ${provider} failed; trying next provider:`, err);
1748
- if (provider === 'gemini') {
1989
+ if (provider === 'codex') {
1990
+ codexConsecutiveFailureCount += 1;
1991
+ if (codexConsecutiveFailureCount >= CODEX_FAILURE_BREAKER_THRESHOLD) {
1992
+ codexCooldownUntil = Date.now() + CODEX_FAILURE_COOLDOWN_MS;
1993
+ console.warn(`[${fnName}] codex circuit breaker opened for ${CODEX_FAILURE_COOLDOWN_MS / 1000}s after ${codexConsecutiveFailureCount} consecutive failures`);
1994
+ }
1995
+ }
1996
+ else if (provider === 'gemini') {
1749
1997
  if (isGemini503Error(err)) {
1750
1998
  geminiConsecutive503Count += 1;
1751
1999
  if (geminiConsecutive503Count >= GEMINI_503_BREAKER_THRESHOLD) {
@@ -1776,6 +2024,14 @@ export class ConcentratorAdapter {
1776
2024
  if (provider === 'gemini') {
1777
2025
  return callGeminiAPI(this.config.apiKey, this.config.model, prompt, maxTokens);
1778
2026
  }
2027
+ if (provider === 'codex') {
2028
+ return runCodexCli(prompt, {
2029
+ model: this.config.codexModel,
2030
+ reasoningEffort: this.config.codexReasoningEffort,
2031
+ workdir: this.config.codexWorkdir,
2032
+ timeoutMs: this.config.codexTimeoutMs,
2033
+ });
2034
+ }
1779
2035
  if (provider === 'deepseek') {
1780
2036
  return callDeepSeekAPI(this.config.deepseekApiKey, this.config.deepseekModel, prompt, maxTokens);
1781
2037
  }
package/dist/engine.js CHANGED
@@ -87,7 +87,11 @@ export class MemoryRiverEngine {
87
87
  apiKey: config.concentration?.geminiApiKey || config.embedding.apiKey || deps.geminiApiKey,
88
88
  model: config.concentration?.model || 'gemini-2.5-flash-lite',
89
89
  inboxPath: config.inboxPath,
90
- provider: config.concentration?.provider || 'gemini',
90
+ provider: config.concentration?.provider || 'codex',
91
+ codexModel: config.concentration?.codexModel,
92
+ codexReasoningEffort: config.concentration?.codexReasoningEffort,
93
+ codexWorkdir: config.concentration?.codexWorkdir,
94
+ codexTimeoutMs: config.concentration?.codexTimeoutMs,
91
95
  maxTokens: config.concentration?.maxTokens ?? 8192,
92
96
  deepseekApiKey: config.concentration?.deepseekApiKey || deps.deepseekApiKey,
93
97
  deepseekModel: config.concentration?.deepseekModel || 'deepseek-v4-flash',
@@ -27,6 +27,7 @@ export declare function normalizeLanceUpdateValues(values: Record<string, unknow
27
27
  type DecayOptions = {
28
28
  coreCategories?: string[];
29
29
  coreImportanceThreshold?: number;
30
+ capsuleBypassCore?: boolean;
30
31
  skillCapsuleProtection?: boolean;
31
32
  dryRun?: boolean;
32
33
  deleteWith?: (id: string) => Promise<boolean>;
@@ -18,11 +18,26 @@ import { Schema, Field, Int64, Utf8, Bool, Float64 } from "apache-arrow";
18
18
  import { optimizeAuxTablesInConnection, recordAuxTableWrite, } from "./aux-table-maintenance.js";
19
19
  // 動態載入 jieba
20
20
  let jieba = null;
21
+ let jiebaUnavailable = false;
21
22
  const loadJieba = async () => {
22
23
  if (jieba)
23
24
  return jieba;
25
+ if (jiebaUnavailable)
26
+ return null;
24
27
  const module = await import("nodejieba");
25
- jieba = module.default ?? module;
28
+ const candidate = module.default ?? module;
29
+ // nodejieba 的 index.js 在原生 binding 缺失時仍能 import 成功,只有第一次真正
30
+ // 呼叫才丟。這裡先探一次,讓失敗落在 tokenizeChinese 的 catch 裡走降級,
31
+ // 而不是逃出去把每一次 store/update/ftsSearch 都打掛。
32
+ try {
33
+ candidate.cut("測");
34
+ }
35
+ catch (error) {
36
+ jiebaUnavailable = true;
37
+ console.warn("[MemoryStore] nodejieba unavailable, falling back to per-character Chinese tokenization:", error?.message ?? error);
38
+ throw error;
39
+ }
40
+ jieba = candidate;
26
41
  return jieba;
27
42
  };
28
43
  let lancedbImportPromise = null;
@@ -56,10 +71,13 @@ export class SchemaViolationError extends Error {
56
71
  }
57
72
  }
58
73
  // 預設健康度配置
74
+ // coreImportanceThreshold 必須與 types.ts 的 DEFAULT_CLEANUP_CONFIG 一致。
75
+ // 兩處曾經分別是 0.85 / 0.75,同一個「記憶要多重要才不被淘汰」的問題會因為
76
+ // 呼叫入口不同而得到差一倍的答案(0.85→41% 受保護、0.75→76%)。改一邊要改兩邊。
59
77
  const DEFAULT_HEALTH_CONFIG = {
60
78
  initialScore: 100,
61
- coreCategories: ["identity", "constraint", "business", "core_rule"],
62
- coreImportanceThreshold: 0.85,
79
+ coreCategories: ["identity", "preference", "constraint", "business", "decision", "core_rule"],
80
+ coreImportanceThreshold: 0.75,
63
81
  skillDecayFactor: 0.25,
64
82
  };
65
83
  const STRING_UPDATE_FIELDS = new Set([
@@ -2678,6 +2696,7 @@ export class MemoryStore {
2678
2696
  let deferredDecay = 0, deferredDelete = 0;
2679
2697
  const effectiveCoreCategories = options.coreCategories ?? this.healthConfig.coreCategories;
2680
2698
  const effectiveCoreImportanceThreshold = options.coreImportanceThreshold ?? this.healthConfig.coreImportanceThreshold;
2699
+ const capsuleBypassCore = options.capsuleBypassCore ?? true;
2681
2700
  const protectSkillCapsules = options.skillCapsuleProtection ?? true;
2682
2701
  const isDryRun = options.dryRun ?? false;
2683
2702
  const maxDelete = options.maxDelete === undefined ? Infinity : Math.max(0, Math.floor(options.maxDelete));
@@ -2706,14 +2725,16 @@ export class MemoryStore {
2706
2725
  const id = memoryRow.id;
2707
2726
  if (id.startsWith("init_"))
2708
2727
  continue;
2709
- const isCore = effectiveCoreCategories.includes(memoryRow.category) ||
2710
- memoryRow.importance >= effectiveCoreImportanceThreshold;
2728
+ const metaObj = this.parseMetadata(memoryRow.metadata);
2729
+ // dynamic capsule 出生就是短命設計(health 30),不該因為 importance 高而被當成核心記憶保護
2730
+ const isDynamicCapsule = metaObj?.type === 'dynamic_capsule';
2731
+ const isCore = (!capsuleBypassCore || !isDynamicCapsule) && (effectiveCoreCategories.includes(memoryRow.category) ||
2732
+ memoryRow.importance >= effectiveCoreImportanceThreshold);
2711
2733
  if (isCore) {
2712
2734
  coreProtected++;
2713
2735
  continue;
2714
2736
  }
2715
2737
  // 技能膠囊不參與灰塵清理(只響應用戶明確刪除)
2716
- const metaObj = this.parseMetadata(memoryRow.metadata);
2717
2738
  if (protectSkillCapsules && metaObj?.capsuleType === 'skill_capsule') {
2718
2739
  continue;
2719
2740
  }
package/dist/types.d.ts CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Unified Types - memory-river
3
- * Merged from v4/types.ts + context-river/concentrator.ts
4
- */
5
1
  export type MemoryCategory = "preference" | "fact" | "decision" | "entity" | "constraint" | "identity" | "business" | "knowledge" | "skill" | "other";
6
2
  export declare const MEMORY_CATEGORIES: readonly ["preference", "fact", "decision", "entity", "constraint", "identity", "business", "knowledge", "skill", "other"];
7
3
  export declare const PROTECTED_CATEGORIES: MemoryCategory[];
@@ -171,7 +167,7 @@ export interface TranscriptWatermarkRow {
171
167
  lineCount: number;
172
168
  updatedAt: number;
173
169
  }
174
- export type ConcentratorProvider = "gemini" | "deepseek" | "all_failed";
170
+ export type ConcentratorProvider = "codex" | "gemini" | "deepseek" | "all_failed";
175
171
  export type ConcentratorStatOutcome = "success" | "partial" | "failure";
176
172
  export type ConcentratorFailureReason = "broken_json" | "timeout" | "quota" | "other";
177
173
  export interface ConcentratorStat {
@@ -284,8 +280,9 @@ export interface InboxWriteOptions {
284
280
  * 實際 embedder-v5.ts 使用 Ollama,不受此欄位影響。
285
281
  *
286
282
  * Concentration(濃縮)Provider 順序:
287
- * - 預設:Gemini → DeepSeek
283
+ * - 預設:Codex CLI → Gemini → DeepSeek
288
284
  * - Gemini 若連續 3 次 503:冷卻 90 秒,期間直接跳過 Gemini
285
+ * - Codex CLI 若連續 3 次失敗:冷卻 90 秒,期間直接跳過 Codex CLI
289
286
  * - concentrate() 全失敗時仍由 ConcentratorAdapter 走 deterministic fallback capsule
290
287
  *
291
288
  * =============================================================================
@@ -351,8 +348,12 @@ export interface PluginConfig {
351
348
  };
352
349
  concentration?: {
353
350
  model?: string;
354
- provider?: 'gemini' | 'deepseek';
351
+ provider?: 'codex' | 'gemini' | 'deepseek';
355
352
  geminiApiKey?: string;
353
+ codexModel?: string;
354
+ codexReasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
355
+ codexWorkdir?: string;
356
+ codexTimeoutMs?: number;
356
357
  maxTokens?: number;
357
358
  deepseekApiKey?: string;
358
359
  deepseekModel?: string;
package/dist/types.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Unified Types - memory-river
3
+ * Merged from v4/types.ts + context-river/concentrator.ts
4
+ */
5
+ import * as os from 'node:os';
1
6
  export const MEMORY_CATEGORIES = [
2
7
  "preference",
3
8
  "fact",
@@ -55,8 +60,11 @@ export const DEFAULT_CONFIG = {
55
60
  decayPerRun: 5,
56
61
  decayIntervalMs: 24 * 60 * 60 * 1000,
57
62
  deleteThreshold: 0,
58
- coreCategories: ["identity", "preference", "constraint", "business", "core_rule"],
59
- coreImportanceThreshold: 0.8,
63
+ // 與上面 cleanupEngine store-v4 的 DEFAULT_HEALTH_CONFIG 保持一致。
64
+ // 這三處曾經各寫各的(0.75 / 0.8 / 0.85,category 也是 6 / 5 / 4 類),
65
+ // 「記憶要多重要才不被淘汰」會因為呼叫入口不同而得到不同答案。改一處要改三處。
66
+ coreCategories: ["identity", "preference", "constraint", "business", "decision", "core_rule"],
67
+ coreImportanceThreshold: 0.75,
60
68
  skillDecayFactor: 0.25,
61
69
  },
62
70
  hooks: {
@@ -73,13 +81,13 @@ export const DEFAULT_CONFIG = {
73
81
  },
74
82
  concentration: {
75
83
  model: 'gemini-2.5-flash-lite',
76
- /**
77
- * @deprecated 此欄位已失效。實際 fallback 順序由 concentrator-adapter.ts 內定
78
- * (Gemini → DeepSeek),不受 config 影響。
79
- * 保留欄位以避免破壞既有宿主設定,請勿依賴其值。
80
- */
81
- provider: 'gemini',
84
+ /** Provider chain starts with Codex when this is 'codex'; old values retain their legacy chain. */
85
+ provider: 'codex',
82
86
  geminiApiKey: "",
87
+ codexModel: 'gpt-5.6-luna',
88
+ codexReasoningEffort: 'low',
89
+ codexWorkdir: os.homedir(),
90
+ codexTimeoutMs: 120000,
83
91
  maxTokens: 8192,
84
92
  deepseekApiKey: "",
85
93
  deepseekModel: 'deepseek-v4-flash',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memory-river/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Framework-neutral long-term memory engine for Node.js agents.",
5
5
  "private": false,
6
6
  "license": "Apache-2.0",