2026-07-15 18:01:56 +08:00
// memory.js —— 会话记忆:把 Codex 会话自动浓缩成「交接摘要」,边用边存。
// 分层记忆(Phase 0):
// 监视 ~/.codex/sessions 的 rollout-*.jsonl → 会话静默 idleSeconds 秒后自动摘要 →
// ① 存进 SQLite( userData/memory.db,历史可检索、只增不减 = 单一真源)
// ② 从库里【确定性聚合】出「项目核心记忆块」直接写进项目 AGENTS.md 标记段
// —— 关键:Codex 每次新会话必注入 AGENTS.md 全文,所以事实必须钉在这里,而不是放指针
// ③ HANDOFF.md 降级为「给人看的近期快照」;每会话摘要另 append 到 ~/.codex/memory/<hash>.md 供 grep 检索
// 摘要优先走「路由中转」里可用分组的 LLM;没配分组或调用失败则退化为本地规则提取。
// 核心块的渲染是【确定性】的(每次从库重新投影,非在上一版上叠加)→ 天然无「传话游戏」漂移。
const { app } = require ( "electron" );
const fs = require ( "fs" );
const path = require ( "path" );
const os = require ( "os" );
const crypto = require ( "crypto" );
const initSqlJs = require ( "sql.js" );
const sessions = require ( "./sessions" );
const router = require ( "./router" );
const AGENTS_START = "<!-- codex-memory:start -->" ;
const AGENTS_END = "<!-- codex-memory:end -->" ;
// —— 运行时状态 ——
let db = null ;
let SQLmod = null ;
let log = () => {};
let notify = () => {};
let watcher = null ;
let sweepTimer = null ;
let persistTimer = null ;
const dirty = new Map (); // rollout 文件 → 最后一次变化的时间戳
const seenRollouts = new Set (); // 已见过的 rollout 文件(判定「新会话开始」)
const queue = [];
let working = false ;
let cfg = null ;
2026-08-16 12:45:32 +08:00
const deletedSessionIds = new Set ();
2026-07-15 18:01:56 +08:00
// ---------------------------------------------------------------------------
// 配置(userData/memory.json)
// ---------------------------------------------------------------------------
function cfgFile () {
return path . join ( app . getPath ( "userData" ), "memory.json" );
}
function loadCfg () {
if ( cfg ) return cfg ;
let raw = {};
try {
raw = JSON . parse ( fs . readFileSync ( cfgFile (), "utf-8" ));
} catch {}
cfg = {
enabled : true , // 总开关
disabledProjects : [], // 关闭写入的项目 cwd 列表(记忆库照常记录,只是不写文件)
idleSeconds : 20 , // 会话静默多少秒后视为「一段落」并摘要(另有「开新会话即刻结算上一个」触发)
backfillDays : 7 , // 启动回填:只补最近 N 天的旧会话,防止首次启动海量调用
handoffSessions : 4 , // HANDOFF.md 里保留最近几次会话
// —— 项目核心记忆块(写进 AGENTS.md,每轮注入)的预算与容量 ——
coreMaxLines : 55 , // 核心块最多行数
coreMaxChars : 1400 , // 核心块最多字符
coreDecisions : 12 , // 核心块保留最近多少条去重决策
coreTopics : 12 , // 历史话题索引保留最近多少条
archiveEnabled : true , // 是否把每会话摘要 append 到 ~/.codex/memory/<hash>.md 供 grep 检索
semanticCompact : true , // 是否用 LLM 做语义去重/取代(仅在配了中转分组时生效)
compactMinItems : 6 , // 活跃条目达到多少条才值得跑一次语义整合
... raw ,
};
// 兼容旧配置:老版本存的是 idleMinutes
if ( typeof raw . idleMinutes === "number" && typeof raw . idleSeconds !== "number" ) {
cfg . idleSeconds = Math . max ( 10 , Math . round ( raw . idleMinutes * 60 ));
}
return cfg ;
}
function saveCfg () {
fs . writeFileSync ( cfgFile (), JSON . stringify ( cfg , null , 2 ));
}
// ---------------------------------------------------------------------------
// SQLite( sql.js:内存库 + 落盘持久化)
// ---------------------------------------------------------------------------
function dbFile () {
return path . join ( app . getPath ( "userData" ), "memory.db" );
}
async function ensureDb () {
if ( db ) return db ;
if ( ! SQLmod ) {
const wasm = require . resolve ( "sql.js/dist/sql-wasm.wasm" );
SQLmod = await initSqlJs ({ wasmBinary : fs . readFileSync ( wasm ) });
}
try {
db = new SQLmod . Database ( fs . readFileSync ( dbFile ()));
} catch {
db = new SQLmod . Database ();
}
db . run ( `CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
ts TEXT, project TEXT, cwd TEXT, topic TEXT,
summary TEXT, decisions TEXT, next_steps TEXT, files TEXT,
rollout_path TEXT, rollout_size INTEGER, engine TEXT, updated_at TEXT
)` );
// —— Phase 1:结构化核心记忆 ——
db . run ( `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)` );
// 每个 scope(项目 cwd)一行滚动状态
db . run ( `CREATE TABLE IF NOT EXISTS core (
scope TEXT PRIMARY KEY,
state TEXT, next_steps TEXT, files TEXT, updated_at TEXT
)` );
// 决策/长期事实:一条一行。pinned=1 的长期事实(身份/偏好)永不淘汰
db . run ( `CREATE TABLE IF NOT EXISTS core_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT, text TEXT, norm TEXT,
pinned INTEGER DEFAULT 0, status TEXT DEFAULT 'active',
session_id TEXT, ts TEXT
)` );
return db ;
}
function persistDb () {
clearTimeout ( persistTimer );
persistTimer = setTimeout ( flushDb , 500 );
}
function flushDb () {
clearTimeout ( persistTimer );
if ( ! db ) return ;
try {
fs . writeFileSync ( dbFile (), Buffer . from ( db . export ()));
} catch ( e ) {
log ( "会话记忆:记忆库写盘失败 " + ( e . message || e ));
}
}
2026-08-16 12:45:32 +08:00
function flushDbStrict () {
clearTimeout ( persistTimer );
if ( ! db ) return ;
const file = dbFile ();
const tmp = file + ".td-delete-" + process . pid + ".tmp" ;
const old = file + ".td-delete-" + process . pid + ".old" ;
fs . rmSync ( old , { force : true });
const fd = fs . openSync ( tmp , "w" );
try {
fs . writeFileSync ( fd , Buffer . from ( db . export ()));
fs . fsyncSync ( fd );
} finally {
fs . closeSync ( fd );
}
try {
if ( fs . existsSync ( file )) fs . renameSync ( file , old );
try {
fs . renameSync ( tmp , file );
} catch ( e ) {
if ( fs . existsSync ( old )) fs . renameSync ( old , file );
throw e ;
}
try { fs . rmSync ( old , { force : true }); } catch {}
} finally {
fs . rmSync ( tmp , { force : true });
}
}
2026-07-15 18:01:56 +08:00
function all ( sql , params = []) {
const rows = [];
const st = db . prepare ( sql );
st . bind ( params );
while ( st . step ()) rows . push ( st . getAsObject ());
st . free ();
return rows ;
}
function parseArr ( s ) {
try {
const a = JSON . parse ( s );
return Array . isArray ( a ) ? a . map ( String ). filter ( Boolean ) : [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// 摘要生成
// ---------------------------------------------------------------------------
function clip ( s , n ) {
s = String ( s || "" ). replace ( /\s+/g , " " ). trim ();
return s . length > n ? s . slice ( 0 , n ) + "…" : s ;
}
// 把整段对话压缩成给 LLM 看的精简文本(控制 token:单条截断 + 总长掐头留尾)
function compressConv ( conv ) {
const parts = [];
for ( const m of conv . messages ) {
if ( m . role === "user" ) parts . push ( "用户: " + clip ( m . text , 600 ));
else if ( m . role === "assistant" ) parts . push ( "助手: " + clip ( m . text , 400 ));
}
let text = parts . join ( "\n" );
if ( text . length > 16000 ) {
text = text . slice ( 0 , 6000 ) + "\n……(中间已省略)……\n" + text . slice ( - 10000 );
}
return text ;
}
const SUMMARY_PROMPT = ( convText ) =>
`你是"会话交接摘要器"。下面是用户与 AI 编程助手(Codex)在某个项目里的对话记录(已压缩,可能有省略)。
请只输出一个 JSON 对象(不要代码块、不要其它文字),字段:
{
"topic": "一句话主题,不超过30字",
"summary": "摘要,不超过200字。只写结论与当前状态,不写过程;要让没看过对话的人能接着干活",
"decisions": ["已确定的关键决策,每条不超过40字,没有就空数组"],
"next_steps": ["明确的下一步待办,每条不超过40字,没有就空数组"],
"files": ["对话中涉及的重要文件路径,最多8个"]
}
对话记录:
<<<
${ convText }
>>>` ;
// 调 LLM(复用路由中转的可用分组;chat / responses 两种协议都支持)
async function llmSummarize ( convText ) {
const g = router . llmGroup ();
if ( ! g ) throw new Error ( "未配置可用的中转分组" );
const base = g . baseUrl . replace ( /\/$/ , "" );
const prompt = SUMMARY_PROMPT ( convText );
const ctrl = new AbortController ();
const timer = setTimeout (() => ctrl . abort (), 90000 );
try {
let content = "" ;
const headers = {
"content-type" : "application/json" ,
authorization : "Bearer " + g . key ,
"User-Agent" : "codex-market" ,
};
if ( g . mode === "chat" ) {
let ep ;
if ( /\/chat\/completions$/ . test ( base )) ep = base ;
else if ( /\/v\d+$/ . test ( base )) ep = base + "/chat/completions" ;
else ep = base + "/v1/chat/completions" ;
const res = await fetch ( ep , {
method : "POST" ,
signal : ctrl . signal ,
headers ,
body : JSON . stringify ({ model : g . model , messages : [{ role : "user" , content : prompt }], stream : false }),
});
if ( ! res . ok ) throw new Error ( "HTTP " + res . status );
const j = await res . json ();
content = ( j . choices && j . choices [ 0 ] && j . choices [ 0 ]. message && j . choices [ 0 ]. message . content ) || "" ;
} else {
const ep = /\/v\d+$/ . test ( base ) ? base + "/responses" : base + "/v1/responses" ;
const res = await fetch ( ep , {
method : "POST" ,
signal : ctrl . signal ,
headers ,
body : JSON . stringify ({ model : g . model , input : prompt , stream : false }),
});
if ( ! res . ok ) throw new Error ( "HTTP " + res . status );
const j = await res . json ();
content = j . output_text || "" ;
if ( ! content && Array . isArray ( j . output )) {
for ( const it of j . output ) {
if ( it . type !== "message" ) continue ;
for ( const c of it . content || []) if ( c . type === "output_text" ) content += c . text || "" ;
}
}
}
const m = String ( content ). match ( /\{[\s\S]*\}/ );
if ( ! m ) throw new Error ( "LLM 未返回 JSON" );
const d = JSON . parse ( m [ 0 ]);
return {
topic : clip ( d . topic , 60 ),
summary : String ( d . summary || "" ). trim (). slice ( 0 , 600 ),
decisions : ( Array . isArray ( d . decisions ) ? d . decisions : []). map (( x ) => clip ( x , 80 )). filter ( Boolean ). slice ( 0 , 8 ),
next_steps : ( Array . isArray ( d . next_steps ) ? d . next_steps : []). map (( x ) => clip ( x , 80 )). filter ( Boolean ). slice ( 0 , 8 ),
files : ( Array . isArray ( d . files ) ? d . files : []). map ( String ). filter ( Boolean ). slice ( 0 , 8 ),
engine : "llm:" + ( g . name || g . model || "" ),
};
} finally {
clearTimeout ( timer );
}
}
// 本地兜底:不调外部服务的规则提取(质量粗糙但可用)
function localSummarize ( item , conv ) {
const users = conv . messages . filter (( m ) => m . role === "user" );
const asst = conv . messages . filter (( m ) => m . role === "assistant" );
const blob = conv . messages . map (( m ) => m . text ). join ( "\n" );
const files = Array . from (
new Set ( blob . match ( /[\w./~-]+\.(?:js|ts|tsx|jsx|py|json|md|toml|yml|yaml|css|html|sh|go|rs|java|sql)\b/g ) || [])
). slice ( 0 , 8 );
const summary = [
"首条需求:" + clip (( users [ 0 ] && users [ 0 ]. text ) || item . title , 200 ),
asst . length ? "最后进展:" + clip ( asst [ asst . length - 1 ]. text , 260 ) : "" ,
]
. filter ( Boolean )
. join ( "\n" );
return {
topic : clip ( item . title || ( users [ 0 ] && users [ 0 ]. text ) || "(无标题)" , 60 ),
summary ,
decisions : [],
next_steps : [],
files ,
engine : "local" ,
};
}
// ---------------------------------------------------------------------------
// 摘要一条会话 → 落库 → 写 HANDOFF / AGENTS
// ---------------------------------------------------------------------------
async function summarizeFile ( file ) {
const items = sessions . listConversations ({ includeSubagent : false });
const item = items . find (( i ) => i . path === file );
if ( ! item || ! item . id ) return ; // 子 agent 线程或读不到元信息 → 跳过
2026-08-16 12:45:32 +08:00
if ( deletedSessionIds . has ( item . id )) return ;
2026-07-15 18:01:56 +08:00
const conv = sessions . readConversation ( file );
if ( ! conv . messages . some (( m ) => m . role === "user" )) return ; // 空会话不摘要
let r ;
try {
r = await llmSummarize ( compressConv ( conv ));
} catch ( e ) {
log ( `会话记忆:LLM 摘要失败( ${ e . message || e } ),改用本地提取` );
r = localSummarize ( item , conv );
}
await ensureDb ();
2026-08-16 12:45:32 +08:00
if ( deletedSessionIds . has ( item . id )) return ;
2026-07-15 18:01:56 +08:00
db . run (
`INSERT INTO sessions (id, ts, project, cwd, topic, summary, decisions, next_steps, files,
rollout_path, rollout_size, engine, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
ts=excluded.ts, project=excluded.project, cwd=excluded.cwd, topic=excluded.topic,
summary=excluded.summary, decisions=excluded.decisions, next_steps=excluded.next_steps,
files=excluded.files, rollout_path=excluded.rollout_path, rollout_size=excluded.rollout_size,
engine=excluded.engine, updated_at=excluded.updated_at` ,
[
item . id ,
item . ts || "" ,
item . project || "" ,
item . cwd || "" ,
r . topic ,
r . summary ,
JSON . stringify ( r . decisions ),
JSON . stringify ( r . next_steps ),
JSON . stringify ( r . files ),
file ,
item . size || 0 ,
r . engine ,
new Date (). toISOString (),
]
);
// Phase 1:把本次摘要并入结构化 core(决策去重/淘汰、长期事实 pin、滚动状态)
// Phase 2:有新决策进来时,跑一次 LLM 语义整合(近义合并 / 推翻作废,只输出操作,抗漂移)
if ( item . cwd ) {
try {
const added = consolidateCore ( item . cwd , item . ts , item . id , r . decisions , r . summary , r . next_steps , r . files );
if ( added > 0 ) await semanticCompact ( item . cwd );
} catch ( e ) {
log ( "会话记忆:整合 core 失败 " + ( e . message || e ));
}
}
flushDb (); // 同步落盘:真源先持久,派生文件后写,避免 CLI/下次会话读到旧数据
if ( projectWritable ( item . cwd )) {
try {
writeProjectCore ( item . cwd ); // 核心块直接进 AGENTS.md( Codex 必读)
writeHandoff ( item . cwd ); // 详细快照(L2)
appendArchive ( item . cwd , item , r ); // grep 归档(L3,只增不删)
} catch ( e ) {
log ( "会话记忆:写核心/HANDOFF/归档失败 " + ( e . message || e ));
}
}
log ( `会话记忆:已更新「 ${ r . topic } 」( ${ item . project || item . cwd || "未知项目" } ) `);
notify ();
}
// 这个项目能不能写文件(总开关 + 项目开关 + 目录合法性)
function projectWritable ( cwd ) {
loadCfg ();
if ( ! cfg . enabled ) return false ;
if ( ! cwd || cwd === os . homedir () || cwd === "/" ) return false ; // 家目录/根目录不写,避免制造垃圾
if ( cfg . disabledProjects . includes ( cwd )) return false ;
try {
return fs . statSync ( cwd ). isDirectory ();
} catch {
return false ;
}
}
function handoffPath ( cwd ) {
return path . join ( cwd , "HANDOFF.md" );
}
// 原子写:先写 .tmp 再 rename,避免 Codex 启动时读到写了一半的半截文件
function writeFileAtomic ( file , text ) {
const tmp = file + ".tmp" ;
fs . writeFileSync ( tmp , text , "utf-8" );
fs . renameSync ( tmp , file );
}
// 项目 → grep 归档文件(放 ~/.codex/memory,不污染用户项目 git 树)
function hashCwd ( cwd ) {
return crypto . createHash ( "sha1" ). update ( String ( cwd )). digest ( "hex" ). slice ( 0 , 12 );
}
function archivePath ( cwd ) {
const d = path . join ( os . homedir (), ".codex" , "memory" );
fs . mkdirSync ( d , { recursive : true });
return path . join ( d , hashCwd ( cwd ) + ".md" );
}
// 每次会话摘要 append 到归档(只增不删,供模型 rg 检索长尾细节)
function appendArchive ( cwd , item , r ) {
if ( ! loadCfg (). archiveEnabled ) return ;
const when = ( item . ts || "" ). slice ( 0 , 16 ). replace ( "T" , " " );
const L = [ `\n## ${ when } · ${ r . topic } [ ${ item . id || "" } ]` ];
if ( r . summary ) L . push ( r . summary );
if (( r . decisions || []). length ) L . push ( "决策:" + r . decisions . join ( "; " ));
if (( r . files || []). length ) L . push ( "文件:" + r . files . join ( "、" ));
try {
fs . appendFileSync ( archivePath ( cwd ), L . join ( "\n" ) + "\n" , "utf-8" );
} catch {}
}
// 写 HANDOFF.md(L2):详细近期快照,给人看 / 被引导时读;核心已在 AGENTS.md,关键信息不依赖本文件
function writeHandoff ( cwd ) {
const rows = all ( `SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?` , [ cwd , loadCfg (). handoffSessions ]);
if ( ! rows . length ) return ;
const L = [];
L . push ( "<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->" );
L . push ( "# HANDOFF —— 会话交接快照(补充细节)" );
L . push ( "" );
L . push ( "> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。" );
L . push ( "" );
rows . forEach (( r , i ) => {
const dec = parseArr ( r . decisions );
const next = parseArr ( r . next_steps );
const files = parseArr ( r . files );
const when = ( r . ts || "" ). slice ( 0 , 16 ). replace ( "T" , " " );
L . push ( `## ${ i === 0 ? "最近会话" : "更早会话" } · ${ when } · ${ r . topic } ` );
L . push ( "" );
if ( r . summary ) L . push ( r . summary , "" );
if ( dec . length ) { L . push ( "**已确定的决策**: " ); dec . forEach (( d ) => L . push ( `- ${ d } ` )); L . push ( "" ); }
if ( next . length ) { L . push ( "**下一步**: " ); next . forEach (( d ) => L . push ( `- ${ d } ` )); L . push ( "" ); }
if ( files . length ) L . push ( `**涉及文件**: ${ files . join ( "、" ) } ` , "" );
});
writeFileAtomic ( handoffPath ( cwd ), L . join ( "\n" ));
}
// 预算裁剪:首尾标记 + 检索指令行始终保留;超预算时优先砍末尾(历史话题),再砍靠后的内容
function enforceBudget ( lines , maxLines , maxChars ) {
const start = lines [ 0 ];
const end = lines [ lines . length - 1 ];
const retrieval = lines . find (( l ) => l . startsWith ( "> 回答涉及" )) || "" ;
let body = lines . slice ( 1 , lines . length - 1 ). filter (( l ) => l !== retrieval );
const assemble = () => [ start , ... body , retrieval , end ]. filter ( Boolean ). join ( "\n" );
while ( body . length && [ start , ... body , retrieval , end ]. filter ( Boolean ). length > maxLines ) body . pop ();
while ( body . length && assemble (). length > maxChars ) body . pop ();
return assemble ();
}
// —— Phase 1:结构化核心记忆的增量整合(同步、确定性 → 无漂移、无 RMW 竞态窗口)——
function normText ( s ) {
return String ( s || "" ). toLowerCase (). replace ( /\s+/g , "" ). replace ( /[。,、;:,.;:!??!「」『』()()]/g , "" );
}
// 命中即视为「长期事实」(身份 / 称呼 / 偏好 / 背景)→ pinned,永不因预算淘汰
const PINNED_RE = /(称呼|叫我|叫你|用户(是|叫|名|有|为|的身份)|我(是|叫)|偏好|以后(都|请|叫|按)|习惯|喜欢|沟通(风格|方式)?|风格|背景|经验|职业|身份|资深|专职)/ ;
function classifyPinned ( text ) {
return PINNED_RE . test ( String ( text || "" )) ? 1 : 0 ;
}
// 把一次会话摘要并入结构化 core:决策去重新增 + 超预算淘汰(归档不删) + 更新滚动状态
function consolidateCore ( scope , ts , sessionId , decisions , summary , nextSteps , files ) {
loadCfg ();
const existing = all ( "SELECT norm FROM core_decisions WHERE scope = ? AND status='active'" , [ scope ]). map (( r ) => r . norm );
const norms = new Set ( existing );
let added = 0 ;
for ( const d of decisions || []) {
const t = String ( d || "" ). trim ();
if ( ! t ) continue ;
const n = normText ( t );
if ( ! n || norms . has ( n )) continue ;
norms . add ( n );
added ++ ;
db . run ( "INSERT INTO core_decisions (scope,text,norm,pinned,status,session_id,ts) VALUES (?,?,?,?,'active',?,?)" , [
scope , t , n , classifyPinned ( t ), sessionId || "" , ts || "" ,
]);
}
// 淘汰:非 pinned 的 active 决策超预算 → 归档(最旧先归档;pinned 永不淘汰、仍在库可检索)
const acts = all ( "SELECT id FROM core_decisions WHERE scope = ? AND status='active' AND pinned=0 ORDER BY ts DESC, id DESC" , [ scope ]);
if ( acts . length > cfg . coreDecisions ) {
for ( const d of acts . slice ( cfg . coreDecisions )) db . run ( "UPDATE core_decisions SET status='archived' WHERE id = ?" , [ d . id ]);
}
// 滚动状态(当前状态 / 下一步 / 关键文件)来自最近一次摘要
db . run (
`INSERT INTO core (scope,state,next_steps,files,updated_at) VALUES (?,?,?,?,?)
ON CONFLICT(scope) DO UPDATE SET state=excluded.state, next_steps=excluded.next_steps, files=excluded.files, updated_at=excluded.updated_at` ,
[ scope , clip ( summary , 300 ), JSON . stringify (( nextSteps || []). slice ( 0 , 6 )), JSON . stringify (( files || []). slice ( 0 , 8 )), new Date (). toISOString ()]
);
return added ;
}
// —— Phase 2: LLM 语义整合(只让 LLM 输出「操作」:哪些 id 是重复/被取代,不重写记忆文本 → 抗漂移)——
// 复用中转分组做一次 LLM 调用,返回原始文本
async function llmRaw ( prompt ) {
const g = router . llmGroup ();
if ( ! g ) throw new Error ( "未配置可用的中转分组" );
const base = g . baseUrl . replace ( /\/$/ , "" );
const ctrl = new AbortController ();
const timer = setTimeout (() => ctrl . abort (), 90000 );
const headers = { "content-type" : "application/json" , authorization : "Bearer " + g . key , "User-Agent" : "codex-market" };
try {
if ( g . mode === "chat" ) {
let ep ;
if ( /\/chat\/completions$/ . test ( base )) ep = base ;
else if ( /\/v\d+$/ . test ( base )) ep = base + "/chat/completions" ;
else ep = base + "/v1/chat/completions" ;
const res = await fetch ( ep , { method : "POST" , signal : ctrl . signal , headers , body : JSON . stringify ({ model : g . model , messages : [{ role : "user" , content : prompt }], stream : false }) });
if ( ! res . ok ) throw new Error ( "HTTP " + res . status );
const j = await res . json ();
return ( j . choices && j . choices [ 0 ] && j . choices [ 0 ]. message && j . choices [ 0 ]. message . content ) || "" ;
}
const ep = /\/v\d+$/ . test ( base ) ? base + "/responses" : base + "/v1/responses" ;
const res = await fetch ( ep , { method : "POST" , signal : ctrl . signal , headers , body : JSON . stringify ({ model : g . model , input : prompt , stream : false }) });
if ( ! res . ok ) throw new Error ( "HTTP " + res . status );
const j = await res . json ();
let content = j . output_text || "" ;
if ( ! content && Array . isArray ( j . output )) {
for ( const it of j . output ) {
if ( it . type !== "message" ) continue ;
for ( const c of it . content || []) if ( c . type === "output_text" ) content += c . text || "" ;
}
}
return content ;
} finally {
clearTimeout ( timer );
}
}
const COMPACT_PROMPT = ( list ) =>
`你是"记忆去重/整合器"。下面是某项目已记录的若干事实/决策条目(每条带数字 id)。请只输出一个 JSON 对象(不要代码块、不要其它文字):
{
"duplicates": [ { "keep": <保留的id>, "remove": [<被合并的同义id>...], "text": "<该组最简洁准确的规范表述>" } ],
"superseded": [ { "old": <被后来推翻/过时的id>, "by": <取代它的新id> } ]
}
规则:
- 只合并【真正同义】的(如"称呼用户为小楼"与"用户身份称呼为小楼");拿不准就不要合并。
- superseded 只在明确"后者推翻前者"时给(如先"用 Redis"后"改用内存缓存");不确定不给。
- keep 选表述最清晰的那条;text 用该组最准确简洁的说法。
- 没有可合并/取代的,返回 {"duplicates":[],"superseded":[]}。
条目:
<<<
${ list }
>>>` ;
// 对某项目的活跃条目做语义整合:LLM 只判断关系,代码按 id 执行合并/作废(归档不删)
async function semanticCompact ( scope ) {
loadCfg ();
if ( ! cfg . semanticCompact || ! router . llmGroup ()) return ;
const rows = all ( "SELECT id, text FROM core_decisions WHERE scope = ? AND status='active' ORDER BY id ASC" , [ scope ]);
if ( rows . length < cfg . compactMinItems ) return ;
const idSet = new Set ( rows . map (( r ) => r . id ));
const list = rows . map (( r ) => `[ ${ r . id } ] ${ r . text } ` ). join ( "\n" );
let ops ;
try {
const raw = await llmRaw ( COMPACT_PROMPT ( list ));
const m = String ( raw ). match ( /\{[\s\S]*\}/ );
if ( ! m ) return ;
ops = JSON . parse ( m [ 0 ]);
} catch ( e ) {
log ( "会话记忆:语义整合跳过(" + ( e . message || e ) + ") " );
return ; // 任何失败 → 保持现状,不动数据
}
applyCompactOps ( scope , ops , idSet );
}
// 执行 LLM 给的操作,严格校验 id 属于本 scope 的活跃条目,非法一律忽略
function applyCompactOps ( scope , ops , idSet ) {
let changed = 0 ;
const valid = ( id ) => Number . isInteger ( id ) && idSet . has ( id );
for ( const g of ( ops && ops . duplicates ) || []) {
if ( ! valid ( g . keep )) continue ;
const removes = ( g . remove || []). filter (( id ) => valid ( id ) && id !== g . keep );
if ( ! removes . length ) continue ;
if ( g . text && String ( g . text ). trim ()) {
const t = String ( g . text ). trim ();
db . run ( "UPDATE core_decisions SET text = ?, norm = ? WHERE id = ? AND scope = ?" , [ t , normText ( t ), g . keep , scope ]);
}
for ( const id of removes ) {
db . run ( "UPDATE core_decisions SET status='merged' WHERE id = ? AND scope = ?" , [ id , scope ]);
idSet . delete ( id );
changed ++ ;
}
}
for ( const s of ( ops && ops . superseded ) || []) {
if ( ! valid ( s . old ) || ! valid ( s . by ) || s . old === s . by ) continue ;
db . run ( "UPDATE core_decisions SET status='superseded', superseded_by = ? WHERE id = ? AND scope = ?" , [ s . by , s . old , scope ]);
idSet . delete ( s . old );
changed ++ ;
}
if ( changed ) {
flushDb ();
log ( `会话记忆:语义整合合并/作废 ${ changed } 条` );
}
}
// 一次性迁移:把已有 sessions 按时间正序 replay 进结构化 core(幂等,靠 meta 标记只跑一次)
function migrateCoreFromSessions () {
let done = false ;
try {
done = all ( "SELECT v FROM meta WHERE k='core_migrated'" ). length > 0 ;
} catch {}
if ( done ) return ;
const rows = all ( "SELECT * FROM sessions WHERE cwd != '' ORDER BY ts ASC" );
for ( const r of rows ) {
consolidateCore ( r . cwd , r . ts , r . id , parseArr ( r . decisions ), r . summary , parseArr ( r . next_steps ), parseArr ( r . files ));
}
db . run ( "INSERT OR REPLACE INTO meta (k,v) VALUES ('core_migrated','1')" );
flushDb ();
}
// 【核心】从结构化 core 表渲染「项目核心记忆块」。pinned 长期事实置顶且永不被预算裁掉。
function renderProjectCore ( cwd ) {
loadCfg ();
const c = all ( "SELECT * FROM core WHERE scope = ?" , [ cwd ])[ 0 ];
const pinned = all ( "SELECT text FROM core_decisions WHERE scope = ? AND pinned=1 AND status='active' ORDER BY id ASC" , [ cwd ]). map (( r ) => r . text );
const decs = all ( "SELECT text FROM core_decisions WHERE scope = ? AND pinned=0 AND status='active' ORDER BY ts DESC, id DESC LIMIT ?" , [ cwd , cfg . coreDecisions ]). map (( r ) => r . text );
const topicRows = all ( "SELECT ts, topic FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?" , [ cwd , cfg . coreTopics ]);
if ( ! c && ! pinned . length && ! decs . length && ! topicRows . length ) return null ;
const nextSteps = c ? parseArr ( c . next_steps ). slice ( 0 , 6 ) : [];
const files = c ? parseArr ( c . files ) : [];
const L = [];
L . push ( AGENTS_START );
L . push ( "## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)" );
if ( pinned . length ) { L . push ( "**关于用户 / 长期事实(务必遵守)**:" ); pinned . forEach (( d ) => L . push ( `- ${ d } ` )); }
if ( c && c . state ) L . push ( `**当前状态**: ${ clip ( c . state , 300 ) } ` );
if ( decs . length ) { L . push ( "**已定决策**: " ); decs . forEach (( d ) => L . push ( `- ${ d } ` )); }
if ( nextSteps . length ) { L . push ( "**下一步**: " ); nextSteps . forEach (( d ) => L . push ( `- ${ d } ` )); }
if ( files . length ) L . push ( `**关键文件**: ${ files . join ( "、" ) } ` );
if ( topicRows . length ) { L . push ( "**历史话题(需其中细节时用下面命令检索)**:" ); topicRows . forEach (( r ) => L . push ( `- ${ ( r . ts || "" ). slice ( 0 , 10 ) } ${ r . topic } ` )); }
L . push ( `> 回答涉及本段未列出的历史细节前,先运行 \`rg -n "关键词" ${ archivePath ( cwd ) } \` 查证再答。` );
L . push ( AGENTS_END );
return enforceBudget ( L , cfg . coreMaxLines , cfg . coreMaxChars );
}
// 把核心记忆块写进项目 AGENTS.md:写前重读,只替换标记段,块外内容原样保留;标记残缺则放弃不硬盖
function writeProjectCore ( cwd ) {
const core = renderProjectCore ( cwd );
if ( ! core ) return ;
const f = path . join ( cwd , "AGENTS.md" );
let text = "" ;
try { text = fs . readFileSync ( f , "utf-8" ); } catch {}
const si = text . indexOf ( AGENTS_START );
const ei = text . indexOf ( AGENTS_END );
let nt ;
if ( si >= 0 && ei > si ) {
nt = text . slice ( 0 , si ) + core + text . slice ( ei + AGENTS_END . length );
} else if ( si >= 0 || ei >= 0 ) {
log ( "会话记忆:AGENTS.md 记忆标记不完整,跳过本次更新以免破坏文件" );
return ;
} else {
nt = ( text ? text . trimEnd () + "\n\n" : "" ) + core + "\n" ;
}
if ( nt !== text ) writeFileAtomic ( f , nt );
}
2026-08-16 12:45:32 +08:00
function removeProjectCore ( cwd ) {
const f = path . join ( cwd , "AGENTS.md" );
let text = "" ;
try { text = fs . readFileSync ( f , "utf-8" ); } catch { return ; }
const si = text . indexOf ( AGENTS_START );
const ei = text . indexOf ( AGENTS_END );
if ( si < 0 || ei <= si ) return ;
const next = text . slice ( 0 , si ) + text . slice ( ei + AGENTS_END . length );
writeFileAtomic ( f , next );
}
function removeArchiveSession ( cwd , id ) {
if ( ! cwd || ! id ) return ;
const f = archivePath ( cwd );
let text = "" ;
try { text = fs . readFileSync ( f , "utf-8" ); } catch { return ; }
const lines = text . split ( "\n" );
const kept = [];
let dropping = false ;
for ( const line of lines ) {
const header = line . match ( /^## .* \[([0-9a-f-]{36})\]\s*$/i );
if ( header ) dropping = header [ 1 ] === id ;
if ( ! dropping ) kept . push ( line );
}
const next = kept . join ( "\n" );
if ( next . trim ()) writeFileAtomic ( f , next );
else fs . rmSync ( f , { force : true });
}
function refreshScopeAfterDelete ( cwd , id ) {
db . run ( "DELETE FROM core_decisions WHERE scope = ? AND session_id = ?" , [ cwd , id ]);
const latest = all ( "SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT 1" , [ cwd ])[ 0 ];
if ( ! latest ) {
db . run ( "DELETE FROM core WHERE scope = ?" , [ cwd ]);
return 0 ;
}
db . run (
`INSERT INTO core (scope,state,next_steps,files,updated_at) VALUES (?,?,?,?,?)
ON CONFLICT(scope) DO UPDATE SET state=excluded.state,next_steps=excluded.next_steps,files=excluded.files,updated_at=excluded.updated_at` ,
[ cwd , clip ( latest . summary , 300 ), JSON . stringify ( parseArr ( latest . next_steps ). slice ( 0 , 6 )), JSON . stringify ( parseArr ( latest . files ). slice ( 0 , 8 )), new Date (). toISOString ()]
);
return 1 ;
}
function removeGeneratedHandoff ( cwd ) {
const file = handoffPath ( cwd );
let text = "" ;
try { text = fs . readFileSync ( file , "utf-8" ); } catch { return ; }
if ( text . startsWith ( "<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->" )) {
fs . rmSync ( file , { force : true });
}
}
function canWriteGeneratedHandoff ( cwd ) {
let text = "" ;
try { text = fs . readFileSync ( handoffPath ( cwd ), "utf-8" ); } catch { return true ; }
return text . startsWith ( "<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->" );
}
2026-07-15 18:01:56 +08:00
// 新版首启:先把已有 sessions 迁移进结构化 core,再把所有已知项目 AGENTS.md 升级为核心块格式
function refreshAllCores () {
try {
migrateCoreFromSessions ();
} catch ( e ) {
log ( "会话记忆:core 迁移失败 " + ( e . message || e ));
}
let cwds = [];
try {
cwds = all ( "SELECT DISTINCT cwd FROM sessions WHERE cwd != ''" ). map (( r ) => r . cwd );
} catch {
return ;
}
for ( const cwd of cwds ) {
if ( ! projectWritable ( cwd )) continue ;
try {
writeProjectCore ( cwd );
writeHandoff ( cwd );
} catch {}
}
}
// ---------------------------------------------------------------------------
// 监视 + 队列(串行处理,避免并发打爆中转 API)
// ---------------------------------------------------------------------------
function enqueue ( file ) {
if ( ! queue . includes ( file )) queue . push ( file );
pump ();
}
async function pump () {
if ( working ) return ;
working = true ;
try {
while ( queue . length ) {
if ( ! loadCfg (). enabled ) {
queue . length = 0 ;
break ;
}
const file = queue . shift ();
try {
await summarizeFile ( file );
} catch ( e ) {
log ( "会话记忆:摘要失败 " + ( e . message || e ));
}
}
} finally {
working = false ;
}
}
// 定时清点:静默超过 idleSeconds 的脏文件 → 入队摘要
function sweep () {
const idleMs = loadCfg (). idleSeconds * 1000 ;
const now = Date . now ();
for ( const [ file , t ] of dirty ) {
if ( now - t >= idleMs ) {
dirty . delete ( file );
enqueue ( file );
}
}
}
// 立刻结算「除 exceptFile 外」所有待处理会话(用于「开新会话即刻把上一个存好」)
function flushPending ( exceptFile ) {
for ( const [ file ] of dirty ) {
if ( file === exceptFile ) continue ;
dirty . delete ( file );
enqueue ( file );
}
}
// 启动回填:把上次运行后新增/长大的会话补摘要(限最近 N 天、单次最多 20 个)
// 一次性:对所有已知项目跑语义整合,清理迁移进来的近义冗余(meta 标记,仅首次 Phase 2 启动跑)
async function compactAllOnce () {
loadCfg ();
if ( ! cfg . semanticCompact || ! router . llmGroup ()) return ;
let done = false ;
try {
done = all ( "SELECT v FROM meta WHERE k='compact_v1'" ). length > 0 ;
} catch {}
if ( done ) return ;
const cwds = all ( "SELECT DISTINCT cwd FROM sessions WHERE cwd != ''" ). map (( r ) => r . cwd );
for ( const cwd of cwds ) {
try {
await semanticCompact ( cwd );
if ( projectWritable ( cwd )) writeProjectCore ( cwd );
} catch {}
}
db . run ( "INSERT OR REPLACE INTO meta (k,v) VALUES ('compact_v1','1')" );
flushDb ();
}
async function backfill () {
await ensureDb ();
loadCfg ();
if ( ! cfg . enabled ) return ;
refreshAllCores (); // 首启即把已有项目 AGENTS.md 升级为核心块格式
compactAllOnce (). catch (() => {}); // 后台一次性语义整合已有近义项
const cutoff = Date . now () - cfg . backfillDays * 86400000 ;
const known = new Map ( all ( "SELECT rollout_path, rollout_size FROM sessions" ). map (( r ) => [ r . rollout_path , r . rollout_size ]));
const items = sessions . listConversations ({ includeSubagent : false });
let queued = 0 ;
let skippedOld = 0 ;
for ( const it of items ) {
const t = Date . parse ( it . ts || "" );
if ( ! t || t < cutoff ) {
if ( ! known . has ( it . path )) skippedOld ++ ;
continue ;
}
if ( known . get ( it . path ) === it . size ) continue ; // 没变化
enqueue ( it . path );
if ( ++ queued >= 20 ) break ;
}
if ( queued ) log ( `会话记忆:发现 ${ queued } 个新增/有变化的会话,开始后台摘要…` );
if ( skippedOld ) log ( `会话记忆: ${ skippedOld } 个 ${ cfg . backfillDays } 天前的旧会话未自动回填(可在「会话记忆」页对单条手动摘要)` );
}
function start ( opts = {}) {
if ( opts . log ) log = opts . log ;
if ( opts . notify ) notify = opts . notify ;
loadCfg ();
2026-08-16 12:45:32 +08:00
const markSeen = () => {
// 标记已存在会话需要递归扫描 ~/.codex/sessions,启动时可延后,避免阻塞主进程响应点击。
try {
for ( const it of sessions . listConversations ({ includeSubagent : true })) seenRollouts . add ( it . path );
} catch {}
};
2026-07-15 18:01:56 +08:00
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
try {
2026-08-16 12:45:32 +08:00
const sessionsDir = sessions . getSessionsDir ();
fs . mkdirSync ( sessionsDir , { recursive : true });
watcher = fs . watch ( sessionsDir , { recursive : true }, ( _ev , name ) => {
2026-07-15 18:01:56 +08:00
if ( ! name || ! /rollout-.*\.jsonl$/ . test ( String ( name ))) return ;
2026-08-16 12:45:32 +08:00
const full = path . join ( sessionsDir , String ( name ));
2026-07-15 18:01:56 +08:00
// 「开新会话即刻结算上一个」:首次见到某 rollout 文件 = 新会话开始 → 立刻把其它待处理的(多半是刚关掉的那个)先存好
if ( ! seenRollouts . has ( full )) {
seenRollouts . add ( full );
flushPending ( full );
}
dirty . set ( full , Date . now ());
});
} catch ( e ) {
log ( "会话记忆:无法监视 sessions 目录 " + ( e . message || e ));
}
sweepTimer = setInterval ( sweep , 5000 );
2026-08-16 12:45:32 +08:00
if ( opts . startupScan === false ) return ;
const startupScan = () => {
if ( ! loadCfg (). enabled ) return ;
markSeen ();
backfill (). catch (( e ) => log ( "会话记忆:启动扫描失败 " + ( e . message || e )));
};
const delay = Number ( opts . startupDelayMs ) || 0 ;
if ( delay > 0 ) setTimeout ( startupScan , delay );
else startupScan ();
2026-07-15 18:01:56 +08:00
}
function stop () {
try {
if ( watcher ) watcher . close ();
} catch {}
watcher = null ;
clearInterval ( sweepTimer );
flushDb (); // 退出前把内存库落盘
}
2026-08-16 12:45:32 +08:00
function restartWatching () {
stop ();
dirty . clear ();
seenRollouts . clear ();
queue . length = 0 ;
start ();
}
2026-07-15 18:01:56 +08:00
// ---------------------------------------------------------------------------
// 给 UI 的接口
// ---------------------------------------------------------------------------
async function statusInfo () {
await ensureDb ();
loadCfg ();
const g = router . llmGroup ();
const projects = all (
`SELECT cwd, project, COUNT(*) AS n, MAX(ts) AS last FROM sessions WHERE cwd != '' GROUP BY cwd ORDER BY last DESC`
). map (( p ) => ({
cwd : p . cwd ,
project : p . project || path . basename ( p . cwd || "" ),
count : p . n ,
last : p . last ,
enabled : ! cfg . disabledProjects . includes ( p . cwd ),
hasHandoff : fs . existsSync ( handoffPath ( p . cwd )),
}));
return {
enabled : cfg . enabled ,
idleSeconds : cfg . idleSeconds ,
engine : g ? `LLM 摘要(分组「 ${ g . name } 」· ${ g . model } ) ` : "本地提取(未配置可用中转分组,质量有限)" ,
total : ( all ( "SELECT COUNT(*) AS n FROM sessions" )[ 0 ] || { n : 0 }). n ,
pending : queue . length + dirty . size ,
projects ,
};
}
async function list ({ project = "" , q = "" , limit = 200 } = {}) {
await ensureDb ();
let sql = "SELECT * FROM sessions" ;
const cond = [];
const params = [];
if ( project ) {
cond . push ( "cwd = ?" );
params . push ( project );
}
if ( q ) {
cond . push ( "(topic LIKE ? OR summary LIKE ? OR decisions LIKE ? OR project LIKE ?)" );
const s = `% ${ q } %` ;
params . push ( s , s , s , s );
}
if ( cond . length ) sql += " WHERE " + cond . join ( " AND " );
sql += " ORDER BY ts DESC LIMIT ?" ;
params . push ( limit );
return all ( sql , params ). map (( r ) => ({
... r ,
decisions : parseArr ( r . decisions ),
next_steps : parseArr ( r . next_steps ),
files : parseArr ( r . files ),
}));
}
function setEnabled ( on ) {
loadCfg ();
cfg . enabled = !! on ;
saveCfg ();
if ( cfg . enabled ) backfill (). catch (() => {});
}
function toggleProject ( cwd , on ) {
loadCfg ();
const set = new Set ( cfg . disabledProjects );
if ( on ) set . delete ( cwd );
else set . add ( cwd );
cfg . disabledProjects = Array . from ( set );
saveCfg ();
if ( on && projectWritable ( cwd )) {
try {
writeProjectCore ( cwd );
writeHandoff ( cwd );
} catch {}
}
}
async function resummarize ( file ) {
await summarizeFile ( file );
}
async function deleteRow ( id ) {
await ensureDb ();
db . run ( "DELETE FROM sessions WHERE id = ?" , [ id ]);
persistDb ();
notify ();
}
2026-08-16 12:45:32 +08:00
async function deleteSession ( id , cwd ) {
deletedSessionIds . add ( String ( id || "" ));
await ensureDb ();
const rows = all ( "SELECT cwd, rollout_path FROM sessions WHERE id = ?" , [ id ]);
for ( const row of rows ) {
if ( row . rollout_path ) {
dirty . delete ( row . rollout_path );
seenRollouts . delete ( row . rollout_path );
for ( let i = queue . length - 1 ; i >= 0 ; i -- ) if ( queue [ i ] === row . rollout_path ) queue . splice ( i , 1 );
}
}
const storedScopes = Array . from ( new Set ( rows . map (( row ) => row . cwd ). filter ( Boolean )));
const scopes = storedScopes . length ? storedScopes : ( cwd ? [ cwd ] : []);
const snapshot = Buffer . from ( db . export ());
try {
db . run ( "BEGIN" );
db . run ( "DELETE FROM sessions WHERE id = ?" , [ id ]);
for ( const scope of scopes ) refreshScopeAfterDelete ( scope , id );
db . run ( "COMMIT" );
flushDbStrict ();
} catch ( e ) {
try { db . run ( "ROLLBACK" ); } catch {}
try { db . close (); } catch {}
db = new SQLmod . Database ( snapshot );
throw e ;
}
for ( const scope of scopes ) removeArchiveSession ( scope , id );
for ( const scope of scopes ) {
if ( ! scope ) continue ;
try { if ( ! fs . statSync ( scope ). isDirectory ()) continue ; } catch { continue ; }
try {
if ( all ( "SELECT 1 FROM sessions WHERE cwd = ? LIMIT 1" , [ scope ]). length ) {
writeProjectCore ( scope );
if ( canWriteGeneratedHandoff ( scope )) writeHandoff ( scope );
} else {
removeProjectCore ( scope );
removeGeneratedHandoff ( scope );
}
} catch ( e ) { log ( "会话记忆派生文件清理失败:" + ( e . message || e )); }
}
notify ();
return { id , scopes };
}
2026-07-15 18:01:56 +08:00
module . exports = {
start ,
stop ,
2026-08-16 12:45:32 +08:00
restartWatching ,
2026-07-15 18:01:56 +08:00
statusInfo ,
list ,
setEnabled ,
toggleProject ,
resummarize ,
deleteRow ,
2026-08-16 12:45:32 +08:00
deleteSession ,
2026-07-15 18:01:56 +08:00
handoffPath ,
renderProjectCore ,
writeProjectCore ,
semanticCompact ,
applyCompactOps ,
};