Skip to content

回移植 PR #795:记忆 UID 解耦与召回补取至 v1.2.6 - #796

Open
Orsted290 wants to merge 18 commits into
MemTensor:v1.2.6from
Orsted290:backport-pr795-v126
Open

Orsted290 wants to merge 18 commits into
MemTensor:v1.2.6from
Orsted290:backport-pr795-v126

Conversation

@Orsted290

@Orsted290 Orsted290 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

问题定位

记忆 owner 和召回候选池此前仍受账号 UID 影响。历史记忆可能因不同账号重复写入,重复项会占用召回额度,导致去重后的有效记忆数量不足。调整 owner 时还必须保留历史行原值,以及既有 source、project、tenant 等范围约束。

修改内容

  • 新建记忆与设备级状态统一使用固定设备 owner,不再从当前账号 UID 推导。
  • 不改写历史记录;对跨账号历史副本按可验证的稳定来源身份去重。没有可靠来源证据时按记录 ID 保留。
  • 将最终 quota 放在去重之后。候选不足时继续分页补取,直到达到目标数量或候选范围耗尽,并受现有时间和工作量边界限制。
  • 保留 bearer token、scope 和原有来源、项目、租户权限检查。

具体改动与代码位置

v1.2.6 适配

本次只回移植 PR #795 的 4 个代码提交,没有合入其他分支提交。冲突解决保留了 v1.2.6 的数据库范围过滤,同时接入召回补取条件;会话服务则同时保留异步让出逻辑和设备 owner import。

测试结果

@Memtensor-AI

Memtensor-AI commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #796
Task: d08ed3d8521dd52d
Base: v1.2.6
Head: backport-pr795-v126

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

⚠️ Review warnings (24)

  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • 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
  • … 14 additional warning(s) omitted.

1. Memory/src/agent-source/integration/hermes/target.ts (L1341-L1342)

Once userId is hardcoded, the root, memory and app locals above (and the yaml.safe_load block that only feeds them) are no longer read in _load_runtime, so the config file is now parsed twice for nothing. Please remove the dead parsing code. Also, this owner id is duplicated as a raw literal here and in the TypeScript side even though the project already defines DEVICE_MEMORY_OWNER; consider defining it once (e.g. a shared constant/interpolated placeholder) to keep both sides in sync.


2. Memory/src/agent-source/integration/hermes/target.ts (L1452-L1453)

runtime is no longer referenced anywhere inside _runtime_envelope after this change, so the parameter is now dead code (callers at lines ~1181/1260/1467/1496 still pass state["runtime"]). Either drop the parameter and update the call sites, or keep a single source of truth for the owner id instead of the literal.


3. App/backend/src/adapters/outbound/skill-writer/hermes/target.ts (L1341-L1342)

Once userId is hardcoded, the root, memory and app locals above (and the yaml.safe_load block that only feeds them) are no longer read in _load_runtime, so the config file is now parsed twice for nothing. Please remove the dead parsing code. Also, this owner id is duplicated as a raw literal here and in the TypeScript side even though the project already defines DEVICE_MEMORY_OWNER; consider defining it once (e.g. a shared constant/interpolated placeholder) to keep both sides in sync.


4. App/backend/src/adapters/outbound/skill-writer/hermes/target.ts (L1452-L1453)

runtime is no longer referenced anywhere inside _runtime_envelope after this change, so the parameter is now dead code (callers at lines ~1181/1260/1467/1496 still pass state["runtime"]). Either drop the parameter and update the call sites, or keep a single source of truth for the owner id instead of the literal.


5. Memory/src/agent-source/integration/workspace-bridge/runtime.ts (L90)

pinnedOwner is now an unused parameter, silenced with void, while the public signature still advertises it. Since readRuntimeConfig has no other callers besides openRuntimeSession/completeSourceTurn and the test, prefer removing the parameter entirely (and updating those call sites) instead of hiding dead parameters. Also consider reusing a shared DEVICE_MEMORY_OWNER constant rather than repeating the raw "__memmy_device_memory__" literal across the bridge/hermes/config modules.


6. App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts (L90)

pinnedOwner is now an unused parameter, silenced with void, while the public signature still advertises it. Since readRuntimeConfig has no other callers besides openRuntimeSession/completeSourceTurn and the test, prefer removing the parameter entirely (and updating those call sites) instead of hiding dead parameters. Also consider reusing a shared DEVICE_MEMORY_OWNER constant rather than repeating the raw "__memmy_device_memory__" literal across the bridge/hermes/config modules.


7. Memory/src/server/http.ts (L1176)

localPanel is only true for local/anonymous principals, so switching getMemory from its previous default (this.mode !== "cloud") to this flag changes semantics in two directions:

  1. Viewer requests (principal kind viewer) and cloud/scoped principals in local mode now resolve local = false. In that branch getMemory throws not_found for UserMemory rows whenever request.namespace.userId is absent — and viewer requests carry no namespace userId (the viewer envelope no longer injects one), so opening a UserMemory detail from the local viewer regresses to 404.
  2. Conversely, in cloud mode a local-service-token request now bypasses the UserMemory owner check that was previously enforced (default false in cloud mode).

Suggest keeping the storage-mode default and only widening it for the trusted local panel path, e.g. localPanel || service.??? / an explicit mode !== "cloud" check, instead of replacing the default with the principal-kind flag.


8. Memory/src/server/viewer-api.ts (L139)

Dropping userId from the viewer envelope removes the last explicit owner scope for these read paths. In local mode panelInput strips userId anyway, so this is a no-op — but if the viewer is served against a cloud-mode service, the panels are now queried with namespace.userId === undefined, which can widen the result set to every stored owner rather than the configured user (see the UserMemory layer path below, now also without userId). Please confirm the viewer is unreachable in cloud mode or that panelInput rejects an unscoped owner in that mode; otherwise pass an explicit device/owner scope instead of relying on omission.


9. App/backend/src/infrastructure/memmy-config/index.ts (L752)

Removing the memory.userId comparison from memoryConfigAffected makes the projection blind to this deletion. If a config written by an older version still contains memmyMemory.userId, the file content changes (changed === true) but memoryConfigAffected stays false, so reloadMemoryConfigIfNeeded (account-service.ts:309) skips memoryClient.reloadConfig and the memory service keeps running with the stale per-user id that this change is meant to drop. Keep a change detector for the removed field, e.g. || existingString(existingMemory.userId) !== "".

💡 Suggested Change

Before:

    delete memory.userId;

After:

    delete memory.userId;

10. App/memmy-agent/src/memmy-memory/config.ts (L10)

Two issues here: (1) stringOrUndefined (lines 15-17) is now unused dead code after dropping the config-based userId — please delete it; (2) the literal "__memmy_device_memory__" is duplicated in discovery.ts (line 110) and in the Memory package. Extract a single exported constant (e.g. DEVICE_MEMORY_USER_ID in memmy-memory/types.ts) and reuse it so the values cannot drift apart.

💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: DEVICE_MEMORY_USER_ID,

11. App/memmy-agent/src/memmy-memory/discovery.ts (L110)

The function no longer uses options, so void options; is a leftover that keeps a dead parameter alive — prefer renaming it to _options (or dropping it and updating the single caller) instead of the void trick. Also reuse the shared device-memory constant here rather than repeating the literal (it is already duplicated in config.ts).

💡 Suggested Change

Before:

  return "__memmy_device_memory__";

After:

export function discoverMemmyMemoryUserId(_options: MemmyMemoryDiscoveryOptions = {}): string {
  return DEVICE_MEMORY_USER_ID;
}

12. Memory/src/agent-source/integration/memmy-runtime-config.ts (L31)

The Memory package already exports DEVICE_MEMORY_OWNER from src/service/namespace/memory-owner.ts and uses it everywhere else for this exact value. Hardcoding the string literal here risks silent divergence if the owner sentinel ever changes — import and use the constant instead.

💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: DEVICE_MEMORY_OWNER,

13. Memory/src/agent-source/integration/workspace-bridge/runtime.ts (L94)

The device owner id is hardcoded here while Memory/src/service/namespace/memory-owner.ts already exports the canonical DEVICE_MEMORY_OWNER constant (and Memory/src/agent-source/integration/memmy-runtime-config.ts writes the same value into the plugin config). Two independent string literals can silently drift and break the namespace contract, so please reuse the shared constant instead of a raw literal.

💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: DEVICE_MEMORY_OWNER,

14. App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts (L94)

The owner id is duplicated as a raw literal here and in the Hermes plugin template, while the sibling memmy-runtime-config.ts still derives userId from app.userId/memmyMemory.userId and writes it into every generated plugin config.json. That means the App/backend mirror is now inconsistent with Memory/src/agent-source/integration/memmy-runtime-config.ts (which was switched to the device owner) and still persists an account uid to disk that no consumer reads. Please extract one shared DEVICE_MEMORY_OWNER constant for the adapter layer and use it in both places.

💡 Suggested Change

Before:

    userId: "__memmy_device_memory__",

After:

    userId: DEVICE_MEMORY_OWNER,

15. Memory/src/server/http.ts (L1509)

Swapping the merge order is the right precedence fix (authenticated scope must win over client headers), but it also makes client-specified fields silently discarded when the token namespace defines them — e.g. a client sending x-memmy-profile-id: "main" with a cloud/scoped token whose namespace pins profileId: "default" will now read/write the default profile with no error. Note this differs from strictEnvelopeWithPrincipal, which throws forbidden when a request namespace conflicts with the authenticated scope. Consider keeping a conflict check (or at least surfacing a warning/error for profileId/source) so callers don't unknowingly write into a different scope; same applies to the scoped branch below (mergeNamespaces(namespaceFromRequest(request, url), scoped.namespace)).


