Skip to content

feat(memory): L3 field cognition, retry failed L3 batches (MEMMY-852), feed scanned sessions into L3 - #739

Open
Anson-Se wants to merge 11 commits into
MemTensor:v1.2.7from
Anson-Se:gh-590-scan-l3
Open

Anson-Se wants to merge 11 commits into
MemTensor:v1.2.7from
Anson-Se:gh-590-scan-l3

Conversation

@Anson-Se

@Anson-Se Anson-Se commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

做了什么

基于 v1.2.7(之前基于 v1.2.6,已整体搬到 1.2.7,代码不依赖 v1.2.6 独有的东西)。2026-10-30 已 rebase 到 v1.2.7 最新(03f915d6)。上游这期间重写了后台任务队列(#734:租约列、公平调度、统一的领取条件、失败退避和配置阻塞),并把 backend 的 Agent 来源扫描挪进了 Memory(MEMMY-533)。解决冲突时上游改动全部保留,只把本 PR 的 L3 接到上游的新机制上:

  • 租约:去掉本 PR 原来放在 payload 里的令牌,改用上游的 lease_token 和 isJobLeaseHeld;
  • 领取:L3 的同 scope 依次执行、等 L1 摘要写完、新 L3 关闭或没配模型时不领取,都并进上游的 mainQueueEligibility,下次唤醒时间用同一套规则;公平调度里登记了 l3_unit_update 的依赖等待,并注册了它的任务策略;
  • 失败:L3 的重试间隔和死信按上游的 failure_count 计数;key 或地址填错交给上游的配置阻塞(改好配置后立即恢复),配额用尽(40309)和断网仍是 30 分钟后再试、不算失败次数;
  • 扫描:backend 的扫描已被上游删除,扫完交给 L3 由 Memory 侧完成,托管导入不变。

另外单独加了一个提交,修上游 bundle 导入的问题:导入的任务带着源库的 enqueue_seq,目标库已有任务时撞唯一约束导致整个导入失败,"替换"方式下还可能删掉本库占着同一编号的任务。现在导入的任务在目标库里重新编号。

这个 PR 包含三件互相依赖的事,按提交顺序:

1. L3 场域认知改造(第 2~8 个提交)

  • 把 L3 从“每个项目四段整段重写的文字”改成按“本机 / 项目”分开存的短条目,只有两类:
    • 环境事实(env_fact):“条件 -> 现象”,必须有工具输出作证据;
    • 项目规矩(project_constraint):用户希望以后在这个项目里一直遵守的做法。
  • 判断标准是“换一个人在这台电脑或这个项目上工作,这条仍然成立”。
  • 以下内容不记:
    • 个人偏好、单次任务的细节;
    • 工作产出的结论、读代码得到的知识;
    • 通用常识、临时状态;
    • Agent 自身的沙箱和工具限制。
  • 条目支持新增、替换、删除、确认:新规矩会替换旧规矩,问题修好后对应条目会失效。较旧的证据不能替换或删除由较新证据写入、确认的条目;项目被重置(删除)前说过的话,之后也不会再写回去。
  • 任务以任何方式关闭(会话关闭、新任务、用户结束话题、空闲超时)时,都会把这个任务的轮次交给 L3,只到这个任务为止,不截断还在进行的任务。
  • 每一轮捕获成功后都有一个持久的负责任务,不会丢:复用上游已有的 work_memory_idle_flush(每个会话一个去重任务,每轮重新排期,会话安静 2 小时后到期),在捕获的同一个事务里排上;到期时先把这个会话还没交给 L3 的轮次冻结,再做原来的 Work Memory。所以对话直接停掉、从没发过会话关闭,最后几轮也能进 L3。
  • Memory 启动时、导入备份后、运行中把开关从关打开时,都会把还有轮次没交给 L3 的会话补上:开着的会话重新排期,已关闭的会话直接冻结。
  • 批次只写入一次:L3 批次任务和空闲任务领取时带领取令牌,租约过期后被别人领走、或任务被重新排期时,旧的那次执行既不写入结果,也不改任务状态。这一条顺带修掉了上游一个漏洞:空闲任务到期时会话还没安静够、自己重新排期后,会被收尾标成已完成,下一次永远不跑。
  • 写入前再查一次证据:引用的原始轮次或 L1 在等模型期间被删除、脱敏的,对应操作丢弃。补跑较早的批次时,模型会看到"较新的证据删掉了哪些条目、在什么时间",不能凭更早的证据写回去。
  • 环境画像由代码扫描生成,模型只读不写。同一个项目 10 分钟内只扫描一次;项目没变化(指纹不含本机运行时版本)时不调模型。
  • 项目识别:先看用户在桌面端选过的项目,再看 git 仓库,最后看普通文件夹。临时目录、系统和工具目录、依赖包目录不算项目。
  • 有开关 algorithm.l3FieldCognition.enabled(环境变量 MEMMY_L3_FIELD_COGNITION),默认打开,只在本地 SQLite 上生效,云端模式不启用。关闭时走原来的 L3,行为不变。
  • 不改 SCHEMA_VERSION(仍为 9),只新增 4 张表,旧数据不转写,旧版 Memmy 仍能打开这个库。

2. MEMMY-852 失败批次补跑(第 9 个提交)

  • 临时失败(空响应、超时、限流、连接中途断开)分别等 1、5、15、30、60 分钟再重试,第 5 次重试还失败就进死信。其他失败(如模型连续两次给出不合格的回答)大概率会重复,只再试 1 次就进死信。L3 任务不会停在 failed 状态挡住后面的批次直到重启,后面的批次照常处理。
  • 额度不足(40309)和完全连不上模型(断网)先挂起,不消耗重试次数,每 30 分钟再试一次,恢复后自动继续;key 或地址填错交给队列的配置阻塞,改好配置后立即恢复;没配进化模型时不领取 L3 任务。
  • 进了死信的新 L3 批次,在 Memory 下次启动时自动重跑一次(每个批次只自动一次);另有补跑入口 POST /api/v1/l3/failed-batches/retry,在原任务上复位后重跑。后面批次的租约已经过期时,不会挡住重跑的前序批次。
  • worker 计算下次唤醒时间和领取任务用同一套条件(同一机器按顺序、L1 摘要已完成),被前序批次挡住的任务不会让 worker 空转。
  • 全部接在现有的 evolution_jobs 队列上(用 runAfter/available_at、failure_count/max_attempts、租约 CAS 和配置阻塞这些现有字段和机制),没有新建队列、定时器或重试表。

3. #590 扫描会话进 L3(第 10 个提交)

  • 只在新 L3 开关打开时生效:扫描新开的会话走新协议,并关闭 Work Memory。开关关闭时扫描和以前一样不进 L3(包括旧 L3)。
  • 每个来源一轮扫描处理完后,把这一轮碰到的会话交给 L3 处理,不等模型跑完,所以不会拖慢扫描。
  • 1.2.7 的桌面端扫描由 backend 执行(agent-source-service 的持久化扫描),每个来源处理完(包括中途取消)后同样把碰到的会话交给 L3;交接请求带完整信封和来源,Memory 按打开这些会话时的同一规则确认归属;交接失败只打一条 warn,不影响扫描。
  • 托管导入路径(backend ingestion-service)也走同样的处理。
  • 先扫描、后来 Hook 又接管同一会话时,由 Hook 接手。
  • 扫描结束时的交接只是快速通道;交接失败、进程在交接前退出时,靠上面的空闲任务和启动补登记兜底,不会丢。

Closes #590

改动范围

提交 内容
number imported queue rows in the target database 修上游 bundle 导入的任务编号冲突(与 L3 无关,单独提交)
add l3 field cognition tables and flag 新表、开关、仓库层、快照记忆
resolve l3 projects from paths 从路径识别项目
accept user-selected project roots for l3 桌面端把用户选过的项目推给 Memory
build l3 evidence fragments with unit candidates 证据片段及其归属
profile machine and project units for l3 本机画像、项目画像
inject and display l3 field context 会话开头注入 L3、面板显示
update l3 field items from frozen batches 更新流水线、提示词、输入裁剪与上限(发生截断时打 debug 日志 input.truncated)
retry and resume failed l3 jobs through the job queue MEMMY-852
feed scanned sessions into l3 #590(Memory 侧扫描、托管导入)
turn the new l3 on by default 新 L3 默认打开;测旧 L3 的测试显式关掉新 L3

共 75 个文件,约 7000 行,其中测试约 3000 行。

怎么验证的

  • 测试(在 rebase 后的 v1.2.7(03f915d6)+ 本 PR 上跑,并在纯上游上跑同样的测试逐条对照):
    • Memory、viewer、App/backend、App/memmy-agent typecheck 都通过;11 个提交逐个单独 typecheck 也都通过。
    • Memory 全量 2805 个通过,29 个失败:其中 23 个纯上游同样失败(多为 worker 调度、扫描响应这类计时测试,并发满负载时超时);其余 6 个所在文件单独运行全部通过。L3、导入、bundle 相关测试全部通过。
    • App/backend 全量 1291 个通过;失败的 3 个与本 PR 无关:plugin 路由 1 个是本机环境导致;Office 插件端到端 2 个需要仓库里没有的发布包,纯上游同样失败。
    • App/memmy-agent 的 memmy-memory、core、entrypoints 测试 3582 个全部通过。
  • 离线回放对比:同样 27 个会话(16 个真实会话 + 11 个测试会话),新旧两版都从空 L3 开始,同一个模型,各跑 3 次;新版用的是本 PR 在 1.2.7 上的代码。存下的条目混在一起交给独立评审盲评,评审不知道条目来自哪个版本。
v1.2.6 本 PR
每次存下的条目 72~94 10~14
其中该存的占比 37% 95%
噪声(一次性、常识、临时状态、Agent 自身限制) 35~50 条 0~2 条
放错地方 6 条 0
模型输入 token 约 445 万 约 52 万
模型调用次数 67~68 47~48
  • 输入 token 少的原因:旧版把整段原始对话原样发给模型,其中工具调用和工具输出占 99%;而且项目契约、领域知识两个字段各发一遍。新版每组只调用一次模型,工具输出只留头尾和报错行,输入上限 65536 字,超出时先丢工具输出。
  • 真机测试(1.2.7 + 本 PR):在本机用 Memmy CLI 和 Codex 跑过多个场景,包括规矩替换、问题修好后失效、临时失败和个人偏好不记、大文件夹里改子项目,结果都符合预期。真机测试中发现大文件夹会抢走子项目的规矩(相对路径没识别、同时碰到大文件夹和子项目时两边都算),已修复并加测试。
  • 本次 rebase 后的审核:GPT-6 Astra(max)只读审核了接入上游新队列的部分,提出 4 个问题,均已修复并并回原提交:死信批次重跑时被后面租约已过期的批次挡住;key 填错时绕过了上游的配置阻塞恢复;公平调度没登记 L3 批次的依赖等待;一个测试比它测的功能早两个提交。前两个加了测试,并确认去掉修复后测试会失败。
  • 默认打开新 L3 后的审核:GPT-6 Astra(max)只读审核了默认打开带来的影响,据此修了:新 L3 打开时暂停已排队的旧 L3 任务(含旧项目画像),不再继续调模型;记忆写入关闭时读取新 L3 上下文不再建单元、写快照,热打开开关也不补冻历史;服务停止后返回的本机探测结果不再写库;测试默认不再在真机上执行探测。都加了测试并确认去掉修复后测试会失败。
  • Codex 审核 4 轮:前 3 轮提的问题都已修复。第 4 轮提出 1 个阻塞问题(模型上下文较小时会进死信),已修复:输出预算降到 16384,超出上下文时缩小输入再试一次。
  • 按上线要求复查(稳定不丢、复用现有队列、不浪费 token、原有功能不变):Fable 5.1 和 GPT-6 Astra 各自独立审了 7 轮,每轮确认成立的问题都修了、加了测试,并确认去掉修复后测试会失败;修复都并回了各自原来的提交。主要修了:
    • 捕获成功的轮次可能没人负责(扫描交接失败、对话直接停掉、热开启开关)→ 上面的空闲任务和补登记;
    • 慢调用超过租约后旧结果写回、或把重新排期的任务标成完成 → 领取令牌;
    • 归档机器快照后客户端仍注入旧内容、旧证据覆盖新条目、删掉的条目被晚到的旧对话写回;
    • 导入备份时:保留了本库单元而被丢掉的未完成批次,对应轮次要在本库重新冻结;同一备份导两次不重复冻结;"替换"导入换掉了某个位置上的轮次时,按轮次本身(L1 和原始轮次 id)判断有没有处理过,而不只看序号;
    • worker 空转、项目画像重复调模型、确定性失败重试过多。
      上面回放里替换/删除操作的证据时间都是递增的,这些保护在回放中不会触发,回放结果不受影响。

已知限制

  • 面板里旧 L3 和新 L3 会同时显示;新 L3 开启时旧 L3 的数据保留但不再注入,旧的更新任务暂停排队,关闭新 L3 后恢复。
  • 默认打开后,升级时之前已采集但还没交给 L3 的会话会被补处理(未关闭的会话在空闲后处理,已关闭的直接冻结),没有单独上限,只受全局 token 预算约束。
  • 记忆写入关闭时,桌面端推送的项目列表仍会保存(属于用户设置,不是记忆内容)。
  • 模型判断有运行间波动:3 次回放里各有 1 次把“没装 tomli_w”“npm 不在默认 PATH”记成了事实,1 次没把 memmy-agent 的“审核只读、不读密钥”当成项目规矩。
  • 补跑入口还没接前端;自动重跑后再次进死信的批次,只能通过补跑入口重跑。
  • memmy-agent 每 60 秒读一次桌面端项目列表,有变化才推给 Memory(列表文件由多个进程共用,没有变更通知)。
  • 条目偶尔会比提示词要求的长;少量只和某个工具相关的事实会被放到“所有地方”。
  • 新 L3 的输入来自 L1:feat(memory): 基于模型判断 L1 提取并复用同 Session 历史 Episode #681 之后由模型判断不记 L1 的轮次(寒暄、只查外部实时信息等)不会进入新 L3。
  • 导入备份时,被跳过批次后面已经处理过的轮次会再处理一次(多花 token,不丢)。
  • 两种很少见的导入组合仍可能让某一轮进不了新 L3:
    • 把脱敏导出(不含原文)的备份,用默认"跳过"方式导回同一个库:导出时游标被推到最后一轮(上游原有行为,旧 L3 也一样),本库还没冻结的轮次会被越过;
    • 先删除或脱敏某一轮的原文、批次处理时跳过了它,之后又用"替换"导入完整备份把原文恢复:这一轮会被当成已经处理过。

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@Anson-Se

Copy link
Copy Markdown
Contributor Author

@origami-chen

@Anson-Se
Anson-Se changed the base branch from v1.2.6 to 1.2.7 October 26, 2026 12:58
@Anson-Se
Anson-Se force-pushed the gh-590-scan-l3 branch 3 times, most recently from 08ec0d4 to 3a88415 Compare October 27, 2026 07:33
@Memtensor-AI

Memtensor-AI commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #739
Task: 90871ad354fc4b32
Base: v1.2.7
Head: gh-590-scan-l3

🔍 OpenCodeReview found 70 issue(s) in this PR.

⚠️ Review warnings (22)

  • semantic groups split by file count and complete diff byte size; all selected files remain in coverage
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • tool output exceeded the byte/line budget; use narrower file ranges or paginated searches; omitted output is not reviewed
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • tool output exceeded the byte/line budget; use narrower file ranges or paginated searches; omitted output is not reviewed
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • … 12 additional warning(s) omitted.

1. App/backend/src/adapters/outbound/memory-client/http-memory-client.ts (L213)

The source here is used by the server as the Session-ownership key for scanned Sessions (session.source === namespace.source). The turns for these Sessions are stored with ctx.memorySource ?? ctx.sourceId (see ingestion-service's memorySource), while the sole caller passes ctx.sourceId. When a source defines a distinct memorySource, source will not match the stored Session source and the server silently returns batches: 0 (no error), so L3 freezing never happens for that source. Consider letting the caller pass the same source used when the turns were stored, or documenting the required invariant next to this method.


2. App/backend/src/adapters/outbound/memory-client/http-memory-client.ts (L214)

L3ScanSessionsFreezeRequestSchema caps sessionIds at 10,000, but an import accumulates one id per touched Session with no upper bound, so a very large import sends an over-limit body and the server answers invalid_argument. Callers swallow that into a warning, so freezing is skipped entirely with no visible failure. Consider chunking the ids (e.g. slices of a few thousand) and summing batches here so the cap is never hit.


3. Memory/src/contracts/memory-l3-world-model.ts (L86-L87)

This absolute-path check rejects Windows UNC roots (\\server\share\project). The desktop registry accepts them: canonicalDirectory only requires path.isAbsolute, which is true for UNC paths on Windows, and realpathSync keeps the UNC form. Since L3UserProjectSync swallows push errors and retries on a timer, a single UNC project in the user's list makes every push fail with 400 and the whole list (including the valid folders) never reaches Memory, silently. Accept the UNC form as well (the App copy at App/backend/local-api-contracts/src/memory-l3-world-model.ts must get the same change — a test enforces byte-identical files).

💡 Suggested Change

Before:

const AbsolutePathSchema = z.string().min(1).max(4096)
  .refine((value) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value), "path must be absolute");

After:

const AbsolutePathSchema = z.string().min(1).max(4096)
  .refine(
    (value) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value),
    "path must be absolute"
  );

