metame-cli 1.6.2 → 1.6.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.
@@ -15,6 +15,8 @@ const CODEX_TOOL_MAP = Object.freeze({
15
15
  web_fetch: 'WebFetch',
16
16
  });
17
17
 
18
+ const CODEX_AUTO_MODEL = 'auto';
19
+
18
20
  const ENGINE_TIMEOUT_DEFAULTS = Object.freeze({
19
21
  codex: Object.freeze({
20
22
  idleMs: 10 * 60 * 1000,
@@ -80,16 +82,19 @@ const ENGINE_MODEL_CONFIG = Object.freeze({
80
82
  hint: null,
81
83
  },
82
84
  codex: {
83
- main: 'gpt-5.4', // recommended for most tasks (official default)
85
+ main: CODEX_AUTO_MODEL, // follow official Codex CLI default model
84
86
  distill: 'gpt-5.1-codex-mini', // cost-effective mini
85
87
  options: [ // quick-pick buttons (official model names)
86
- { value: 'gpt-5.4', label: 'gpt-5.4 · 推荐' },
87
- { value: 'gpt-5.3-codex', label: 'gpt-5.3-codex · 最新 Codex 专用' },
88
+ { value: CODEX_AUTO_MODEL, label: 'auto · 跟随 Codex 官方默认' },
89
+ { value: 'gpt-5-codex', label: 'gpt-5-codex · 官方滚动别名' },
90
+ { value: 'gpt-5.5', label: 'gpt-5.5 · 固定版本' },
91
+ { value: 'gpt-5.4', label: 'gpt-5.4 · 固定版本' },
92
+ { value: 'gpt-5.3-codex', label: 'gpt-5.3-codex · 固定 Codex' },
88
93
  { value: 'gpt-5.1-codex-max', label: 'gpt-5.1-codex-max · 长任务' },
89
94
  { value: 'gpt-5.1-codex-mini', label: 'gpt-5.1-codex-mini · 轻量' },
90
95
  ],
91
96
  provider: 'openai',
92
- hint: '或直接发送任意 OpenAI 模型名切换',
97
+ hint: '推荐 `auto` 或 `gpt-5-codex`,也可直接发送任意 OpenAI 模型名切换',
93
98
  },
94
99
  });
95
100
 
@@ -121,7 +126,8 @@ function looksLikeCodexModel(model) {
121
126
  const raw = String(model || '').trim().toLowerCase();
122
127
  if (!raw) return false;
123
128
  return (
124
- raw.startsWith('gpt-')
129
+ raw === CODEX_AUTO_MODEL
130
+ || raw.startsWith('gpt-')
125
131
  || raw.startsWith('o1')
126
132
  || raw.startsWith('o3')
127
133
  || raw.startsWith('o4')
@@ -129,6 +135,10 @@ function looksLikeCodexModel(model) {
129
135
  );
130
136
  }
131
137
 
138
+ function isCodexAutoModel(model) {
139
+ return String(model || '').trim().toLowerCase() === CODEX_AUTO_MODEL;
140
+ }
141
+
132
142
  function resolveEngineModel(engineName, daemonCfg = {}, overrideModel = '') {
133
143
  const engine = normalizeEngineName(engineName);
134
144
  const engineCfg = ENGINE_MODEL_CONFIG[engine] || ENGINE_MODEL_CONFIG.claude;
@@ -393,7 +403,7 @@ function buildCodexArgs(options = {}) {
393
403
  : ['exec'];
394
404
 
395
405
  args.push('--json', '--skip-git-repo-check');
396
- if (model) args.push('-m', model);
406
+ if (model && !isCodexAutoModel(model)) args.push('-m', model);
397
407
  // -C (cwd) is only supported on fresh exec, not resume
398
408
  if (cwd && !isResume) args.push('-C', cwd);
399
409
 
@@ -178,9 +178,15 @@ const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
178
178
  * 根据 senderId 解析用户上下文
179
179
  * @param {string|null} senderId 飞书 open_id
180
180
  * @param {object} config daemon 配置(兼容旧 operator_ids)
181
+ * @param {object} [opts]
182
+ * @param {boolean} [opts.fromAllowedChat] 消息来自 allowed_chat_ids 命中的群。
183
+ * 当为 true 且 senderId 未在 users.yaml 显式登记时,直接判为 admin
184
+ * (群级白名单已经做过准入控制,再叠用户级 operator_ids 是冗余)。
185
+ * users.yaml 里显式登记的角色仍然受尊重。
181
186
  * @returns {object} userCtx { senderId, role, name, allowedActions, can(action) }
182
187
  */
183
- function resolveUserCtx(senderId, config) {
188
+ function resolveUserCtx(senderId, config, opts = {}) {
189
+ const fromAllowedChat = !!(opts && opts.fromAllowedChat);
184
190
  const userData = loadUsers();
185
191
  // Per-platform bootstrap: Feishu open_id starts with "ou_", Telegram IDs are numeric.
186
192
  const allUsers = userData && userData.users ? userData.users : {};
@@ -194,6 +200,7 @@ function resolveUserCtx(senderId, config) {
194
200
  const hasConfiguredUsers = hasPlatformAdmin;
195
201
 
196
202
  let role, name, allowedActions;
203
+ let implicitAdmin = false;
197
204
 
198
205
  if (!senderId) {
199
206
  // 无 ID(Telegram 等)— 兼容旧逻辑,视为 admin
@@ -215,6 +222,16 @@ function resolveUserCtx(senderId, config) {
215
222
  } else {
216
223
  allowedActions = [];
217
224
  }
225
+ } else if (fromAllowedChat) {
226
+ // Group-whitelist trust: messages reaching us through allowed_chat_ids
227
+ // already passed chat-level access control. Anyone who can speak in the
228
+ // group is treated as admin without needing operator_ids or yaml entry.
229
+ // (Explicit users.yaml entries above still win — admins can demote
230
+ // someone by writing them in.)
231
+ role = 'admin';
232
+ name = senderId.slice(-6);
233
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
234
+ implicitAdmin = true;
218
235
  } else {
219
236
  // 兼容旧 operator_ids:若 senderId 在 operator_ids 中,视为 admin
220
237
  const operatorIds = (config && config.feishu && config.feishu.operator_ids) || [];
@@ -254,6 +271,7 @@ function resolveUserCtx(senderId, config) {
254
271
  isAdmin: role === 'admin',
255
272
  isMember: role === 'member',
256
273
  isStranger: role === 'stranger',
274
+ implicitAdmin,
257
275
  can(action) { return allowedActions.includes(action); },
258
276
  readOnly: role !== 'admin',
259
277
  };
@@ -182,6 +182,8 @@ function createWeixinBridge(deps = {}) {
182
182
  let currentAccount = null;
183
183
  let currentBot = null;
184
184
  let missingAccountLogged = false;
185
+ let pollErrorDelay = 1000; // exponential backoff on poll errors
186
+ const MAX_POLL_ERROR_DELAY = 30000;
185
187
 
186
188
  function sameAccount(a, b) {
187
189
  if (!a || !b) return false;
@@ -238,6 +240,7 @@ function createWeixinBridge(deps = {}) {
238
240
  getUpdatesBuf,
239
241
  timeoutMs: liveBridgeCfg.pollTimeoutMs,
240
242
  });
243
+ pollErrorDelay = 1000; // reset as soon as HTTP poll succeeds — downstream processMessage errors should not cause poll backoff
241
244
  if (resp && typeof resp.get_updates_buf === 'string' && resp.get_updates_buf) {
242
245
  getUpdatesBuf = resp.get_updates_buf;
243
246
  }
@@ -263,8 +266,9 @@ function createWeixinBridge(deps = {}) {
263
266
  });
264
267
  }
265
268
  } catch (err) {
266
- log('WARN', `[WEIXIN] poll error: ${err.message}`);
267
- nextDelayMs = 1000;
269
+ log('WARN', `[WEIXIN] poll error: ${err.message} — retrying in ${Math.round(pollErrorDelay / 1000)}s`);
270
+ nextDelayMs = pollErrorDelay;
271
+ pollErrorDelay = Math.min(pollErrorDelay * 2, MAX_POLL_ERROR_DELAY);
268
272
  } finally {
269
273
  processing = false;
270
274
  scheduleNext(nextDelayMs);
package/scripts/daemon.js CHANGED
@@ -106,6 +106,9 @@ const SKILL_ROUTES = [
106
106
  { name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
107
107
  { name: 'skill-manager', pattern: /找技能|管理技能|更新技能|安装技能|skill manager|skill scout|(?:find|look for)\s+skills?/i },
108
108
  { name: 'skill-evolution-manager', pattern: /\/evolve\b|复盘一下|记录一下(这个)?经验|保存到\s*skill|skill evolution/i },
109
+ // (?<!转) excludes "转发" forwarding semantics (e.g. "把这条消息转发给我")
110
+ // which is conversation relay, not file delivery.
111
+ { name: 'send-to-user', pattern: /(?<!转)发(?:到|给|出)?\s*(?:我|手机|飞书)|发(?:个|条|份)?\s*(?:文件|附件|图(?:片)?|日志|压缩包|截图|csv|pdf|zip|excel|表格)|(?<!转)发我|给我(?:下载|发个|发份)|send\s+(?:me|file|attachment|to\s+me)|push\s+(?:to\s+)?(?:me|phone|file)|attach\s+file/i },
109
112
  ];
110
113
 
111
114
  function routeSkill(prompt) {
@@ -1959,9 +1962,49 @@ const pendingAgentFlows = new Map();
1959
1962
  const pendingTeamFlows = new Map();
1960
1963
 
1961
1964
  // Pending activation: after creating an agent with skipChatBinding=true,
1962
- // store here so any new unbound group can activate it with /activate
1963
- // { agentKey, agentName, cwd, createdAt }
1964
- const pendingActivations = new Map(); // key: agentKey -> activation record
1965
+ // store here so any new unbound group can activate it with /activate.
1966
+ // { agentKey, agentName, cwd, createdAt, preCreatedChatId?, preCreatedChatName? }
1967
+ //
1968
+ // Persisted to disk so an auto-update / lid-close / SIGUSR2 restart in the
1969
+ // 30-minute activation window doesn't strand a freshly created Feishu chat
1970
+ // that hadn't bound yet (esp. the orphan path: createChat ok + bind fail).
1971
+ // Stale entries (>30min) are dropped on load.
1972
+ const pendingActivations = new Map();
1973
+ const PENDING_ACTIVATIONS_FILE = path.join(HOME, '.metame', 'pending_activations.json');
1974
+ const PENDING_ACTIVATIONS_TTL_MS = 30 * 60 * 1000;
1975
+ function _persistPendingActivations() {
1976
+ try {
1977
+ const obj = Object.fromEntries(pendingActivations);
1978
+ const tmp = PENDING_ACTIVATIONS_FILE + '.tmp';
1979
+ fs.writeFileSync(tmp, JSON.stringify(obj), 'utf8');
1980
+ fs.renameSync(tmp, PENDING_ACTIVATIONS_FILE);
1981
+ } catch { /* best-effort, never throw from persistence */ }
1982
+ }
1983
+ function _restorePendingActivations() {
1984
+ try {
1985
+ if (!fs.existsSync(PENDING_ACTIVATIONS_FILE)) return;
1986
+ const data = JSON.parse(fs.readFileSync(PENDING_ACTIVATIONS_FILE, 'utf8'));
1987
+ const now = Date.now();
1988
+ let restored = 0;
1989
+ for (const [k, v] of Object.entries(data)) {
1990
+ if (v && v.createdAt && now - v.createdAt < PENDING_ACTIVATIONS_TTL_MS) {
1991
+ pendingActivations.set(k, v);
1992
+ restored += 1;
1993
+ }
1994
+ }
1995
+ if (restored > 0) log('INFO', `[pending] restored ${restored} pending activation(s) from disk`);
1996
+ } catch { /* corrupt file → start fresh */ }
1997
+ }
1998
+ // Wrap Map.set/.delete so every mutation hits disk. Wrapping the prototype
1999
+ // methods (rather than reassigning) keeps the Map reference stable for callers
2000
+ // that stored it before the wrap (the daemon does not, but defensive).
2001
+ const _origPendingSet = pendingActivations.set.bind(pendingActivations);
2002
+ const _origPendingDelete = pendingActivations.delete.bind(pendingActivations);
2003
+ const _origPendingClear = pendingActivations.clear.bind(pendingActivations);
2004
+ pendingActivations.set = function (k, v) { const r = _origPendingSet(k, v); _persistPendingActivations(); return r; };
2005
+ pendingActivations.delete = function (k) { const r = _origPendingDelete(k); _persistPendingActivations(); return r; };
2006
+ pendingActivations.clear = function () { const r = _origPendingClear(); _persistPendingActivations(); return r; };
2007
+ _restorePendingActivations();
1965
2008
 
1966
2009
  const { handleAdminCommand } = createAdminCommandHandler({
1967
2010
  fs,
@@ -0,0 +1,461 @@
1
+ # MetaMe Hermes-Style Memory Upgrade: Converged Landing Plan
2
+
3
+ Status: converged proposal for implementation review
4
+ Principle: extend the existing memory pipeline; do not replace it
5
+
6
+ ## 0. Final Architecture Decision
7
+
8
+ Do not build a parallel Hermes clone.
9
+
10
+ MetaMe already has the right primitives:
11
+
12
+ - `memory_items` in `scripts/memory.js` for facts, conventions, profiles, and episodes.
13
+ - FTS5 over `memory_items`.
14
+ - `wiki_pages`, `content_chunks`, and `embedding_queue` from `scripts/memory-wiki-schema.js`.
15
+ - Hybrid wiki retrieval in `scripts/core/hybrid-search.js`.
16
+ - Session skeleton/evidence extraction in `scripts/session-analytics.js`.
17
+ - Atomic fact extraction in `scripts/memory-extract.js`.
18
+ - Per-agent memory snapshot injection in `scripts/agent-layer.js`.
19
+
20
+ The elegant landing is therefore:
21
+
22
+ ```
23
+ raw transcript provenance table
24
+ + existing memory_items(kind='episode') as session note
25
+ + existing memory_items(kind='convention'|'insight') as extracted facts
26
+ + existing wiki_pages as compiled knowledge
27
+ + new recall-router for on-demand prompt injection
28
+ ```
29
+
30
+ Only one new durable table is required in the first landing: `session_sources`.
31
+
32
+ ## 1. Why This Is the Converged Version
33
+
34
+ The previous RFC suggested both `session_episodes` and `session_notes`. After reviewing current source, that is heavier than necessary:
35
+
36
+ - `memory_items.kind='episode'` already represents session summaries and is consumed by `searchSessions()` and wiki export.
37
+ - `wiki-reflect-export.js` already exports session summaries into `wiki/sessions/`.
38
+ - `memory-extract.js` already calls `memory.saveSession()` after processing Claude and Codex sessions.
39
+ - Adding a separate `session_notes` table would duplicate existing `episode` rows and force new export/search paths.
40
+
41
+ So the minimum stable design is:
42
+
43
+ - Add `session_sources` for L0 provenance.
44
+ - Upgrade `saveSession()` content quality and metadata.
45
+ - Add query-time recall routing.
46
+ - Keep all existing public memory APIs backward compatible.
47
+
48
+ ## 2. Target Data Model
49
+
50
+ ### 2.1 New Table: `session_sources`
51
+
52
+ Add in `scripts/memory-wiki-schema.js` or a new schema helper called by `memory.js`.
53
+
54
+ Purpose: immutable-ish provenance index for raw session material.
55
+
56
+ ```sql
57
+ CREATE TABLE IF NOT EXISTS session_sources (
58
+ id TEXT PRIMARY KEY,
59
+ engine TEXT NOT NULL DEFAULT 'unknown',
60
+ session_id TEXT NOT NULL,
61
+ project TEXT DEFAULT '*',
62
+ scope TEXT,
63
+ agent_key TEXT,
64
+ cwd TEXT,
65
+ source_path TEXT,
66
+ source_hash TEXT,
67
+ source_size INTEGER DEFAULT 0,
68
+ first_ts TEXT,
69
+ last_ts TEXT,
70
+ message_count INTEGER DEFAULT 0,
71
+ tool_call_count INTEGER DEFAULT 0,
72
+ tool_error_count INTEGER DEFAULT 0,
73
+ status TEXT DEFAULT 'indexed'
74
+ CHECK (status IN ('indexed','summarized','extracted','error','archived')),
75
+ error_message TEXT,
76
+ created_at TEXT DEFAULT (datetime('now')),
77
+ updated_at TEXT DEFAULT (datetime('now')),
78
+ UNIQUE(engine, session_id, source_hash)
79
+ );
80
+ ```
81
+
82
+ Indexes:
83
+
84
+ ```sql
85
+ CREATE INDEX IF NOT EXISTS idx_session_sources_session ON session_sources(session_id);
86
+ CREATE INDEX IF NOT EXISTS idx_session_sources_project ON session_sources(project, scope, last_ts);
87
+ CREATE INDEX IF NOT EXISTS idx_session_sources_agent ON session_sources(agent_key, last_ts);
88
+ ```
89
+
90
+ ### 2.2 Reuse Existing Table: `memory_items`
91
+
92
+ Do not add `session_notes` table.
93
+
94
+ Use:
95
+
96
+ - `kind='episode'` for L1 session note.
97
+ - `source_type='session' | 'codex'`.
98
+ - `source_id=session_id`.
99
+ - `session_id=session_id`.
100
+ - `project`, `scope`, and `agent_key` as routing metadata.
101
+
102
+ Add only these columns if reviewers agree they are worth it:
103
+
104
+ ```sql
105
+ ALTER TABLE memory_items ADD COLUMN source_hash TEXT;
106
+ ALTER TABLE memory_items ADD COLUMN review_state TEXT DEFAULT 'unreviewed';
107
+ ALTER TABLE memory_items ADD COLUMN valid_from TEXT;
108
+ ALTER TABLE memory_items ADD COLUMN valid_to TEXT;
109
+ ```
110
+
111
+ Strict minimum: `source_hash` is enough for phase 1.
112
+
113
+ ## 3. Implementation Sequence
114
+
115
+ ### PR 1: Provenance Index Only
116
+
117
+ Goal: make every processed Claude/Codex transcript traceable without changing behavior.
118
+
119
+ Files:
120
+
121
+ - Add `scripts/core/session-source-db.js`
122
+ - Update `scripts/memory-wiki-schema.js`
123
+ - Update `scripts/memory.js` DB init if schema is split
124
+ - Add `scripts/core/session-source-db.test.js`
125
+
126
+ `session-source-db.js` API:
127
+
128
+ ```js
129
+ function upsertSessionSource(db, source) {}
130
+ function getSessionSource(db, { engine, sessionId, sourceHash }) {}
131
+ function findSessionSources(db, { project, scope, engine, limit }) {}
132
+ function markSessionSourceStatus(db, id, status, errorMessage) {}
133
+ ```
134
+
135
+ Rules:
136
+
137
+ - The module is pure DB logic.
138
+ - It does not read transcript files.
139
+ - It does not call LLMs.
140
+ - It does not know daemon state.
141
+
142
+ Integration:
143
+
144
+ - In `memory-extract.js`, after `extractSkeleton()` or `buildCodexInput()`, compute `source_hash` from the source file.
145
+ - Upsert `session_sources`.
146
+ - Continue existing fact/session extraction unchanged.
147
+
148
+ Acceptance:
149
+
150
+ - Re-running `memory-extract.js` on the same transcript does not duplicate rows.
151
+ - Existing tests for `memory-extract` and wiki still pass.
152
+ - `memory.searchSessions()` output is unchanged.
153
+
154
+ ### PR 2: Better Episode Notes, Same Storage
155
+
156
+ Goal: make `memory_items.kind='episode'` useful as a session wiki source even when no atomic facts are extracted.
157
+
158
+ Files:
159
+
160
+ - Add `scripts/core/session-note.js`
161
+ - Update `scripts/memory-extract.js`
162
+ - Extend `scripts/memory.js saveSession()`
163
+ - Add `scripts/core/session-note.test.js`
164
+
165
+ `session-note.js` should be pure:
166
+
167
+ ```js
168
+ function buildSessionNote({ skeleton, evidence, sessionName, facts }) {
169
+ return {
170
+ title,
171
+ summary,
172
+ keywords,
173
+ content,
174
+ };
175
+ }
176
+ ```
177
+
178
+ Recommended content format:
179
+
180
+ ```markdown
181
+ ## Task
182
+ <from user snippets / inferred intent>
183
+
184
+ ## Outcome
185
+ <facts or concise execution result>
186
+
187
+ ## Evidence
188
+ - session_id: ...
189
+ - project: ...
190
+ - duration: ...
191
+ - tools: ...
192
+ - errors: ...
193
+ ```
194
+
195
+ Use this content as the `summary` passed to `saveSession()`. Keep the API name for compatibility even if the content becomes richer than a one-line summary.
196
+
197
+ Acceptance:
198
+
199
+ - Sessions with zero extracted facts still produce a useful episode row.
200
+ - `memory-search.js --sessions "<query>"` can find session notes by task words, file paths, and error terms.
201
+ - `wiki-reflect-export.js` continues to export session summaries without a new path.
202
+
203
+ ### PR 3: Scope Resolver
204
+
205
+ Goal: stop scattering project/scope/agent inference.
206
+
207
+ Files:
208
+
209
+ - Add `scripts/core/memory-scope.js`
210
+ - Use it in `memory-extract.js`, `agent-layer.js`, later `daemon-claude-engine.js`
211
+ - Add `scripts/core/memory-scope.test.js`
212
+
213
+ API:
214
+
215
+ ```js
216
+ function normalizeMemoryScope(input) {
217
+ return {
218
+ project,
219
+ scope,
220
+ agentKey,
221
+ sessionId,
222
+ engine,
223
+ cwd,
224
+ };
225
+ }
226
+ ```
227
+
228
+ Rules:
229
+
230
+ - It is pure.
231
+ - It accepts partial inputs.
232
+ - It never reads `daemon.yaml`.
233
+ - Callers that need daemon config resolve project before calling this helper.
234
+
235
+ Acceptance:
236
+
237
+ - Claude and Codex extraction produce consistent `project/scope/engine`.
238
+ - Existing `project_id` behavior remains compatible.
239
+
240
+ ### PR 4: Recall Router in Observe-Only Mode
241
+
242
+ Goal: decide when historical memory should be retrieved, but do not inject yet.
243
+
244
+ Files:
245
+
246
+ - Add `scripts/core/recall-router.js`
247
+ - Add `scripts/core/recall-router.test.js`
248
+ - Optionally log decisions from `daemon-claude-engine.js` without changing prompt
249
+
250
+ API:
251
+
252
+ ```js
253
+ function planRecall({ text, scope, hasActiveSession }) {
254
+ return {
255
+ shouldRecall,
256
+ reason,
257
+ queries,
258
+ modes,
259
+ maxChars,
260
+ };
261
+ }
262
+ ```
263
+
264
+ Initial trigger rules:
265
+
266
+ - history words: `之前`, `上次`, `以前`, `记得`, `回忆`, `查历史`, `有没有踩过坑`
267
+ - decision words: `为什么这么定`, `当时怎么决定`, `以前怎么处理`
268
+ - bug recurrence: `又`, `再次`, `之前的 bug`, `同样的问题`
269
+ - exact identifiers: file path, function name, config key, error code
270
+
271
+ Initial non-triggers:
272
+
273
+ - pure commands: `/status`, `/tasks`, `/agent`
274
+ - obvious one-shot shell/math/date requests
275
+ - messages shorter than 4 chars unless they are explicit history commands
276
+
277
+ Acceptance:
278
+
279
+ - Router is deterministic.
280
+ - Unit tests cover Chinese triggers.
281
+ - No prompt behavior changes in this PR.
282
+
283
+ ### PR 5: Recall Assembly API
284
+
285
+ Goal: centralize query-conditioned recall.
286
+
287
+ Files:
288
+
289
+ - Extend `scripts/memory.js`
290
+ - Possibly add `scripts/core/recall-format.js`
291
+ - Add tests in `scripts/memory-wiki-integration.test.js` or new `scripts/memory-recall.test.js`
292
+
293
+ API:
294
+
295
+ ```js
296
+ async function assembleRecallContext({
297
+ query,
298
+ scope,
299
+ modes = ['facts', 'sessions', 'wiki'],
300
+ maxChars = 4000,
301
+ ftsOnly = false,
302
+ }) {}
303
+ ```
304
+
305
+ Output:
306
+
307
+ ```js
308
+ {
309
+ text: "...bounded markdown block...",
310
+ sources: [
311
+ { type: 'fact', id: '...' },
312
+ { type: 'session', session_id: '...' },
313
+ { type: 'wiki', slug: '...' }
314
+ ],
315
+ truncated: false
316
+ }
317
+ ```
318
+
319
+ Rules:
320
+
321
+ - Facts: active facts only by default.
322
+ - Sessions: use `searchSessions()`.
323
+ - Wiki: use existing `hybridSearchWiki()`.
324
+ - Include provenance labels.
325
+ - Never include raw transcripts.
326
+ - Hard cap by characters.
327
+
328
+ Acceptance:
329
+
330
+ - Empty search returns empty text.
331
+ - Relevance is stable under missing embeddings.
332
+ - Works with FTS-only fallback.
333
+
334
+ ### PR 6: Prompt Injection Behind Feature Flag
335
+
336
+ Goal: add Hermes-style on-demand memory without destabilizing normal chats.
337
+
338
+ Files:
339
+
340
+ - `scripts/daemon-claude-engine.js`
341
+ - `scripts/daemon-prompt-context.js`
342
+ - `scripts/daemon-prompt-context.test.js`
343
+ - Maybe `scripts/daemon-claude-engine.test.js`
344
+
345
+ Behavior:
346
+
347
+ - Existing `memory-snapshot.md` injection remains unchanged.
348
+ - Before composing the final prompt, call `planRecall()`.
349
+ - If enabled and `shouldRecall`, call `memory.assembleRecallContext()`.
350
+ - Inject as:
351
+
352
+ ```text
353
+ [Relevant memory recall:
354
+ ...
355
+ ]
356
+ ```
357
+
358
+ Feature flag:
359
+
360
+ Do not require config in PR 1-5.
361
+
362
+ For PR 6, read optional runtime config from `~/.metame/daemon.yaml`:
363
+
364
+ ```yaml
365
+ daemon:
366
+ memory_recall_enabled: false
367
+ memory_recall_max_chars: 4000
368
+ ```
369
+
370
+ Template changes in `scripts/daemon-default.yaml` are optional and must contain placeholders only.
371
+
372
+ Acceptance:
373
+
374
+ - Default behavior is unchanged because flag is false.
375
+ - With flag true, recall block appears only for router-triggered messages.
376
+ - No daemon prompt exceeds configured recall cap.
377
+ - If DB/search fails, prompt proceeds without recall.
378
+
379
+ ## 4. What Not To Do
380
+
381
+ Do not:
382
+
383
+ - Create a second memory DB.
384
+ - Create `session_notes` table in the first landing.
385
+ - Copy full transcripts into `memory.db`.
386
+ - Inject recent sessions into every prompt.
387
+ - Let subagents write verified/global memory directly.
388
+ - Change `plugin/scripts/` or deployed `~/.metame/` files directly.
389
+ - Edit real user config in `~/.metame/daemon.yaml` as part of source changes.
390
+
391
+ ## 5. Review-Critical Invariants
392
+
393
+ Experts should hold the implementation to these invariants:
394
+
395
+ 1. L0 provenance is immutable enough.
396
+ A raw transcript pointer + hash must survive all derived memory changes.
397
+
398
+ 2. Derived memory is rebuildable.
399
+ Episode notes and facts can be regenerated from source material.
400
+
401
+ 3. Recall is query-conditioned.
402
+ No broad "always inject history" behavior.
403
+
404
+ 4. Prompt cache is mostly preserved.
405
+ Normal messages should not receive dynamic recall blocks.
406
+
407
+ 5. Existing APIs stay compatible.
408
+ `saveFacts`, `saveSession`, `searchFacts`, `searchSessions`, `recentSessions`, and `hybridSearchWiki` keep working.
409
+
410
+ 6. Scope is explicit.
411
+ Project/agent/thread memory does not bleed into unrelated sessions.
412
+
413
+ 7. Failure is soft.
414
+ Memory extraction/search failures never block the user-facing task.
415
+
416
+ ## 6. Minimal Test Plan
417
+
418
+ Run after PR 1-3:
419
+
420
+ ```bash
421
+ node --test scripts/core/session-source-db.test.js
422
+ node --test scripts/core/session-note.test.js
423
+ node --test scripts/memory-wiki-integration.test.js
424
+ node --test scripts/memory-extract-step4.test.js
425
+ ```
426
+
427
+ Run after PR 4-5:
428
+
429
+ ```bash
430
+ node --test scripts/core/recall-router.test.js
431
+ node --test scripts/memory-wiki-integration.test.js
432
+ node --test scripts/wiki-reflect-export.test.js
433
+ ```
434
+
435
+ Run after PR 6:
436
+
437
+ ```bash
438
+ npx eslint scripts/daemon*.js
439
+ node --test scripts/daemon-prompt-context.test.js
440
+ node --test scripts/daemon-claude-engine.test.js
441
+ node --test scripts/daemon-*.test.js
442
+ ```
443
+
444
+ ## 7. First Implementation Patch Shape
445
+
446
+ The first implementation patch should be this small:
447
+
448
+ 1. Add `session_sources` DDL.
449
+ 2. Add `scripts/core/session-source-db.js`.
450
+ 3. Add `scripts/core/session-source-db.test.js`.
451
+ 4. Call `upsertSessionSource()` from `memory-extract.js` for Claude and Codex paths.
452
+ 5. Do not add prompt recall.
453
+ 6. Do not add config.
454
+ 7. Do not alter wiki export behavior.
455
+
456
+ Expected review outcome:
457
+
458
+ - Easy to verify.
459
+ - Easy to roll back.
460
+ - Gives future PRs a provenance anchor.
461
+