16. App/memmy-agent/src/memmy-memory/hook.ts (L793)

sourceMemoryIds is semantically a set, but JSON.stringify compares it positionally. If the Memory service ever returns the ids in a different (e.g. non-ORDER BY) order for the same memoryId/memoryVersion/renderedContext, this equality check fails and the cached entry is rebuilt on every turn. Compare as an unordered set instead, e.g.:

💡 Suggested Change

Before:

      && JSON.stringify(current.sourceMemoryIds) === JSON.stringify(response.sourceMemoryIds)

After:

      && sameStringSet(current.sourceMemoryIds, response.sourceMemoryIds)

17. Memory/src/service/evolution/l3-world-model-pipeline.ts (L80-L82)

This inline nested-ternary mapping duplicates the field→property mapping that already exists in two places (fieldValue in this file and l3WorldModelFieldProperty in storage/repositories.ts). Note the silent domainKnowledge fallback: if a new target field is ever added to L3WorldModelTargetField, this code will happily return the domain-knowledge revision and pass the wrong expectedFieldRevision to applyTraceTarget, weakening the stale-base guard instead of failing loudly. Please reuse a single shared mapping helper (export l3WorldModelFieldProperty and use it both here and in fieldValue).

💡 Suggested Change

Before:

    const fieldProperty = payload.targetField === "general_rules_and_safety_constraints" ? "generalRulesAndSafetyConstraints"
      : payload.targetField === "project_contract" ? "projectContract" : "domainKnowledge";
    const expectedFieldRevision = snapshot.revisions[fieldProperty];

After:

    const fieldProperty = l3WorldModelFieldProperty(payload.targetField);
    const expectedFieldRevision = snapshot.revisions[fieldProperty];

18. Memory/src/service/evolution/l3-world-model-pipeline.ts (L278-L280)

The userId parameter is now unused (kept alive only by void userId;). Dead parameters mislead future readers into thinking raw turns are still owner-checked. Remove the parameter and update the call site at line 186 (assertRawTurnScope(rawTurn, batch.sessionId)).

💡 Suggested Change

Before:

function assertRawTurnScope(rawTurn: RawTurnRecord, userId: string, sessionId: string): void {
  void userId;
  if (rawTurn.sessionId !== sessionId) {

After:

function assertRawTurnScope(rawTurn: RawTurnRecord, sessionId: string): void {
  if (rawTurn.sessionId !== sessionId) {

19. Memory/src/service/evolution/reward-pipeline.ts (L457)

Dropping userId from the value-distribution context hash makes the cooldown lookup (line 461) effectively global: listDecisionRepairs no longer filters by user_id, so any session/user sharing the same trace signature now suppresses new repairs. That may be intended under the single device-owner model, but two side effects are worth confirming: (1) pre-upgrade decision_repairs rows were persisted with the old hash format, so their cooldown protection silently stops matching right after upgrade (one duplicate repair per signature is possible); (2) the userId argument passed to listDecisionRepairs here is now ignored and only looks like it still scopes the query. If the global cooldown is intended, consider versioning the hash prefix (e.g. value-distribution:v2:) so the behaviour change is explicit.


20. Memory/src/service/namespace/shared-project.ts (L12)

sharedProjectIdentity is called from hot, per-entity paths, and it issues 4-5 unindexed full-table scans plus a db.prepare per call on every invocation. Concrete hot spots: Repositories.fieldStateKey/recordFieldState call it once per L3 field (4 fields) inside effectiveFields, effectiveFields also calls sharedMemories → sharedProjectIds (another full resolve), memories filter clauses call it per query (addWorkMemoryScopeClause), and work-memory-pipeline.retrieveCandidates calls sharedProjectIds inside a .filter() callback (once per candidate memory row). sessions has no index on project_id or on json_extract(meta_json,'$.workspace_uri'), and l3_world_model_scopes is only indexed on (user_id, project_id), so every predicate here degenerates to a table scan. Please memoize the result (e.g. per-request/statement cache keyed by projectId in Repositories, or accept an optional cache argument) so a single resolve serves all field/row iterations.


21. Memory/src/service/namespace/shared-project.ts (L30)

json_valid(meta_json) only guarantees syntactically valid JSON, not an object: sessions.meta_json allows 'null', '1', '"x"'. JSON.parse('null') returns null, and the very next line dereferences meta.workspace_host_id, which throws TypeError: Cannot read properties of null — uncaught, so a single odd row crashes the whole retrieval/commit path instead of failing closed. The earlier loop is already defensive here; make this one consistent and typed (unknown + isRecord) instead of asserting Record<string, unknown>.

💡 Suggested Change

Before:

    const meta = JSON.parse(row.meta_json) as Record<string, unknown>;

After:

    const meta: unknown = JSON.parse(row.meta_json);
    if (!isRecord(meta) || typeof meta.workspace_uri !== "string") { invalid.add(row.project_id); continue; }
    const parsed = WorkspaceIdentityFieldsSchema.safeParse({workspaceUri: uri, workspaceHostId: meta.workspace_host_id});

22. Memory/src/service/namespace/shared-project.ts (L73)

deviceId/legacyId re-implement the ws_${sha256Hex(identity)} derivation that now lives in workspace-identity.ts (projectId: \ws_${workspaceId}`), and the local\0host\0uri/remote\0uriidentity string is a third copy of the same formula already inlined incross-account-candidate-dedup.ts:28. These three copies must stay byte-identical or aliases silently stop matching, so please export a single workspaceIdentityKey(uri, hostId)(+workspaceProjectId(identity)` / legacy variant) from the namespace module and reuse it here.


23. Memory/src/service/namespace/shared-project.ts (L19)

One corrupt/unparsable meta_json row aborts with return isolated for the entire project, silently disabling alias resolution everywhere (recall dedup, L3 field state, work-memory scope) with no diagnostic. Consider skipping the offending row (and marking only its project invalid) and emitting a warn-level log so a single bad row is actionable instead of silently changing visibility semantics.


24. Memory/src/service/namespace/workspace-identity.ts (L26)

Two problems with void effectiveUserId: (1) the parameter is now dead but every caller still threads a user id through it, so the signature misleads readers into thinking account identity affects the result; (2) deleting the if (!userId) throw new TypeError("effectiveUserId must be non-empty") guard silently changes the contract for existing callers that relied on the validation. Better to drop the parameter entirely (and update the single caller in namespace-scope.ts) than to keep it and void it for lint.


25. Memory/src/service/namespace/namespace-scope.ts (L19)

normalizeNamespace now silently ignores namespace?.userId while its return type still declares userId: string. Callers passing an authenticated/legacy userId (e.g. external-evidence-source.ts, feedback-experience.ts, session-turn-service) will get the sentinel back without any signal, which is easy to misread as "the caller's owner wins". Since this is intentional decoupling, document it in the JSDoc/signature (e.g. rename the result field or add a comment stating userId is always DEVICE_MEMORY_OWNER) so future readers don't try to pass a real owner here.


26. Memory/src/service/namespace/memory-owner.ts (L7)

This sentinel is written into NOT NULL user_id columns and wire payloads, but nothing reserves or validates it. If a persisted row already carries "__memmy_device_memory__" as a real account id (previous data, imported fixtures, or a hostile request that set userId), it becomes indistinguishable from a device-owned row — which matters now that several read paths treat row.userId === DEVICE_MEMORY_OWNER as proof of local ownership (repositories.ts:6465-6470). Worth adding a short comment/guard that the value is reserved and must never be accepted from callers.


27. Memory/src/service/project-environment/project-environment-service.ts (L79)

session.meta.workspace_uri is typed unknown inside SessionRecord.meta: Record<string, unknown> and is not validated before the comparison. For any session whose meta lacks workspace_uri (pre-v2 / legacy sessions, imported sessions), the comparison is always against undefined, so the guard throws project_environment_workspace_uri_mismatch on every attempt — after beginScan already moved the state to scanning — and the job is retried then dead-lettered instead of cleanly skipping a scan it cannot authorize. Narrow the value to a string and treat an absent meta uri as "not authorizable" rather than a hard failure.

💡 Suggested Change

Before:

      if (scope.workspaceUri !== session.meta.workspace_uri) {

After:

      const sessionWorkspaceUri = session.meta.workspace_uri;
      if (typeof sessionWorkspaceUri !== "string" || scope.workspaceUri !== sessionWorkspaceUri) {

28. Memory/src/service/project-environment/project-environment-service.ts (L73)

Using JSON.stringify on both payloads as a deep-equality check is key-order sensitive (any code path that rebuilds the payload object with a different insertion order reports a mismatch) and stringifies the whole payload for every attempt. Comparing the identifying fields that actually matter (jobType, payload.userId, payload.projectId, payload.scanId) is cheaper and cannot produce false mismatches.


29. Memory/src/service/project-environment/project-environment-service.ts (L38)

L3WorldModelRepository.getScope ignores its userId argument (void userId in the implementation) and always resolves the device scope, so passing DEVICE_MEMORY_OWNER here is redundant and misleading — it suggests the lookup is owner-scoped when it is not.

Also note the dedupeKey now embeds the constant owner instead of session.userId: keys persisted before this deploy used the real user id, so those rows can no longer be matched and one duplicate session-start scan can be enqueued right after the upgrade. If that churn is acceptable, no change is needed; otherwise include a stable migration/version token in the key.


30. Memory/src/service/project-environment/profile-pipeline.ts (L78)

This guard is duplicated with the post-error guard below (lines 113-118) and partially redundant: isCurrentScan already re-reads the state row and returns false when getState(...).currentScanId !== scanId, so state.currentScanId !== payload.scanId is checked twice (and getState is executed twice per call). Extracting a single isScanStillCurrent(payload) helper would remove the double read and keep the two paths from drifting apart.


31. Memory/src/service/project-environment/profile-pipeline.ts (L80)

The read is now device-scoped (effectiveFields(projectId) ignores the owner) while the write path applyProfile still receives payload.userId, and the error branch below still calls l3WorldModels.fields(payload.userId, ...) (which itself delegates to effectiveFields). Three different scoping styles for the same field in one method makes the intended ownership rule hard to follow — worth a short comment stating that the profile is device-owned regardless of payload.userId, and using one accessor consistently.


32. Memory/src/storage/project-environment-scan-boundary.ts (L20)

JSON.parse is called outside any error handling, so a malformed value_json surfaces as a raw SyntaxError from a read path (isCurrentScan, beginScan, import/GC flows) instead of the descriptive corrupt project environment scan boundary error this function deliberately throws for semantically invalid values. Parse defensively and route the failure through the same error.

💡 Suggested Change

Before:

    const value = JSON.parse(row.value_json) as ScanBoundary;

After:

    let value: ScanBoundary;
    try { value = JSON.parse(row.value_json) as ScanBoundary; }
    catch { throw new Error("corrupt project environment scan boundary"); }

33. Memory/src/storage/project-environment-scan-boundary.ts (L15)

Every boundary read re-derives the shared project group via sharedProjectIds, i.e. sharedProjectIdentity, which runs several whole-table queries (JSON extraction over sessions.meta_json, scope mappings, candidate validation). This function is on the hot path of isCurrentScan, which is itself called multiple times per scan transition (beginScan, markSummarizing, markCleanWithoutModel, applyProfile, plus the two new pipeline guards), and ensureProjectEnvironmentScanBoundary calls it up to twice more. Consider caching the resolved project alias set for the duration of a job/transaction, or accepting pre-resolved ids, to avoid repeating this scan-heavy resolution.


34. Memory/src/service/evolution/skill-pipeline.ts (L472)

After removing the owner check, mutableSkillsForPolicy(policy: PolicyMeta) no longer references policy at all (it just lists every non-archived mutable skill). It is a private method with three call sites (lines 230, 458, 484), so the parameter is now dead code that suggests filtering that no longer happens. Consider dropping the parameter (e.g. private mutableSkills(): SkillMeta[]) and updating the call sites to this.mutableSkills(); the policy/project scoping continues to be enforced by skillPolicyCompatibility and skillsAreCompatibleForMerge.


35. Memory/src/service/read-model/l3-world-model-context.ts (L13-L14)

The per-user scope lookup (getMemory(session.userId, projectId)) is replaced by effectiveFields(projectId), which never consults session.userId. For projectId === null, sharedMemories(null) selects every row in l3_world_model_scopes with project_id IS NULL regardless of owner, and effectiveFields even falls back to memories.filter(row => row.userId !== DEVICE_MEMORY_OWNER) for legacy rows. Any pre-upgrade/legacy L3 memory owned by a different uid in the same DB will now be merged into this session's context (and its sourceMemoryIds/fields exposed) instead of being filtered out. Please confirm owner scoping for the null-project path (e.g. restrict to the device owner scope or pass the session owner into effectiveFields).


36. Memory/src/storage/l3-field-deletion-boundary.ts (L66-L69)

hasL3FieldDeletion performs a fresh db.prepare(...) + query for every candidate key, so protectedL3FieldOverrideKeys becomes an N+1 prepare/execute pattern during bundle import (which can carry many override keys). Prepare the statement once outside the loop and reuse it, or resolve all shared project ids per key in a single set-based query.


37. Memory/src/storage/l3-field-deletion-boundary.ts (L49)

The SQL already filters json_extract(value_json,'$.cleared')=1, so typeof state.cleared !== "boolean" can never be false here and the repair only ever triggers on a missing/non-string revision. The cleared half of the condition is dead and misleading — either drop it or widen the WHERE clause if rows with a non-boolean cleared were also meant to be repaired.


38. Memory/src/storage/panel-cache.ts (L114-L118)

After the backfill drains, every subsequent fillPanelCacheBatch(..., "memories", ...) call re-executes both CREATE INDEX IF NOT EXISTS statements (DDL + schema re-check) in steady state. Consider caching the completed state (module-level flag or a sqlite_master existence check) so the DDL only runs once per process after the final backfill batch.


39. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L23)

This JSON.parse (and the identical unguarded JSON.parse(raw.message_payload_json) in sourceKey) can throw on a corrupt row. canonicalScope/keyOf are called from the recall hot path (IndexedCandidatePool.refill and the dedup loop), and the exception is not caught there, so one malformed sessions.meta_json value rejects the whole Promise.all(routeTasks) and fails the entire recall instead of just skipping that alias. Note the sibling implementation shared-project.ts wraps the same parse in try/catch and even filters with json_valid(meta_json); do the same here and treat unparsable metadata as unknown evidence.

💡 Suggested Change

Before:

        const meta: unknown = JSON.parse(row.meta_json);

After:

        let meta: unknown;
        try { meta = JSON.parse(row.meta_json); } catch { continue; }

40. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L106)

memoryIdByMemoryId is populated before the content check, so a group member whose body/version differs from the representative is still remapped to that representative id. retrieval-service.run() happens to re-check sameRecallContent before using the remap, but the map is part of the public result contract and any other consumer would silently substitute one body for another. Map mismatched members to themselves instead of only suppressing their channel evidence.

💡 Suggested Change

Before:

      if (!sameRecallContent(memory, group.representative)) continue;

After:

      if (!sameRecallContent(memory, group.representative)) {
        memoryIdByMemoryId.set(memory.id, memory.id);
        continue;
      }

41. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L194)

sameRecallContent compares JSON.stringify of an ad-hoc field subset that includes arrays/objects such as trace.tool_calls, trace.error_signatures, tags. JSON serialisation is key-order sensitive, so two semantically identical memories produced by different code paths (different key insertion order) compare as different. That silently disables channel-evidence merging here and makes run() drop a legitimately remapped hit (return []). Prefer comparing stable identifiers (memoryValue + contentHash + trace.step_index/turn_id) or a canonical (sorted-key) serialisation.


42. Memory/src/service/retrieval/cross-account-candidate-dedup.ts (L128)

New dead code: recallSourceKey has no call site anywhere in the repo, and the newly returned groupKeyByMemoryId is never read by any consumer (IndexedCandidatePool and RetrievalService only use memories, channelScoresByMemory and memoryIdByMemoryId). Please drop the unused export/field or wire it to a real consumer to avoid carrying an unused API surface.


43. Memory/src/service/retrieval/indexed-candidate-pool.ts (L147)

Route failures are swallowed here (stopReason="error" + break) and in the non-L1 fast path above (catch { ... return []; }). A failing FTS/pattern/vector query therefore silently produces fewer candidates with no log and no surfaced error — only an opaque status string later. Surface the error (logger or propagation) so recall degradation is diagnosable; at minimum keep the same error handling for the non-L1 branch instead of returning an empty result set.


44. Memory/src/service/retrieval/indexed-candidate-pool.ts (L112-L113)

The refill work budget is hardcoded here (5s deadline, 10k rows, 50k metadata rows) and duplicated again in RetrievalService.retrieveSearchMemories/retrieveUserMemories (Date.now() + 5_000, 10_000, 2_000, 128 rounds, 256 batch, 2048/2049 session window, 64/65 capture cap). These are operational tuning values, not constants; extract shared named constants (or config) so the pool and the service cannot drift and so limits can be tuned per deployment.


45. Memory/src/service/retrieval/retrieval-service.ts (L2530)

This dedup call does not receive resolveSourceKey, so a brand-new createRecallSourceResolver is built on every invocation and re-runs the sessions scan + source_turn_captures/raw_turns provenance queries for every memory. run() is executed once per query plan and again on every refill round (up to 8 outer + 8 inner iterations), and IndexedCandidatePool already resolved the same keys. Pass the pool's resolver through SearchMemoryOutput (or memoise per request) and reuse it here.


46. Memory/src/service/retrieval/retrieval-service.ts (L2528)

Every merged hit is recorded under the fts channel regardless of the route it came from (vec, pattern, structural, ...), and an existing entry is overwritten instead of being merged. candidateScore currently only takes max, so ranking happens to survive, but the map semantics are wrong and this will silently skew representative selection/merged evidence if the scoring changes. Derive the key from hit.channel and merge per channel.

💡 Suggested Change

Before:

      for (const output of outputs) for (const hit of output.retrieval.hits) scores.set(hit.id, {fts: hit.score});

After:

      for (const output of outputs) for (const hit of output.retrieval.hits) {
        const key = (hit.channel ?? "fts") as keyof SeededChannelScores;
        scores.set(hit.id, { ...(scores.get(hit.id) ?? {}), [key]: Math.max(scores.get(hit.id)?.[key] ?? 0, hit.score) });
      }

47. Memory/src/service/retrieval/retrieval-service.ts (L2462-L2463)

The previous implementation returned memories.slice(0, routeLimit); the new code returns every accepted candidate (bounded only by the 10k row budget). Callers use this list for expectedUsers/currentUsers maps and full-record JSON.stringify revalidation plus candidateMemoryIds, so a user with thousands of memories now allocates/compares thousands of records per recall (and again on every refill round). Only hits capped at limit are injectable, so return injectable plus a bounded reserve instead of the whole candidate set.

💡 Suggested Change

Before:

    const memories = [...candidates.values()].sort((left, right) =>
      (bestScoreById.get(right.id) ?? 0) - (bestScoreById.get(left.id) ?? 0) ||

After:

    const memories = [...candidates.values()].sort((left, right) =>
      (bestScoreById.get(right.id) ?? 0) - (bestScoreById.get(left.id) ?? 0) ||

48. Memory/src/service/retrieval/retrieval-service.ts (L2098)

The user-memory revalidation compares the entire serialised UserMemoryRecord, which includes volatile fields such as embedding, embeddingModel, updatedAt and deletedAt. Background embedding refresh deliberately preserves updatedAt and only swaps the vector, so a perfectly valid hit will be classified as a concurrent_change, dropped from hits, and can trigger refill churn. Compare the fields that actually affect injected content (status, sourceTurnId, content, sorted memoryTypes, deletedAt) rather than the whole record.

💡 Suggested Change

Before:

      return !user || Boolean(currentUsers.get(id)?.status === "active" && JSON.stringify(currentUsers.get(id)) === JSON.stringify(user));

After:

      const currentUser = currentUsers.get(id);
      return !user || Boolean(currentUser && currentUser.status === "active" &&
        JSON.stringify([currentUser.sourceTurnId, currentUser.content, [...currentUser.memoryTypes].sort(), currentUser.deletedAt]) ===
        JSON.stringify([user.sourceTurnId, user.content, [...user.memoryTypes].sort(), user.deletedAt]));

49. Memory/src/service/session/session-turn-service.ts (L3352-L3357)

This paged scan re-runs deduplicateCrossAccountMemoryCandidates over the entire accumulated candidates array on every iteration. Because no resolveSourceKey resolver is passed, each call builds a fresh createRecallSourceResolver cache, so canonicalScope re-queries sessions and sourceKey re-queries raw_turns/source_turn_captures per memory — the work grows quadratically with the number of batches (up to 10 000 rows / 20 batches) inside a synchronous request path. The Date.now() < deadline guard only runs between batches, so a single batch can already blow past the 5 s budget. Also, [...excluded] is rebuilt into a growing NOT IN json_each(?) list on every iteration. Consider: collecting the raw rows with a single bounded query (or keeping one resolver across iterations via resolveSourceKey), and de-duplicating once after the scan instead of per batch.


50. Memory/src/service/session/session-turn-service.ts (L3365-L3366)

This termination condition is very hard to verify: 10_000-(excluded.size-fresh.length) re-derives the previous scan count after excluded has already been mutated, and since the SQL already excludes excludedIds, fresh.length === batch.length is always true, making the arithmetic look intentionally wrong. Hoist the limit into a named const before the list call and reuse it here (e.g. const limit = Math.min(500, 10_000 - scanned); ... if (batch.length < limit) break;), so the paging/termination semantics are readable. The literals 500 / 10_000 / 5_000 are also unexplained magic numbers on a hot path — a short comment (or config values) documenting why the pool is capped would help future maintainers.


51. Memory/src/service/read-model/episode.ts (L273)

The "read as local scope" predicate is now spelled out inline here while refsForMemory/effectivePolicyLinks compute the same value into local. Two copies of the same authorization decision will drift as soon as the policy changes (e.g. adding another bypass flag). Compute const local = panelRead || this.deps.localMemory; once at the top of getMemory and reuse it here.

💡 Suggested Change

Before:

        toolCalls: rawTurn && !rawTurn.deletedAt && !rawTurn.redactedAt && (panelRead || this.deps.localMemory ? !memory.sessionId || rawTurn.sessionId===memory.sessionId : rawTurn.userId===memory.userId)

After:

        toolCalls: rawTurn && !rawTurn.deletedAt && !rawTurn.redactedAt && (local ? !memory.sessionId || rawTurn.sessionId===memory.sessionId : rawTurn.userId===memory.userId)

52. Memory/src/service/read-model/panel-read.ts (L711)

Both panel task queries now pass ownerScoped = true, so buildEpisodeWhere adds user_id = ? whenever input.namespace?.userId is present. Per Memory/src/service/namespace/memory-owner.ts, historical rows keep their original account uid with no data migration, while new rows use DEVICE_MEMORY_OWNER. If a panel request still carries a uid (header supplied by an older client, or the device owner on a DB that contains pre-migration episodes), the list/count will silently hide those legacy episodes and total/pagination will disagree with what panelPageByIds/requireTask can still resolve by id. Please confirm the panel path always supplies a uid consistent with the stored episodes.user_id, or gate the flag the same way the surrounding helpers do (!input.namespace?.userId).


53. Memory/src/service/memory-service.ts (L2506)

This guard becomes unsatisfiable in cloud mode: UserMemoryRepository.insert and MemoryRepository.insertRow now force userId = DEVICE_MEMORY_OWNER, so a cloud request's real account id can never equal userMemory.userId, and every user-memory detail read returns not_found. The related ownership checks were also dropped entirely from deleteMemory (user memory + L3 World Model) and from the recall-evidence read, while this read keeps a cloud-only check — so in cloud mode deletes are now unrestricted by account. Either gate the device-owner write on this.mode !== "cloud", or apply the same local flag consistently to the delete/recall paths.


54. Memory/src/storage/repositories.ts (L1953)

The userId argument is now ignored: the WHERE clause still filters user_id = ? but the binding is hardcoded to DEVICE_MEMORY_OWNER. For rows created before this change (which keep their historical owner UID, per the new memory-owner.ts contract) getActiveByNormalizedText can never match again, so the caller at line ~1680 (this.getActiveByNormalizedText(memory.userId, ...)) loses dedupe and will create duplicate user memories for the same normalized text after upgrade. Either drop the user_id predicate (matching the other deviced-scoped queries) or bind the passed userId.

💡 Suggested Change

Before:

    ).get(DEVICE_MEMORY_OWNER, hash) as UserMemorySqlRow | undefined;

After:

    ).get(hash) as UserMemorySqlRow | undefined;

55. Memory/src/storage/repositories.ts (L2354)

The owner predicate was removed here but the caller still passes the owner in the binding object: bindRawTurnSourceConversation({ id, sessionId, userId, turnId }, conversationId) runs .run({ ...scope, conversationId }) where scope still contains userId, and hasCompletedSourceTurnInScope(input) binds the whole input (also containing userId) to a query that no longer references @userId. The type (Pick<RawTurnRecord, "id" | "sessionId" | "userId" | "turnId">) and bindings should be trimmed so the statement parameter map matches the object; otherwise the binding depends on better-sqlite3 tolerating unknown named parameters and reports a misleading owner input.


56. Memory/src/storage/repositories.ts

userId is now unused in this query (and in embeddingMaintenanceEntries, embeddingMaintenanceEntriesByIds, embeddingDimensionCounts). The signature still advertises an owner filter that no longer exists, and callers keep passing namespace.userId — either remove/deprecate the parameter or keep it meaningful (e.g. map it to DEVICE_MEMORY_OWNER internally).

💡 Suggested Change

Before:

    ).all(memoryKey) as MemorySqlRow[];

After:

    ).all(memoryKey) as MemorySqlRow[];

57. Memory/src/storage/repositories.ts (L1452)

rank AS raw_rank is selected but never read — the row type is Array<{ id: string }> and the mapper only uses row.id. Drop the extra column (the ORDER BY rank, memories.id already gives a deterministic ordering).

💡 Suggested Change

Before:

          `SELECT memories.id AS id, rank AS raw_rank

After:

          `SELECT memories.id AS id

58. Memory/src/types.ts (L192-L193)

MemoryFilter.userId is retained but buildMemoryWhere no longer applies it (the addValueClause("user_id", filter.userId) call was replaced by a comment), while addWorkMemoryScopeClause also voids the user id. Any caller still passing userId silently gets an owner-unscoped read. Mark the field @deprecated/removed so the contract change is explicit and dead calls can be cleaned up.


59. Memory/src/storage/memory-relations.ts (L194)

In local mode a row is now only deletable when its episode link can be resolved (taskEpisode IS NOT NULL) or when its stored user_id equals episode.userId. Rows captured before this change keep a historical owner UID, so any L1 whose episode cannot be resolved through raw_turns/properties is silently skipped by ownedEpisodeMemoryIds and left behind as an orphan after the episode delete cascade. Please confirm this is intended, and consider resolving the episode link (rather than owner) as the fallback criterion for local mode.


60. Memory/src/service/worker/worker-work-requirements.ts (L51-L52)

sharedProjectIds → sharedProjectIdentity runs ~6 queries (including a full sessions scan with json_extract(meta_json,'$.workspace_uri'), which cannot use an index) on every invocation. workerWorkRequirements is called per admitted candidate inside the claim transaction (worker-runner.ts:698), and this function is explicitly designed to stay cheap during scanning (see comments at lines 19–20 and 45). This adds heavy per-candidate DB work to the hot admission path.

Also note sort()[0] picks a lexicographic alias, which is not the canonical sharedProjectIdentity(...).deviceProjectId used elsewhere (e.g. l3FieldStateKey), so the permit key changes whenever a new alias is discovered.

Suggestion: resolve the canonical shared project once per claim batch (or memoize per projectId for the lifetime of one scan) and reuse sharedProjectIdentity(...).deviceProjectId as the key.


61. Memory/src/service/worker/job-handlers.ts (L786-L787)

Dropping namespace.userId changes the string produced by namespaceIdFromContext, which is persisted in change/recall rows (namespace_id columns) and used as a filter key by runtime.latestChangeSeq(userId, namespaceId) (see session-turn-service.ts:1419/2631/2799). Existing rows were written with the old tenant:user:project:source:profile format, so after this change historical sessions will compute a different namespace id and their change cursors will read as 0. Please confirm a compatibility strategy (key normalization on read, or an explicit cursor reset) is in place; otherwise incremental panel/change-feed consumers may silently stop seeing prior changes.


62. Memory/src/service/work-memory/work-memory-pipeline.ts (L432)

sharedProjectIds() runs several sync SQLite queries (sessions + l3_world_model_scopes lookups and validation) but is invoked inside the per-candidate map, so the same project aliases are recomputed for every candidate on every extraction. Compute it once before the map (and skip it entirely when filter.workMemoryProjectId === undefined, since isWorkMemoryInScope short-circuits in that case).

💡 Suggested Change

Before:

      return { candidate, matches: memories.filter((memory) => isWorkMemoryInScope(memory, filter, sharedProjectIds(this.deps.repos.db, filter.workMemoryProjectId ?? null))) };

After:

      return { candidate, matches: memories.filter((memory) => isWorkMemoryInScope(memory, filter, projectIds)) };

63. Memory/src/service/work-memory/work-memory-pipeline.ts (L347)

Reassigning the input parameter silently discards the caller-provided owner, which makes the flow hard to follow and hides the fact that userId is no longer meaningful. Prefer a local constant for the owner (and keep the parameter immutable).

💡 Suggested Change

Before:

    input = { ...input, userId: DEVICE_MEMORY_OWNER };

After:

    const userId = DEVICE_MEMORY_OWNER;

64. Memory/src/service/work-memory/work-memory-pipeline.ts (L324)

After dropping the owner comparison, scope.userId is no longer read by qaForRawTurn (the callers at qaForRawTurn(rawTurnId, { userId: batch.userId, ... }) still build it). Remove userId from the scope parameter type and from the call sites, otherwise it is dead wiring that suggests a check which no longer exists.

💡 Suggested Change

Before:

    if (rawTurn.sessionId !== scope.sessionId) {

After:

    if (rawTurn.sessionId !== scope.sessionId) { // scope: { sessionId: string }

65. Memory/src/service/work-memory/work-memory-pipeline.ts (L777)

After removing the owner check here, filter.workMemoryUserId has no consumer left: isWorkMemoryInScope no longer reads it, and addWorkMemoryScopeClause in repositories.ts explicitly ignores it (void userId). The filter built in extract() (workMemoryUserId: job.userId) is therefore dead wiring that still implies an active owner boundary. Drop that field from the filter and from MemoryFilter so the retrieval contract reflects the device-scoped owner model.


🧹 Filtered 1 low-confidence OCR finding(s) before posting/fix-loop (existing_code_mismatch: 1).

Generated by cloud-assistant via Open Code Review.

@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/src/adapters/outbound/skill-writer/hermes/target.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts, App/backend/src/infrastructure/memmy-config/index.ts, App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts, App/memmy-agent/src/memmy-memory/config.ts, App/memmy-agent/src/memmy-memory/discovery.ts, App/memmy-agent/src/memmy-memory/hook.ts, App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts, App/memmy-agent/tests/memmy-memory/discovery.test.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: backport-pr795-v126

Comment thread Memory/src/storage/repositories.ts Outdated

listActive(userId: string, limit = 2000): UserMemoryRecord[] {
private activeWhere(userId: string, options: UserMemoryRecallOptions = {}) {
const clauses = ["user_memories.user_id = ?", "user_memories.status = 'active'", "user_memories.deleted_at IS NULL"];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

召回开关开启时,历史 User Memory 连原账号也无法召回

问题

升级前已保存的 User Memory(用户偏好、事实等),升级后仍能在记忆面板看到,但正式搜索无法召回;即使用原来写入它的账号查询也一样。补充测试保留历史行的 user_id=account-a,并显式开启 enableUserMemoryRecall,通过 service.search() 查询同一内容:指定 base 返回该记忆,新 head 返回空结果。新增的历史跨账号测试也在此处失败。这个问题发生在 User Memory 召回开关开启时,开关关闭时 Agent 不收到 User Memory 是预期行为;修复此底层查询问题不代表要打开开关或撤销关闭设置,而是保证开关以后开启时能够正常召回。

原因

请求的账号已在 namespace 归一化时替换为固定设备 owner,而这里的 activeWhere() 仍要求 user_memories.user_id 等于传入 owner。历史行按本 PR 的约定保留旧 UID,因此全部被过滤。这个条件同时影响有效数量统计、普通列表、全文检索、关键词检索和向量检索;后续分页补取无法补回已经被查询条件排除的行。记录没有被删除,是读取规则没有完成 UID 解耦。

建议做法

让 User Memory 的所有召回入口统一使用设备共享的读取规则,移除账号 UID 过滤,同时保留有效状态、删除状态、来源范围、排除回合及向量模型匹配等条件。不要通过批量改写历史 UID 修复。补充正式搜索的旧库升级/重启测试,覆盖原账号、另一账号和 API Key 模式,以及全文、关键词、向量三条补取路线;修复后跑通当前失败的历史保留与补取测试。

});
// A read projection only: retain each historical record and its ordering.
// Never seed/merge the writable device scope with these historical fields.
const field = (key: keyof typeof records[number]["fields"]) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

修改或清空旧 L3 规则后,Agent 仍会收到旧规则

问题

例如,v9 历史项目规则是 Run tests。升级后把它改成 Use pnpm test instead of npm test,Agent 实际收到的上下文却同时包含旧规则和新规则;再清空该字段,Agent 又会收到旧的 Run tests。补充测试直接读取实际 L3 上下文:更新后预期只有新规则,head 返回新旧两条;清空后预期为空,head 返回旧规则。直接更新、执行排队演化任务、更新后清空这三个场景在指定 base 全部通过。这里不是要求删除历史数据库行;问题是旧行保留后,读取层仍把旧值当成当前有效规则。

原因

读取层把历史账号记录和设备记录的同一字段直接拼接,并过滤掉 null,没有表达“新版已覆盖旧版”或“此字段已明确删除”。同时,演化任务读取当前字段时调用的 fields() 只看设备记录,读不到仍会注入 Agent 的历史内容。于是演化可能把旧的 Run tests 当作空字段并按“新建”处理;写入新值后上下文仍拼出新旧两条;清空设备字段时,因 null 没有作为删除标记保存,历史旧值又成为唯一非空值并重新出现。

建议做法

统一演化输入与 Agent 上下文所使用的有效字段规则:没有设备更新时读取经验证的历史字段;设备明确更新或删除后,以该结果覆盖历史字段,区分“尚未写入”和“明确清空”。可以单独记录覆盖/删除状态,保持历史行原值不变。继续保留旧 batch 原始哈希及项目检查;环境画像还需携带其对应的已应用项目环境扫描编号。回归覆盖修订、清空、重启、跨账号读取及旧排队任务,直接断言 Agent 收到的内容。

if (projectId === null) return [null];
const rows = db.prepare(`
SELECT DISTINCT peer.project_id AS projectId
FROM sessions origin JOIN sessions peer

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

历史记录缺少机器标识时,相同路径会被误认成同一项目

问题

PR 前生成的数据确实有缺少机器标识的可能。目标 base v1.2.6 的 hook 在 Memory 服务未报告支持 v2 时会走旧协议兼容分支,只传 workspacePath,不传 workspaceHostId(App/memmy-agent/src/memmy-memory/hook.ts,第 728、742 行);服务端旧版 session 写入使用清洗后的元数据(Memory/src/service/session/session-turn-service.ts,第 896 行),清洗函数会移除 workspace_host_id(同文件第 4608 行)。因此通过该路径创建的 session 不会保存机器标识。

本次复现没有运行真实导出/导入流程。测试构造的是两台机器曾各自生成不同历史项目编号、但 session 中 workspace_host_id 缺失且 workspace URI 相同的情形。测试 fixture 原先有 host 值,随后专门移除它来模拟缺失;这不能说明真实存量中缺失记录的比例,也不能证明标准导入会丢掉该字段。读取其中一个项目的 L3 时,head 会把另一个项目的规则也读进来,base 只返回本项目规则。机器标识仍存在且能区分两台机器时,该测试没有复现问题。

原因

这段 JOIN 只比较 workspace URI,并使用 SQL IS 比较 host;两个缺失值满足 NULL IS NULL,所以两条记录进入同一别名集合。结果是查询时把两个原本不同的项目范围当成别名一起读取,而不是把数据库中的项目记录物理合并。这里没有校验项目编号是否确实由该历史 owner、workspace 和 host 推导,也没有拒绝冲突证据。后面的 host/hash 校验只会追加别名,不会撤销前面已加入的错误项目。L3 读取和工作记忆范围查询都会使用这个集合。

建议做法

所有别名入口统一采用可验证的项目身份规则:本地路径必须有可靠 host 证据,并验证旧 UID 派生编号与设备编号的关系;缺失、矛盾或无法验证时保留原项目隔离,不因路径相同而扩大读取范围。补充同路径不同机器、双方缺 host、伪造或修改 workspace 元数据、冲突映射和无 session 的历史 scope 测试,同时验证正确的跨账号项目仍可共享。

@Orsted290

Copy link
Copy Markdown
Contributor Author

三条 review comment 已逐项修复,修复提交为 2f64bd5b;同步最新 v1.2.6 并解决冲突后,已推送到 87d5cf3726c79b6ddc0dfcbd3be050f661b6abbd。

1. 历史 User Memory 在召回开启时仍无法召回

  • 移除 activeWhere() 中的账号 UID 过滤,使有效数量统计、普通列表、全文、关键词、向量检索和分页补取统一按设备共享规则读取历史记录。
  • 保留有效/删除状态、排除 ID、排除回合和表达式,以及向量模型/维度匹配等条件。enableUserMemoryRecall 的开关含义保持不变:关闭时不向 Agent 返回 User Memory,开启时能够召回历史记录。
  • 同时修复向量窗口不足 2,000 条、但首批命中被重复来源占满时的提前停止条件,继续补取窗口内剩余候选。
  • 验证覆盖:保留旧 user_id 的历史行,原账号、另一账号、无 UID 和 HTTP API Key 查询;开关开启/关闭;重启;全文、关键词、向量分别作为有效路线补足 20 个唯一来源;归档行排除;无效 token 返回 401、无读取权限的 key 返回 403。

代码:统一读取条件、窗口内继续补取。

2. 修改或清空旧 L3 后仍注入旧规则

  • fields()、Agent 上下文、演化输入和提交校验统一使用 effectiveFields()。每个字段分别区分“尚未写入”“明确覆盖”“明确清空”:尚未写入时读取经验证的历史字段,新值覆盖该字段旧值,明确清空后不再回退到历史值。
  • 使用现有 runtime_kv 独立保存字段覆盖/清空状态和修订号,与新字段写入在同一事务内提交;历史 L3 行保持原值。修订号还能识别模型调用期间“清空后又恢复原值”的并发修改,旧结果被拒绝后从当前有效字段重试。
  • 保留旧 batch 的 owner、scope、原始 payload 哈希、session/项目与证据校验。环境画像按其源记录对应的 applied_scan_id 验证;历史画像的 noop 不会把旧内容复制进设备 scope。
  • 验证直接断言实际 L3 上下文:更新后只保留新规则,清空后为空;覆盖字段独立性、重启、跨账号、新旧排队 batch 演化、并发失效与重试、历史行快照保留,以及画像扫描编号失效和明确清空。

代码:有效字段与独立状态、演化输入和修订号校验、Agent 上下文。

3. 历史记录缺少 host 时同路径项目串读

  • 所有项目别名入口统一先验证身份,再纳入共享集合。本地路径要求有效 host,并校验旧 owner、规范化 URI、host 与旧 UID 派生项目编号/设备项目编号的关系。
  • 缺失、矛盾或无法验证的证据保持原项目隔离;不再因双方 host 都为 NULL 且 URI 相同而扩大读取范围。冲突映射按批查询。
  • 无 session 的历史 scope 也必须能通过可靠 peer host 与旧 owner/hash 验证,才允许关联。
  • 验证覆盖:双方/单方缺 host、错误 host、修改 URI、伪造项目编号、冲突 session 映射、无 session 历史 scope,以及正确跨账号共享和另一台机器隔离。

代码:统一项目身份验证。

测试结果与验收边界

验证范围 结果
同步及冲突解决后的 87d5cf37:向量维护 reader、历史 UID 维护、UID/L3、召回补取和项目画像定向回归 201 项通过,20 个测试文件
87d5cf37:Memory TypeScript 检查、git diff --check 通过
同步前修复提交 2f64bd5b:npm run memory:lint(含 Memory/viewer 类型检查) 通过
2f64bd5b:Memory 全量回归 2,735 通过、2 项原有跳过、1 项工具链失败

全量回归的唯一失败是 build-standalone.test.ts:本地缓存的 pkg-fetch 无法识别要求的 Node v24.22.1。同一失败已在原始 PR 提交 f0b163ef 上独立复现,不将该套件列为全部通过。同步目标分支后保留了新增的异步 reader、分页和按目标集合查询,并验证历史 UID 行能够完成向量维护且不改写原 UID。

新增回归集中在 uid-review-regressions.test.ts,并复跑了既有 UID、L3、召回补取、画像与向量维护测试。

上述验证使用独立 SQLite fixture、隔离的 Memory home 和本地 HTTP 服务;未进行 DMG 安装/真实桌面 GUI 验收、真实模型调用或真实历史库导入导出。缺 host 的复现证明该数据形态下存在串读风险,不据此推断真实导入会丢失 host 或存量发生比例。

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: GIT PREPARATION FAILED

Git preparation did not complete. Tests were not executed; inspect the recorded command evidence.

Details: Executor error: Git clone failed (timeout, attempt 1/2, timeout 300000ms). The command deadline expired; this alone does not identify a network or authentication root cause.
Branch: backport-pr795-v126

const before = this.memories.get(memoryId);
if (!before) throw new Error(`corrupt L3 World Model scope memory: ${memoryId}`);
validateL3WorldModelMemory(before, scope.userId, scope.projectId);
for (const field of L3_FIELD_NAMES) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

删除旧 L3 记录会同时屏蔽其他未删除记录的有效字段

问题

同一项目保留历史 L3 记录,并且升级后已经生成新的设备级 L3 时,删除旧记录会连带清空其他记录的注入内容。例如旧记录只有项目约定,新设备记录保存了独立的领域知识;通过正式 deleteMemory() 删除旧记录后,新记录仍在数据库中,正文和状态都没变,但 Agent 上下文里的领域知识变成了空。相同场景在指定 base 通过、当前 head 失败。

原因

这里不检查被删记录实际包含哪些字段,也不检查哪个记录提供当前有效值,而是把该项目的画像、约定、领域知识全部写成 cleared: true。这些标记按共享项目编号保存,effectiveFields() (line 6081) 看到标记就不再读取任何候选,因此一次按记录 ID 的删除实际变成了整个共享项目的字段清空。

建议做法

让按 ID 删除只影响选中的记录。写清空标记前,逐字段确认当前有效内容是否由该记录提供;来自其他未删除记录的有效值和已有覆盖标记应保留。显式清空字段仍需防止旧内容重新生效,但不能借此覆盖无关字段。补充“旧记录仅有约定、新记录另有知识或画像,删除旧记录后其他字段继续注入”的正式服务测试,并验证重启后的结果一致。

Comment thread Memory/src/storage/repositories.ts Outdated
) {}

getScope(userId: string, projectId?: string | null): L3WorldModelScopeRecord | undefined {
userId = DEVICE_MEMORY_OWNER;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

升级前排队的项目环境扫描无法正确完成

关联位置:project-environment-service.ts (line 70)、repositories.ts (line 6834)。

问题

升级前已有排队的项目环境扫描任务时,旧任务和旧 scope 仍保存账号 UID。升级后继续执行该任务,会报 project_environment_workspace_uri_missing,尽管旧 scope 中确实保存了 workspace URI。补充测试从 v9 数据库恢复任务,通过正式 processProfileJob() 执行:base 成功,新 head 失败。这里是生成项目环境画像的目录扫描,不是 agent-source 对话扫描。

原因

processProfileJob() 用旧任务的 UID 读取 scope,但 getScope() 把传入 UID 强制替换为设备 owner,因此找不到历史 scope。只补这个读取也不够:下游 applyProfile() 将新画像写成设备 owner,却仍按旧 UID 更新已应用扫描记录;读取画像时又按画像的设备 owner 校验扫描编号。单独执行画像生成阶段的补充测试已确认:状态变成 clean、正文保存成功,但上下文仍返回空。

建议做法

为旧任务保留可验证的历史 scope 读取,并统一任务执行后的设备级画像与扫描完成记录的关联。旧任务的 owner、项目和扫描编号仍需校验,历史行保持原值;不要只放宽读取校验或批量改写历史 UID。覆盖“升级前排队→升级后完成→Agent 读到画像→重启后仍可读”的完整流程,同时验证过期任务不能覆盖较新的扫描结果。

@Orsted290

Copy link
Copy Markdown
Contributor Author

已补充修复“旧会话恢复后,新建的设备扫描任务被误拒绝”,本次更新包含以下两条修复提交:

  • a6f2c67f:修复按 ID 删除旧 L3 时误清空其他记录字段,以及历史 UID 排队扫描的 scope 读取与设备画像扫描编号关联。
  • 0213c0c4:修正上述返修中新增的 session owner 相等限制。

本次新增修复

历史 session 保留原账号 UID,新扫描任务使用 __memmy_device_memory__,两者不同是合法的升级兼容状态。此前新增的 session.userId === payload.userId 要求会拒绝恢复会话和 token-compaction 创建的设备任务。

这次仅移除该相等要求,保留任务 owner/payload/持久化任务一致性、session 项目、精确 owner 的 scope_key、workspace URI、扫描编号、字段修订号和 worker lease 校验。没有改写历史 session、scope 或 Memory,也没有新增 UID 分支、扫描版本或修改队列排序。

代码:processProfileJob、回归测试。

测试及本地 auto-cr

  • 新增两个正式流程回归:openSession(sessionId) 恢复扫描,以及 completeTurn + l3WorldModelBoundary(token_compaction) 压缩扫描;验证画像与 applied_scan_id、跨账号读取、重启,以及历史记录保持原值。
  • 主任务定向回归:105 项通过;Memory 全量:2,782 通过、2 跳过、1 项既有工具链失败。唯一失败是 pkg-fetch 无法解析 Node v24.22.1,不计为全量通过。
  • npm run lint、Memory 类型检查通过;仓库级 typecheck 仍受桌面模块缺少 Electron 类型依赖影响。
  • 使用本地 auto-cr(review-branch.sh),独立比较 a6f2c67f → 0213c0c4:未发现有证据的新增缺陷。head 14 个文件 152 项通过;父提交同命令 150 项通过。另用完全相同 fixture 对照:head 两项通过,父提交两项均在原 owner 校验处失败。
  • auto-cr 的 Memory/viewer 类型检查、相关依赖构建和 diff check 均通过。执行器退出码为 2,因为本轮按范围未运行全仓检查和远端 CI,不代表整个 PR 全绿。

范围与边界

本次新增修复只处理 session UID 相等限制。另两项本地 CR(删除较新画像后旧任务重新生效、导入旧任务后 enqueue_seq 顺序判断)未在本轮调整。验证使用隔离 SQLite、临时目录真实扫描及模型 mock;未进行真实模型、生产旧库、DMG/GUI 或远端 CI 验收。

请按以上范围复审。

@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/src/adapters/outbound/skill-writer/hermes/target.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts, App/backend/src/infrastructure/memmy-config/index.ts, App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts, App/memmy-agent/src/memmy-memory/config.ts, App/memmy-agent/src/memmy-memory/discovery.ts, App/memmy-agent/src/memmy-memory/hook.ts, App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts, App/memmy-agent/tests/memmy-memory/discovery.test.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: backport-pr795-v126

`UPDATE l3_world_model_scopes SET memory_id = NULL, updated_at = ? WHERE scope_key = ?`
).run(at, scope.scopeKey);

const pendingTargets = this.db.prepare(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

删除 L3 后,历史排队任务仍可重新生成已删除内容

关联当前文件 6305–6319。

问题

删除当前设备级 L3 后,旧账号遗留的任务仍可重新生成已清空的内容,重启后也会继续注入 Agent 上下文。已确认两种路径:

  • 环境画像扫描:较新的设备扫描完成后,删除其画像;更早排队的旧账号扫描随后执行,又生成一份画像。
  • 对话演化任务:删除项目约定、领域知识或无项目通用规则后,旧任务仍可根据旧对话重新创建这些字段;生成过程中被删除操作阻止的任务,重试后也能重新写入。
    这些场景都没有在删除后创建新任务,也不是另一个原本已存在的记忆副本。隔离数据库与固定模型输出的服务测试已复现,并验证重启后内容仍存在。

原因

deleteScopeMemory() 取消任务时,仍按被删记录的原始范围编号,或精确的 owner、project ID 查找任务,没有覆盖已验证属于同一共享范围的历史账号和项目别名。

对于画像扫描,删除会清空最新扫描状态,留下的旧任务又被 isCurrentScan() 判定为当前任务。对于对话演化,旧任务开始或重试时会读取删除后的空值和最新版本,把它们当作合法起点;模型返回 create 后,提交检查通过,并覆盖删除标记。因此,现有版本检查只能阻止一次生成期间的数据冲突,不能确保旧任务在删除后持续失效。

建议做法

针对本次删除实际清空的字段,将旧任务失效范围扩展到已验证属于同一共享范围的历史 owner/项目别名,或保存删除边界,让任务开始、提交和重试都检查它。画像扫描的先后判定也不能因清空当前状态而倒退。

保留历史 batch 的身份和内容校验,避免影响其他未删除字段、其他 workspace,以及删除后正常创建的新任务。补充两条路径的回归,覆盖旧任务尚未开始、正在生成、失败后重试及数据库重启。

Copy link
Copy Markdown
Contributor Author

已将本地 4 个提交推送到本 PR,最新 head:e38bc5fc。本轮补齐 L3 删除后的旧任务复活、导入绕过撤销,以及删除约定误伤独立画像刷新等链路,具体处理如下。

1. 删除按“实际有效字段”生效

删除前读取 effectiveFields.sources,逐字段判断被删记录是否提供当前有效内容。只有该记录提供的有效字段才写清空标记、撤销对应旧任务;其他未删除记录提供的画像、领域知识及已有覆盖标记继续保留。

例如同一工作区对应历史项目别名 P_A/P_B 和设备项目 P_D,这里只通过既有 host/workspace/项目派生证据确认它们属于同一读取范围,不按路径相同直接扩域。删除 P_D 当前有效约定时,撤销范围覆盖已验证别名下能够回写该约定的旧任务;不影响无关工作区和未删除字段。无项目通用规则按其独立字段范围处理。

2. 对话演化:旧任务在开始、提交和重试时都不能恢复已删字段

删除时将受影响的 queued target 持久化为 applied/no_change;已有 applied/dead_letter target 保持终态。对应尚未完成的 job 同步终结,并清理失效 lease、availableAt、配置阻塞及错误信息,避免已撤销任务继续占据 FIFO、挡住后续新任务。

任务入口检查 target 终态;模型生成后的提交事务再次检查终态,并保留原有字段版本/hash 校验。因此任务尚未开始、正在生成、失败重试及数据库重启四条路径均受约束,生成中的旧输出也不能覆盖删除结果。

3. 导入:保留本地删除意图,覆盖分批和乱序到达

使用本地私有 l3_field_deletion_v1 元数据保存字段删除边界。它是本机的撤销证据,不是 shared=true 之类的共享标记;随后写入新值也不会丢掉对旧任务的撤销意图。

每次导入事务在改动任何行之前,先保留本地已有的旧版 cleared 证据;因此“先 replace 元数据、再 skip/导入旧任务”也不能擦掉删除意图。外部包携带的私有删除/扫描边界不能替换本地边界。

只处理本次实际 insert/replace 的 target/job,不误取消 skip 保留的本地新任务。补齐 target/job/batch 分批到达、job 先到、target 省略状态默认为 queued、applied/dead_letter target 后又到达 failed/configuration job 等情况;已有项目或精确 field scope 证据不足时不扩大范围。已撤销的迟到任务既不能恢复内容,也不能阻塞后续正常任务。

4. 环境画像扫描:删除后旧任务不能重新取得发布资格

环境画像是扫描项目目录后生成的项目环境说明,与对话演化生成的约定/知识分开处理。本地私有 project_environment_scan_boundary_v1 保存可信的扫描任务身份及本地顺序边界,覆盖已验证项目别名;删除或显式清空画像后,清掉 current_scan_id 不会让更早的旧账号扫描重新成为“最新”。

扫描执行和最终发布均检查资格与现有并发条件。导入包的 enqueue_seq 不能赋予旧扫描新的发布资格;删除后真正新请求的本地扫描仍可正常生成画像。

同时修正删除约定误伤画像的链路:只有被删记录实际提供当前有效画像,才撤销画像扫描并重置相关状态。若该记录仅提供约定,其他记录的有效画像、来源信息、排队/生成中的刷新,以及首次独立扫描继续保留。旧 lifecycle 测试预期已同步调整,并增加真实临时目录扫描回归。

5. 身份与范围保持

  • 不批量改写历史 UID、项目编号、scopeKey、batch 身份/hash、raw-turn/L1/input-trace 或历史正文;必要更新仅涉及被删记录、执行终态和本地删除/扫描元数据。
  • 不恢复 session.userId === payload.userId 限制,继续兼容旧会话恢复后创建的设备任务,并保留既有 session/project/workspace 等完整性校验。
  • 删除后真正新建的任务仍可写入;未删除字段、其他 host/workspace 及独立画像任务不受连带影响。
  • 独立的当前格式 bundle enqueue_seq 唯一约束冲突按约定未处理;没有改队列 schema、计数器或在生产导入时删除序号列。

回归与本地 auto-cr

  • 本轮新增导入/删除 47 项、独立画像扫描 6 项,共 53 项全部通过;最终相关五文件回归 100 项通过,Memory/viewer 类型检查通过,差异检查通过。
  • 完整 Memory:2874 通过、2 跳过、1 失败。唯一失败为 standalone 的 pkg-fetch 无法解析 Node v24.22.1,已在本轮修复基线复现相同错误,不能称完整套件全绿。
  • 已实际运行本地 auto-cr/review-branch.sh。最终完整修复差异的 findings=[];独立同数据探针 head 30 项通过,base 19 失败/11 通过,覆盖旧内容恢复、迟到任务阻塞、正式上下文读取及重启。前三轮发现的 applied/dead_letter 迟到 job 与元数据先到遗漏均已补齐并复审。
  • auto-cr 最终退出码 2:head 相关套件 343 通过/1 失败,base 337 通过/同一项失败;该 HTTP/API-key 用例受审查沙箱 listen EPERM 限制。两侧类型检查、构建及新增 53 项回归通过;此结果不等同于所有自动化完成。
  • 验证使用隔离 SQLite、真实 worker/lease、临时项目目录、固定模型输出和数据库重启;未据此声称真实账号/模型、GUI、跨平台安装或远程 CI 已验收。

对应提交:3fedf0d7、e956c5d8、809c11e6、e38bc5fc。

@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/src/adapters/outbound/skill-writer/hermes/target.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts, App/backend/src/infrastructure/memmy-config/index.ts, App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts, App/memmy-agent/src/memmy-memory/config.ts, App/memmy-agent/src/memmy-memory/discovery.ts, App/memmy-agent/src/memmy-memory/hook.ts, App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts, App/memmy-agent/tests/memmy-memory/discovery.test.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: backport-pr795-v126

合入 upstream/v1.2.6 的 330c393,解决 5 个文件的重叠冲突。
保留目标分支的面板缓存、轻量读取、事务与来源归属保护,同时保留设备 owner、历史项目别名读取及 L3 删除/导入撤销边界。
适配新详情和任务删除接口:本地以任务/会话/来源身份兼容历史 UID,云端面板保留账号范围;旧 Viewer 读取不再按配置 UID 过滤。
同步冲突旧测试,补充历史任务与设备记忆混用、来源转移、云端范围及历史 Viewer 读取回归。

验证:完整 Memory 3041 通过、2 跳过、1 项 pkg-fetch Node v24.22.1 工具链失败;lint、Memory/viewer 类型检查、runtime-bundle 6 项与 diff check 通过。
全仓 typecheck 受桌面 Electron 模块/类型缺失阻塞。

Copy link
Copy Markdown
Contributor Author

已解决与最新 v1.2.6(330c393e)的冲突并推送,合并提交为 13b0d7a4。GitHub 当前显示 mergeable=true,PR 尚未合并。

本次同步保留双方逻辑:

  • 保留目标分支的面板缓存、轻量详情读取、写入协调、采集事务和任务来源归属检查;同时保留本 PR 的设备 owner、历史项目别名解析、召回去重补取、L3 字段删除及旧任务/导入撤销边界。
  • 新增的显示查询统一接入本 PR 的项目范围过滤;记忆插入同时保留设备 owner 和目标分支事务。召回索引与面板索引均保留。
  • 任务删除兼容“旧账号任务 + 新设备记忆”:本地依据任务、会话及 raw/trace/span 来源判断归属,不要求历史 UID 相等;仍保留来源已转移到其他任务、来源会话不匹配时的保护,云端继续保留 owner 校验。
  • 详情及 L2 关联读取在本地使用相同的 UID 解耦规则,并继续检查有效来源、删除/脱敏状态和会话关系;不把管理页的授权放宽逻辑直接套给普通详情入口。
  • 本地 Viewer 不再把配置 UID 传进新读取 worker 过滤历史 User Memory;云端凭据的 User Memory 详情和面板列表/任务范围仍保持账号边界,无效 token 和 scope 检查继续保留。
  • 同步冲突旧测试,并将 A/B fixture 明确构造成历史 UID 数据;这些 SQL 只用于测试。生产代码没有改写历史 UID、迁移历史正文或处理此前排除的导入序号冲突。

最终验证:

  • 完整 Memory:3041 通过、2 跳过、1 失败。唯一失败仍是 pkg-fetch 无法解析 Node v24.22.1 的工具链问题。
  • npm run lint、Memory/viewer 类型检查、git diff --check 通过;运行时打包规则 6 项通过。
  • 全仓 npm run typecheck 未通过,停在桌面模块缺少 electron 模块/类型及其连带类型错误;未把它计为通过。
  • 本次是目标分支同步及必要兼容适配;上述结果不代表真实 GUI/安装包或远程 CI 已验收。

@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/src/adapters/outbound/skill-writer/hermes/target.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts, App/backend/src/infrastructure/memmy-config/index.ts, App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts, App/memmy-agent/src/memmy-memory/config.ts, App/memmy-agent/src/memmy-memory/discovery.ts, App/memmy-agent/src/memmy-memory/hook.ts, App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts, App/memmy-agent/tests/memmy-memory/discovery.test.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: backport-pr795-v126

Copy link
Copy Markdown
Contributor Author

已将本地修复和 PR #800 的兼容合并推送到本 PR,最新 head:6b43a65b40413e5609892af17dd4a8057aacbd00。

本次更新包括 06bd4232(云端任务浏览范围及 L3 导入字段绑定修复)和 6b43a65b(按 #800 先合入的情况处理冲突)。合并采用 #800 当前 head 4a538786;审核基线也固定为该提交。

与 PR #800 的冲突处理

冲突集中在 Memory/src/storage/repositories.ts 的 panelMemberIds():#800 新增了缓存成员查询,本 PR 则将过滤入口改为带数据库上下文的 this.memoryWhere(),用于保留历史项目别名等范围规则。

  • 保留 fix: 复用来源缓存加速记忆页面 Agent 筛选 #800 的缓存查询、来源分类、Agent 筛选、搜索/标签/排序逻辑,以及索引、可重启冷回填和 dirty 增量更新。
  • 普通查询继续使用本 PR 的 this.memoryWhere();缓存分支新增的标签过滤也改用同一入口,避免遗漏必需的数据库参数。
  • 缓存分支仍受原有过滤键白名单约束。带项目范围、session、IDs、排除 IDs、时间等条件时继续回退到完整过滤,避免缓存捷径绕过范围约束。

相对合并前 06bd4232,只变化五个文件:#800 的四个文件,加一份 UID/项目范围兼容回归。panel-cache.ts 和 #800 两份原测试与 #800 保持字节一致;去掉新增缓存块及相应 import 后,repositories.ts 与合并前一致。

保留的修复逻辑

  • 历史 User Memory 的读取和召回不依赖当前账号 UID;继续尊重召回开关、有效状态、来源及权限检查。
  • 项目别名只在 workspace、host 和项目派生身份等证据验证后参与读取及任务撤销;相同路径但不同机器、缺失或冲突证据仍隔离。不恢复 session.userId === payload.userId 限制,旧会话仍可执行新设备扫描任务,同时保留 session/project/workspace 校验。
  • L3 删除按被删记录实际提供的有效字段处理,保留其他记录的有效内容及覆盖状态。环境扫描的持久化发布/撤销边界、对话演化的旧任务终态和提交检查继续生效,覆盖排队、生成中、重试、重启及导入路径;删除后真正新建的任务仍允许执行。
  • 06bd4232 保留云端/scoped 任务浏览的 owner 范围,并确保已认证主体不会被 source query 或 UID header 覆盖;本地管理继续兼容历史 UID/NULL owner。
  • L3 导入继续保护本地删除意图和有效字段绑定,覆盖 metadata-only、分批/不同顺序导入、合法完整恢复、损坏 revision、回滚及重启;不从缺字段或空 scope 推断“明确清空”。

生产代码没有批量改写历史 UID、项目编号、batch 身份/hash 或历史正文;独立的 enqueue_seq 导入序号冲突仍按约定未处理。

本次回归结果

  • 新增缓存与历史 UID/项目范围组合回归,验证经验证的历史项目别名可读、其他 host/不可验证项目不被并入,历史行读取前后不变。首轮缓存/来源/兼容回归 10 项通过。
  • 合并后完整 npm run memory:test:3089 通过、2 跳过、1 失败。唯一失败为 standalone 测试的 pkg-fetch 无法解析 Node v24.22.1;已在 fix: 复用来源缓存加速记忆页面 Agent 筛选 #800 基线用同命令复现相同失败,完整套件不计为全绿。该轮在允许监听本机端口的环境运行,HTTP 相关用例通过。
  • npm run lint(包含 Memory/viewer 类型检查)、差异检查通过。独立审核中,head/base 两侧 Memory 构建及 root lint 均通过。

本地 auto-cr 结果与验证边界

已实际运行本地 auto-cr/review-branch.sh,固定 base 为 #800 的 4a538786、head 为 6b43a65b,审核完整 PR 差异及相关调用链,并单独对照合并第一父 06bd4232。结果为 findings=[],修正后的 50 项独立探针全部通过,未获得此前 comment 修复回退或新增代码缺陷的证据。

auto-cr 退出码为 2(代码审查未发现阻塞项,但自动化验证未完成),以下未通过/未验证项单独保留:

  • 审核沙盒相关 Memory 集合:head 1108 通过、16 失败、1 跳过;fix: 复用来源缓存加速记忆页面 Agent 筛选 #800 基线同命令 889 通过、15 失败、1 跳过。共同失败对应 listen EPERM 或其后续等待超时;head 多出的新增 API Key 用例也在监听阶段受阻。补充的进程内真实路由探针已通过,但不将其当作该沙盒 TCP 网络层验证通过。
  • 全仓类型检查两侧均失败,168 条诊断逐行一致,涉及缺失 Electron 模块/类型及连带错误;Memory/viewer 类型检查单独通过。
  • 补充应用配置、发现逻辑及 workflow 集合两侧均 162 通过、5 跳过;Hook 测试文件两侧均因缺少 @anthropic-ai/sdk 无法加载,未计为通过。
  • standalone 工具链失败如上,尚未完成可用工具链下的 standalone 包生成验收。
  • 验证使用隔离数据库、临时项目目录和受控模型输出;本轮未进行真实账号/真实模型、桌面 GUI、安装包、跨平台原生构建或签名发布验收。远程 CI 状态需另行确认,本地 auto-cr 不代表 CI 已通过。

审核未修改待审源码;最终开发工作区干净。

@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/src/adapters/outbound/skill-writer/hermes/target.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts, App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts, App/backend/src/infrastructure/memmy-config/index.ts, App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts, App/memmy-agent/src/memmy-memory/config.ts, App/memmy-agent/src/memmy-memory/discovery.ts, App/memmy-agent/src/memmy-memory/hook.ts, App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts, App/memmy-agent/tests/memmy-memory/discovery.test.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: backport-pr795-v126

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.

4 participants