4. App/backend/local-api-contracts/src/memory-l3-world-model.ts (L121)

The 10,000 cap is a hard rejection and the only caller does not respect it: IngestionService passes every distinct session id of an import in one freezeScanSessions call and swallows the failure (.catch(... console.warn)), so an import touching more than 10k Sessions gets a 400 and none of those Sessions are ever frozen into L3 — with no visible error. Either drop/raise the cap, or make the ingestion caller chunk sessionIds into batches; if the cap stays, it should be documented where callers build the request. Same applies to the Memory copy of this file.


5. App/backend/src/services/ingestion-service.ts (L139-L141)

All touched session IDs are sent in a single request, but the server schema caps sessionIds at 10,000 (L3ScanSessionsFreezeRequestSchema). A full-history import that touches more Sessions than that is rejected with invalid_argument on every attempt, so the freeze silently never happens (only a console.warn) and L3 stays stale forever. Chunk the list (e.g. slices of a few thousand) and sum/ignore the per-chunk results.


6. App/memmy-agent/src/memmy-memory/l3-user-project-sync.ts (L78-L81)

Both failure paths (readProjects() throwing / corrupt registry, and the push failing) return false with no diagnostic at all, and syncNow()'s callers in start()/setInterval ignore the result. A persistent failure (endpoint missing on an older Memory build, namespace/payload validation error, UNC path rejected by AbsolutePathSchema) leaves project recognition permanently broken with nothing in the logs. Please log at least once per distinct failure, consistent with the rest of this module (e.g. console.warn("[memmy-memory] l3.user_projects.sync_failed", error)), and consider rate-limiting the log by key.


7. App/memmy-agent/src/memmy-memory/l3-user-project-sync.ts (L63-L64)

The doc comment on syncNow() promises it "never throws", but only readProjects() and the HTTP push are guarded: standaloneRoots() (which resolves config/env paths) and the mapping run outside the try. If either throws, the returned promise rejects and void this.syncNow() in start()/the interval becomes an unhandled rejection, which can crash the long-running gateway under Node's default --unhandled-rejections=throw. Move these two lines inside a try (or wrap pushIfChanged() in try/catch inside syncNow) so the documented contract actually holds.


8. App/memmy-agent/src/memmy-memory/register.ts (L75-L76)

This envelope duplicates the literals that MemmyMemoryHook owns (ADAPTER_ID = "memmy-agent", PROFILE_ID = "default", SOURCE in hook.ts) and also drops profileLabel, which the hook always sends in its L3 envelopes. If those constants ever change (or a caller passes adapterId/profileId in MemmyMemoryInstallOptions, which this path ignores), the user-project list is written under a different namespace than every other L3 request and project recognition breaks silently. Export/reuse the hook's constants (or a shared envelope builder) instead of copying the strings.


9. App/memmy-agent/src/entrypoints/cli/commands.ts (L1164)

startL3UserProjectSync is declared as a required method on AgentLoop (loop.ts:1299), so the optional call is redundant here and would hide a future rename behind a silent no-op instead of a compile error. Only MemmyMemoryIntegration.startL3UserProjectSync needs the optional guard, and that is already handled inside the loop method.


10. App/backend/src/tests/support/mock-memory-client.ts (L144-L147)

The mock silently drops both arguments and always reports batches: 0. Any test that uses the shared mock therefore cannot assert which source/sessionIds were frozen, and code paths that branch on batches > 0 (e.g. scheduling work after a freeze) stay unexercised. Consider recording the calls and allowing the batch count to be configured/derived from the input, mirroring how other mock methods take their input.

💡 Suggested Change

Before:

    async freezeScanSessions() {
      failIfNeeded();
      return { batches: 0 };
    },

After:

    async freezeScanSessions(source, sessionIds) {
      failIfNeeded();
      frozenScanSessions.push({ source, sessionIds: [...sessionIds] });
      return { batches: sessionIds.length > 0 ? 1 : 0 };
    },

11. App/backend/src/adapters/outbound/memory-client/types.ts (L88)

The inline return type duplicates the wire contract; the implementation actually resolves L3ScanSessionsFreezeResponse (which also carries serverTime). Reusing the contract type keeps the port and the HTTP adapter in sync if the response shape grows, instead of silently hiding new fields behind a narrower structural type.

💡 Suggested Change

Before:

  freezeScanSessions(source: string, sessionIds: string[], context?: MemoryRequestContext): Promise<{ batches: number }>;

After:

  freezeScanSessions(source: string, sessionIds: string[], context?: MemoryRequestContext): Promise<L3ScanSessionsFreezeResponse>;

12. Memory/src/contracts/memory-l3-world-model.ts (L96-L98)

standaloneRoots is declared with .default([]) and the exported request type is z.input<...>, so the field is optional for callers. But the endpoint has replace semantics (replaceUserProjects writes the list and setStandaloneRoots(userId, request.standaloneRoots ?? [], at) overwrites the stored roots), so a caller that omits the field — which the type actively permits — silently wipes the user's standalone roots, and those roots are what resolveProject uses to exclude Memmy task folders from becoming projects. Since the type is the public contract for callers such as L3UserProjectSync, consider making the full list mandatory (drop .default([]) and derive the type with z.infer, like the other request types in this file) so an incomplete push fails loudly instead of clearing state. The same change is needed in the App-side copy, which must stay byte-identical.

💡 Suggested Change

Before:

  standaloneRoots: z.array(AbsolutePathSchema).max(20).default([])
}).strict().superRefine(assertEnvelopeSourceConsistency);
export type L3UserProjectsReplaceRequest = z.input<typeof L3UserProjectsReplaceRequestSchema>;

After:

  standaloneRoots: z.array(AbsolutePathSchema).max(20)
}).strict().superRefine(assertEnvelopeSourceConsistency);
export type L3UserProjectsReplaceRequest = z.infer<typeof L3UserProjectsReplaceRequestSchema>;

13. Memory/src/service/l3-field/session-units.ts (L28-L31)

isLocalWorkspaceUri calls new URL(workspaceUri) and throws TypeError for a non-URI string, but it is called outside the try (only fileURLToPath is guarded). meta.workspace_uri comes from free-form SessionRecord.meta (persisted JSON, potentially written by legacy/other paths), so a malformed value escapes sessionWorkingDirectory and fails resolveSessionUnits/findSessionUnits callers such as session start and memory add. Move the locality check inside the try.

💡 Suggested Change

Before:

  if (uri) {
    if (!isLocalWorkspaceUri(uri)) return { cwd: null, isLocal: false };
    try {
      return { cwd: fileURLToPath(uri), isLocal: true };

After:

  if (uri) {
    try {
      if (!isLocalWorkspaceUri(uri)) return { cwd: null, isLocal: false };
      return { cwd: fileURLToPath(uri), isLocal: true };

14. Memory/src/service/l3-field/session-units.ts (L83)

resolveSessionUnits and findSessionUnits duplicate the whole "working directory → resolverContextFor → resolveProject" pipeline (and the ResolveProjectInput literal with sessionProjectRoot: null). Any future change to the resolution inputs must be applied in both places or the read and write paths will resolve different projects. Consider extracting one helper, e.g. resolveSessionProject(repos, env, session, { create }), that both functions share.


15. Memory/src/service/l3-field/session-units.ts (L50)

The fallback standalone root hardcodes the folder name Memmy, duplicating the agent-side task-workspace location (getStandaloneTaskWorkspacesDir() in App/memmy-agent/src/config/paths.ts). If that location ever changes, the Memory service silently keeps treating a different folder as a throwaway root (and stops excluding the real one). Extract it to a named constant (or a shared config/contract value) with a comment explaining why the fallback exists.


16. Memory/src/service/l3-field/evidence-fragments.ts (L78)

A call that names no paths immediately falls back to sessionUnits(), and sessionUnits() never records its root in rootOf. So in a turn that also touched a nested project, touched keeps both the nested project and the broad session folder (mostSpecificUnits cannot drop the latter because its root is missing). The user's words and the no-path call are then fed to the broad folder's unit as well, duplicating facts at the wrong scope - exactly what the function docstring promises not to do. Apply the fallback only after touched is known (make unitsForPaths([]) return []), and add a test mixing a no-path call (e.g. WebSearch) with a project call.

💡 Suggested Change

Before:

  const touched = mostSpecificUnits([...new Set(calls.flatMap((call) => call.candidateUnitKeys))], rootOf);

After:

  const touched = mostSpecificUnits([...new Set(calls.flatMap((call) => call.candidateUnitKeys))], rootOf);
  const sessionFallback = touched.length > 0 ? touched : sessionUnits();
  for (const call of calls) {
    if (call.candidateUnitKeys.length === 0) call.candidateUnitKeys = sessionFallback;
  }

17. Memory/src/service/l3-field/git-identity.ts (L59)

decodeURIComponent throws URIError on a pathname containing a malformed percent escape (e.g. a remote URL with a literal %). normalizeRemoteUrl is reached from readGitIdentity -> resolveProject, which runs during session-context requests and L3 worker jobs, so a single bad git remote becomes an uncaught exception. Decode defensively (try/catch or a safe helper). While here, note that lowercasing cleanedPath merges paths that are distinct on case-sensitive hosts.

💡 Suggested Change

Before:

    repoPath = decodeURIComponent(parsed.pathname);

After:

    try {
      repoPath = decodeURIComponent(parsed.pathname);
    } catch {
      repoPath = parsed.pathname;
    }

18. Memory/src/service/l3-field/evidence-fragments.ts (L48)

Every path calls input.resolve, which runs lenientRealpath, several stat/realpathSync calls, isThrowawayLocation, and walks up the tree reading .git/config in readGitIdentity. A patch touching dozens of files in one repository therefore repeats the same filesystem/git work dozens of times per turn, inside a worker job. Memoize resolution per directory (or per target) for the lifetime of buildEvidenceFragments.

💡 Suggested Change

Before:

      const project = input.resolve(target, "tool_path");

After:

      const project = input.resolve(target, "tool_path"); // memoize by target/dir here

19. Memory/src/service/l3-field/evidence-fragments.ts (L151)

Nested ternary (string vs array vs null) is disallowed by the project rules and hard to read. Use if/else or a small helper.

💡 Suggested Change

Before:

    const command = typeof raw === "string" ? raw : Array.isArray(raw) ? raw.filter((part) => typeof part === "string").join(" ") : null;

After:

    let command: string | null = null;
    if (typeof raw === "string") command = raw;
    else if (Array.isArray(raw)) command = raw.filter((part) => typeof part === "string").join(" ");

20. Memory/src/service/l3-field/evidence-fragments.ts (L384)

Nested ternary here too; flatten it into explicit branches.

💡 Suggested Change

Before:

  const text = typeof output === "string" ? output : output === undefined || output === null ? null : JSON.stringify(output);

After:

  let text: string | null;
  if (typeof output === "string") text = output;
  else if (output === undefined || output === null) text = null;
  else text = JSON.stringify(output);

21. Memory/src/service/l3-field/evidence-fragments.ts (L33)

sessionProjectRoot is declared in the input type but never read inside buildEvidenceFragments; the caller already bakes it into the resolve closure. This is dead API surface and a misleading hint that the function handles the project-root fallback itself. Remove it (and from the test helpers) or actually use it.


22. Memory/src/service/l3-field/evidence-fragments.ts (L343-L344)

The array includes parent itself only to filter it out (prefix !== parent), and child !== parent is redundant (a child equal to parent can never start with parent + separator). Simplify so the containment check is obvious.

💡 Suggested Change

Before:

  const inside = (child: string, parent: string) =>
    child !== parent && [parent, parent + "/", parent + "\\"].some((prefix) => prefix !== parent && child.startsWith(prefix));

After:

  const inside = (child: string, parent: string) =>
    child.startsWith(parent + "/") || child.startsWith(parent + "\\");

23. Memory/src/service/l3-field/project-resolver.ts (L121)

Agent-specific home-directory conventions (~/.codex/memories*, ~/Documents/Codex/<date>, the Claude scratch folder) are hardcoded in the resolver. If an agent changes these locations, scratch workspaces silently stop being treated as throwaway and start becoming L3 projects (polluting memory). Centralize these paths in one config/constant or derive them from the adapter's workspace metadata.


24. Memory/src/service/l3-field/l3-field-context.ts (L93)

emptyResponse repeats every field of the response literal; adding or renaming a field later can leave the empty case silently out of sync. Build both from one helper (or spread a base object) so the shapes cannot drift.


25. Memory/src/service/l3-field/l3-field-context.ts (L61)

projectUnit ?? units.machine is L3UnitRecord | null | undefined; the non-null assertion only holds because of the earlier hasContent guard. If that guard changes (or a unit gets filtered as archived differently), this throws instead of returning an empty response. Prefer an explicit check that returns emptyResponse(projectId).


26. Memory/src/service/l3-field/unit-ops.ts (L65)

This silently discards the whole op, not just the pending ids: a replace whose ids are [<real item>, "added-in-batch-1"] is dropped entirely, so the legitimate replacement of the real item is lost. Nothing is pushed to problems and nothing is logged, so the model is never told and the caller cannot distinguish this from a clean answer. Consider filtering out the in-batch ids from targets/ids and continuing with the rest, or at least recording a problem/dropping with a log so the intent is visible.


27. Memory/src/service/l3-field/unit-ops.ts (L134)

The "a machine item must not mention a project" guard only ever sees the current group's project (group.project), so a machine item written while processing project A can name project B, and in a machine-only group (group.project === null) no check runs at all — a machine fact may still name any repository or folder, contradicting the prompt rule. Note also that names shorter than MIN_REPOSITORY_NAME_LENGTH (e.g. api, web) always pass.


28. Memory/src/service/l3-field/unit-update-pipeline.ts (L307)

Two problems in the shrink loop: (1) canonicalJson(input) re-serializes the whole payload (up to 65 KB) on every iteration, so dropping hundreds of fragments is quadratic; (2) the loop stops at one fragment and never shrinks batch_l1/items/profiles, so if those alone exceed maxChars the input stays oversized — after the first model_input_too_long the fallback ask(input) is not wrapped in a try/catch either, so the second identical error propagates. Consider measuring fragment sizes once (or dropping in batches), and letting the fallback path also be handled.


29. Memory/src/service/l3-field/unit-update-pipeline.ts (L216)

let first; gets TypeScript's evolving any, so first.ops, first as unknown as JsonValue and the later second.ops accesses are unchecked. The type is available from ask's return value; declaring it removes the implicit any (and the double cast).

💡 Suggested Change

Before:

    let first;

After:

    let first: Awaited<ReturnType<typeof ask>>;

30. Memory/src/service/l3-field/unit-update-pipeline.ts (L422)

insertItem stores lastConfirmedAt = evidenceAt, so items added by this very run (notably a dead-letter rerun whose evidence is old) sort as the oldest confirmations and are the first to be evicted here — a batch can insert its items and immediately inactivate them, then still be marked applied, with only an item_evicted_over_limit log. Consider excluding items with batchId === batch.id from eviction, or ordering by max(lastConfirmedAt, createdAt) for freshly inserted rows.


31. Memory/src/service/l3-field/unit-update-pipeline.ts (L361-L362)

This drop path is silent, unlike the sibling deleted_evidence_op_dropped / stale_op_dropped branches: if the unit was reset or is missing, the op disappears with no log. The same silent-drop pattern exists in buildGroups, where raw turns whose L1 memory is missing/deleted are skipped with no trace, so a batch can end up with zero groups and still be marked applied. Both paths are worth a logEvolutionDecision call so a lost batch can be diagnosed.


32. Memory/src/service/l3-field/session-units.ts (L21-L22)

SessionUnits.sessionCwd and SessionUnits.workspaceIsLocal are never read by any consumer: memory-service.requestL3FieldProfiles only uses units.machine/units.project, and loadL3FieldContext only maps machine/project. Callers that need the working directory (e.g. unit-update-pipeline.buildGroups/buildInput) call sessionWorkingDirectory directly. These two fields are dead surface on an exported type — either drop them or let callers consume them from SessionUnits so the resolution result stays the single source of truth.


33. Memory/src/service/l3-field/git-identity.ts (L66-L70)

toLowerCase() is applied to the whole repository path, not just the host. Repository paths are case-sensitive on many self-hosted servers (GitLab/Gitea/plain git over ssh), so group/Foo and group/foo collapse into one identity. Because this identity becomes the L3 project unit key, evidence from one repository would be merged into the other's project memory (the kind of cross-project contamination the fragment logic is designed to avoid). Only the host (and scheme/default port) is case-insensitive; keep the path as written.

💡 Suggested Change

Before:

  const cleanedPath = repoPath
    .replace(/^\/+/, "")
    .replace(/\/+$/, "")
    .replace(/\.git$/i, "")
    .toLowerCase();

After:

  const cleanedPath = repoPath
    .replace(/^\/+/, "")
    .replace(/\/+$/, "")
    .replace(/\.git$/i, "");

34. Memory/src/service/l3-field/evidence-fragments.ts (L89)

The user's own words are cut at 4000 chars with a bare slice, with no truncation marker, unlike tool output which is clipped by clipToolOutput and marked [已截断]. The prompt relies on the user text for project_constraint items, so a long message silently loses its tail (often the actual requirement) while the model has no way to know evidence was truncated. Use the same clipping helper (or at least append a truncation marker) so the model can tell content is missing.

💡 Suggested Change

Before:

      output: userText.slice(0, USER_TEXT_LIMIT),

After:

      output: clipToolOutput(userText.slice(0, USER_TEXT_LIMIT)),

35. Memory/src/service/l3-field/unit-ops.ts (L64)

Nested ternary (a ? x : b ? y : z), which the repo conventions forbid. It is also hard to read: targets means "ids this op would change" for replace/delete/confirm and is (deliberately) empty for add. Please flatten it into plain branches or a small named helper.

💡 Suggested Change

Before:

    const targets = raw.op === "replace" ? raw.ids ?? [] : raw.op === "add" ? [] : [raw.id ?? ""];

After:

    let targets: string[] = [];
    if (raw.op === "replace") targets = raw.ids ?? [];
    else if (raw.op !== "add") targets = [raw.id ?? ""];

36. Memory/src/service/l3-field/unit-update-pipeline.ts (L31-L32)

This duplicates the existing JobLeaseLostError (src/storage/job-lease-context.ts), which WorkerRunner.failLeasedWorkerJob / finishFailedEmbeddingItem explicitly recognise via error instanceof JobLeaseLostError. A new, look-alike class is only handled indirectly: l3FailureHandling treats it as an ordinary retryable L3 failure, and the run is saved only by the secondary !isJobLeaseHeld(job) check in the runner. Reusing JobLeaseLostError keeps one lease-lost signal across the codebase.


37. Memory/src/service/l3-field/unit-update-prompt.ts (L10)

The pipeline appends machine items written by earlier groups of the same batch to machine.items with a synthetic id (added-in-batch-N) and an added_in_this_batch: true marker, but the prompt never explains either. validateL3UnitOps silently discards any op that targets these ids (they are not in the active-item map), so a model that tries to confirm/replace such an entry (exactly what the unit test's reply does) loses the op without feedback and may also skip the intended consolidation. Document the marker in the input description.

💡 Suggested Change

Before:

- machine: this computer's profile (read-only) and its current items.

After:

- machine: this computer's profile (read-only) and its current items.
  Items marked "added_in_this_batch" were written by an earlier group of this same batch: never add,
  replace, confirm or delete them; only use them to avoid writing the same fact again.

38. Memory/src/storage/l3-field-repository.ts (L276)

markUnitDeleted clears snapshot_memory_id but never deletes/archives the snapshot memory row itself. Since memory_key only has a non-unique index (idx_memories_key_layer), the next writeSnapshot for this unit sees no linked memory and inserts a second row with the same world_model:l3_field:<unitKey> key. The production delete path soft-deletes the memory first, but this method's contract relies on that caller and direct callers (tests, or any future reset path) leave a stale active snapshot plus a duplicate key. Capture the id and remove/archive the snapshot memory here (or look up by key on rebuild) so the reset stays self-consistent.


39. Memory/src/storage/repositories.ts (L8815)

l3_unit_batches is pruned to applied rows, but the l3_unit_update jobs above are kept when they are succeeded or dead_letter. A batch is marked failed on dead lettering (see the dead_letter handler just above), so a dead-lettered job survives while its batch is removed, leaving a dangling payload.batchId in the bundle. Requeue then can't find the batch (JOIN l3_unit_batches misses) and the job is permanently stuck. Either keep non-applied batches or drop dead-lettered l3_unit_update jobs whose batch was pruned.


40. Memory/src/storage/repositories.ts (L5545-L5546)

Only conflictStrategy === "error" is special-cased; for "replace" the unit is still added to kept and then skipped at line ~5451. A caller selecting replace therefore gets existing L3 units silently skipped rather than replaced (reported as skipped, not replaced), which contradicts the usual meaning of replace. Please confirm this is intended, or implement actual replacement for replace while preserving unit consistency (e.g. delete the unit's rows first).


41. Memory/src/storage/l3-field-repository.ts (L263)

Every machine snapshot change re-writes all of the machine's project snapshots with a forced new version, even when a project's rendered content is unchanged. Each forced write also inserts a memory_change_log row, so the cost and change-log volume scale with the number of projects on the machine and fire on every machine profile refresh (session start / compaction). Consider bounding this, or only bumping projects whose rendered snapshot actually changed.


42. Memory/src/storage/l3-field-repository.ts (L394)

The filter fragment is interpolated directly into the SQL string. Today it is only supplied from internal constants with no user input, so it is safe, but this pattern is fragile: any future caller passing a dynamic value becomes an injection point. Prefer passing the auto-requeue condition as a parameterized/built-in variant (e.g. a boolean that selects one of two fixed clauses).


43. Memory/src/storage/schema.ts (L755)

l3_items.unit_key has a FK with ON DELETE CASCADE, but l3_unit_batches.machine_unit_key and l3_items.replaced_by have no FK constraints. Batches can therefore outlive/reference a missing unit, and replaced_by can point at a purged item (purgeInactive deletes inactive rows). Adding FKs (to l3_units(unit_key) / l3_items(id)) would keep the new tables consistent; if intentionally omitted for import ordering, a note would help.


44. Memory/src/service/project-environment/project-environment-service.ts (L90-L91)

The "at most one pending job per unit" invariant is enforced by a check-then-insert that is neither transactional nor backed by a unique key: the dedupe key embeds a fresh scanId, so it can never collide. Two concurrent session opens (this is called from requestL3FieldProfiles on every session start/compaction) both see no recent job and both enqueue, doubling the scan/LLM cost for the same checkout. Wrap the SELECT + insert in this.deps.repos.transaction(...), or make the dedupe key deterministic per unit + time bucket so the unique index turns the race into a no-op insert.


45. Memory/src/service/project-environment/project-environment-service.ts (L177-L178)

checkoutRoot is taken from the job payload but never compared with the unit's current checkoutRoot, which can change (ensureProjectUnit upserts checkout_root = excluded.checkout_root). A job queued before the project moved (or before the path was reused by another project) will scan the stale directory and write that profile onto the unit. Guard it, keeping the case where the unit has no recorded root working:

if (unit.checkoutRoot && unit.checkoutRoot !== checkoutRoot) return;
💡 Suggested Change

Before:

    const unit = this.deps.repos.l3Field.getUnit(unitKey);
    if (!unit || unit.userId !== userId || unit.kind !== "project") return;

After:

    const unit = this.deps.repos.l3Field.getUnit(unitKey);
    if (!unit || unit.userId !== userId || unit.kind !== "project") return;
    // A job queued before the checkout moved must not profile the old directory.
    if (unit.checkoutRoot && unit.checkoutRoot !== checkoutRoot) return;

46. Memory/src/service/project-environment/project-environment-service.ts (L183)

resolveLocalWorkspaceRoot rejects any URI that is not the canonical pathToFileURL(realpath(root)) (project_environment_workspace_not_canonical), and every other producer of a local workspace URI normalizes it (see workspace-bridge/runtime.ts using normalizeWorkspaceUri). Building the URI straight from the stored path makes the job fail with that error whenever checkoutRoot was persisted with a symlinked segment or a spelling that realpath rewrites (e.g. /tmp vs /private/tmp, macOS case-insensitive volumes). Resolve the path first (await realpath(checkoutRoot)) before converting, or pass normalizeWorkspaceUri(pathToFileURL(...).href) and make deriveScanEvidence accept that value.


47. Memory/src/service/worker/job-handlers.ts (L395)

Nested ternary (a project style violation) plus a lossy fallback: ClosedEpisodeTrigger has six values, so episode_rewarded, capture_decided, topic_boundary and end_topic are all recorded as new_task. capture_decided/episode_rewarded are re-finalizations of an already closed task, not new-task boundaries, yet the row written to l3_unit_batches.trigger will claim new_task. A lookup map keeps the mapping explicit and lets unknown triggers be handled deliberately, and it also removes the double ternary.

const CLOSED_TASK_TRIGGERS: Record<ClosedEpisodeTrigger, L3WorldModelBatchTrigger> = {
  idle_timeout: "episode_idle_close",
  session_closed: "session_close",
  topic_boundary: "new_task",
  end_topic: "new_task",
  episode_rewarded: "new_task",
  capture_decided: "new_task"
};
💡 Suggested Change

Before:

    trigger: trigger === "idle_timeout" ? "episode_idle_close" : trigger === "session_closed" ? "session_close" : "new_task",

After:

    trigger: CLOSED_TASK_TRIGGERS[trigger],

48. Memory/src/service/worker/worker-runner.ts (L472-L474)

These three new sweeps run unguarded inside reconcileWorkerStartup. Every other step there is either safe or already accepted as fatal for startup, but these are optional best-effort L3 chores; a throw (e.g. a missing/malformed l3_unit_batches row, a bad payload in a dead-lettered job) aborts the whole reconciliation, skipping refreshObservationModelConfiguration() and discarding the counters for the work already done (import summaries, embedding repairs, retrieval reindexes). Wrap them so a sweeper failure is logged and does not stop startup recovery.

💡 Suggested Change

Before:

    this.deps.purgeL3InactiveItems?.(at);
    this.deps.requeueL3DeadLettersOnce?.(at);
    this.deps.armPendingL3Flushes?.();

After:

    this.runL3StartupSweeps(at);

49. Memory/src/service/worker/fair-worker-queue.ts (L76-L77)

dependencyKey is now impure: this adds one indexed query (unsummarizedL3BatchL1, which scans json_each(l1_memory_ids_json) and joins memory_processing_state/memories) for every non-eligible candidate visited in the claim loop, i.e. up to FAIR_SCAN_CANDIDATE_LIMIT times per scan and again on every re-scan of the same parked rows. Since the parking key only has to name some outstanding dependency, consider resolving it once per (batchId) inside the scan/drain and reusing the result instead of re-querying per candidate.


50. Memory/src/service/project-environment/project-environment-service.ts (L87)

The failed branch of this guard has no time bound: while any project_environment_profile job for the unit sits in failed, every later request is dropped, no matter how old the job is or whether it can still be retried (e.g. a job held by blocked_reason = 'configuration'). The unit's profile then stays stale until a restart requeues the row. Consider bounding failed the same way succeeded is bounded, so a stuck failure cannot suppress re-scans indefinitely.


51. Memory/src/service/project-environment/project-environment-service.ts (L81)

input.at is a caller-supplied injection point, but Date.parse returns NaN for anything it cannot parse (e.g. "2026-10-01 00:00:00"), and new Date(NaN).toISOString() throws RangeError: Invalid time value. That would turn a session open into a hard failure instead of a skipped scan. Normalize/validate at once and fall back to nowIso().

💡 Suggested Change

Before:

    const at = input.at ?? nowIso();

After:

    const requested = input.at ?? nowIso();
    const at = Number.isFinite(Date.parse(requested)) ? requested : nowIso();

52. Memory/src/service/project-environment/project-environment-service.ts (L30)

This ten-minute business window is also hardcoded in memory-service.ts (refreshL3MachineProfile, now - ... < 10 * 60_000) and both comments describe the same policy. Keeping two independent magic numbers means the project and machine profiles can silently drift apart; move the interval into the shared L3 field config/constants and reference it from both places.


53. Memory/src/storage/l3-field-repository.ts (L488-L490)

The update path always writes deletedAt: null. The early-return guard skips the no-change case only when !existing.deletedAt, so when the linked snapshot memory has been soft-deleted the code falls through to memories.update(memory) and silently un-deletes a memory the user deleted (archived is deliberately preserved a few lines above, so deleted should be treated consistently). Either respect the deleted state or document why it is resurrected.

💡 Suggested Change

Before:

      updatedAt: at,
      deletedAt: null
    };

After:

      updatedAt: at,
      // A user-deleted snapshot stays deleted; only a fresh link (no existing row) starts active.
      deletedAt: existing?.deletedAt ?? null
    };

54. Memory/src/storage/repositories.ts (L2774-L2775)

This repeats the "an L1 with a pending summary holds the batch" rule that already lives in scopedJobReadySql() (queue-eligibility.ts, the job_type = 'l3_unit_update' AND EXISTS (l3_unit_batches ... memory_processing_state ...) block). Two copies of the same eligibility rule must be kept in sync, otherwise the worker can lease/reschedule a batch the scheduler considers blocked (or vice versa). Extract the predicate into one exported helper and reuse it in both places.


55. Memory/src/storage/l3-field-repository.ts (L76)

Nothing imports these re-exports from this module: repositories.ts, worker-runner.ts, plugin-algorithms.ts, embedding-job-processor.ts and memory-service.ts all import them directly from service/l3-field/snapshot-memory.js. Keeping a second export surface means two import paths for the same predicate; drop it unless a consumer relies on it.


56. Memory/src/storage/repositories.ts (L5456-L5457)

The cursor can only be moved to one point, so when the covered range has a gap the rewind lands on the first uncovered trace and every trace after that gap is frozen again, even if the local database already covers it. In that (mixed local/imported) case the same traces end up in a second l3_unit_update batch, feeding the unit twice with the same evidence. Worth handling explicitly (e.g. re-freeze only when nothing in the range is covered, or track per-trace coverage) or at least documenting that duplicates are acceptable because the update pipeline dedupes by content.


57. Memory/src/storage/queue-eligibility.ts (L152-L153)

The new leased_until > ? guard was only added to the "same-scope already leased" subquery, while the "earlier scope_seq still in the queue" subquery below still treats any better-scoped row in status IN ('queued','leased','failed') as a blocker. Despite the doc comment ("a lease that ran out no longer holds the scope"), a zombie leased row for an earlier scope_seq (worker died between the lease and crash recovery) permanently blocks every later batch of that scope from being leased — the exact head-of-line stall this comment promises to avoid. Apply the same expiry condition to the earlier-job check (or exclude expired leases there too).


58. Memory/src/service/worker/job-handlers.ts (L703)

suspend is unbounded: it refunds the attempt, never increments failureCount, and never reaches dead_letter. For an L3 job that keeps failing with a permanent condition (e.g. an exhausted/revoked provider quota reported as 40309, or an offline machine that never reconnects), the row stays queued forever, and because scopedJobReadySql() counts earlier queued jobs of the same scope_key/job_type as blockers, every later l3_unit_update / l3_world_model_update batch of that scope is starved indefinitely. Non-L3 40309 is force-dead-lettered precisely to avoid this. Consider a suspension cap (after N suspensions fall through to dead_letter, or exclude suspended rows from the scope-order blocker check).


59. Memory/src/agent-source/runtime.ts (L1113)

service is a non-optional MemoryService whose freezeScannedL3Sessions is a required public method, so the optional call (?.) and ?? 0 are dead defensive code: they hide a real compile-time break if the method is ever renamed/removed, instead of surfacing it. Also, the comment says "the idle sweep hands these Sessions over later", but armPendingL3Flushes() only runs at worker startup / config reload (plus the per-scan-pass freeze), so a failed freeze inside a long-running process is not retried until one of those happens — worth wording the comment to match the actual recovery path.

💡 Suggested Change

Before:

      if ((service.freezeScannedL3Sessions?.(scannedSessionIds) ?? 0) > 0) scheduleWorker?.();

After:

      if (service.freezeScannedL3Sessions(scannedSessionIds) > 0) scheduleWorker?.();

60. Memory/src/config/index.ts (L1344-L1347)

inactiveRetentionDays is taken straight from config with only a numberValue fallback — no positivity/integer validation. The value feeds Date.parse(at) - inactiveRetentionDays * 86_400_000 in the L3 purge (memory-service), so 0 purges every currently inactive item and a negative value moves the cutoff into the future and purges everything. Please clamp/validate to a positive integer (and consider an env override like the other tunables, since MEMMY_L3_FIELD_COGNITION only controls enabled).

💡 Suggested Change

Before:

      inactiveRetentionDays: numberValue(
        l3FieldCognition.inactiveRetentionDays,
        DEFAULT_MEMMY_CONFIG.algorithm.l3FieldCognition.inactiveRetentionDays
      )

After:

      inactiveRetentionDays: Math.max(
        1,
        Math.trunc(numberValue(
          l3FieldCognition.inactiveRetentionDays,
          DEFAULT_MEMMY_CONFIG.algorithm.l3FieldCognition.inactiveRetentionDays
        ))
      )

61. Memory/src/service/memory-service.ts (L1177)

The rate-limit timestamp is recorded before the probe runs, but this early return (service closing / writes turned off) does not release it, unlike the catch branch. A probe aborted during shutdown therefore blocks the next profile refresh for a full 10 minutes even though nothing was written. Release the slot on this path too.

💡 Suggested Change

Before:

      if (this.closing || !this.memoryAddEnabled()) return;

After:

      if (this.closing || !this.memoryAddEnabled()) {
        this.l3MachineProfileRefreshedAt.delete(unitKey);
        return;
      }

62. Memory/src/service/memory-service.ts (L1624-L1625)

retryFailedL3Batches only checks assertMemoryAddEnabled() but not l3FieldActive(), so with the new L3 turned off it still requeues l3_unit_update dead letters and reports a non-zero requeued count, while the worker excludes that job type (worker-runner.ts) so nothing actually runs. Either gate on l3FieldActive() and return { requeued: 0 }, or document the queued-but-inactive behaviour explicitly.


63. Memory/src/service/memory-service.ts (L1616-L1617)

The ownership filter lets a caller freeze any session of its source as long as work_memory_disabled === true, regardless of userId. Since normalizeNamespace defaults source, sessions from a different user sharing the default source can be frozen here (and this triggers freezeBatches, i.e. writes + job enqueueing). Consider requiring session.userId === namespace.userId and only relaxing it for sessions this scan actually produced (e.g. via the source-turn ledger), rather than on the source string alone.


64. Memory/src/service/memory-service.ts (L1627)

This snapshot/append block is duplicated almost verbatim in requeueL3DeadLettersOnce below (and the 10_000 magic limit is repeated). Extract a private helper such as requeueL3DeadLetters(run: () => string[]) that captures the before-state, run it, appends the job changes and returns the count, so both entry points share one implementation and one limit constant.


65. Memory/src/service/session/session-turn-service.ts (L1980)

Closing the mapped scan Session here can leave the caller holding a gapEpisode that belongs to it: in the caller, targetSessionId (and gapEpisode) are derived from the previous capture before resolveSourceSession runs, so if this takeover returns a different live Session, completeTurn is invoked with episodeId of an Episode owned by the just-closed Session. Skip the takeover in that case (or re-resolve the episode) to avoid attributing the turn to a foreign Episode / throwing on the episode scope check.


66. Memory/src/agent-source/runtime.ts (L1288)

The collector appends one entry per ingested turn without de-duplication, so on a large import scannedSessionIds grows with the number of turns (mostly duplicates of the same few conversations) and is held for the whole pass. Dedupe at collection time with a Set — freezeScannedL3Sessions already collapses duplicates internally, so this only wastes memory/allocations here. (Related: the finally then runs one synchronous getSession + freezeBatches SQLite round-trip per distinct Session inside the scan, so a scan that touched thousands of Sessions blocks the event loop at the end of every stage.)

💡 Suggested Change

Before:

        if (result.result?.sessionId) scannedSessionIds.push(result.result.sessionId);

After:

        if (result.result?.sessionId) scannedSessionIds.add(result.result.sessionId);

67. Memory/src/algorithm/plugin-algorithms.ts (L3450-L3452)

This branch re-derives the l3_field shape with two unchecked casts (internal.l3_field as Record<string, unknown> and field.unit_key as string), duplicating knowledge that already lives in service/l3-field/snapshot-memory.ts. If the property is ever renamed/moved, TS will not complain here and field.kind would throw at runtime. Consider exposing a typed accessor from snapshot-memory.ts (e.g. returning { unitKey, kind, legacyFields }) and using it here instead of casting.


68. Memory/src/server/http.ts (L699-L701)

The three new routes repeat the exact same safeParse + issues.map(...) error-mapping block (lines ~699-707, ~713-721, ~729-737). Extracting a small helper (e.g. parseStrictOrThrow(schema, body, routeName)) would remove the triplication and keep the error format consistent with the existing helper used for OpenSessionInputSchema.


69. Memory/src/service/memory-service.ts (L1601)

freezeScannedL3Sessions calls freezeBatches once per session, and each call opens its own DB transaction; L3ScanSessionsFreezeRequestSchema allows up to 10,000 sessionIds, so one scan/hosted-import request can issue thousands of separate transactions. Consider wrapping the whole loop in a single repos.transaction(...) (freezeBatches already nests safely, as flushIdle does) to keep the scan pass from doing per-session commits.

💡 Suggested Change

Before:

      batches += this.repos.l3WorldModels.freezeBatches({ sessionId, trigger: "scan_pass" }).batchIds.length;

After:

      batches += this.repos.l3WorldModels.freezeBatches({ sessionId, trigger: "scan_pass" }).batchIds.length; // still per-session; see note about wrapping the loop in one transaction

70. Memory/src/service/memory-service.ts (L1644)

sessionsWithUnfrozenTurns() has no LIMIT, yet this runs on every worker startup and after every import, while returning pending.length (the number of scanned sessions) rather than the number of flushes actually armed/frozen. After a large import of v2 sessions this queues one idle-flush job per open session in a single loop, and the reported count is misleading. Consider capping the work per run (or batching it) and returning the number of sessions actually armed/frozen.

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Executor error: Command failed: git clone --depth 1 --branch gh-590-scan-l3 git@github.com:Anson-Se/memmy-agent.git /data/test-workspaces/6f43c128ec9c9410/repo
Cloning into '/data/test-workspaces/6f43c128ec9c9410/repo'...
Branch: gh-590-scan-l3

Anson-Se and others added 6 commits October 30, 2026 02:53
A bundle import copied each evolution_jobs and embedding_retry_queue row with
the enqueue_seq it had in the source database. Importing into a database that
already had queued work failed on the unique enqueue_seq index, and with
conflictStrategy "replace" INSERT OR REPLACE could instead delete the local row
holding that number. Imported queue rows now leave enqueue_seq out and are
numbered on insert, after what is already queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the storage base for itemized machine/project L3, behind
algorithm.l3FieldCognition (off by default, local SQLite only):

- four new tables (l3_units, l3_items, l3_unit_batches,
  l3_user_projects) created alongside the existing ones without
  bumping SCHEMA_VERSION; old L3 tables and rows are untouched
- L3FieldRepository for units, items, snapshot memories, unit
  deletion, inactive-item purge and the user project list; an item
  removed by a delete or replace keeps when that evidence happened
- register the tables for bundle export/import and clear-all; a unit
  that already exists is kept whole on import (its unit, items, batches,
  jobs and snapshot from the bundle are skipped; conflictStrategy
  "error" fails instead; its unfinished batches this database lacks
  move their sessions' cursors back so those turns are frozen again),
  and unfinished unit batches are dropped from redacted exports
- countL3Units for the per-item length limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decide which project a path belongs to for the new L3, by reading files
only (no git process):

- the nearer of a user-selected folder and a git repository wins; a
  shared root counts once with the git identity
- git identity is the normalized origin (ssh, scp-like and https forms
  map to host/path), else the common git dir, so a clone's worktrees
  and subdirectories share one project
- a session folder outside any repository is a plain-folder project; a
  tool path only joins the session project it lands in
- temp dirs, Codex dated dirs, Claude scratch workspaces,
  ~/.codex/memories*, Memmy task folders and the home/Desktop/
  Documents/Downloads folders themselves are never projects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app now tells Memory which folders the user picked as
projects, so L3 can treat them as project roots:

- POST /api/v1/l3/user-projects replaces the user's whole list and
  the Memmy task-folder roots; relative paths are rejected; the list is
  stored regardless of the L3 field cognition flag
- memmy-agent's long-running gateway reads the project registry every
  minute and pushes only when the list changed or the last push
  failed; a corrupt registry is never pushed
- the contract is added identically to the Memory and App copies, with
  a test that keeps them in sync

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut a raw turn into evidence fragments for the new L3 so each fact can
be attributed by the paths its own evidence touched:

- one fragment for the user's words, one per tool call with its paired
  result (reusing the existing strict call/result pairing), and one per
  result that matches no call
- paths come from file/dir arguments, cd and git -C targets and absolute
  command arguments; Codex code-mode snippets are read for their
  cmd/workdir literals; relative paths resolve against the call's
  workdir, else the session folder; URLs are ignored
- each fragment gets the project units its paths resolve to, falling
  back to the session folder; long outputs keep head, tail and the
  error lines in between

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- machine profile: probe OS (macOS product version, not the Darwin
  kernel), arch, shell and node/python3/git/npm/brew versions with
  2-second timeouts, and render it in a fixed format by code only
- project profile for the new L3: reuse the existing scan and the
  shared summarizeProjectProfile model call, but feed it only what the
  project declares (runtime versions probed on this machine belong to
  the machine profile); write it into the project unit and refresh its
  snapshot; skip the model when the scan is unchanged and drop the
  result when the unit was deleted meanwhile
- at most one pending profile job per unit, and a unit scanned in the
  last ten minutes is not scanned again (each session open asks for
  one); each request carries its own scan id because the immutable
  dedupe index never frees a key
- the old project-profile pipeline is unchanged apart from calling the
  extracted summarizeProjectProfile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anson-Se and others added 2 commits October 30, 2026 04:03
The new-L3 project profile fingerprint leaves out the runtime versions probed on
this machine (they belong to the machine profile), and an unchanged fingerprint
skips the model even when the last summary found nothing worth a profile. The
scan for it does not run the declared runtimes at all.

A unit whose snapshot the user archived is not injected, and rewriting the
snapshot keeps it archived. Deleting a snapshot and resetting its unit happen in
one transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wherever a task closes (session close, new task, ended topic, idle timeout),
its turns are frozen for the new L3, only through that task so a task still in
progress is not cut; a conversation that simply stops therefore still reaches
L3. With the switch off nothing changes. Only one L3 runs at a time: the queued
jobs of the other one (including its project profiles) wait in the queue until
the switch turns back, and do not wake the worker.

Older evidence never overturns newer state: a replace or delete whose cited
evidence is older than the item's last confirmation is dropped, and what was said
before a unit reset does not refill it, even when frozen or rerun later. The
worker's next wake-up uses the same readiness rules as leasing (scope order, L1
summaries), so a job it cannot lease does not make it spin. A batch is shown the
items removed on evidence newer than some of its own, with when (a batch rerun
late, history scanned late, or one spanning the removal), so older evidence does
not bring them back. Each lease of a batch job carries a token: a run that
outlived its lease neither writes nor changes the job, and a run that finds its
batch dead-lettered drops its result. Replace and delete skip items already gone,
and ops citing a turn or L1 deleted during the model call are dropped. A machine probe that was running when
the machine was reset is dropped. A snapshot is rewritten only when what it shows
changed, and project snapshots move only when the machine's did, or when the
machine snapshot is archived (so clients drop the cached machine part).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anson-Se and others added 3 commits October 30, 2026 04:04
Temporary model failures of L3 jobs wait 1, 5, 15, 30 and 60 minutes before
retrying (runAfter) and the sixth goes to dead letter; any other failure (a
rejected answer, a bug) would likely repeat, so it gets one retry. Later batches
then continue: no failure leaves a job that holds back its scope until restart.
Used-up quota (40309) and an unreachable model endpoint (offline) wait 30
minutes without using an attempt; a bad key or endpoint is held by the queue's
configuration hold until the configuration changes. L3 jobs are not leased at all
while no evolution model is configured. Each dead-lettered new-L3 batch is rerun
once automatically when the worker starts; a later batch whose lease ran out
does not keep it waiting. Adds POST /api/v1/l3/failed-batches/retry,
which resets the user's dead-letter l3_unit_update jobs and batches in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the new L3 on, conversations first opened by an agent-source scan use the
L3 v2 session protocol with Work Memory disabled, so their turns register as L3
input like Hook sessions; with it off, scans stay out of L3 as before, and a
session scanned while it was on never goes to the old L3 after it is switched
off: its turns wait for the new L3.

Every captured turn has a durable owner: with the new L3 on, the existing Work
Memory idle flush (one re-armed job per session, runAfter two hours after the
last turn) is armed for every v2 session, scanned ones included, and when it
comes due it freezes the session's remaining turns into L3 (trigger
session_idle). A conversation that just stops therefore still reaches L3 without
a close or a later turn. On worker start and after an import, open sessions with
unfrozen turns get it armed and closed ones are frozen right away.

The scan end is the fast path: when a scan pass finishes a source, the sessions
it touched are frozen (trigger scan_pass), also when the pass stops early, and the
worker is woken. Hosted imports (importManaged) do the same through POST
/api/v1/l3/scan-sessions/freeze, also when an import stops early; it freezes the
caller's own sessions and scanned sessions of its source. Freezing
only writes rows, so neither scans nor imports wait on L3. Existing sessions and
detached legacy history keep their protocol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new L3 (field cognition) is now on unless configured off
(algorithm.l3FieldCognition.enabled or MEMMY_L3_FIELD_COGNITION=0). It still
runs only on local SQLite storage and never in cloud mode; there the old L3
World Model stays in use.

Test suites that cover the old L3 World Model keep it explicitly: the shared
LEGACY_TEST_CONFIG turns the new L3 off, as it already does for turn.judge.v1,
and the turn.judge.v1 cases that check old L3 evidence do the same. Services
built by the test fixture get a fixed machine id, a temporary home folder and a
machine probe that never settles, so tests neither run probes on the real
computer nor read its installation id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: App/backend/local-api-contracts/src/memory-l3-world-model.ts, App/backend/local-api-contracts/src/memory-runtime.ts, App/backend/src/adapters/outbound/memory-client/http-memory-client.ts, App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts, App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts, App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts, App/backend/src/adapters/outbound/memory-client/types.ts, App/backend/src/services/ingestion-service.ts, App/backend/src/services/tests/ingestion-service.test.ts, App/backend/src/tests/support/mock-memory-client.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: gh-590-scan-l3

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants