From fbdaac2bc4034500616bdbdd48a695c86b8cae76 Mon Sep 17 00:00:00 2001
From: Hustzdy <67457465+wustzdy@users.noreply.github.com>
Date: Fri, 31 Jul 2026 10:44:57 +0800
Subject: [PATCH 01/35] feat: exclude dependency tests and docs from desktop
packages (#123)
---
.../src/pages/tests/app-frame.test.tsx | 5 +-
...project-target-picker.interaction.test.tsx | 1 -
.../desktop/electron-builder.unsigned.yml | 4 +
.../desktop/electron-builder.win.unsigned.yml | 4 +
App/shell/desktop/electron-builder.win.yml | 4 +
App/shell/desktop/electron-builder.yml | 4 +
.../tests/packaged-runtime-boundary.test.ts | 46 +++++++++
scripts/internal/package-mac-dmg.sh | 93 +++++++++++++++++++
8 files changed, 159 insertions(+), 2 deletions(-)
diff --git a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx
index 3b15c7c39..a3a487855 100644
--- a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx
+++ b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx
@@ -237,7 +237,7 @@ describe("AppFrame", () => {
it("positions the task action menu as a top-level viewport overlay", () => {
const overlayStyle = resolveSidebarMenuOverlayStyle(
- { right: 188, bottom: 424 },
+ { left: 196, right: 188, bottom: 424 },
{ width: 512, height: 768 },
{ width: 128, height: 128, margin: 8, gap: 4 }
);
@@ -720,6 +720,7 @@ describe("AppFrame", () => {
onPin={() => undefined}
onRequestArchive={() => undefined}
onConfirmArchive={() => undefined}
+ onCancelArchive={() => undefined}
/>
);
@@ -731,6 +732,7 @@ describe("AppFrame", () => {
onPin={() => undefined}
onRequestArchive={() => undefined}
onConfirmArchive={() => undefined}
+ onCancelArchive={() => undefined}
/>
);
@@ -742,6 +744,7 @@ describe("AppFrame", () => {
onPin={() => undefined}
onRequestArchive={() => undefined}
onConfirmArchive={() => undefined}
+ onCancelArchive={() => undefined}
/>
);
diff --git a/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx
index 5e19f91b3..0ed115695 100644
--- a/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx
+++ b/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx
@@ -242,7 +242,6 @@ function PickerHarness(props: {
projects={projects}
registryState={props.registryState ?? "ready"}
disabled={false}
- canChooseOtherFolder
onToggle={() => setOpen((current) => !current)}
onClose={() => setOpen(false)}
onSelect={(nextTarget) => {
diff --git a/App/shell/desktop/electron-builder.unsigned.yml b/App/shell/desktop/electron-builder.unsigned.yml
index b35483193..5c26ab431 100644
--- a/App/shell/desktop/electron-builder.unsigned.yml
+++ b/App/shell/desktop/electron-builder.unsigned.yml
@@ -9,6 +9,10 @@ directories:
files:
- dist/**/*
- package.json
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/**/*.{test,spec}.*"
+ - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
asar: true
asarUnpack:
diff --git a/App/shell/desktop/electron-builder.win.unsigned.yml b/App/shell/desktop/electron-builder.win.unsigned.yml
index 7068fa33c..3b57c9297 100644
--- a/App/shell/desktop/electron-builder.win.unsigned.yml
+++ b/App/shell/desktop/electron-builder.win.unsigned.yml
@@ -9,6 +9,10 @@ directories:
files:
- dist/**/*
- package.json
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/**/*.{test,spec}.*"
+ - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
asar: true
asarUnpack:
diff --git a/App/shell/desktop/electron-builder.win.yml b/App/shell/desktop/electron-builder.win.yml
index 751a617c2..453e1d5ec 100644
--- a/App/shell/desktop/electron-builder.win.yml
+++ b/App/shell/desktop/electron-builder.win.yml
@@ -9,6 +9,10 @@ directories:
files:
- dist/**/*
- package.json
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/**/*.{test,spec}.*"
+ - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
asar: true
asarUnpack:
diff --git a/App/shell/desktop/electron-builder.yml b/App/shell/desktop/electron-builder.yml
index 983d4eedf..ca761e1fb 100644
--- a/App/shell/desktop/electron-builder.yml
+++ b/App/shell/desktop/electron-builder.yml
@@ -9,6 +9,10 @@ directories:
files:
- dist/**/*
- package.json
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/**/*.{test,spec}.*"
+ - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
asar: true
asarUnpack:
diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts
index 255d54b58..d5bc8e9bc 100644
--- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts
+++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts
@@ -234,6 +234,29 @@ describe("desktop packaged runtime boundaries", () => {
}
});
+ it("excludes dependency tests and docs from every desktop app archive", () => {
+ for (const configPath of [
+ electronBuilderPath,
+ unsignedElectronBuilderPath,
+ winElectronBuilderPath,
+ winUnsignedBuilderPath
+ ]) {
+ const config = parseYaml(readFileSync(configPath, "utf8")) as {
+ files?: string[];
+ };
+ const files = config.files ?? [];
+
+ expect(files).toContain("dist/**/*");
+ expect(files).toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}");
+ expect(files).toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*");
+ expect(files).toContain("!**/node_modules/**/*.{test,spec}.*");
+ expect(files).toContain(
+ "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
+ );
+ expect(files).not.toContain("!**/node_modules/**/*.md");
+ }
+ });
+
it("unpacks the sqlite-vec native extension in every desktop package variant", () => {
for (const configPath of [
electronBuilderPath,
@@ -1019,6 +1042,29 @@ describe("desktop packaged runtime boundaries", () => {
expect(winSource).toContain("sqlite-vec-windows-x64/vec0.*");
});
+ it("prunes third-party package docs and tests from macOS runtime before packaging", () => {
+ const source = readFileSync(packageMacDmgPath, "utf8");
+
+ expect(source).toContain("prune_node_modules_non_runtime_files");
+ expect(source).toContain('prune_node_modules_non_runtime_files "$RUNTIME_DIR"');
+ expect(source).toContain("-name tests");
+ expect(source).toContain("-name docs");
+ expect(source).toContain('-iname "README*.md"');
+ expect(source).toContain('-iname "README*.mdown"');
+ expect(source).toContain('-iname "CHANGELOG*.md"');
+ expect(source).toContain('-iname "SECURITY*.md"');
+ expect(source).toContain('-iname "*.test.js"');
+ expect(source).toContain('-iname "*.test.ts"');
+ expect(source).toContain('! \\( \\');
+ expect(source).toContain('-iname "LICENSE*"');
+ expect(source).toContain('-iname "NOTICE*"');
+ expect(source).toContain('rm -f "$RUNTIME_DIR/memmy-agent/dist/skills/README.md"');
+
+ expect(source.indexOf('prune_node_modules_non_runtime_files "$RUNTIME_DIR"')).toBeLessThan(
+ source.indexOf("npx electron-builder"),
+ );
+ });
+
it("sets an explicit edition in macOS package wrappers", () => {
for (const [name, accountChannel, edition] of [
["cn-unsigned", "phone", "cn"],
diff --git a/scripts/internal/package-mac-dmg.sh b/scripts/internal/package-mac-dmg.sh
index d595e49b0..82dbc5462 100755
--- a/scripts/internal/package-mac-dmg.sh
+++ b/scripts/internal/package-mac-dmg.sh
@@ -431,6 +431,97 @@ prune_onnxruntime_native_artifacts() {
esac
}
+prune_node_modules_non_runtime_files() {
+ local runtime_root="$1"
+
+ if [ ! -d "$runtime_root" ]; then
+ return
+ fi
+
+ local modules_dir
+ while IFS= read -r modules_dir; do
+ if [ ! -d "$modules_dir" ]; then
+ continue
+ fi
+
+ local disposable_list
+ disposable_list="$(mktemp)"
+ find "$modules_dir" -depth -type d \( \
+ -name test -o \
+ -name tests -o \
+ -name __tests__ -o \
+ -name doc -o \
+ -name docs -o \
+ -name example -o \
+ -name examples -o \
+ -name coverage -o \
+ -name .github \
+ \) > "$disposable_list"
+
+ local disposable_dir
+ while IFS= read -r disposable_dir; do
+ rm -rf "$disposable_dir"
+ done < "$disposable_list"
+ rm -f "$disposable_list"
+
+ if [ ! -d "$modules_dir" ]; then
+ continue
+ fi
+
+ find "$modules_dir" -type f \( \
+ -iname "README" -o \
+ -iname "README*.md" -o \
+ -iname "README*.mdown" -o \
+ -iname "README*.markdown" -o \
+ -iname "README*.rst" -o \
+ -iname "README*.txt" -o \
+ -iname "CHANGELOG" -o \
+ -iname "CHANGELOG*.md" -o \
+ -iname "CHANGELOG*.mdown" -o \
+ -iname "CHANGELOG*.markdown" -o \
+ -iname "CHANGELOG*.rst" -o \
+ -iname "CHANGELOG*.txt" -o \
+ -iname "CONTRIBUTING" -o \
+ -iname "CONTRIBUTING*.md" -o \
+ -iname "CONTRIBUTING*.mdown" -o \
+ -iname "CONTRIBUTING*.markdown" -o \
+ -iname "CONTRIBUTING*.rst" -o \
+ -iname "CONTRIBUTING*.txt" -o \
+ -iname "CODE_OF_CONDUCT" -o \
+ -iname "CODE_OF_CONDUCT*.md" -o \
+ -iname "CODE_OF_CONDUCT*.mdown" -o \
+ -iname "CODE_OF_CONDUCT*.markdown" -o \
+ -iname "CODE_OF_CONDUCT*.rst" -o \
+ -iname "CODE_OF_CONDUCT*.txt" -o \
+ -iname "SECURITY" -o \
+ -iname "SECURITY*.md" -o \
+ -iname "SECURITY*.mdown" -o \
+ -iname "SECURITY*.markdown" -o \
+ -iname "SECURITY*.rst" -o \
+ -iname "SECURITY*.txt" \
+ \) ! \( \
+ -iname "LICENSE*" -o \
+ -iname "NOTICE*" -o \
+ -iname "COPYING*" \
+ \) -delete
+
+ find "$modules_dir" -type f \( \
+ -iname "*.test.js" -o \
+ -iname "*.test.cjs" -o \
+ -iname "*.test.mjs" -o \
+ -iname "*.test.ts" -o \
+ -iname "*.test.tsx" -o \
+ -iname "*.spec.js" -o \
+ -iname "*.spec.cjs" -o \
+ -iname "*.spec.mjs" -o \
+ -iname "*.spec.ts" -o \
+ -iname "*.spec.tsx" -o \
+ -iname "test.js" -o \
+ -iname "tests.json" \
+ \) -delete
+ done < <(find "$runtime_root" -type d -name node_modules)
+}
+
require_packaged_runtime_file() {
local required_file="$1"
@@ -502,6 +593,8 @@ prune_mac_runtime_artifacts() {
echo "Pruning macOS runtime artifacts for darwin-$target_cpu."
find "$RUNTIME_DIR" -type f -name "*.map" -delete
+ prune_node_modules_non_runtime_files "$RUNTIME_DIR"
+ rm -f "$RUNTIME_DIR/memmy-agent/dist/skills/README.md"
while IFS= read -r module_dir; do
prune_better_sqlite3_build_artifacts "$module_dir"
From 302b8cddded50803716788086727c22a716d221d Mon Sep 17 00:00:00 2001
From: jiang
Date: Fri, 31 Jul 2026 16:13:06 +0800
Subject: [PATCH 02/35] fix: unify cross-agent action backgrounds
---
App/frontend/desktop/src/pages/memory-sources-page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx
index 1c2a24f93..8eff685f8 100644
--- a/App/frontend/desktop/src/pages/memory-sources-page.tsx
+++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx
@@ -858,7 +858,7 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) {
type="button"
onClick={openFullScanConfirm}
disabled={isScanning}
- className="flex items-start gap-3 rounded-card border-content-panel bg-status-error-soft/50 p-3 text-left transition-all hover:bg-status-error-soft/60 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-status-error/20"
+ className="flex items-start gap-3 rounded-card border-content-panel bg-background-paper/70 p-3 text-left transition-all hover:bg-background-paper disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-status-error/20"
>
From f108ad41075f259d06b0541cb589ed2527a4df77 Mon Sep 17 00:00:00 2001
From: jiang
Date: Fri, 31 Jul 2026 16:40:40 +0800
Subject: [PATCH 03/35] fix: simplify memory tool call headings
---
.../desktop/src/pages/memory/memories-sub-page.tsx | 2 +-
.../pages/memory/tests/memories-sub-page.test.tsx | 13 ++++++++-----
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
index 76ee1244e..93c3e8e36 100644
--- a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
@@ -924,7 +924,7 @@ function TraceTurnEventBlock(props: { event: TraceTurnEvent }) {
if (event.kind === "tool") {
return (
-
+
diff --git a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx
index 6c3422d99..425b03126 100644
--- a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx
+++ b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx
@@ -199,8 +199,10 @@ describe("MemoriesSubPage", () => {
expect(html).toContain("摘要");
expect(html).toContain("阅读策略、指标和组合模型相关文件。");
expect(html).toContain("相关步骤");
- expect(html).toContain("工具调用 · rg");
- expect(html).toContain("工具调用 · npm_test");
+ expect(html).not.toContain("工具调用 · rg");
+ expect(html).not.toContain("工具调用 · npm_test");
+ expect(html).toContain('memory-tool-card__name">rg');
+ expect(html).toContain('memory-tool-card__name">npm_test');
expect(html).not.toContain("正文");
expect(html).not.toContain("Goal:");
expect(html).not.toContain("Summary:");
@@ -521,7 +523,7 @@ describe("MemoriesSubPage", () => {
});
const firstThinkingIndex = html.indexOf("先调用系统命令检查内存。");
- const toolIndex = html.indexOf("工具调用 · exec");
+ const toolIndex = html.indexOf('memory-tool-card__name">exec');
const secondThinkingIndex = html.indexOf("工具返回 16 GB 后确认答案。");
const assistantIndex = html.indexOf("这台电脑的内存是 16 GB。");
expect(firstThinkingIndex).toBeGreaterThan(-1);
@@ -629,8 +631,9 @@ describe("MemoriesSubPage", () => {
expect(html).toContain("用户");
expect(html).not.toContain("用户 Query");
expect(html).toContain("记忆管理");
- expect(html).toContain("工具调用 · read_file");
- expect(html).toContain("read_file");
+ expect(html).not.toContain("工具调用 · read_file");
+ expect(html.match(/read_file/g)?.length).toBe(1);
+ expect(html).toContain('memory-tool-card__name">read_file');
expect(html).toContain("输入");
expect(html).toContain("输出");
expect(html).toContain("读取 MemoryPage 页面结构。");
From 664296656cb89beeb20abeee9c78da264b72b5ff Mon Sep 17 00:00:00 2001
From: jiang
Date: Mon, 3 Aug 2026 16:25:11 +0800
Subject: [PATCH 04/35] feat(memory): commit episode routing on turn complete
---
.../local-api-contracts/src/memory-runtime.ts | 8 +-
.../tests/agent-runtime-routes.test.ts | 3 +-
.../tests/http-memory-client.test.ts | 3 +-
.../claude-code/tests/target.test.ts | 3 +-
.../skill-writer/codex/tests/target.test.ts | 3 +-
.../skill-writer/cursor/tests/target.test.ts | 3 +-
.../opencode/tests/target.test.ts | 3 +-
.../skill-writer/templates/memmy-default.ts | 2 +-
.../tests/memory-runtime-contracts.test.ts | 4 +-
.../src/tests/support/mock-memory-client.ts | 3 +-
.../memory/tests/memory-runtime-fixtures.ts | 3 +-
.../tests/memmy-memory/hook.test.ts | 3 +-
.../memmy-memory/references/turn-complete.md | 2 +
.../memmy-memory/references/turn-start.md | 9 +-
Memory/src/server/http.ts | 6 +-
Memory/src/service/memory-service.ts | 9 +-
.../service/retrieval/retrieval-service.ts | 2 +
.../service/session/session-turn-service.ts | 696 +++++++++++-------
Memory/src/storage/repositories.ts | 30 +-
.../contract/memory-rest-service.test.ts | 14 +-
.../retrieval/injected-context.test.ts | 5 +-
.../service/session/episode-relation.test.ts | 283 ++++++-
.../tests/service/session/idle-sweep.test.ts | 13 +-
.../service/session/turn-capture.test.ts | 180 +++--
24 files changed, 867 insertions(+), 423 deletions(-)
diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts
index 9be0d84fa..27c03e73e 100644
--- a/App/backend/local-api-contracts/src/memory-runtime.ts
+++ b/App/backend/local-api-contracts/src/memory-runtime.ts
@@ -300,7 +300,6 @@ export const StartTurnOutputSchema = z.object({
turnId: NonEmptyStringSchema,
contextPacketId: NonEmptyStringSchema,
sessionId: NonEmptyStringSchema,
- episodeId: NonEmptyStringSchema,
injectedContext: InjectedContextSchema,
searchEventId: NonEmptyStringSchema,
sourceMemoryIds: z.array(NonEmptyStringSchema),
@@ -333,11 +332,14 @@ export const CompleteTurnOutputSchema = z.object({
sessionId: NonEmptyStringSchema,
episodeId: NonEmptyStringSchema,
rawTurnId: NonEmptyStringSchema,
- l1MemoryId: NonEmptyStringSchema,
+ l1MemoryId: z.string(),
+ l1MemoryIds: z.array(NonEmptyStringSchema),
+ closedEpisodeIds: z.array(NonEmptyStringSchema),
scheduledEvolution: z.boolean(),
jobs: z.array(JobRefSchema),
changeSeq: z.number().int().nonnegative(),
- serverTime: IsoTimeSchema
+ serverTime: IsoTimeSchema,
+ duplicate: z.boolean().optional()
});
export type CompleteTurnOutput = z.infer;
diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts
index 90cf8a93f..f1f051fe0 100644
--- a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts
+++ b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts
@@ -439,7 +439,6 @@ function startTurnOutput() {
turnId: "turn-1",
contextPacketId: "context-1",
sessionId: "session-1",
- episodeId: "episode-1",
injectedContext: { markdown: "", sections: [] },
searchEventId: "search-1",
sourceMemoryIds: [],
@@ -454,6 +453,8 @@ function completeTurnOutput() {
turnId: "turn-1",
sessionId: "session-1",
l1MemoryId: "memory-1",
+ l1MemoryIds: ["memory-1"],
+ closedEpisodeIds: [],
rawTurnId: "raw-1",
episodeId: "episode-1",
scheduledEvolution: false,
diff --git a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts
index b5239c504..aba0cf7f8 100644
--- a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts
+++ b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts
@@ -440,7 +440,6 @@ function startTurnOutput(body: unknown) {
turnId: input.turnId ?? "turn-1",
contextPacketId: "context-1",
sessionId: input.sessionId,
- episodeId: "episode-1",
injectedContext: { markdown: "", sections: [] },
searchEventId: "search-1",
sourceMemoryIds: [],
@@ -455,6 +454,8 @@ function completeTurnOutput() {
turnId: "turn-1",
sessionId: "session-1",
l1MemoryId: "memory-1",
+ l1MemoryIds: ["memory-1"],
+ closedEpisodeIds: [],
rawTurnId: "raw-1",
episodeId: "episode-1",
scheduledEvolution: false,
diff --git a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts
index 7dc463ab7..067caaeae 100644
--- a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts
+++ b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts
@@ -259,7 +259,6 @@ describe("claude code skill target", () => {
if (url.pathname === "/api/v1/turns/start") {
writeJsonResponse(response, 200, {
turnId: "claude-turn-1",
- episodeId: "claude-episode-1",
sourceMemoryIds: ["claude-memory-1"],
injectedContext: { markdown: "Claude historical context" }
});
@@ -333,7 +332,7 @@ describe("claude code skill target", () => {
answer: "修复已经完成",
sourceMemoryIds: ["claude-memory-1"]
});
- expect(requests[3]?.body.episodeId).toBe("claude-episode-1");
+ expect(requests[3]?.body).not.toHaveProperty("episodeId");
} finally {
await close(server);
}
diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts
index 9d7fb12de..7aa38b09e 100644
--- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts
+++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts
@@ -248,7 +248,6 @@ describe("codex skill target", () => {
if (request.method === "POST" && url.pathname === "/api/v1/turns/start") {
writeJsonResponse(response, 200, {
turnId: "turn-stop-1",
- episodeId: "episode-1",
sourceMemoryIds: ["memory-1"],
injectedContext: { markdown: "Relevant prior context" }
});
@@ -331,7 +330,7 @@ describe("codex skill target", () => {
source: "codex",
sourceMemoryIds: ["memory-1"]
});
- expect(requests[3]?.body.episodeId).toBe("episode-1");
+ expect(requests[3]?.body).not.toHaveProperty("episodeId");
} finally {
await close(server);
}
diff --git a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts
index d2d169392..1020028ea 100644
--- a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts
+++ b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts
@@ -209,7 +209,6 @@ describe("cursor skill target", () => {
if (url.pathname === "/api/v1/turns/start") {
writeJsonResponse(response, 200, {
turnId: "cursor-turn-1",
- episodeId: "cursor-episode-1",
sourceMemoryIds: ["cursor-memory-1"],
injectedContext: { markdown: "Cursor historical context" }
});
@@ -296,7 +295,7 @@ describe("cursor skill target", () => {
sourceMemoryIds: ["cursor-memory-1"],
status: "succeeded"
});
- expect(requests[3]?.body.episodeId).toBe("cursor-episode-1");
+ expect(requests[3]?.body).not.toHaveProperty("episodeId");
const cancelledEvent = {
...eventBase,
diff --git a/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts
index f77f05722..e1b0d2186 100644
--- a/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts
+++ b/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts
@@ -138,7 +138,6 @@ describe("opencode skill target", () => {
if (targetUrl.pathname === "/api/v1/turns/start") {
return jsonResponse({
turnId: "memmy-turn-1",
- episodeId: "episode-1",
sourceMemoryIds: ["trace-1"],
injectedContext: { markdown: "User prefers concise answers." }
});
@@ -188,7 +187,6 @@ describe("opencode skill target", () => {
expect(requests.find((request) => request.path.endsWith("/complete"))?.body).toMatchObject({
adapterId: "memmy-opencode-plugin",
sessionId: "memmy-session-1",
- episodeId: "episode-1",
query: "请检查 README",
answer: "检查完成",
status: "succeeded",
@@ -196,6 +194,7 @@ describe("opencode skill target", () => {
toolResults: [{ tool_call_id: "call-1", content: "README contents", output: "README contents" }],
sourceMemoryIds: ["trace-1"]
});
+ expect(requests.find((request) => request.path.endsWith("/complete"))?.body).not.toHaveProperty("episodeId");
} finally {
globalThis.fetch = originalFetch;
}
diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts
index faba239d9..591c85eed 100644
--- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts
+++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts
@@ -50,7 +50,7 @@ export function renderMemmyDefaultContent(source: string): string {
`memmy-memory turn start --source ${source} --session-id "$SESSION_ID" --query "$USER_QUERY"`,
"```",
"",
- "Use returned `injectedContext` as historical memory context only. Keep the returned `turnId` for completion; `episodeId` identifies the episode selected at turn start. Keep the current user query separate from recalled memory.",
+ "Use returned `injectedContext` as historical memory context only. Keep the returned `turnId` for completion; the final `episodeId` is returned by `turn complete`. Keep the current user query separate from recalled memory.",
"",
"At the end of the turn, write the final interaction:",
"",
diff --git a/App/backend/src/tests/memory-runtime-contracts.test.ts b/App/backend/src/tests/memory-runtime-contracts.test.ts
index 82b12c88f..4f8bff9ec 100644
--- a/App/backend/src/tests/memory-runtime-contracts.test.ts
+++ b/App/backend/src/tests/memory-runtime-contracts.test.ts
@@ -225,11 +225,11 @@ function closeSessionOutput() {
}
function startTurnOutput() {
- return { turnId: "turn-1", contextPacketId: "context-1", sessionId: "session-1", episodeId: "episode-1", injectedContext: injectedContext(), searchEventId: "search-1", sourceMemoryIds: ["memory-1"], hits: [recallHit()], status: [], serverTime: ISO };
+ return { turnId: "turn-1", contextPacketId: "context-1", sessionId: "session-1", injectedContext: injectedContext(), searchEventId: "search-1", sourceMemoryIds: ["memory-1"], hits: [recallHit()], status: [], serverTime: ISO };
}
function completeTurnOutput() {
- return { turnId: "turn-1", sessionId: "session-1", l1MemoryId: "memory-1", rawTurnId: "raw-1", episodeId: "episode-1", scheduledEvolution: true, jobs: [jobRef()], changeSeq: 3, serverTime: ISO };
+ return { turnId: "turn-1", sessionId: "session-1", l1MemoryId: "memory-1", l1MemoryIds: ["memory-1"], closedEpisodeIds: [], rawTurnId: "raw-1", episodeId: "episode-1", scheduledEvolution: true, jobs: [jobRef()], changeSeq: 3, serverTime: ISO };
}
function searchOutput() {
diff --git a/App/backend/src/tests/support/mock-memory-client.ts b/App/backend/src/tests/support/mock-memory-client.ts
index 4dcd8f0cf..acaa73e7b 100644
--- a/App/backend/src/tests/support/mock-memory-client.ts
+++ b/App/backend/src/tests/support/mock-memory-client.ts
@@ -101,7 +101,6 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions =
turnId: input.turnId ?? randomUUID(),
contextPacketId: randomUUID(),
sessionId: input.sessionId,
- episodeId: randomUUID(),
injectedContext: {
markdown: "",
sections: []
@@ -122,6 +121,8 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions =
episodeId: randomUUID(),
rawTurnId: randomUUID(),
l1MemoryId: randomUUID(),
+ l1MemoryIds: [],
+ closedEpisodeIds: [],
scheduledEvolution: false,
jobs: [],
...nextChange(),
diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts
index a3f19e090..2442b256b 100644
--- a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts
+++ b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts
@@ -329,7 +329,6 @@ export function createMockMemoryRuntimeClient(): MemoryRuntimeClient {
turnId: input.turnId ?? "mock-turn",
contextPacketId: "context-1",
sessionId: input.sessionId,
- episodeId: "mock-episode",
injectedContext: { markdown: "- 用户偏好中文注释", sections: [] },
searchEventId: "search-1",
sourceMemoryIds: hits.map((hit) => hit.id),
@@ -343,6 +342,8 @@ export function createMockMemoryRuntimeClient(): MemoryRuntimeClient {
turnId: "mock-turn",
sessionId: "mock-session",
l1MemoryId: "memory-trace-1",
+ l1MemoryIds: ["memory-trace-1"],
+ closedEpisodeIds: [],
rawTurnId: "raw-turn-1",
episodeId: "mock-episode",
scheduledEvolution: true,
diff --git a/App/memmy-agent/tests/memmy-memory/hook.test.ts b/App/memmy-agent/tests/memmy-memory/hook.test.ts
index b95261f5c..18af9d1a3 100644
--- a/App/memmy-agent/tests/memmy-memory/hook.test.ts
+++ b/App/memmy-agent/tests/memmy-memory/hook.test.ts
@@ -13,7 +13,6 @@ function fakeClient() {
startTurn: vi.fn(async (turnId: string, body: any) => ({
turnId,
sessionId: body.sessionId,
- episodeId: "ep-1",
sourceMemoryIds: ["trace-source"],
injectedContext: { markdown: "Relevant prior memory." },
})),
@@ -115,12 +114,12 @@ describe("MemmyMemoryHook", () => {
const completeBody = (client.completeTurn as any).mock.calls[0][1];
expect(completeBody).toMatchObject({
sessionId: "session-generated-1",
- episodeId: "ep-1",
query: "Please continue",
answer: "Done",
sourceMemoryIds: ["trace-source"],
status: "succeeded"
});
+ expect(completeBody).not.toHaveProperty("episodeId");
expect(completeBody.requestId).toMatch(/^memmy-agent-complete:/u);
expect(hook.currentTurnId("cli:direct")).toBeNull();
});
diff --git a/Memory/src/cli/skills/memmy-memory/references/turn-complete.md b/Memory/src/cli/skills/memmy-memory/references/turn-complete.md
index bee90556c..75be561b9 100644
--- a/Memory/src/cli/skills/memmy-memory/references/turn-complete.md
+++ b/Memory/src/cli/skills/memmy-memory/references/turn-complete.md
@@ -17,6 +17,7 @@ API shape:
- `sessionId`, `query`, and `answer` are required;
- `status` is optional and normalized to `succeeded` or `failed`.
- `source` should be passed as `--source ` by installed agent skills.
+- the response returns the final `episodeId` after episode routing and turn persistence commit together.
Never store:
- secrets, credentials, access tokens, private keys, or passwords;
@@ -51,4 +52,5 @@ Working rules:
- keep `answer` accurate to the actual result;
- use `--status failed` when the task failed but the result is still useful to remember;
- do not call this command for a user-cancelled turn;
+- use the returned `episodeId` as the turn's final episode assignment;
- save returned memory ids when later inspection or deletion may be needed.
diff --git a/Memory/src/cli/skills/memmy-memory/references/turn-start.md b/Memory/src/cli/skills/memmy-memory/references/turn-start.md
index f75a461a9..e33915af2 100644
--- a/Memory/src/cli/skills/memmy-memory/references/turn-start.md
+++ b/Memory/src/cli/skills/memmy-memory/references/turn-start.md
@@ -17,10 +17,10 @@ API shape:
- `query` is required;
- `turnId` is optional;
- `source` should be passed as `--source ` by installed agent skills;
-- the response includes the selected `episodeId` and may include injected context, hits, status, and source memory ids;
-- the operation selects, opens, closes, or reopens an episode as needed and records the recall;
-- it creates a `started` RawTurn, attaches it to the selected episode, and records the recall;
-- it does not create an L1 memory before the turn is completed.
+- the response includes `turnId` and may include injected context, hits, status, and source memory ids;
+- the operation records the recall and an internal episode-routing proposal without changing episode state;
+- it does not create a RawTurn, episode, L1 memory, or evolution job before the turn is completed;
+- the final `episodeId` is selected and returned by `turn complete`.
Do not use this command to:
- create a session;
@@ -48,7 +48,6 @@ memmy-memory turn start --source codex --session-id se_123 --query "fix failing
Working rules:
- use the returned `turnId` in `turn complete`;
-- retain the returned `episodeId`; the same `turnId` is also used server-side to bind `turn complete` to that episode;
- read `injectedContext`, `hits`, and `status` before relying on the context;
- treat returned `injectedContext` as historical memory only, not as the current user request;
- keep the current user request separate and authoritative when using recalled memory;
diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts
index c6405d1c2..5c92de782 100644
--- a/Memory/src/server/http.ts
+++ b/Memory/src/server/http.ts
@@ -744,10 +744,13 @@ function publicCompleteTurnResponse(result: unknown): Record {
episodeId: record.episodeId,
rawTurnId: record.rawTurnId,
l1MemoryId: record.l1MemoryId,
+ l1MemoryIds: record.l1MemoryIds,
+ closedEpisodeIds: record.closedEpisodeIds,
scheduledEvolution: record.scheduledEvolution,
jobs: record.jobs,
changeSeq: record.changeSeq,
- serverTime: record.serverTime
+ serverTime: record.serverTime,
+ ...(record.duplicate === true ? { duplicate: true } : {})
};
}
@@ -757,7 +760,6 @@ function publicStartTurnResponse(result: unknown): Record {
turnId: record.turnId,
contextPacketId: record.contextPacketId,
sessionId: record.sessionId,
- episodeId: record.episodeId,
searchEventId: record.searchEventId,
injectedContext: record.injectedContext,
sourceMemoryIds: record.sourceMemoryIds,
diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts
index 0d0bc450f..f49f251bb 100644
--- a/Memory/src/service/memory-service.ts
+++ b/Memory/src/service/memory-service.ts
@@ -214,6 +214,8 @@ type InternalMemorySearchRequest = MemorySearchRequest & {
targetSkillId?: string;
contextHints?: Record;
injectedContextQuery?: string;
+ turnIntentDecision?: unknown;
+ routeProposal?: unknown;
recordEvent?: boolean;
};
@@ -783,8 +785,6 @@ export class MemoryService {
contextPacketId: string;
turnId: string;
sessionId: string;
- episodeId: string;
- closedEpisodeIds: string[];
searchEventId: string;
hits: RecallHit[];
injectedContext: InjectedContext;
@@ -1840,7 +1840,6 @@ export class MemoryService {
request: TurnStartRequest & Record
): ReturnType {
const turnId = request.turnId ?? newId("turn");
- const episodeId = `episode_${stableHash(`readonly:${request.sessionId}:${turnId}`).slice(0, 20)}`;
const contextHints = turnStartContextHints(request);
const search = await this.search({
requestId: request.requestId,
@@ -1858,11 +1857,9 @@ export class MemoryService {
injectedContextQuery: request.query
});
return {
- contextPacketId: `ctx_${stableHash(`${request.sessionId}:${episodeId}:${turnId}:${search.searchEventId}`).slice(0, 20)}`,
+ contextPacketId: `ctx_${stableHash(`${request.sessionId}:unbound:${turnId}:${search.searchEventId}`).slice(0, 20)}`,
turnId,
sessionId: request.sessionId,
- episodeId,
- closedEpisodeIds: [],
searchEventId: search.searchEventId,
hits: search.hits,
injectedContext: search.injectedContext,
diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts
index dbd083363..d0e5c1ada 100644
--- a/Memory/src/service/retrieval/retrieval-service.ts
+++ b/Memory/src/service/retrieval/retrieval-service.ts
@@ -71,6 +71,8 @@ type InternalMemorySearchRequest = MemorySearchRequest & {
targetSkillId?: string;
contextHints?: Record;
injectedContextQuery?: string;
+ turnIntentDecision?: unknown;
+ routeProposal?: unknown;
recordEvent?: boolean;
};
diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts
index 951d39b68..fe338776a 100644
--- a/Memory/src/service/session/session-turn-service.ts
+++ b/Memory/src/service/session/session-turn-service.ts
@@ -9,6 +9,7 @@ import {
retrievePluginMemories,
signatureFromTraceParts,
traceMetaFromMemory,
+ type IntentDecision,
type TurnRelationDecision
} from "../../algorithm/plugin-algorithms.js";
import {
@@ -92,6 +93,22 @@ type SessionTurnDependencies = {
interface CompleteTurnResponse { turnId: string; sessionId: string; episodeId: string; rawTurnId: string; l1MemoryId: string; l1MemoryIds: string[]; closedEpisodeIds: string[]; scheduledEvolution: boolean; jobs: JobRef[]; changeSeq: number; syncCursor: string; etag: string; serverTime: string; duplicate?: boolean; }
type EndTopicDecision = TurnRelationDecision & { relation: "end_topic" };
interface EpisodeTurnRoute { episode: EpisodeRecord; endTopicDecision?: EndTopicDecision; }
+type TurnRouteAction = "create_first" | "append" | "split" | "end_topic";
+interface TurnRouteProposal {
+ action: TurnRouteAction;
+ baseEpisodeId?: string;
+ relationDecision: TurnRelationDecision;
+ proposedAt: string;
+ mergeMode: boolean;
+ withinMergeWindow: boolean;
+ gapMs: number;
+}
+interface CommittedTurnRoute extends EpisodeTurnRoute {
+ closedEpisodeIds: string[];
+ jobs: EvolutionJobRecord[];
+ proposal: TurnRouteProposal;
+ proposalStale: boolean;
+}
export interface ToolOutcomeObservation { toolId: string; success?: boolean; reason?: string; }
@@ -133,8 +150,78 @@ const EXPLICIT_END_TOPIC_COMMANDS = new Set([
"不聊了"
]);
-function episodeTurnRoute(episode: EpisodeRecord, endTopicDecision?: EndTopicDecision): EpisodeTurnRoute {
- return { episode, endTopicDecision };
+function turnRouteProposalFromRecallRequest(request: unknown): TurnRouteProposal | undefined {
+ if (!isRecord(request) || !isRecord(request.routeProposal)) return undefined;
+ const proposal = request.routeProposal;
+ const decision = isRecord(proposal.relationDecision) ? proposal.relationDecision : undefined;
+ const action = proposal.action;
+ if (
+ (action !== "create_first" && action !== "append" && action !== "split" && action !== "end_topic") ||
+ !decision ||
+ (decision.relation !== "revision" &&
+ decision.relation !== "follow_up" &&
+ decision.relation !== "new_task" &&
+ decision.relation !== "end_topic" &&
+ decision.relation !== "unknown") ||
+ typeof decision.confidence !== "number" ||
+ typeof decision.reason !== "string" ||
+ !Array.isArray(decision.signals) ||
+ !decision.signals.every((signal) => typeof signal === "string") ||
+ typeof proposal.proposedAt !== "string" ||
+ typeof proposal.mergeMode !== "boolean" ||
+ typeof proposal.withinMergeWindow !== "boolean" ||
+ typeof proposal.gapMs !== "number"
+ ) {
+ return undefined;
+ }
+ return {
+ action,
+ ...(typeof proposal.baseEpisodeId === "string" ? { baseEpisodeId: proposal.baseEpisodeId } : {}),
+ relationDecision: {
+ relation: decision.relation,
+ confidence: decision.confidence,
+ reason: decision.reason,
+ signals: decision.signals as string[],
+ ...(typeof decision.llmModel === "string" ? { llmModel: decision.llmModel } : {})
+ },
+ proposedAt: proposal.proposedAt,
+ mergeMode: proposal.mergeMode,
+ withinMergeWindow: proposal.withinMergeWindow,
+ gapMs: proposal.gapMs
+ };
+}
+
+function turnIntentDecisionFromRecallRequest(request: unknown): IntentDecision | undefined {
+ if (!isRecord(request) || !isRecord(request.turnIntentDecision)) return undefined;
+ const decision = request.turnIntentDecision;
+ const retrieval = isRecord(decision.retrieval) ? decision.retrieval : undefined;
+ if (
+ (decision.kind !== "task" &&
+ decision.kind !== "memory_probe" &&
+ decision.kind !== "chitchat" &&
+ decision.kind !== "meta" &&
+ decision.kind !== "unknown") ||
+ typeof decision.confidence !== "number" ||
+ typeof decision.reason !== "string" ||
+ !Array.isArray(decision.signals) ||
+ !decision.signals.every((signal) => typeof signal === "string") ||
+ !retrieval ||
+ typeof retrieval.tier1 !== "boolean" ||
+ typeof retrieval.tier2 !== "boolean" ||
+ typeof retrieval.tier3 !== "boolean"
+ ) {
+ return undefined;
+ }
+ return decision as unknown as IntentDecision;
+}
+
+function turnContextPacketId(
+ sessionId: string,
+ baseEpisodeId: string | undefined,
+ turnId: string,
+ searchEventId: string
+): string {
+ return `ctx_${stableHash(`${sessionId}:${baseEpisodeId ?? "unbound"}:${turnId}:${searchEventId}`).slice(0, 20)}`;
}
function explicitEndTopicDecision(text: string): EndTopicDecision | undefined {
@@ -200,17 +287,6 @@ function episodeClosedByEndTopicTurn(episode: EpisodeRecord, turnId: string): bo
episode.meta.endTopicTurnId === turnId;
}
-export function closedEpisodeIdsFromBoundary(
- before: EpisodeRecord | undefined,
- selected: EpisodeRecord,
- after: EpisodeRecord | undefined
-): string[] {
- if (!before || before.id === selected.id || before.status !== "open" || after?.status !== "closed") {
- return [];
- }
- return [before.id];
-}
-
export function summarizeTurn(rawTurn: RawTurnRecord): string {
const parts = [
`Turn: ${rawTurn.turnId}`,
@@ -700,8 +776,6 @@ export class SessionTurnService {
contextPacketId: string;
turnId: string;
sessionId: string;
- episodeId: string;
- closedEpisodeIds: string[];
searchEventId: string;
hits: RecallHit[];
injectedContext: InjectedContext;
@@ -725,35 +799,18 @@ export class SessionTurnService {
const turnId = request.turnId ?? newId("turn");
const intentDecision = classifyIntent(request.query);
const endTopicDecision = explicitEndTopicDecision(request.query);
- const existingRawTurn = this.deps.repos.runtime.getRawTurnBySessionTurn(session.id, turnId);
- if (existingRawTurn) {
- this.deps.assertRawTurnInScope(existingRawTurn, request.namespace);
- }
- const latestEpisodeBefore = existingRawTurn
- ? undefined
- : this.deps.repos.runtime.latestEpisodeForSession(session.id);
- const episode = existingRawTurn
- ? this.deps.requireEpisode(existingRawTurn.episodeId)
- : endTopicDecision
- ? this.ensureEpisode(session)
- : await this.ensureEpisodeForTurnWithLlm(session, undefined, request.query, "turn.start");
- const closedEpisodeIds = closedEpisodeIdsFromBoundary(
- latestEpisodeBefore,
- episode,
- latestEpisodeBefore ? this.deps.repos.runtime.getEpisode(latestEpisodeBefore.id) : undefined
+ const routeProposal = await this.proposeEpisodeRouteWithLlm(
+ session,
+ request.query,
+ endTopicDecision
);
- if (episode.rawTurnIds.length === 0) {
- this.deps.repos.runtime.updateEpisodeMeta(episode.id, {
- intentDecision
- });
- }
const contextHints = turnStartContextHints(request);
const search = await this.deps.search({
requestId: request.requestId,
adapterId: request.adapterId,
namespace: namespaceForSession(session),
sessionId: session.id,
- episodeId: episode.id,
+ episodeId: routeProposal.baseEpisodeId,
turnId,
query: buildSearchQuery({ ...request, contextHints }, this.deps.config.domain),
layers: endTopicDecision
@@ -764,64 +821,22 @@ export class SessionTurnService {
includeInjectedContext: true,
retrievalMode: "turn_start",
contextHints,
- injectedContextQuery: request.query
+ injectedContextQuery: request.query,
+ turnIntentDecision: intentDecision,
+ routeProposal
});
- const contextPacketId = `ctx_${stableHash(`${session.id}:${episode.id}:${turnId}:${search.searchEventId}`).slice(0, 20)}`;
- if (!existingRawTurn) {
- const at = nowIso();
- this.deps.repos.runtime.touchSession(session.id, at);
- const rawTurn = this.deps.repos.runtime.insertRawTurn({
- id: rawTurnIdForSessionTurn(session.id, turnId),
- sessionId: session.id,
- episodeId: episode.id,
- turnId,
- userId: session.userId,
- conversationId: session.conversationId,
- userText: request.query,
- toolCalls: [],
- toolResults: [],
- sourceMemoryIds: search.sourceMemoryIds,
- usage: {},
- messagePayload: {
- turn_start: {
- contextPacketId,
- searchEventId: search.searchEventId,
- sourceMemoryIds: search.sourceMemoryIds,
- intent_decision: intentDecision,
- ...(endTopicDecision
- ? {
- episode_close: {
- closeAfterComplete: true,
- decision: endTopicDecision
- }
- }
- : {})
- }
- },
- status: "started",
- createdAt: at
- });
- this.deps.repos.runtime.appendEpisodeRawTurn(episode.id, rawTurn.id, at);
- this.deps.repos.runtime.appendChange({
- memoryId: rawTurn.id,
- namespaceId: this.deps.namespaceIdFromSession(session),
- kind: "raw_turn",
- op: "created",
- entityId: rawTurn.id,
- userId: session.userId,
- changeType: "raw_turn_created",
- after: rawTurn,
- source: "turn.start",
- createdAt: at
- });
- }
+ const contextPacketId = turnContextPacketId(
+ session.id,
+ routeProposal.baseEpisodeId,
+ turnId,
+ search.searchEventId
+ );
+ this.deps.repos.runtime.touchSession(session.id, nowIso());
return {
contextPacketId,
turnId,
sessionId: session.id,
- episodeId: episode.id,
- closedEpisodeIds,
searchEventId: search.searchEventId,
hits: search.hits,
injectedContext: search.injectedContext,
@@ -832,7 +847,7 @@ export class SessionTurnService {
...(intentDecision.kind === "chitchat" || intentDecision.kind === "meta"
? [`intent:${intentDecision.kind}:retrieval_skipped`]
: []),
- ...(endTopicDecision ? ["relation:end_topic"] : [])
+ `relation:${routeProposal.relationDecision.relation}:proposed`
],
serverTime: nowIso()
};
@@ -869,6 +884,8 @@ export class SessionTurnService {
}
return {
...(existing.response as CompleteTurnResponse),
+ scheduledEvolution: false,
+ jobs: [],
duplicate: true
};
}
@@ -881,6 +898,42 @@ export class SessionTurnService {
if (existingRawTurn) {
this.deps.assertRawTurnInScope(existingRawTurn, request.namespace);
}
+ if (existingRawTurn && isRecord(existingRawTurn.messagePayload?.turn_complete)) {
+ const at = nowIso();
+ const episode = this.deps.requireEpisode(existingRawTurn.episodeId);
+ const l1MemoryIds = episode.l1MemoryIds.filter((memoryId: string) => {
+ const memory = this.deps.repos.memories.get(memoryId);
+ return memory && this.deps.rawTurnIdFromMemory(memory) === existingRawTurn.id;
+ });
+ const responseChangeSeq = this.deps.repos.runtime.latestChangeSeq(
+ session.userId,
+ this.deps.namespaceIdFromSession(session)
+ );
+ const body: CompleteTurnResponse = {
+ turnId,
+ sessionId: session.id,
+ episodeId: episode.id,
+ rawTurnId: existingRawTurn.id,
+ l1MemoryId: l1MemoryIds[0] ?? "",
+ l1MemoryIds,
+ closedEpisodeIds: episodeClosedByEndTopicTurn(episode, turnId) ? [episode.id] : [],
+ scheduledEvolution: false,
+ jobs: [],
+ changeSeq: responseChangeSeq,
+ syncCursor: this.deps.encodeChangeCursor(responseChangeSeq, namespaceForSession(session)),
+ etag: stableHash({
+ changeSeq: responseChangeSeq,
+ l1MemoryIds,
+ rawTurnId: existingRawTurn.id
+ }),
+ serverTime: at,
+ duplicate: true
+ };
+ if (idempotencyKey) {
+ this.deps.repos.runtime.saveIdempotency(idempotencyKey, requestHash, body, at);
+ }
+ return body;
+ }
const turnStartRecall = this.deps.repos.runtime.getTurnStartRecallEvent(session.id, turnId);
const requestSourceMemoryIds = normalizeCompleteTurnSourceMemoryIds(request);
const sourceMemoryIds = requestSourceMemoryIds.length > 0
@@ -889,35 +942,101 @@ export class SessionTurnService {
const completionRequest = sourceMemoryIds === requestSourceMemoryIds
? request
: { ...request, sourceMemoryIds };
- const intentDecision = classifyIntent(request.query);
+ const intentDecision = turnIntentDecisionFromRecallRequest(turnStartRecall?.request) ??
+ classifyIntent(request.query);
const endTopicDecision =
explicitEndTopicDecision(request.query) ??
(existingRawTurn ? endTopicDecisionFromRawTurn(existingRawTurn) : undefined);
- const latestEpisodeBefore = existingRawTurn
- ? undefined
- : this.deps.repos.runtime.latestEpisodeForSession(session.id);
- const route = existingRawTurn
- ? episodeTurnRoute(
- this.deps.requireEpisode(existingRawTurn.episodeId),
- endTopicDecision
- )
- : episodeTurnRoute(
- this.ensureEpisodeForTurn(
- session,
- request.episodeId ?? turnStartRecall?.episodeId,
- request.query,
- "turn.complete"
- ),
- endTopicDecision
+ const at = nowIso();
+ const recalledProposal = turnRouteProposalFromRecallRequest(turnStartRecall?.request);
+ let route: CommittedTurnRoute;
+ if (request.episodeId) {
+ const episode = this.ensureEpisode(session, request.episodeId);
+ const decision = endTopicDecision ?? recalledProposal?.relationDecision ?? classifyTurnRelation({
+ prevUserText: "",
+ prevAssistantText: "",
+ newUserText: request.query,
+ prevTags: []
+ });
+ const routedEndTopicDecision = endTopicDecision ?? (
+ decision.relation === "end_topic" ? decision as EndTopicDecision : undefined
+ );
+ route = {
+ episode,
+ ...(routedEndTopicDecision ? { endTopicDecision: routedEndTopicDecision } : {}),
+ closedEpisodeIds: [],
+ jobs: [],
+ proposal: {
+ ...(recalledProposal ?? this.buildTurnRouteProposal(episode, decision, undefined, at)),
+ action: routedEndTopicDecision ? "end_topic" : "append",
+ baseEpisodeId: episode.id,
+ relationDecision: decision
+ },
+ proposalStale: false
+ };
+ } else {
+ const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id);
+ const proposalUsesObservedUnboundEpisode = Boolean(
+ (recalledProposal?.action === "create_first" || recalledProposal?.action === "end_topic") &&
+ recalledProposal.baseEpisodeId === undefined &&
+ existingRawTurn &&
+ latest?.id === existingRawTurn.episodeId &&
+ !this.episodeRelationContext(latest).prevUserText
+ );
+ const proposalIsCurrent = Boolean(recalledProposal) &&
+ (recalledProposal?.baseEpisodeId === latest?.id || proposalUsesObservedUnboundEpisode) &&
+ !(recalledProposal?.action === "append" &&
+ latest?.status === "closed" &&
+ latest.meta.closeReason === "end_topic");
+ if (!recalledProposal && existingRawTurn) {
+ const episode = this.deps.requireEpisode(existingRawTurn.episodeId);
+ const decision = endTopicDecision ?? classifyTurnRelation({
+ prevUserText: "",
+ prevAssistantText: "",
+ newUserText: request.query,
+ prevTags: []
+ });
+ const routedEndTopicDecision = decision.relation === "end_topic"
+ ? decision as EndTopicDecision
+ : undefined;
+ route = {
+ episode,
+ ...(routedEndTopicDecision ? { endTopicDecision: routedEndTopicDecision } : {}),
+ closedEpisodeIds: [],
+ jobs: [],
+ proposal: {
+ ...this.buildTurnRouteProposal(episode, decision, undefined, at),
+ action: routedEndTopicDecision ? "end_topic" : "append",
+ baseEpisodeId: episode.id
+ },
+ proposalStale: true
+ };
+ } else {
+ const proposal = proposalIsCurrent
+ ? recalledProposal!
+ : this.proposeEpisodeRoute(session, request.query, endTopicDecision);
+ route = this.commitTurnRouteProposal(
+ session,
+ proposal,
+ request.query,
+ "turn.complete",
+ at,
+ !proposalIsCurrent
);
+ }
+ }
const episode = route.episode;
- const closedEpisodeIds = closedEpisodeIdsFromBoundary(
- latestEpisodeBefore,
- episode,
- latestEpisodeBefore ? this.deps.repos.runtime.getEpisode(latestEpisodeBefore.id) : undefined
- );
+ const committedEndTopicDecision = route.endTopicDecision ?? endTopicDecision;
+ const closedEpisodeIds = [...route.closedEpisodeIds];
this.deps.assertEpisodeInScope(episode, request.namespace);
- const at = nowIso();
+ if (existingRawTurn && existingRawTurn.episodeId !== episode.id) {
+ this.deps.repos.runtime.rebindRawTurnEpisode(
+ existingRawTurn.id,
+ existingRawTurn.episodeId,
+ episode.id,
+ at
+ );
+ }
this.deps.repos.runtime.touchSession(session.id, at);
const rawTurnId = rawTurnIdForSessionTurn(session.id, turnId);
const requestToolCalls = normalizeCompleteTurnToolCalls(completionRequest);
@@ -925,20 +1044,25 @@ export class SessionTurnService {
const requestArtifacts = normalizeCompleteTurnArtifacts(completionRequest);
const turnStartPayload = {
intent_decision: intentDecision,
+ routeProposal: recalledProposal ?? route.proposal,
+ ...(route.proposalStale ? { routeProposalStale: true } : {}),
...(turnStartRecall
? {
- contextPacketId: `ctx_${stableHash(
- `${session.id}:${turnStartRecall.episodeId ?? episode.id}:${turnId}:${turnStartRecall.id}`
- ).slice(0, 20)}`,
+ contextPacketId: turnContextPacketId(
+ session.id,
+ turnStartRecall.episodeId,
+ turnId,
+ turnStartRecall.id
+ ),
searchEventId: turnStartRecall.id,
sourceMemoryIds
}
: {}),
- ...(endTopicDecision
+ ...(committedEndTopicDecision
? {
episode_close: {
closeAfterComplete: true,
- decision: endTopicDecision
+ decision: committedEndTopicDecision
}
}
: {})
@@ -974,7 +1098,10 @@ export class SessionTurnService {
const rawTurnFirstCompleted = rawTurnCreated
|| !isRecord(existingRawTurn.messagePayload?.turn_complete);
const completedObservedRawTurn = existingRawTurn
- ? completeObservedRawTurn(existingRawTurn, completionRequest, at)
+ ? {
+ ...completeObservedRawTurn(existingRawTurn, completionRequest, at),
+ episodeId: episode.id
+ }
: undefined;
const rawTurn = completedObservedRawTurn
? this.deps.repos.runtime.updateRawTurn({
@@ -1031,7 +1158,7 @@ export class SessionTurnService {
const l1MemoryIds: string[] = [];
let changeSeq = 0;
- const jobs: EvolutionJobRecord[] = [];
+ const jobs: EvolutionJobRecord[] = [...route.jobs];
for (const step of capturedSteps) {
const stepRawTurnId = step.rawTurnId ?? rawTurn.id;
@@ -1192,7 +1319,7 @@ export class SessionTurnService {
createdAt: at
});
}
- const completedEndTopicDecision = route.endTopicDecision ?? endTopicDecisionFromRawTurn(rawTurn);
+ const completedEndTopicDecision = committedEndTopicDecision ?? endTopicDecisionFromRawTurn(rawTurn);
if (rawTurnFirstCompleted && completedEndTopicDecision) {
const beforeClose = this.deps.repos.runtime.getEpisode(episode.id) ?? episode;
const closed = this.deps.repos.runtime.closeEpisode(episode.id, {
@@ -1265,7 +1392,7 @@ export class SessionTurnService {
return body;
});
- for (const memoryId of response.l1MemoryIds) {
+ for (const memoryId of response.duplicate ? [] : response.l1MemoryIds) {
const memory = this.deps.repos.memories.get(memoryId);
recordApiLog(this.deps.repos.runtime, "memory_add", {
sessionId: response.sessionId,
@@ -2004,51 +2131,87 @@ export class SessionTurnService {
);
}
- private ensureEpisodeForTurn(
+ private buildTurnRouteProposal(
+ latest: EpisodeRecord | undefined,
+ decision: TurnRelationDecision,
+ lastTurnAtMs?: number,
+ proposedAt = nowIso()
+ ): TurnRouteProposal {
+ const mergeMode = this.deps.config.algorithm.session.followUpMode === "merge_follow_ups";
+ const proposedAtMs = Date.parse(proposedAt);
+ const gapMs = lastTurnAtMs
+ ? Math.max(0, (Number.isFinite(proposedAtMs) ? proposedAtMs : Date.now()) - lastTurnAtMs)
+ : 0;
+ const withinMergeWindow =
+ this.deps.config.algorithm.session.mergeMaxGapMs === 0 ||
+ gapMs <= this.deps.config.algorithm.session.mergeMaxGapMs;
+ const shouldAppendOpen =
+ mergeMode &&
+ withinMergeWindow &&
+ (decision.relation === "revision" ||
+ decision.relation === "follow_up" ||
+ decision.relation === "unknown");
+ const shouldReopenClosed = latest !== undefined && latest.meta.closeReason !== "end_topic" && (
+ decision.relation === "revision" ||
+ (mergeMode &&
+ withinMergeWindow &&
+ (decision.relation === "follow_up" || decision.relation === "unknown"))
+ );
+ const action: TurnRouteAction = decision.relation === "end_topic"
+ ? "end_topic"
+ : !latest
+ ? "create_first"
+ : latest.status === "open"
+ ? (shouldAppendOpen ? "append" : "split")
+ : (shouldReopenClosed ? "append" : "split");
+ return {
+ action,
+ ...(latest ? { baseEpisodeId: latest.id } : {}),
+ relationDecision: decision,
+ proposedAt,
+ mergeMode,
+ withinMergeWindow,
+ gapMs
+ };
+ }
+
+ private proposeEpisodeRoute(
session: SessionRecord,
- episodeId: string | undefined,
- userText: string | undefined,
- source: string
- ): EpisodeRecord {
- if (episodeId || !userText?.trim()) {
- return this.ensureEpisode(session, episodeId);
- }
+ userText: string,
+ forcedDecision?: TurnRelationDecision
+ ): TurnRouteProposal {
const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id);
- if (!latest) {
- return this.ensureEpisode(session);
- }
- const relationContext = this.episodeRelationContext(latest);
- if (!relationContext.prevUserText) {
- return this.ensureEpisode(session);
- }
- const decision = classifyTurnRelation({
- prevUserText: relationContext.prevUserText,
- prevAssistantText: relationContext.prevAssistantText,
+ const relationContext = latest ? this.episodeRelationContext(latest) : undefined;
+ const decision = forcedDecision ?? classifyTurnRelation({
+ prevUserText: relationContext?.prevUserText ?? "",
+ prevAssistantText: relationContext?.prevAssistantText ?? "",
newUserText: userText,
- gapMs: relationContext.lastTurnAtMs
+ gapMs: relationContext?.lastTurnAtMs
? Math.max(0, Date.now() - relationContext.lastTurnAtMs)
: undefined,
- prevTags: relationContext.tags
+ prevTags: relationContext?.tags ?? []
});
- return this.applyEpisodeRelationDecision(session, latest, decision, userText, source, relationContext.lastTurnAtMs);
+ return this.buildTurnRouteProposal(latest, decision, relationContext?.lastTurnAtMs);
}
- private async ensureEpisodeForTurnWithLlm(
+ private async proposeEpisodeRouteWithLlm(
session: SessionRecord,
- episodeId: string | undefined,
- userText: string | undefined,
- source: string
- ): Promise {
- if (episodeId || !userText?.trim()) {
- return this.ensureEpisode(session, episodeId);
- }
+ userText: string,
+ forcedDecision?: TurnRelationDecision
+ ): Promise {
const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id);
- if (!latest) {
- return this.ensureEpisode(session);
- }
- const relationContext = this.episodeRelationContext(latest);
- if (!relationContext.prevUserText) {
- return this.ensureEpisode(session);
+ const relationContext = latest ? this.episodeRelationContext(latest) : undefined;
+ if (forcedDecision || !latest || !relationContext?.prevUserText) {
+ const decision = forcedDecision ?? classifyTurnRelation({
+ prevUserText: relationContext?.prevUserText ?? "",
+ prevAssistantText: relationContext?.prevAssistantText ?? "",
+ newUserText: userText,
+ gapMs: relationContext?.lastTurnAtMs
+ ? Math.max(0, Date.now() - relationContext.lastTurnAtMs)
+ : undefined,
+ prevTags: relationContext?.tags ?? []
+ });
+ return this.buildTurnRouteProposal(latest, decision, relationContext?.lastTurnAtMs);
}
const decision = await classifyTurnRelationWithLlm({
prevUserText: relationContext.prevUserText,
@@ -2061,114 +2224,66 @@ export class SessionTurnService {
}, {
llm: this.deps.llm
});
- return this.applyEpisodeRelationDecision(session, latest, decision, userText, source, relationContext.lastTurnAtMs);
+ return this.buildTurnRouteProposal(latest, decision, relationContext.lastTurnAtMs);
}
- private applyEpisodeRelationDecision(
+ private commitTurnRouteProposal(
session: SessionRecord,
- latest: EpisodeRecord,
- decision: ReturnType,
+ proposal: TurnRouteProposal,
userText: string,
source: string,
- lastTurnAtMs?: number
- ): EpisodeRecord {
- const mergeMode = this.deps.config.algorithm.session.followUpMode === "merge_follow_ups";
- const gapMs = lastTurnAtMs ? Math.max(0, Date.now() - lastTurnAtMs) : 0;
- const withinMergeWindow =
- this.deps.config.algorithm.session.mergeMaxGapMs === 0 ||
- gapMs <= this.deps.config.algorithm.session.mergeMaxGapMs;
- const shouldAppendOpen =
- mergeMode &&
- withinMergeWindow &&
- (decision.relation === "revision" ||
- decision.relation === "follow_up" ||
- decision.relation === "unknown");
- if (latest.status === "open") {
- if (shouldAppendOpen) {
+ at: string,
+ proposalStale: boolean
+ ): CommittedTurnRoute {
+ const decision = proposal.relationDecision;
+ const closedEpisodeIds: string[] = [];
+ const jobs: EvolutionJobRecord[] = [];
+ if (proposal.action === "create_first") {
+ return {
+ episode: this.ensureEpisode(session),
+ closedEpisodeIds,
+ jobs,
+ proposal,
+ proposalStale
+ };
+ }
+ if (proposal.action === "end_topic") {
+ const base = proposal.baseEpisodeId
+ ? this.deps.repos.runtime.getEpisode(proposal.baseEpisodeId)
+ : undefined;
+ const episode = base?.status === "open" ? base : this.ensureEpisode(session);
+ return {
+ episode,
+ endTopicDecision: decision as EndTopicDecision,
+ closedEpisodeIds,
+ jobs,
+ proposal,
+ proposalStale
+ };
+ }
+
+ const baseEpisodeId = proposal.baseEpisodeId;
+ if (!baseEpisodeId) {
+ throw new MemoryServiceError("conflict", "episode route proposal is missing its base episode");
+ }
+ const latest = this.deps.requireEpisode(baseEpisodeId);
+ if (proposal.action === "append") {
+ if (latest.status === "open") {
if (decision.relation === "revision") {
this.recordRevisionFeedback(session, latest, userText, source);
}
- return this.deps.repos.runtime.updateEpisodeMeta(latest.id, {
+ const episode = this.deps.repos.runtime.updateEpisodeMeta(latest.id, {
relation: decision.relation,
relationDecision: decision,
relationRouting: {
action: "append_to_open_episode",
- mergeMode,
- withinMergeWindow,
- gapMs
+ mergeMode: proposal.mergeMode,
+ withinMergeWindow: proposal.withinMergeWindow,
+ gapMs: proposal.gapMs
}
- }) ?? latest;
+ }, at) ?? latest;
+ return { episode, closedEpisodeIds, jobs, proposal, proposalStale };
}
- if (decision.relation === "new_task" || !shouldAppendOpen) {
- this.recordImplicitTurnFeedback(session, latest, userText);
- const at = nowIso();
- const closed = this.deps.repos.runtime.closeEpisode(latest.id, {
- closeReason: "topic_boundary",
- relation: decision.relation,
- relationDecision: decision,
- relationRouting: {
- action: decision.relation === "new_task"
- ? "close_open_and_start_new_task"
- : "close_open_and_start_new_episode",
- mergeMode,
- withinMergeWindow,
- gapMs
- },
- closedBy: source
- }, at);
- if (closed) {
- this.deps.repos.runtime.appendChange({
- memoryId: closed.id,
- namespaceId: this.deps.namespaceIdFromSession(session),
- kind: "episode",
- op: "updated",
- entityId: closed.id,
- userId: closed.userId,
- changeType: "episode_closed",
- before: latest,
- after: closed,
- source,
- createdAt: at
- });
- this.deps.finalizeClosedEpisode(closed, at, "topic_boundary");
- }
- const next = this.ensureEpisode(session);
- return this.deps.repos.runtime.updateEpisodeMeta(next.id, {
- relation: decision.relation,
- relationDecision: decision,
- previousEpisodeId: latest.id,
- relationRouting: {
- action: decision.relation === "new_task"
- ? "start_new_task_episode"
- : "start_new_episode",
- mergeMode,
- withinMergeWindow,
- gapMs
- }
- }, at) ?? next;
- }
- return this.deps.repos.runtime.updateEpisodeMeta(latest.id, {
- relation: decision.relation,
- relationDecision: decision
- }) ?? latest;
- }
-
- if (latest.meta.closeReason === "end_topic") {
- const next = this.ensureEpisode(session);
- return this.deps.repos.runtime.updateEpisodeMeta(next.id, {
- relation: decision.relation,
- relationDecision: decision,
- previousEpisodeId: latest.id
- }) ?? next;
- }
-
- const shouldReopenClosed =
- decision.relation === "revision" ||
- (mergeMode &&
- withinMergeWindow &&
- (decision.relation === "follow_up" || decision.relation === "unknown"));
- if (shouldReopenClosed) {
- const at = nowIso();
const reopened = this.deps.repos.runtime.reopenEpisode(latest.id, {
relation: decision.relation,
relationDecision: decision,
@@ -2176,9 +2291,9 @@ export class SessionTurnService {
reopenReason: decision.relation === "revision" ? "revision" : "follow_up",
relationRouting: {
action: "reopen_previous_episode",
- mergeMode,
- withinMergeWindow,
- gapMs
+ mergeMode: proposal.mergeMode,
+ withinMergeWindow: proposal.withinMergeWindow,
+ gapMs: proposal.gapMs
},
rewardDirty: {
reason: "episode_reopened",
@@ -2186,41 +2301,77 @@ export class SessionTurnService {
at
}
}, at);
- if (reopened) {
+ if (!reopened) {
+ throw new MemoryServiceError("conflict", "failed to reopen the proposed episode");
+ }
+ this.deps.repos.runtime.appendChange({
+ memoryId: reopened.id,
+ namespaceId: this.deps.namespaceIdFromSession(session),
+ kind: "episode",
+ op: "updated",
+ entityId: reopened.id,
+ userId: reopened.userId,
+ changeType: "episode_reopened",
+ before: latest,
+ after: reopened,
+ source,
+ createdAt: at
+ });
+ if (decision.relation === "revision") {
+ this.recordRevisionFeedback(session, reopened, userText, source);
+ }
+ return { episode: reopened, closedEpisodeIds, jobs, proposal, proposalStale };
+ }
+
+ this.recordImplicitTurnFeedback(session, latest, userText);
+ if (latest.status === "open") {
+ const closed = this.deps.repos.runtime.closeEpisode(latest.id, {
+ closeReason: "topic_boundary",
+ relation: decision.relation,
+ relationDecision: decision,
+ relationRouting: {
+ action: decision.relation === "new_task"
+ ? "close_open_and_start_new_task"
+ : "close_open_and_start_new_episode",
+ mergeMode: proposal.mergeMode,
+ withinMergeWindow: proposal.withinMergeWindow,
+ gapMs: proposal.gapMs
+ },
+ closedBy: source
+ }, at);
+ if (closed) {
this.deps.repos.runtime.appendChange({
- memoryId: reopened.id,
+ memoryId: closed.id,
namespaceId: this.deps.namespaceIdFromSession(session),
kind: "episode",
op: "updated",
- entityId: reopened.id,
- userId: reopened.userId,
- changeType: "episode_reopened",
+ entityId: closed.id,
+ userId: closed.userId,
+ changeType: "episode_closed",
before: latest,
- after: reopened,
+ after: closed,
source,
createdAt: at
});
- if (decision.relation === "revision") {
- this.recordRevisionFeedback(session, reopened, userText, source);
- }
- return reopened;
+ jobs.push(...this.deps.finalizeClosedEpisode(closed, at, "topic_boundary"));
+ closedEpisodeIds.push(closed.id);
}
+ } else {
+ jobs.push(...this.deps.finalizeClosedEpisode(latest, at, "topic_boundary"));
}
-
- this.recordImplicitTurnFeedback(session, latest, userText);
- this.deps.finalizeClosedEpisode(latest, nowIso(), "topic_boundary");
const next = this.ensureEpisode(session);
- return this.deps.repos.runtime.updateEpisodeMeta(next.id, {
+ const episode = this.deps.repos.runtime.updateEpisodeMeta(next.id, {
relation: decision.relation,
relationDecision: decision,
previousEpisodeId: latest.id,
relationRouting: {
action: decision.relation === "new_task" ? "start_new_task_episode" : "start_new_episode",
- mergeMode,
- withinMergeWindow,
- gapMs
+ mergeMode: proposal.mergeMode,
+ withinMergeWindow: proposal.withinMergeWindow,
+ gapMs: proposal.gapMs
}
- }) ?? next;
+ }, at) ?? next;
+ return { episode, closedEpisodeIds, jobs, proposal, proposalStale };
}
private episodeRelationContext(episode: EpisodeRecord): {
@@ -2232,6 +2383,7 @@ export class SessionTurnService {
const rawTurns = episode.rawTurnIds
.map((id) => this.deps.repos.runtime.getRawTurn(id))
.filter((rawTurn): rawTurn is RawTurnRecord => Boolean(rawTurn))
+ .filter((rawTurn) => isRecord(rawTurn.messagePayload?.turn_complete))
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
const userTurns = rawTurns
.map((rawTurn) => rawTurn.userText?.trim())
diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts
index f4abd3266..28fa7ed6e 100644
--- a/Memory/src/storage/repositories.ts
+++ b/Memory/src/storage/repositories.ts
@@ -1559,6 +1559,32 @@ export class RuntimeRepository {
};
}
+ rebindRawTurnEpisode(
+ rawTurnId: string,
+ fromEpisodeId: string,
+ toEpisodeId: string,
+ at = nowIso()
+ ): void {
+ if (fromEpisodeId === toEpisodeId) return;
+ const fromEpisode = this.getEpisode(fromEpisodeId);
+ if (!fromEpisode || !this.getEpisode(toEpisodeId)) {
+ throw new Error("cannot rebind a raw turn to a missing episode");
+ }
+ const remainingRawTurnIds = fromEpisode.rawTurnIds.filter((id) => id !== rawTurnId);
+ this.db
+ .prepare(
+ `UPDATE episodes
+ SET raw_turn_ids_json = ?,
+ turn_count = ?,
+ updated_at = ?
+ WHERE id = ?`
+ )
+ .run(toJson(remainingRawTurnIds), remainingRawTurnIds.length, at, fromEpisodeId);
+ this.db.prepare("UPDATE raw_turns SET episode_id = ? WHERE id = ?").run(toEpisodeId, rawTurnId);
+ this.db.prepare("UPDATE artifacts SET episode_id = ? WHERE raw_turn_id = ?").run(toEpisodeId, rawTurnId);
+ this.appendEpisodeRawTurn(toEpisodeId, rawTurnId, at);
+ }
+
appendEpisodeFeedback(episodeId: string, feedbackId: string, at = nowIso()): EpisodeRecord | undefined {
return this.appendEpisodeArrayValue(episodeId, "feedbackIds", "feedback_ids_json", feedbackId, at);
}
@@ -1680,7 +1706,8 @@ export class RuntimeRepository {
this.db
.prepare(
`UPDATE raw_turns
- SET user_text = @userText,
+ SET episode_id = @episodeId,
+ user_text = @userText,
assistant_text = @assistantText,
reasoning_summary = @reasoningSummary,
tool_calls_json = @toolCallsJson,
@@ -1695,6 +1722,7 @@ export class RuntimeRepository {
)
.run({
id: rawTurn.id,
+ episodeId: rawTurn.episodeId,
userText: rawTurn.userText ?? null,
assistantText: rawTurn.assistantText ?? null,
reasoningSummary: rawTurn.reasoningSummary ?? null,
diff --git a/Memory/tests/contract/memory-rest-service.test.ts b/Memory/tests/contract/memory-rest-service.test.ts
index 04ba953c6..844aea8de 100644
--- a/Memory/tests/contract/memory-rest-service.test.ts
+++ b/Memory/tests/contract/memory-rest-service.test.ts
@@ -212,13 +212,12 @@ describe("MemoryService / REST contract", () => {
body: JSON.stringify(startRequestBody)
});
const started = await startResponse.json() as {
- episodeId: string;
searchEventId: string;
turnId: string;
};
expect(startResponse.status).toBe(200);
expect(started.turnId).toBe("cursor-http-turn");
- expect(started.episodeId).toMatch(/^episode_/u);
+ expect(started).not.toHaveProperty("episodeId");
const afterFirstStart = {
episodes: (db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get() as { count: number }).count,
rawTurns: (db.db.prepare("SELECT COUNT(*) AS count FROM raw_turns").get() as { count: number }).count,
@@ -228,8 +227,6 @@ describe("MemoryService / REST contract", () => {
};
expect(afterFirstStart).toEqual({
...beforeStart,
- episodes: beforeStart.episodes + 1,
- rawTurns: beforeStart.rawTurns + 1,
recalls: beforeStart.recalls + 1,
apiLogs: beforeStart.apiLogs + 1,
idempotency: beforeStart.idempotency + 1
@@ -238,11 +235,7 @@ describe("MemoryService / REST contract", () => {
`SELECT episode_id, assistant_text, status
FROM raw_turns
WHERE session_id = ? AND turn_id = ?`
- ).get(opened.sessionId, started.turnId)).toEqual({
- episode_id: started.episodeId,
- assistant_text: null,
- status: "started"
- });
+ ).get(opened.sessionId, started.turnId)).toBeUndefined();
expect(db.db.prepare(
`SELECT tool_name, json_extract(input_json, '$.retrievalMode') AS retrieval_mode
FROM api_logs
@@ -258,7 +251,6 @@ describe("MemoryService / REST contract", () => {
body: JSON.stringify(startRequestBody)
});
const duplicateStarted = await duplicateStartResponse.json() as {
- episodeId: string;
searchEventId: string;
turnId: string;
};
@@ -300,7 +292,7 @@ describe("MemoryService / REST contract", () => {
});
const completed = await completeResponse.json() as { episodeId: string; rawTurnId: string };
expect(completeResponse.status).toBe(200);
- expect(completed.episodeId).toBe(started.episodeId);
+ expect(completed.episodeId).toMatch(/^episode_/u);
const sessionRow = db.db.prepare(
"SELECT source, profile_id, workspace_path FROM sessions WHERE id = ?"
diff --git a/Memory/tests/service/retrieval/injected-context.test.ts b/Memory/tests/service/retrieval/injected-context.test.ts
index 219ac90b1..dc6948d18 100644
--- a/Memory/tests/service/retrieval/injected-context.test.ts
+++ b/Memory/tests/service/retrieval/injected-context.test.ts
@@ -375,7 +375,8 @@ describe("MemoryService / retrieval / injected context", () => {
query: "fix sqlite budget migration",
answer: "The sqlite budget migration is fixed."
});
- expect(completed.episodeId).toBe(prepared.episodeId);
+ expect(prepared).not.toHaveProperty("episodeId");
+ expect(completed.episodeId).toMatch(/^episode_/u);
const rawTurn = db.db.prepare(
"SELECT source_memory_ids_json, message_payload_json FROM raw_turns WHERE id = ?"
).get(completed.rawTurnId) as {
@@ -787,7 +788,7 @@ describe("MemoryService / retrieval / injected context", () => {
});
expect(unknown.status).not.toContain("intent:chitchat:retrieval_skipped");
- expect(db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get()).toEqual({ count: 3 });
+ expect(db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get()).toEqual({ count: 0 });
expect(db.db.prepare("SELECT COUNT(*) AS count FROM recall_events").get()).toEqual({ count: 3 });
expect(db.db.prepare(
"SELECT tool_name, COUNT(*) AS count FROM api_logs GROUP BY tool_name"
diff --git a/Memory/tests/service/session/episode-relation.test.ts b/Memory/tests/service/session/episode-relation.test.ts
index dc00cbc4e..1763d7794 100644
--- a/Memory/tests/service/session/episode-relation.test.ts
+++ b/Memory/tests/service/session/episode-relation.test.ts
@@ -147,13 +147,10 @@ describe("MemoryService / session / episode relation", () => {
query: "结束会话"
});
- expect(prepared).toMatchObject({
- episodeId: first.episodeId,
- closedEpisodeIds: [],
- hits: [],
- sourceMemoryIds: []
- });
- expect(prepared.status).toContain("relation:end_topic");
+ expect(prepared).toMatchObject({ hits: [], sourceMemoryIds: [] });
+ expect(prepared).not.toHaveProperty("episodeId");
+ expect(prepared).not.toHaveProperty("closedEpisodeIds");
+ expect(prepared.status).toContain("relation:end_topic:proposed");
expect(relationCalls).toEqual([]);
expect(service.getMemory(first.episodeId)).toMatchObject({
kind: "episode",
@@ -220,7 +217,7 @@ describe("MemoryService / session / episode relation", () => {
sessionId: session.sessionId,
query
});
- expect(prepared.status).not.toContain("relation:end_topic");
+ expect(prepared.status).not.toContain("relation:end_topic:proposed");
}
const prepared = await service.startTurn({
@@ -228,7 +225,7 @@ describe("MemoryService / session / episode relation", () => {
sessionId: session.sessionId,
query: "不聊了!"
});
- expect(prepared.status).toContain("relation:end_topic");
+ expect(prepared.status).toContain("relation:end_topic:proposed");
const completed = service.completeTurn("turn-explicit-end-topic-close", {
sessionId: session.sessionId,
@@ -239,6 +236,48 @@ describe("MemoryService / session / episode relation", () => {
expect(completed.l1MemoryIds).toEqual([]);
});
+ it("commits an LLM end-topic proposal without capturing the control turn as L1", async () => {
+ const relationCalls: string[] = [];
+ const { service } = createTestService({
+ llm: createRelationClassifierLlm(relationCalls, undefined, "end_topic")
+ });
+ const session = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "jiang",
+ userId: "user-llm-end-topic"
+ }
+ });
+ const first = service.completeTurn("turn-llm-end-topic-first", {
+ sessionId: session.sessionId,
+ query: "Configure nginx TLS",
+ answer: "Use port 443."
+ });
+ const started = await service.startTurn({
+ turnId: "turn-llm-end-topic-close",
+ sessionId: session.sessionId,
+ query: "That covers everything for this topic"
+ });
+ expect(started.status).toContain("relation:end_topic:proposed");
+ expect(relationCalls).toContain("relation.classify.v1");
+ expect(service.getMemory(first.episodeId)).toMatchObject({
+ kind: "episode",
+ status: "open"
+ });
+
+ const completed = service.completeTurn("turn-llm-end-topic-close", {
+ sessionId: session.sessionId,
+ query: "That covers everything for this topic",
+ answer: "Understood."
+ });
+ expect(completed.closedEpisodeIds).toEqual([first.episodeId]);
+ expect(completed.l1MemoryIds).toEqual([]);
+ expect(service.getMemory(first.episodeId)).toMatchObject({
+ kind: "episode",
+ status: "closed"
+ });
+ });
+
it("keeps end-topic start and complete retries idempotent", async () => {
const relationCalls: string[] = [];
const { service } = createTestService({
@@ -265,11 +304,12 @@ describe("MemoryService / session / episode relation", () => {
const firstStart = await service.startTurn(request);
const secondStart = await service.startTurn(request);
- expect(firstStart.episodeId).toBe(secondStart.episodeId);
- expect(firstStart.closedEpisodeIds).toEqual([]);
- expect(secondStart.closedEpisodeIds).toEqual([]);
- expect(firstStart.status).toContain("relation:end_topic");
- expect(secondStart.status).toContain("relation:end_topic");
+ expect(firstStart).not.toHaveProperty("episodeId");
+ expect(secondStart).not.toHaveProperty("episodeId");
+ expect(firstStart).not.toHaveProperty("closedEpisodeIds");
+ expect(secondStart).not.toHaveProperty("closedEpisodeIds");
+ expect(firstStart.status).toContain("relation:end_topic:proposed");
+ expect(secondStart.status).toContain("relation:end_topic:proposed");
expect(relationCalls).toEqual([]);
const completeRequest = {
@@ -282,11 +322,13 @@ describe("MemoryService / session / episode relation", () => {
expect(secondComplete.closedEpisodeIds).toEqual(firstComplete.closedEpisodeIds);
expect(secondComplete.jobs).toEqual([]);
+ expect(secondComplete.scheduledEvolution).toBe(false);
+ expect(secondComplete.duplicate).toBe(true);
});
it("does not reopen an episode after an explicit end-topic boundary", async () => {
const { service } = createTestService({
- llm: createRelationClassifierLlm([], undefined, ["end_topic", "follow_up"])
+ llm: createRelationClassifierLlm([], undefined, "follow_up")
});
const session = service.openSession({
namespace: {
@@ -322,7 +364,7 @@ describe("MemoryService / session / episode relation", () => {
answer: "可以使用 certbot 自动续期。"
});
- expect(nextStart.episodeId).toBe(next.episodeId);
+ expect(nextStart).not.toHaveProperty("episodeId");
expect(next.episodeId).not.toBe(first.episodeId);
expect(service.getMemory(first.episodeId)).toMatchObject({
kind: "episode",
@@ -337,7 +379,7 @@ describe("MemoryService / session / episode relation", () => {
it("binds a following turn after the explicit end-topic completion", async () => {
const relationCalls: string[] = [];
const { service } = createTestService({
- llm: createRelationClassifierLlm(relationCalls, undefined, ["end_topic", "follow_up"])
+ llm: createRelationClassifierLlm(relationCalls, undefined, "follow_up")
});
const session = service.openSession({
namespace: {
@@ -363,7 +405,11 @@ describe("MemoryService / session / episode relation", () => {
query: "继续说明证书续期"
});
- expect(nextStart.episodeId).not.toBe(first.episodeId);
+ expect(nextStart).not.toHaveProperty("episodeId");
+ expect(service.getMemory(first.episodeId)).toMatchObject({
+ kind: "episode",
+ status: "open"
+ });
expect(relationCalls).toEqual(["relation.classify.v1"]);
service.completeTurn("turn-pending-end-topic-close", {
@@ -401,22 +447,34 @@ describe("MemoryService / session / episode relation", () => {
query: "Configure nginx TLS for the service",
answer: "Use port 443, install the certificate, and verify with curl."
});
+ const jobsBeforeStart = (db.db.prepare(
+ "SELECT COUNT(*) AS count FROM evolution_jobs"
+ ).get() as { count: number }).count;
const prepared = await service.startTurn({ turnId: "turn-relation-new-task",
sessionId: session.sessionId,
query: "new task: summarize the Q4 hiring plan"
});
- expect(prepared.episodeId).not.toBe(first.episodeId);
- expect(prepared.closedEpisodeIds).toEqual([first.episodeId]);
+ expect(prepared).not.toHaveProperty("episodeId");
+ expect(prepared).not.toHaveProperty("closedEpisodeIds");
expect(db.db.prepare(
"SELECT COUNT(*) AS count FROM episodes WHERE session_id = ?"
- ).get(session.sessionId)).toEqual({ count: 2 });
+ ).get(session.sessionId)).toEqual({ count: 1 });
+ expect(service.getMemory(first.episodeId)).toMatchObject({
+ kind: "episode",
+ status: "open"
+ });
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM evolution_jobs"
+ ).get()).toEqual({ count: jobsBeforeStart });
const completed = service.completeTurn("turn-relation-new-task", {
sessionId: session.sessionId,
query: "new task: summarize the Q4 hiring plan",
answer: "The Q4 hiring plan has been summarized."
});
- expect(completed.episodeId).toBe(prepared.episodeId);
+ expect(completed.episodeId).not.toBe(first.episodeId);
+ expect(completed.closedEpisodeIds).toEqual([first.episodeId]);
+ expect(completed.jobs.map((job) => job.jobType)).toContain("reflection");
const rows = db.db.prepare(
`SELECT id, status, meta_json
@@ -441,6 +499,134 @@ describe("MemoryService / session / episode relation", () => {
db.close();
});
+ it("ignores an uncompleted new-task proposal when routing the next completed turn", async () => {
+ const { db, service } = createTestService({
+ llm: createRelationClassifierLlm([], undefined, "follow_up")
+ });
+ const session = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "jiang",
+ userId: "user-cancelled-route-proposal"
+ }
+ });
+ const first = service.completeTurn("turn-cancelled-proposal-first", {
+ sessionId: session.sessionId,
+ query: "Configure nginx TLS",
+ answer: "Use port 443."
+ });
+
+ await service.startTurn({
+ turnId: "turn-cancelled-proposal",
+ sessionId: session.sessionId,
+ query: "换个任务:总结招聘计划"
+ });
+ const nextStart = await service.startTurn({
+ turnId: "turn-after-cancelled-proposal",
+ sessionId: session.sessionId,
+ query: "那证书自动续期呢"
+ });
+
+ expect(nextStart).not.toHaveProperty("episodeId");
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM episodes WHERE session_id = ?"
+ ).get(session.sessionId)).toEqual({ count: 1 });
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM raw_turns WHERE session_id = ? AND turn_id = ?"
+ ).get(session.sessionId, "turn-cancelled-proposal")).toEqual({ count: 0 });
+
+ const completed = service.completeTurn("turn-after-cancelled-proposal", {
+ sessionId: session.sessionId,
+ query: "那证书自动续期呢",
+ answer: "Use certbot renewal hooks."
+ });
+ expect(completed.episodeId).toBe(first.episodeId);
+ expect(completed.closedEpisodeIds).toEqual([]);
+ db.close();
+ });
+
+ it("reclassifies a stale route proposal and records the stale marker", async () => {
+ const { db, service } = createTestService();
+ const session = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "jiang",
+ userId: "user-stale-route-proposal"
+ }
+ });
+ const first = service.completeTurn("turn-stale-first", {
+ sessionId: session.sessionId,
+ query: "Configure nginx TLS",
+ answer: "Use port 443."
+ });
+ await service.startTurn({
+ turnId: "turn-stale-proposed",
+ sessionId: session.sessionId,
+ query: "new task: summarize the hiring plan"
+ });
+ const intervening = service.completeTurn("turn-stale-intervening", {
+ sessionId: session.sessionId,
+ query: "new task: audit database backups",
+ answer: "The database backup audit is complete."
+ });
+ expect(intervening.episodeId).not.toBe(first.episodeId);
+
+ const completed = service.completeTurn("turn-stale-proposed", {
+ sessionId: session.sessionId,
+ query: "new task: summarize the hiring plan",
+ answer: "The hiring plan is summarized."
+ });
+ expect(completed.episodeId).not.toBe(intervening.episodeId);
+ expect(completed.closedEpisodeIds).toEqual([intervening.episodeId]);
+ const raw = db.db.prepare(
+ "SELECT message_payload_json FROM raw_turns WHERE id = ?"
+ ).get(completed.rawTurnId) as { message_payload_json: string };
+ expect(JSON.parse(raw.message_payload_json)).toMatchObject({
+ turn_start: {
+ routeProposalStale: true,
+ routeProposal: {
+ baseEpisodeId: first.episodeId,
+ action: "split"
+ }
+ }
+ });
+ db.close();
+ });
+
+ it("honors an explicit episode id over a conflicting start proposal", async () => {
+ const { db, service } = createTestService();
+ const session = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "jiang",
+ userId: "user-explicit-complete-episode"
+ }
+ });
+ const first = service.completeTurn("turn-explicit-episode-first", {
+ sessionId: session.sessionId,
+ query: "Configure nginx TLS",
+ answer: "Use port 443."
+ });
+ await service.startTurn({
+ turnId: "turn-explicit-episode",
+ sessionId: session.sessionId,
+ query: "new task: summarize the hiring plan"
+ });
+
+ const completed = service.completeTurn("turn-explicit-episode", {
+ sessionId: session.sessionId,
+ episodeId: first.episodeId,
+ query: "new task: summarize the hiring plan",
+ answer: "The hiring plan is summarized."
+ });
+ expect(completed.episodeId).toBe(first.episodeId);
+ expect(completed.closedEpisodeIds).toEqual([]);
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM episodes WHERE session_id = ?"
+ ).get(session.sessionId)).toEqual({ count: 1 });
+ db.close();
+ });
+
it("keeps follow-up turns in the same episode", async () => {
const { db, service } = createTestService();
const session = service.openSession({
@@ -460,8 +646,8 @@ describe("MemoryService / session / episode relation", () => {
sessionId: session.sessionId,
query: "那证书自动续期呢"
});
- expect(prepared.episodeId).toBe(first.episodeId);
- expect(prepared.closedEpisodeIds).toEqual([]);
+ expect(prepared).not.toHaveProperty("episodeId");
+ expect(prepared).not.toHaveProperty("closedEpisodeIds");
const rows = db.db.prepare(
`SELECT id, status, meta_json
@@ -471,9 +657,7 @@ describe("MemoryService / session / episode relation", () => {
).all(session.sessionId) as Array<{ id: string; status: string; meta_json: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: first.episodeId, status: "open" });
- expect(JSON.parse(rows[0]!.meta_json)).toMatchObject({
- relation: "follow_up"
- });
+ expect(JSON.parse(rows[0]!.meta_json)).not.toHaveProperty("relation");
const completed = service.completeTurn("turn-relation-follow-up-next", {
sessionId: session.sessionId,
@@ -543,7 +727,7 @@ describe("MemoryService / session / episode relation", () => {
db.close();
});
- it("reserves a started raw turn in the selected episode and completes that same turn", async () => {
+ it("does not reserve a raw turn until completion commits the proposed episode", async () => {
const root = createTestRoot("mindock-memory-turn-bind-");
const db = new MemoryDb({
path: join(root, "memory.sqlite")
@@ -572,18 +756,14 @@ describe("MemoryService / session / episode relation", () => {
sessionId: session.sessionId,
query: "青竹项目的部署端口是多少?林浩偏好什么回答风格?"
});
- expect(prepared.episodeId).toBe(first.episodeId);
+ expect(prepared).not.toHaveProperty("episodeId");
expect(relationCalls).toEqual(["relation.classify.v1"]);
const reserved = db.db.prepare(
`SELECT id, episode_id, status
FROM raw_turns
WHERE session_id = ? AND turn_id = ?`
).get(session.sessionId, "turn-bind-second") as { id: string; episode_id: string; status: string } | undefined;
- expect(reserved).toEqual({
- id: expect.stringMatching(/^raw_/u),
- episode_id: prepared.episodeId,
- status: "started"
- });
+ expect(reserved).toBeUndefined();
const completed = service.completeTurn("turn-bind-second", {
sessionId: session.sessionId,
@@ -592,7 +772,7 @@ describe("MemoryService / session / episode relation", () => {
});
expect(completed.episodeId).toBe(first.episodeId);
- expect(completed.rawTurnId).toBe(reserved?.id);
+ expect(completed.rawTurnId).toMatch(/^raw_/u);
const episodes = db.db.prepare(
`SELECT id, turn_count, raw_turn_ids_json
FROM episodes
@@ -700,8 +880,21 @@ describe("MemoryService / session / episode relation", () => {
query: "Database certificate rotation details please"
});
- expect(prepared.episodeId).toBe(first.episodeId);
+ expect(prepared).not.toHaveProperty("episodeId");
expect(calls).toEqual(["relation.classify.v1", "relation.arbitration.v1"]);
+ const beforeComplete = db.db.prepare(
+ `SELECT meta_json
+ FROM episodes
+ WHERE session_id = ?`
+ ).get(session.sessionId) as { meta_json: string };
+ expect(JSON.parse(beforeComplete.meta_json)).not.toHaveProperty("relationDecision");
+
+ const completed = service.completeTurn("turn-relation-llm-next", {
+ sessionId: session.sessionId,
+ query: "Database certificate rotation details please",
+ answer: "Rotate the certificate and reload the database client."
+ });
+ expect(completed.episodeId).toBe(first.episodeId);
const rows = db.db.prepare(
`SELECT meta_json
FROM episodes
@@ -781,16 +974,19 @@ describe("MemoryService / session / episode relation", () => {
sessionId: session.sessionId,
query: "wrong, use port 443 instead and verify TLS"
});
- expect(prepared.episodeId).toBe(first.episodeId);
+ expect(prepared).not.toHaveProperty("episodeId");
expect(db.db.prepare(
"SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-relation-revision'"
- ).get()).toEqual({ count: 1 });
+ ).get()).toEqual({ count: 0 });
const correction = service.completeTurn("turn-relation-revision-fix", {
sessionId: session.sessionId,
query: "wrong, use port 443 instead and verify TLS",
answer: "Corrected: use port 443 and verify TLS."
});
expect(correction.episodeId).toBe(first.episodeId);
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-relation-revision'"
+ ).get()).toEqual({ count: 1 });
const feedback = db.db.prepare(
`SELECT id, l1_memory_id, raw_turn_id, polarity, raw_payload_json
@@ -883,8 +1079,15 @@ describe("MemoryService / session / episode relation", () => {
sessionId: session.sessionId,
query: "不对,应该用递归实现,这样性能不好。换个任务:实现二叉树层序遍历"
});
- expect(prepared.episodeId).not.toBe(first.episodeId);
- expect(prepared.closedEpisodeIds).toEqual([first.episodeId]);
+ expect(prepared).not.toHaveProperty("episodeId");
+ expect(prepared).not.toHaveProperty("closedEpisodeIds");
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-implicit-turn-feedback'"
+ ).get()).toEqual({ count: 0 });
+ expect(service.getMemory(first.episodeId)).toMatchObject({
+ kind: "episode",
+ status: "open"
+ });
const correction = service.completeTurn("turn-implicit-feedback-correction", {
sessionId: session.sessionId,
query: "不对,应该用递归实现,这样性能不好。换个任务:实现二叉树层序遍历",
diff --git a/Memory/tests/service/session/idle-sweep.test.ts b/Memory/tests/service/session/idle-sweep.test.ts
index bbb7eced5..58450705a 100644
--- a/Memory/tests/service/session/idle-sweep.test.ts
+++ b/Memory/tests/service/session/idle-sweep.test.ts
@@ -206,19 +206,16 @@ describe("MemoryService / session / idle sweep", () => {
sessionId: longSession.sessionId,
query: "Run a long deployment verification"
});
- expect(started.episodeId).toMatch(/^episode_/u);
+ expect(started).not.toHaveProperty("episodeId");
expect(db.db.prepare(
"SELECT episode_id, status FROM raw_turns WHERE session_id = ? AND turn_id = ?"
- ).get(longSession.sessionId, "turn-long-running")).toEqual({
- episode_id: started.episodeId,
- status: "started"
- });
+ ).get(longSession.sessionId, "turn-long-running")).toBeUndefined();
const completed = service.completeTurn("turn-long-running", {
sessionId: longSession.sessionId,
query: "Run a long deployment verification",
answer: "The long deployment verification completed."
});
- expect(completed.episodeId).toBe(started.episodeId);
+ expect(completed.episodeId).toMatch(/^episode_/u);
db.db.prepare(
`UPDATE episodes
SET updated_at = ?
@@ -264,7 +261,7 @@ describe("MemoryService / session / idle sweep", () => {
sessionId: activeSession.sessionId,
query: "Run a long tool-driven deployment"
});
- expect(started.episodeId).toMatch(/^episode_/u);
+ expect(started).not.toHaveProperty("episodeId");
await service.observeTool({
sessionId: activeSession.sessionId,
turnId: "turn-active-tool",
@@ -278,7 +275,7 @@ describe("MemoryService / session / idle sweep", () => {
WHERE session_id = ?
AND turn_id = ?`
).get(activeSession.sessionId, "turn-active-tool") as { id: string; episode_id: string };
- expect(rawTurn.episode_id).toBe(started.episodeId);
+ expect(rawTurn.episode_id).toMatch(/^episode_/u);
const oldAt = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
setRawTurnActivityAt(db, rawTurn.id, oldAt);
await service.observeTool({
diff --git a/Memory/tests/service/session/turn-capture.test.ts b/Memory/tests/service/session/turn-capture.test.ts
index 832505212..dc621de28 100644
--- a/Memory/tests/service/session/turn-capture.test.ts
+++ b/Memory/tests/service/session/turn-capture.test.ts
@@ -17,7 +17,7 @@ const {
afterEach(cleanup);
describe("MemoryService / session / turn capture", () => {
- it("records a started RawTurn at turn.start and creates L1 only after turn.complete", async () => {
+ it("records only recall audit at turn.start and commits episode, RawTurn, and L1 at turn.complete", async () => {
const { db, service } = createTestService();
const session = service.openSession({
namespace: {
@@ -45,40 +45,26 @@ describe("MemoryService / session / turn capture", () => {
});
expect(started.turnId).toBe("turn-start-readonly");
- expect(started.episodeId).toMatch(/^episode_/u);
- expect(started.closedEpisodeIds).toEqual([]);
+ expect(started).not.toHaveProperty("episodeId");
+ expect(started).not.toHaveProperty("closedEpisodeIds");
expect(counts()).toEqual({
...before,
- episodes: before.episodes + 1,
- rawTurns: before.rawTurns + 1,
recalls: before.recalls + 1,
apiLogs: before.apiLogs + 1
});
- const startedRawTurn = db.db.prepare(
- `SELECT episode_id, user_text, assistant_text, source_memory_ids_json,
- message_payload_json, status
- FROM raw_turns
- WHERE session_id = ? AND turn_id = ?`
- ).get(session.sessionId, started.turnId) as {
- episode_id: string;
- user_text: string;
- assistant_text: string | null;
- source_memory_ids_json: string;
- message_payload_json: string;
- status: string;
- };
- expect(startedRawTurn).toMatchObject({
- episode_id: started.episodeId,
- user_text: "Do not create L1 until the assistant finishes.",
- assistant_text: null,
- status: "started"
- });
- expect(JSON.parse(startedRawTurn.source_memory_ids_json)).toEqual(started.sourceMemoryIds);
- expect(JSON.parse(startedRawTurn.message_payload_json)).toMatchObject({
- turn_start: {
- contextPacketId: started.contextPacketId,
- searchEventId: started.searchEventId,
- sourceMemoryIds: started.sourceMemoryIds
+ expect(db.db.prepare(
+ "SELECT COUNT(*) AS count FROM raw_turns WHERE session_id = ? AND turn_id = ?"
+ ).get(session.sessionId, started.turnId)).toEqual({ count: 0 });
+ const recall = db.db.prepare(
+ `SELECT episode_id, request_json
+ FROM recall_events
+ WHERE id = ?`
+ ).get(started.searchEventId) as { episode_id: string | null; request_json: string };
+ expect(recall.episode_id).toBeNull();
+ expect(JSON.parse(recall.request_json)).toMatchObject({
+ routeProposal: {
+ action: "create_first",
+ relationDecision: { relation: "new_task" }
}
});
expect(db.db.prepare(
@@ -110,6 +96,34 @@ describe("MemoryService / session / turn capture", () => {
apiLogs: before.apiLogs + 2,
idempotency: before.idempotency + 1
});
+ const completedRawTurn = db.db.prepare(
+ `SELECT episode_id, user_text, assistant_text, source_memory_ids_json,
+ message_payload_json, status
+ FROM raw_turns
+ WHERE id = ?`
+ ).get(completed.rawTurnId) as {
+ episode_id: string;
+ user_text: string;
+ assistant_text: string;
+ source_memory_ids_json: string;
+ message_payload_json: string;
+ status: string;
+ };
+ expect(completedRawTurn).toMatchObject({
+ episode_id: completed.episodeId,
+ user_text: "Do not create L1 until the assistant finishes.",
+ assistant_text: "The complete user and assistant turn is now safe to persist.",
+ status: "succeeded"
+ });
+ expect(JSON.parse(completedRawTurn.source_memory_ids_json)).toEqual(started.sourceMemoryIds);
+ expect(JSON.parse(completedRawTurn.message_payload_json)).toMatchObject({
+ turn_start: {
+ contextPacketId: started.contextPacketId,
+ searchEventId: started.searchEventId,
+ sourceMemoryIds: started.sourceMemoryIds,
+ routeProposal: { action: "create_first" }
+ }
+ });
expect(completed.jobs.map((job) => job.jobType)).toContain("episode_idle_close");
db.close();
});
@@ -138,25 +152,15 @@ describe("MemoryService / session / turn capture", () => {
query: "For that sqlite migration, inspect the schema first."
});
- expect(replacement.episodeId).toBe(interrupted.episodeId);
+ expect(interrupted).not.toHaveProperty("episodeId");
+ expect(replacement).not.toHaveProperty("episodeId");
expect(memoryCount()).toBe(beforeMemories);
expect(db.db.prepare(
`SELECT turn_id, status, assistant_text
FROM raw_turns
WHERE session_id = ?
ORDER BY created_at ASC, turn_id ASC`
- ).all(session.sessionId)).toEqual([
- {
- turn_id: "turn-interrupted",
- status: "started",
- assistant_text: null
- },
- {
- turn_id: "turn-replacement",
- status: "started",
- assistant_text: null
- }
- ]);
+ ).all(session.sessionId)).toEqual([]);
const completed = service.completeTurn("turn-replacement", {
sessionId: session.sessionId,
@@ -171,16 +175,10 @@ describe("MemoryService / session / turn capture", () => {
FROM raw_turns
WHERE session_id = ?
ORDER BY created_at ASC, turn_id ASC`
- ).all(session.sessionId)).toEqual([
- {
- turn_id: "turn-interrupted",
- status: "started"
- },
- {
- turn_id: "turn-replacement",
- status: "succeeded"
- }
- ]);
+ ).all(session.sessionId)).toEqual([{
+ turn_id: "turn-replacement",
+ status: "succeeded"
+ }]);
expect(db.db.prepare(
`SELECT json_extract(properties_json, '$.internal_info.raw_turn_id') AS raw_turn_id
FROM memories
@@ -253,13 +251,83 @@ describe("MemoryService / session / turn capture", () => {
});
expect(completed.l1MemoryIds).toHaveLength(1);
- expect(db.db.prepare(
- "SELECT status, user_text, assistant_text FROM raw_turns WHERE id = ?"
- ).get(completed.rawTurnId)).toEqual({
+ const raw = db.db.prepare(
+ "SELECT status, user_text, assistant_text, message_payload_json FROM raw_turns WHERE id = ?"
+ ).get(completed.rawTurnId) as {
+ status: string;
+ user_text: string;
+ assistant_text: string;
+ message_payload_json: string;
+ };
+ expect(raw).toMatchObject({
status: "failed",
user_text: "Run the deployment.",
assistant_text: "Deployment failed: connection timed out."
});
+ expect(JSON.parse(raw.message_payload_json)).toMatchObject({
+ turn_start: {
+ routeProposalStale: true,
+ routeProposal: { action: "create_first" }
+ }
+ });
+ db.close();
+ });
+
+ it("rebinds observed tool data when turn.complete commits a split proposal", async () => {
+ const { db, service } = createTestService();
+ const session = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "default",
+ userId: "turn-observed-route-user"
+ }
+ });
+ const first = service.completeTurn("turn-observed-route-first", {
+ sessionId: session.sessionId,
+ query: "Configure nginx TLS",
+ answer: "Use port 443."
+ });
+ await service.startTurn({
+ turnId: "turn-observed-route-split",
+ sessionId: session.sessionId,
+ query: "new task: summarize the hiring plan"
+ });
+ const observed = await service.observeTool({
+ sessionId: session.sessionId,
+ turnId: "turn-observed-route-split",
+ toolCallId: "call-hiring-plan",
+ toolName: "read_file",
+ args: { path: "hiring-plan.md" }
+ });
+ expect(observed.rawTurnId).toMatch(/^raw_/u);
+ expect(db.db.prepare(
+ "SELECT episode_id FROM raw_turns WHERE id = ?"
+ ).get(observed.rawTurnId)).toEqual({ episode_id: first.episodeId });
+
+ const completed = service.completeTurn("turn-observed-route-split", {
+ sessionId: session.sessionId,
+ query: "new task: summarize the hiring plan",
+ answer: "The hiring plan is summarized."
+ });
+ expect(completed.rawTurnId).toBe(observed.rawTurnId);
+ expect(completed.episodeId).not.toBe(first.episodeId);
+ expect(completed.closedEpisodeIds).toEqual([first.episodeId]);
+ expect(db.db.prepare(
+ "SELECT episode_id FROM raw_turns WHERE id = ?"
+ ).get(observed.rawTurnId)).toEqual({ episode_id: completed.episodeId });
+ expect(db.db.prepare(
+ "SELECT DISTINCT episode_id FROM artifacts WHERE raw_turn_id = ?"
+ ).all(observed.rawTurnId)).toEqual([{ episode_id: completed.episodeId }]);
+ const episodeRows = db.db.prepare(
+ "SELECT id, raw_turn_ids_json FROM episodes WHERE id IN (?, ?) ORDER BY id"
+ ).all(first.episodeId, completed.episodeId) as Array<{
+ id: string;
+ raw_turn_ids_json: string;
+ }>;
+ const firstRow = episodeRows.find((row) => row.id === first.episodeId);
+ const completedRow = episodeRows.find((row) => row.id === completed.episodeId);
+ expect(JSON.parse(firstRow!.raw_turn_ids_json)).not.toContain(observed.rawTurnId);
+ expect(JSON.parse(completedRow!.raw_turn_ids_json)).toContain(observed.rawTurnId);
db.close();
});
From b7129d736a1fb901c58014d1e7b60cf3be3c6fc0 Mon Sep 17 00:00:00 2001
From: jiang
Date: Mon, 3 Aug 2026 17:03:04 +0800
Subject: [PATCH 05/35] fix(memory): separate retrieval content and validate L3
evidence
---
.../local-api-contracts/src/memory-runtime.ts | 5 +-
App/frontend/desktop/src/i18n/messages.ts | 4 +-
.../src/pages/memory/skills-sub-page.tsx | 34 +++--
.../memory/tests/skills-sub-page.test.tsx | 34 ++++-
.../tests/world-model-sub-page.test.tsx | 8 +-
.../src/pages/memory/world-model-sub-page.tsx | 30 +++--
Memory/src/algorithm/plugin-algorithms.ts | 80 ++++++++++-
.../embedding/embedding-job-processor.ts | 17 ++-
.../service/embedding/embedding-pipeline.ts | 15 ++-
.../service/evolution/world-model-pipeline.ts | 55 ++++++--
Memory/src/service/read-model/memory.ts | 4 +-
Memory/src/service/worker/worker-runner.ts | 56 +++++++-
Memory/src/storage/repositories.ts | 9 +-
.../repository/memory-retrieval-index.test.ts | 68 ++++++++++
.../embedding/embedding-processing.test.ts | 126 +++++++++++++++++-
.../service/evolution/evolution-llm-stubs.ts | 6 +-
.../service/evolution/orchestration.test.ts | 4 +-
.../service/evolution/world-model.test.ts | 46 ++++++-
.../feedback/decision-repair-llm-stub.ts | 2 +-
19 files changed, 527 insertions(+), 76 deletions(-)
diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts
index 27c03e73e..2cc81ba62 100644
--- a/App/backend/local-api-contracts/src/memory-runtime.ts
+++ b/App/backend/local-api-contracts/src/memory-runtime.ts
@@ -425,12 +425,15 @@ export const GetMemoryOutputSchema = z.object({
worldModel: z
.object({
sourceMemoryIds: z.array(NonEmptyStringSchema),
- confidence: z.number().optional()
+ confidence: z.number().optional(),
+ summary: z.string().optional()
})
.optional(),
skill: z
.object({
invocationGuide: z.string(),
+ retrievalBlurb: z.string().optional(),
+ triggerContext: z.string().optional(),
procedure: z.array(z.string()).optional(),
sourcePolicyIds: z.array(NonEmptyStringSchema),
sourceWorldModelIds: z.array(NonEmptyStringSchema),
diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts
index d3c6284cf..297440f0c 100644
--- a/App/frontend/desktop/src/i18n/messages.ts
+++ b/App/frontend/desktop/src/i18n/messages.ts
@@ -1001,7 +1001,7 @@ export const zhCNMessages = {
"memory.policies.sourceMemories": "来源记忆",
"memory.policies.noSourceTasks": "暂无来源任务",
"memory.policies.noSourceMemories": "暂无来源记忆",
- "memory.skills.invocationGuide": "调用指南",
+ "memory.skills.invocationGuide": "适用场景",
"memory.skills.body": "SKILL.md 内容",
"memory.skills.decisionGuidance": "决策指引",
"memory.skills.prefer": "推荐做法",
@@ -2360,7 +2360,7 @@ export const enUSMessages: Record = {
"memory.policies.sourceMemories": "Source memories",
"memory.policies.noSourceTasks": "No source tasks",
"memory.policies.noSourceMemories": "No source memories",
- "memory.skills.invocationGuide": "Invocation guide",
+ "memory.skills.invocationGuide": "When to use",
"memory.skills.body": "SKILL.md content",
"memory.skills.decisionGuidance": "Decision guidance",
"memory.skills.prefer": "Preferred actions",
diff --git a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx
index d524f957e..02d764cb1 100644
--- a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx
@@ -60,8 +60,7 @@ interface SkillView {
createdAt: string;
updatedAt: string;
body: string;
- summary: string;
- invocationGuide: string;
+ usageGuide: string;
decisionGuidance: SkillDecisionGuidance;
evidenceAnchors: string[];
sourcePolicyIds: string[];
@@ -443,7 +442,7 @@ function SkillDetail(props: { detail: GetMemoryOutput; timeline: SkillTimelineEn
-
+ {skill.usageGuide && }
{hasDecisionGuidance && (
@@ -676,10 +675,28 @@ function skillFromDetail(detail: GetMemoryOutput): SkillView {
const properties = recordValue(metadata.properties);
const info = recordValue(metadata.info);
const internalInfo = recordValue(properties.internal_info);
+ const layerSkill = recordValue(detail.item.skill);
const skill = recordValue(firstDefined(internalInfo.skill, metadata.skill, properties.skill));
+ const procedure = recordValue(firstDefined(skill.procedureJson, skill.procedure_json, internalInfo.procedureJson, internalInfo.procedure_json));
const decisionGuidance = readDecisionGuidance(
firstDefined(skill.decisionGuidance, skill.decision_guidance, internalInfo.decisionGuidance, internalInfo.decision_guidance)
);
+ const body = cleanMemoryBody(detail.item.body);
+ const shortUsageGuide = uniqueStrings([
+ firstString(layerSkill.retrievalBlurb, layerSkill.retrieval_blurb, procedure.retrievalBlurb, procedure.retrieval_blurb) ?? "",
+ firstString(layerSkill.triggerContext, layerSkill.trigger_context, procedure.triggerContext, procedure.trigger_context) ?? ""
+ ]).join("\n\n");
+ const parsedUsageGuide = parseMarkdownSection(detail.item.body, ["When to use", "\u9002\u7528\u573a\u666f", "\u8c03\u7528\u65f6\u673a"]);
+ const legacyInvocationGuide = firstString(
+ layerSkill.invocationGuide,
+ skill.invocationGuide,
+ skill.invocation_guide,
+ internalInfo.invocationGuide,
+ internalInfo.invocation_guide
+ );
+ const distinctLegacyGuide = legacyInvocationGuide && cleanMemoryBody(legacyInvocationGuide) !== body
+ ? legacyInvocationGuide
+ : "";
return {
title: displaySkillTitle(detail.item, firstString(skill.title, internalInfo.title)),
@@ -687,15 +704,8 @@ function skillFromDetail(detail: GetMemoryOutput): SkillView {
source: firstString(metadata.source, internalInfo.source),
createdAt: detail.item.createdAt,
updatedAt: detail.item.updatedAt,
- body: cleanMemoryBody(detail.item.body),
- summary: cleanMemoryText(detail.item.summary),
- invocationGuide: firstString(
- skill.invocationGuide,
- skill.invocation_guide,
- internalInfo.invocationGuide,
- internalInfo.invocation_guide,
- parseMarkdownSection(detail.item.body, ["Invocation", "\u8c03\u7528\u6307\u5357", "\u8c03\u7528"])
- ) ?? "",
+ body,
+ usageGuide: shortUsageGuide || parsedUsageGuide || distinctLegacyGuide,
decisionGuidance,
evidenceAnchors: readEvidenceAnchors(firstDefined(skill.evidenceAnchors, skill.evidence_anchors, internalInfo.evidenceAnchors, internalInfo.evidence_anchors)),
sourcePolicyIds: stringArray(firstDefined(skill.sourcePolicyIds, skill.source_policy_ids, internalInfo.sourcePolicyIds, internalInfo.source_policy_ids)),
diff --git a/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx
index 14287522d..c9636705b 100644
--- a/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx
+++ b/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx
@@ -36,7 +36,25 @@ describe("SkillsSubPage", () => {
const html = renderSkills({
status: "ready",
data: skillPanelItemsFixture,
- detail: { status: "ready", data: { detail: skillPanelDetailFixture, timeline: skillTimelineEntries() } }
+ detail: {
+ status: "ready",
+ data: {
+ detail: {
+ ...skillPanelDetailFixture,
+ item: {
+ ...skillPanelDetailFixture.item,
+ skill: {
+ invocationGuide: skillPanelDetailFixture.item.body,
+ retrievalBlurb: "根据仓库真实代码补齐中文文件级、函数级和字段含义注释。",
+ triggerContext: "当用户要求补充或修正中文代码注释时使用。",
+ sourcePolicyIds: ["memory-policy-1"],
+ sourceWorldModelIds: []
+ }
+ }
+ },
+ timeline: skillTimelineEntries()
+ }
+ }
});
expect(html).toContain("根据仓库真实代码补齐中文文件级、函数级和字段含义注释。");
expect(html).toContain("先读文件");
@@ -48,7 +66,7 @@ describe("SkillsSubPage", () => {
expect(html).toContain("memory-delete-button");
expect(html).toContain('data-icon="trash-2"');
expect(html).not.toContain(">v4<");
- expect(html).toContain("调用指南");
+ expect(html).toContain("适用场景");
expect(html).toContain("来源经验");
expect(html).toContain("memory-policy-1");
expect(html).toContain("进化时间线");
@@ -91,6 +109,18 @@ describe("SkillsSubPage", () => {
expect(html).not.toContain(">resolving<");
});
+ it("旧技能缺少短召回字段时不把完整正文重复显示为适用场景", () => {
+ const html = renderSkills({
+ status: "ready",
+ data: skillPanelItemsFixture,
+ detail: { status: "ready", data: { detail: skillPanelDetailFixture, timeline: [] } }
+ });
+
+ expect(html).toContain("SKILL.md 内容");
+ expect(html).not.toContain("适用场景");
+ expect(html).not.toContain("根据仓库真实代码补齐中文文件级、函数级和字段含义注释。");
+ });
+
it("技能列表生命周期和详情技能状态使用同一套展示状态", () => {
expect(skillStatusTone("resolving")).toBe("candidate");
expect(skillStatusTone("candidate")).toBe("candidate");
diff --git a/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx
index 8e5820f1a..22595cab9 100644
--- a/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx
+++ b/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx
@@ -32,13 +32,14 @@ const worldDetail: GetMemoryOutput = {
properties: {
internal_info: {
world_model: {
+ summary: "Memmy 的本地记忆服务按层暴露记忆,并由管理页直接读取。",
policyIds: ["memory-policy-1"],
structure: {
environment: [
{
label: "本地记忆底座",
description: "记忆服务通过 panel items 暴露 L1/L2/L3/Skill 数据。",
- evidenceIds: ["memory-trace-1"]
+ evidenceIds: ["memory-trace-1", "po_1", "tr_fake"]
}
],
inference: [
@@ -147,8 +148,13 @@ describe("WorldModelSubPage", () => {
expect(html).toContain('data-icon="trash-2"');
expect(html).toContain("候选");
expect(html).toContain("结构化认知");
+ expect(html).toContain("Memmy 的本地记忆服务按层暴露记忆,并由管理页直接读取。");
+ expect(html).not.toContain("Memmy 是本地记忆 sidecar,不负责调度外部 Agent 任务队列。");
expect(html).toContain("环境拓扑");
expect(html).toContain("本地记忆底座");
+ expect(html).toContain("memory-trace-1");
+ expect(html).not.toContain("po_1");
+ expect(html).not.toContain("tr_fake");
expect(html).toContain("memory-policy-1");
});
});
diff --git a/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx b/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx
index 9d9c93bf9..7cd5bdc83 100644
--- a/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx
@@ -350,6 +350,9 @@ function WorldModelDrawer(props: { detail: WorldModelDetailState; onClose: () =>
function WorldModelDetail(props: { detail: GetMemoryOutput }) {
const { t } = useTranslation();
const worldModel = worldModelFromDetail(props.detail);
+ const hasStructuredCognition = worldModel.structure.environment.length > 0 ||
+ worldModel.structure.inference.length > 0 ||
+ worldModel.structure.constraints.length > 0;
return (
<>
@@ -370,9 +373,10 @@ function WorldModelDetail(props: { detail: GetMemoryOutput }) {
)}
-
-
-
+ {worldModel.summary && }
+ {hasStructuredCognition
+ ?
+ : }
>
@@ -491,6 +495,7 @@ function worldModelFromDetail(detail: GetMemoryOutput): WorldModelView {
const metadata = detail.item.metadata;
const properties = recordValue(metadata.properties);
const internalInfo = recordValue(properties.internal_info);
+ const layerWorldModel = recordValue(detail.item.worldModel);
const worldModel = recordValue(firstDefined(internalInfo.world_model, internalInfo.worldModel, metadata.world_model, metadata.worldModel));
const structure = readWorldModelStructure(
firstDefined(worldModel.structure, internalInfo.structure, properties.structure, metadata.structure)
@@ -503,7 +508,7 @@ function worldModelFromDetail(detail: GetMemoryOutput): WorldModelView {
createdAt: detail.item.createdAt,
updatedAt: detail.item.updatedAt,
body: cleanMemoryBody(detail.item.body),
- summary: displayWorldModelSummary(detail.item),
+ summary: cleanWorldModelText(firstString(layerWorldModel.summary, worldModel.summary, internalInfo.summary)),
policyIds: stringArray(firstDefined(worldModel.policyIds, worldModel.policy_ids, internalInfo.policyIds, internalInfo.policy_ids)),
sourceMemoryIds: detail.item.sourceMemoryIds,
structure
@@ -550,10 +555,16 @@ function structureEntry(value: unknown, key?: string): WorldModelStructureEntry
return {
label: label ?? description,
description,
- evidenceIds: stringArray(firstDefined(record.evidenceIds, record.evidence_ids, record.sourceMemoryIds, record.source_memory_ids))
+ evidenceIds: stringArray(
+ firstDefined(record.evidenceIds, record.evidence_ids, record.sourceMemoryIds, record.source_memory_ids)
+ ).filter(isDisplayableWorldModelEvidenceId)
};
}
+function isDisplayableWorldModelEvidenceId(value: string): boolean {
+ return /^(?:policy_|trace_|memory-(?:policy|trace)-)[a-z0-9_-]+$/i.test(value);
+}
+
function displayWorldModelTitle(
item: Pick & { body?: string },
...candidates: Array
@@ -566,15 +577,6 @@ function displayWorldModelTitle(
return displayMemoryId(item.id);
}
-function displayWorldModelSummary(item: Pick & { body?: string }): string {
- for (const value of [item.summary, firstReadableWorldBodyLine(item.body), item.title]) {
- const text = cleanWorldModelText(value);
- if (isDisplayableWorldModelText(text)) return text;
- }
-
- return "";
-}
-
function firstReadableWorldBodyLine(body?: string): string | undefined {
return cleanMemoryBody(body)
.split(/\r?\n/)
diff --git a/Memory/src/algorithm/plugin-algorithms.ts b/Memory/src/algorithm/plugin-algorithms.ts
index 1942a21da..5a3e02aac 100644
--- a/Memory/src/algorithm/plugin-algorithms.ts
+++ b/Memory/src/algorithm/plugin-algorithms.ts
@@ -102,6 +102,8 @@ export interface SkillMemoryMeta {
sourceWorldModelIds: string[];
evidenceAnchorIds: string[];
invocationGuide: string;
+ retrievalBlurb?: string;
+ triggerContext?: string;
trialsAttempted: number;
trialsPassed: number;
repairOrigin: boolean;
@@ -1278,7 +1280,7 @@ Rules:
export const L3_ABSTRACTION_PROMPT = {
id: "l3.abstraction",
- version: 2,
+ version: 3,
description:
"Distill an L3 world model (declarative environment knowledge) from a cluster of L2 policies, with explicit boundaries against L2 procedural drift.",
system: `You abstract environment world models from cross-task policy evidence.
@@ -1357,14 +1359,19 @@ Return JSON:
"title": "short noun phrase, e.g. 'Alpine python dependency model'",
"domain_tags": ["tag1", "tag2"], // 1-4 short, lowercase, no spaces
"environment": [
- { "label": "...", "description": "...", "evidenceIds": ["po_...", "tr_..."] }
+ { "label": "...", "description": "...", "evidenceIds": ["policy_", "trace_"] }
],
"inference": [ { "label": "...", "description": "...", "evidenceIds": [] } ],
"constraints": [ { "label": "...", "description": "...", "evidenceIds": [] } ],
- "body": "rendered markdown summary of the three sections",
+ "summary": "1-3 sentences describing the environment and its most important invariants",
"confidence": number in [0, 1],
"supersedes_world_ids": []
-}`
+}
+
+Evidence ID rules:
+- Copy evidence IDs exactly from the input lines prefixed with "policy" or "trace".
+- Never abbreviate, rewrite, or invent an evidence ID.
+- Use [] when no supplied ID directly supports an entry.`
} as const;
export const SKILL_CRYSTALLIZE_PROMPT = {
@@ -1484,6 +1491,7 @@ export interface WorldModelMemoryMeta {
cohesion: number;
admission: "strict" | "loose";
structure: WorldModelStructure;
+ summary?: string;
body: string;
vec: number[] | null;
}
@@ -1510,6 +1518,7 @@ export interface WorldModelDraft {
cohesion: number;
admission: "strict" | "loose";
structure: WorldModelStructure;
+ summary: string;
body: string;
vec: number[] | null;
tags: string[];
@@ -3254,6 +3263,8 @@ export function skillMetaFromMemory(memory: MemoryRow): SkillMemoryMeta | null {
if (memory.memoryLayer !== "Skill") return null;
const skill = getInternal>(memory, "skill");
if (!skill) return null;
+ const procedure = recordField(skill, "procedure_json") ??
+ recordField(memory.properties.internal_info as Record, "procedure_json");
return {
id: memory.id,
memory,
@@ -3266,6 +3277,12 @@ export function skillMetaFromMemory(memory: MemoryRow): SkillMemoryMeta | null {
evidenceAnchorIds: stringArrayField(skill, "evidence_anchor_ids")
.concat(stringArrayField(skill, "evidence_anchors")),
invocationGuide: stringField(skill, "invocation_guide") ?? memory.memoryValue,
+ retrievalBlurb: procedure
+ ? stringField(procedure, "retrievalBlurb") ?? stringField(procedure, "retrieval_blurb")
+ : undefined,
+ triggerContext: procedure
+ ? stringField(procedure, "triggerContext") ?? stringField(procedure, "trigger_context")
+ : undefined,
trialsAttempted: numberField(skill, "trials_attempted") ?? 0,
trialsPassed: numberField(skill, "trials_passed") ?? 0,
repairOrigin: booleanishField(skill, "repairOrigin") ?? booleanishField(skill, "repair_origin") ?? false,
@@ -3297,11 +3314,51 @@ export function worldModelMetaFromMemory(memory: MemoryRow): WorldModelMemoryMet
cohesion: numberField(wm, "cohesion") ?? 1,
admission: statusField(wm, "admission", ["strict", "loose"]) ?? "strict",
structure: worldModelStructureField(wm, "structure"),
+ summary: stringField(wm, "summary") ?? stringField(memory.properties.internal_info as Record, "summary"),
body: stringField(wm, "body") ?? memory.memoryValue,
vec: memoryVector(memory, "vec")
};
}
+export const RETRIEVAL_DOCUMENT_VERSION = 2;
+
+/** Builds the canonical text shared by vector, FTS, and in-memory retrieval for Skill and L3. */
+export function retrievalDocumentForMemory(memory: MemoryRow): string {
+ const skill = skillMetaFromMemory(memory);
+ if (skill) {
+ const shortGuide = [skill.retrievalBlurb, skill.triggerContext].filter(Boolean);
+ return [skill.name, ...(shortGuide.length > 0 ? shortGuide : [skill.invocationGuide]), memory.tags.join(" ")]
+ .filter(Boolean)
+ .join("\n");
+ }
+
+ const world = worldModelMetaFromMemory(memory);
+ if (world) {
+ const structuredFacts = world.summary
+ ? [
+ ...world.structure.environment,
+ ...world.structure.inference,
+ ...world.structure.constraints
+ ].map((entry) => [entry.label, entry.description].filter(Boolean).join(": "))
+ : [world.body];
+ return [world.title, world.summary, world.domainTags.join(" "), ...structuredFacts]
+ .filter(Boolean)
+ .join("\n");
+ }
+
+ return memory.memoryValue;
+}
+
+export function retrievalDocumentSourceHash(memory: MemoryRow): string {
+ return stableHash(retrievalDocumentForMemory(memory));
+}
+
+export function retrievalDocumentIsCurrent(memory: MemoryRow): boolean {
+ const index = recordField(memory.properties.internal_info as Record, "retrieval_index");
+ return numberField(index ?? {}, "version") === RETRIEVAL_DOCUMENT_VERSION &&
+ stringField(index ?? {}, "source_hash") === retrievalDocumentSourceHash(memory);
+}
+
function worldModelTitleFromMemory(memory: MemoryRow, wm: Record): string {
return firstWorldModelDisplayString(
stringField(wm, "title"),
@@ -3611,6 +3668,7 @@ export function buildWorldModelDraft(args: {
admission,
cohesion
});
+ const summary = fallbackWorldModelSummary(title, structure);
const body = [
title,
`Admission: ${admission} (cohesion=${round(cohesion, 4)})`,
@@ -3635,6 +3693,7 @@ export function buildWorldModelDraft(args: {
cohesion,
admission,
structure,
+ summary,
body,
vec: center,
tags: distinct(["world_model", ...tags])
@@ -4621,6 +4680,15 @@ function fallbackWorldModelStructure(input: {
};
}
+function fallbackWorldModelSummary(title: string, structure: WorldModelStructure): string {
+ const facts = [
+ structure.environment[0]?.description,
+ structure.inference[0]?.description,
+ structure.constraints[0]?.description
+ ].filter((value): value is string => Boolean(value?.trim()));
+ return [title, ...facts].join(" — ");
+}
+
function skillNameFromPolicy(policy: PolicyMemoryMeta): string {
const raw = policy.title
.replace(/^Policy:\s*/i, "")
@@ -5461,11 +5529,11 @@ function memoryTextForRetrieval(memory: MemoryRow): string {
}
const skill = skillMetaFromMemory(memory);
if (skill) {
- return [skill.name, skill.invocationGuide].join("\n");
+ return retrievalDocumentForMemory(memory);
}
const world = worldModelMetaFromMemory(memory);
if (world) {
- return [world.title, world.body, world.domainTags.join(" ")].join("\n");
+ return retrievalDocumentForMemory(memory);
}
return memory.memoryValue;
}
diff --git a/Memory/src/service/embedding/embedding-job-processor.ts b/Memory/src/service/embedding/embedding-job-processor.ts
index 7a304c1a1..eaa85e819 100644
--- a/Memory/src/service/embedding/embedding-job-processor.ts
+++ b/Memory/src/service/embedding/embedding-job-processor.ts
@@ -7,7 +7,7 @@ import { clip,firstLine } from "../../utils/text.js";
* generic job-enqueue policy; this processor owns the job-specific state
* transitions, model calls, and change records.
*/
-import { traceMetaFromMemory } from "../../algorithm/plugin-algorithms.js";
+import { retrievalDocumentSourceHash,traceMetaFromMemory } from "../../algorithm/plugin-algorithms.js";
import type { Embedder,LlmClient } from "../../model/types.js";
import type { EmbeddingRetryRecord,EmbeddingRetryVectorField,EvolutionJobRecord,Repositories } from "../../storage/repositories.js";
import { kindFromMemory } from "../../storage/repositories.js";
@@ -42,6 +42,7 @@ export interface PreparedEmbeddingJob {
text: string;
role: "document" | "query";
vectorField: EmbeddingRetryVectorField;
+ sourceHash?: string;
}
export interface EnqueueWorkerJobInput {
@@ -62,6 +63,7 @@ export interface PersistEmbeddingVectorInput {
vector: number[];
attemptCount: number;
source: string;
+ sourceHash?: string;
allowedProcessingStates?: MemoryProcessingState[];
finalize?: (saved: MemoryRow, hadProcessing: boolean, at: string) => void;
}
@@ -142,12 +144,16 @@ export class EmbeddingJobProcessor {
return { job, memory, text, role: "document", vectorField: "vec_summary" };
}
+ const text = embeddingTextForMemory(memory);
return {
job,
memory,
- text: embeddingTextForMemory(memory),
+ text,
role: "query",
- vectorField: "vec"
+ vectorField: "vec",
+ sourceHash: memory.memoryLayer === "Skill" || memory.memoryLayer === "L3"
+ ? retrievalDocumentSourceHash(memory)
+ : undefined
};
}
@@ -155,12 +161,14 @@ export class EmbeddingJobProcessor {
const current = this.deps.repos.memories.get(item.memory.id);
if (!current) throw new Error(`embedding target not found: ${item.memory.id}`);
if (!processingJobMatchesMemory(item.job, current)) return;
+ if (item.sourceHash && retrievalDocumentSourceHash(current) !== item.sourceHash) return;
this.persistEmbeddingVector({
memoryId: current.id,
vectorField: item.vectorField,
vector,
attemptCount: item.job.attempts,
source: "worker.embedding",
+ sourceHash: item.sourceHash,
allowedProcessingStates: ["embedding_pending", "embedding"],
finalize: (_saved, hadProcessing, at) => {
if (hadProcessing) this.deps.repos.runtime.completeJob(item.job.id, at);
@@ -177,7 +185,8 @@ export class EmbeddingJobProcessor {
const vectorized = updateMemoryVectorField(current, input.vectorField, input.vector, {
model: this.deps.embedder.config.model ?? this.deps.embedder.config.provider,
provider: this.deps.embedder.config.provider,
- updatedAt: at
+ updatedAt: at,
+ sourceHash: input.sourceHash
});
saved = this.deps.repos.memories.updateMaintenance(
current.memoryLayer === "L1" ? updateImportPipelineStatus(vectorized, "indexed", at) : vectorized
diff --git a/Memory/src/service/embedding/embedding-pipeline.ts b/Memory/src/service/embedding/embedding-pipeline.ts
index 8716bc750..10a2a1cef 100644
--- a/Memory/src/service/embedding/embedding-pipeline.ts
+++ b/Memory/src/service/embedding/embedding-pipeline.ts
@@ -7,6 +7,8 @@ import type {
} from "../../storage/repositories.js";
import {
policyMetaFromMemory,
+ RETRIEVAL_DOCUMENT_VERSION,
+ retrievalDocumentForMemory,
skillMetaFromMemory,
traceMetaFromMemory,
worldModelMetaFromMemory
@@ -49,11 +51,11 @@ export function embeddingTextForMemory(memory: MemoryRow): string {
}
const skill = skillMetaFromMemory(memory);
if (skill) {
- return [skill.name, skill.invocationGuide].filter(Boolean).join("\n");
+ return retrievalDocumentForMemory(memory);
}
const world = worldModelMetaFromMemory(memory);
if (world) {
- return [world.title, world.body, world.domainTags.join(" ")].filter(Boolean).join("\n");
+ return retrievalDocumentForMemory(memory);
}
return memory.memoryValue;
}
@@ -129,7 +131,7 @@ export function updateMemoryVectorField(
memory: MemoryRow,
vectorField: EmbeddingRetryVectorField,
vector: number[],
- input: { provider: string; model: string; updatedAt: string }
+ input: { provider: string; model: string; updatedAt: string; sourceHash?: string }
): MemoryRow {
const internal = memory.properties.internal_info;
const nextInternal: Record = { ...internal };
@@ -142,6 +144,13 @@ export function updateMemoryVectorField(
} else if (memory.memoryLayer === "Skill" && isRecord(internal.skill)) {
nextInternal.skill = { ...internal.skill };
}
+ if ((memory.memoryLayer === "L3" || memory.memoryLayer === "Skill") && input.sourceHash) {
+ nextInternal.retrieval_index = {
+ version: RETRIEVAL_DOCUMENT_VERSION,
+ source_hash: input.sourceHash,
+ indexed_at: input.updatedAt
+ };
+ }
const updated = {
...memory,
diff --git a/Memory/src/service/evolution/world-model-pipeline.ts b/Memory/src/service/evolution/world-model-pipeline.ts
index ad94bdcf4..226ed923c 100644
--- a/Memory/src/service/evolution/world-model-pipeline.ts
+++ b/Memory/src/service/evolution/world-model-pipeline.ts
@@ -140,9 +140,13 @@ export class WorldModelPipeline {
}
const rawDraft = enhancement.draft;
const existing = this.findWorldModelMergeTarget(rawDraft);
- const draft = existing
+ const mergedDraft = existing
? mergeWorldModelDraftForUpdate(rawDraft, existing, this.deps.config.algorithm.l3Abstraction.confidenceDelta)
: rawDraft;
+ const draft = {
+ ...mergedDraft,
+ body: renderWorldModelBody(mergedDraft.title, mergedDraft.structure)
+ };
const l3 = this.deps.buildMemory({
userId,
conversationId: source?.conversationId,
@@ -169,6 +173,7 @@ export class WorldModelPipeline {
plugin_algorithm: "l3.abstraction.v7",
source_memory_ids: draft.policyIds,
title: draft.title,
+ summary: draft.summary,
body: draft.body,
structure: draft.structure,
domain_tags: draft.domainTags,
@@ -183,6 +188,7 @@ export class WorldModelPipeline {
cohesion: draft.cohesion,
admission: draft.admission,
structure: draft.structure,
+ summary: draft.summary,
body: draft.body,
vec: draft.vec
}
@@ -362,9 +368,11 @@ private async enhanceWorldModelDrafts(
const selectedPolicies = policies
.filter((policy) => fallback.policyIds.includes(policy.id))
.slice(0, 8);
+ const allowedEvidenceIds = new Set();
const languageSamples: Array = [];
const policySummaries = selectedPolicies
.map((policy) => {
+ allowedEvidenceIds.add(policy.id);
const traces = this.gatherWorldModelEvidence(policy);
languageSamples.push(
policy.title,
@@ -374,6 +382,7 @@ private async enhanceWorldModelDrafts(
policy.boundary
);
for (const trace of traces) {
+ allowedEvidenceIds.add(trace.id);
languageSamples.push(trace.userText, trace.agentText, trace.reflection);
}
const traceBlocks = traces
@@ -386,7 +395,7 @@ private async enhanceWorldModelDrafts(
].join("\n"))
.join("\n");
return capText([
- `- ${policy.title}`,
+ `- policy ${policy.id}: ${policy.title}`,
` trigger=${policy.trigger}`,
` procedure=${policy.procedure}`,
` verification=${policy.verification}`,
@@ -398,7 +407,7 @@ private async enhanceWorldModelDrafts(
.join("\n");
const result = await this.deps.skillLlm.completeJson<{
title?: unknown;
- body?: unknown;
+ summary?: unknown;
structure?: unknown;
environment?: unknown;
inference?: unknown;
@@ -436,10 +445,12 @@ private async enhanceWorldModelDrafts(
continue;
}
const title = skillText(result.title);
- const structure = coerceWorldModelStructure(result, fallback.structure);
- const body = typeof result.body === "string" && skillMarkdown(result.body)
- ? skillMarkdown(result.body)
- : renderWorldModelBody(title, structure);
+ const structure = coerceWorldModelStructure(result, fallback.structure, allowedEvidenceIds);
+ const body = renderWorldModelBody(title, structure);
+ const generatedSummary = skillText(result.summary);
+ const summary = generatedSummary && generatedSummary !== body
+ ? generatedSummary
+ : renderWorldModelSummary(title, structure);
const domainTags = normaliseWorldModelTags(result.domain_tags);
const effectiveDomainTags = domainTags.length > 0 ? domainTags : fallback.domainTags;
out.push({
@@ -447,6 +458,7 @@ private async enhanceWorldModelDrafts(
draft: {
...fallback,
title,
+ summary,
body,
structure,
confidence: shapeWorldModelConfidence(
@@ -569,19 +581,21 @@ function l3AbstractionInvalidReason(result: unknown): string | null {
function coerceWorldModelStructure(
result: Record,
- fallback: WorldModelDraft["structure"]
+ fallback: WorldModelDraft["structure"],
+ allowedEvidenceIds: ReadonlySet
): WorldModelDraft["structure"] {
const rawStructure = isRecord(result.structure) ? result.structure : {};
return {
- environment: coerceWorldModelEntries(rawStructure.environment ?? result.environment, fallback.environment),
- inference: coerceWorldModelEntries(rawStructure.inference ?? result.inference, fallback.inference),
- constraints: coerceWorldModelEntries(rawStructure.constraints ?? result.constraints, fallback.constraints)
+ environment: coerceWorldModelEntries(rawStructure.environment ?? result.environment, fallback.environment, allowedEvidenceIds),
+ inference: coerceWorldModelEntries(rawStructure.inference ?? result.inference, fallback.inference, allowedEvidenceIds),
+ constraints: coerceWorldModelEntries(rawStructure.constraints ?? result.constraints, fallback.constraints, allowedEvidenceIds)
};
}
function coerceWorldModelEntries(
value: unknown,
- fallback: WorldModelDraft["structure"]["environment"]
+ fallback: WorldModelDraft["structure"]["environment"],
+ allowedEvidenceIds: ReadonlySet
): WorldModelDraft["structure"]["environment"] {
if (!Array.isArray(value)) return fallback;
const entries = value
@@ -590,7 +604,10 @@ function coerceWorldModelEntries(
const label = skillText(item.label);
const description = skillMarkdown(firstString(item.description, item.body, item.text));
if (!label && !description) return null;
- const evidenceIds = stringArray(item.evidenceIds ?? item.evidence_ids);
+ const evidenceIds = uniq(
+ stringArray(item.evidenceIds ?? item.evidence_ids)
+ .filter((id) => allowedEvidenceIds.has(id))
+ );
return {
label: label || description.slice(0, 32),
description,
@@ -635,6 +652,18 @@ function renderWorldModelBody(
return lines.join("\n").trim();
}
+function renderWorldModelSummary(
+ title: string,
+ structure: WorldModelDraft["structure"]
+): string {
+ const facts = [
+ structure.environment[0]?.description,
+ structure.inference[0]?.description,
+ structure.constraints[0]?.description
+ ].filter((value): value is string => Boolean(value?.trim()));
+ return capText([title, ...facts].join(" — "), 500);
+}
+
function skillText(value: unknown): string {
return stripDangerousMarkdownLinks(stripUnsafeHtml(skillRawString(value)))
.replace(SKILL_CONTROL_RE, "")
diff --git a/Memory/src/service/read-model/memory.ts b/Memory/src/service/read-model/memory.ts
index 2335485b2..b77a84db4 100644
--- a/Memory/src/service/read-model/memory.ts
+++ b/Memory/src/service/read-model/memory.ts
@@ -37,9 +37,9 @@ export function memoryDetailWithLayerPayload(detail: MemoryDetailItem, memory: M
} else if (memory.memoryLayer === "L2") {
const policy = policyMetaFromMemory(memory); item.policy = { utilityScore: policy?.gain, confidence: policy?.confidence, evidenceMemoryIds: policy?.sourceTraceIds ?? sourceMemoryIdsFromMemory(memory), repairHints: policy?.verification ? [policy.verification] : [] };
} else if (memory.memoryLayer === "L3") {
- const worldModel = worldModelMetaFromMemory(memory); item.worldModel = { sourceMemoryIds: worldModel?.policyIds ?? sourceMemoryIdsFromMemory(memory), confidence: worldModel?.confidence };
+ const worldModel = worldModelMetaFromMemory(memory); item.worldModel = { sourceMemoryIds: worldModel?.policyIds ?? sourceMemoryIdsFromMemory(memory), confidence: worldModel?.confidence, summary: worldModel?.summary };
} else if (memory.memoryLayer === "Skill") {
- const skill = skillMetaFromMemory(memory); item.skill = { invocationGuide: skill?.invocationGuide ?? detail.body, procedure: procedureFromSkillMemory(memory), sourcePolicyIds: skill?.sourcePolicyIds ?? [], sourceWorldModelIds: skill?.sourceWorldModelIds ?? [], reliabilityScore: skill?.eta, utilityScore: skill?.eta, evidenceCount: skill?.evidenceAnchorIds.length };
+ const skill = skillMetaFromMemory(memory); item.skill = { invocationGuide: skill?.invocationGuide ?? detail.body, retrievalBlurb: skill?.retrievalBlurb, triggerContext: skill?.triggerContext, procedure: procedureFromSkillMemory(memory), sourcePolicyIds: skill?.sourcePolicyIds ?? [], sourceWorldModelIds: skill?.sourceWorldModelIds ?? [], reliabilityScore: skill?.eta, utilityScore: skill?.eta, evidenceCount: skill?.evidenceAnchorIds.length };
}
return item;
}
diff --git a/Memory/src/service/worker/worker-runner.ts b/Memory/src/service/worker/worker-runner.ts
index 38ddd10c9..32847214e 100644
--- a/Memory/src/service/worker/worker-runner.ts
+++ b/Memory/src/service/worker/worker-runner.ts
@@ -5,6 +5,10 @@
* injected explicitly so this module has no service-class dependency.
*/
import type { Embedder } from "../../model/types.js";
+import {
+ retrievalDocumentIsCurrent,
+ retrievalDocumentSourceHash
+} from "../../algorithm/plugin-algorithms.js";
import { createMemoryLogger, memoryErrorFields } from "../../logging/logger.js";
import {
jobToRef,
@@ -20,6 +24,7 @@ import type {
PreparedEmbeddingJob
} from "../embedding/embedding-job-processor.js";
import {
+ embeddingTextForMemory,
embeddingRetryBackoffMs,
embeddingRetryToRunItem
} from "../embedding/embedding-pipeline.js";
@@ -76,6 +81,7 @@ export interface WorkerStartupReconciliation {
restartedFailedProcessing: number;
enqueuedImportSummaries: number;
enqueuedEmbeddingRepairs: number;
+ enqueuedRetrievalReindexes: number;
}
export interface EmbeddingRetryClaim {
@@ -151,7 +157,8 @@ export class WorkerRunner {
requeuedEmbeddingRetries: 0,
restartedFailedProcessing: 0,
enqueuedImportSummaries: 0,
- enqueuedEmbeddingRepairs: 0
+ enqueuedEmbeddingRepairs: 0,
+ enqueuedRetrievalReindexes: 0
};
}
@@ -170,6 +177,7 @@ export class WorkerRunner {
let enqueuedImportSummaries = 0;
let enqueuedEmbeddingRepairs = 0;
+ let enqueuedRetrievalReindexes = 0;
const activeProcessing = this.deps.repos.processing.listByStates([
"summary_pending",
"summarizing",
@@ -257,12 +265,38 @@ export class WorkerRunner {
}, ["embedding_pending", "embedding"]);
}
+ const retrievalMemories = this.deps.repos.memories.list({
+ memoryLayer: ["Skill", "L3"],
+ status: ["activated", "resolving"]
+ }, limit);
+ for (const memory of retrievalMemories) {
+ this.deps.repos.memories.reindexFts(memory);
+ if (!this.deps.capture.embedAfterCapture || retrievalDocumentIsCurrent(memory)) continue;
+ if (this.deps.repos.runtime.hasPendingJob(memory.id, "embedding")) continue;
+ const sourceHash = retrievalDocumentSourceHash(memory);
+ this.deps.enqueueJob({
+ jobType: "embedding",
+ userId: memory.userId,
+ sessionId: memory.sessionId,
+ targetMemoryId: memory.id,
+ dedupeKey: `embedding:retrieval-v2:${memory.id}:${sourceHash}`,
+ payload: {
+ reason: "startup.retrieval_document_v2",
+ retrievalSourceHash: sourceHash
+ },
+ maxAttempts: 6,
+ createdAt: at
+ });
+ enqueuedRetrievalReindexes += 1;
+ }
+
return {
requeuedJobs: interruptedJobs.length + failedJobs.length,
requeuedEmbeddingRetries: embeddingRetries.length,
restartedFailedProcessing,
enqueuedImportSummaries,
- enqueuedEmbeddingRepairs
+ enqueuedEmbeddingRepairs,
+ enqueuedRetrievalReindexes
};
}
@@ -555,6 +589,21 @@ export class WorkerRunner {
if (!memory) {
throw new Error(`embedding retry target not found: ${retry.targetKind}:${retry.targetId}`);
}
+ if ((memory.memoryLayer === "Skill" || memory.memoryLayer === "L3") && embeddingTextForMemory(memory) !== retry.sourceText) {
+ const completed = this.deps.repos.runtime.markEmbeddingRetrySucceededClaimed(retry.id, {
+ ...claim,
+ now: this.nowMs()
+ });
+ if (completed) this.deps.appendEmbeddingRetryChange(completed, "succeeded", retry);
+ const replacement = this.deps.enqueueEmbeddingRetry(
+ memory,
+ embeddingTextForMemory(memory),
+ this.deps.nowIso(),
+ retry.vectorField
+ );
+ this.deps.appendEmbeddingRetryChange(replacement, "queued");
+ return { succeeded: 0, failed: 0, item: completed ? embeddingRetryToRunItem(completed) : null };
+ }
let completed: EmbeddingRetryRecord | undefined;
this.deps.embeddingJobs.persistEmbeddingVector({
memoryId: memory.id,
@@ -562,6 +611,9 @@ export class WorkerRunner {
vector,
attemptCount: retry.attempts + 1,
source: "worker.embedding_retry",
+ sourceHash: memory.memoryLayer === "Skill" || memory.memoryLayer === "L3"
+ ? retrievalDocumentSourceHash(memory)
+ : undefined,
allowedProcessingStates: ["embedding_pending", "embedding"],
finalize: () => {
completed = this.deps.repos.runtime.markEmbeddingRetrySucceededClaimed(retry.id, {
diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts
index 28fa7ed6e..37d1aae0c 100644
--- a/Memory/src/storage/repositories.ts
+++ b/Memory/src/storage/repositories.ts
@@ -1,4 +1,5 @@
import type Database from "better-sqlite3";
+import { retrievalDocumentForMemory } from "../algorithm/plugin-algorithms.js";
import type {
FeedbackRequest,
JobRef,
@@ -384,7 +385,7 @@ export class MemoryRepository {
)
.run(memoryToSql(prepared.memory));
this.vectors.replace(prepared.memory.id, prepared.vectors, prepared.memory.updatedAt);
- this.indexFts(prepared.memory);
+ this.reindexFts(prepared.memory);
return attachMemoryVectors(prepared.memory, prepared.vectors);
}
@@ -442,7 +443,7 @@ export class MemoryRepository {
this.vectors.upsert(updated.id, vector, updated.updatedAt);
}
}
- this.indexFts(updated);
+ this.reindexFts(updated);
return attachMemoryVectors(updated, updated.deletedAt || updated.status === "deleted" ? [] : mergedVectors);
}
@@ -940,13 +941,13 @@ export class MemoryRepository {
};
}
- private indexFts(memory: MemoryRow): void {
+ reindexFts(memory: MemoryRow): void {
try {
this.db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(memory.id);
if (!memory.deletedAt && memory.status !== "deleted") {
this.db
.prepare(`INSERT INTO memories_fts (id, identifier, memory_value, tags) VALUES (?, ?, ?, ?)`)
- .run(memory.id, memory.id, memory.memoryValue, memory.tags.join(" "));
+ .run(memory.id, memory.id, retrievalDocumentForMemory(memory), memory.tags.join(" "));
}
} catch {
// The service search path is deterministic JS scoring; FTS is maintained
diff --git a/Memory/tests/repository/memory-retrieval-index.test.ts b/Memory/tests/repository/memory-retrieval-index.test.ts
index 8ca1a95b2..c6ee4aaeb 100644
--- a/Memory/tests/repository/memory-retrieval-index.test.ts
+++ b/Memory/tests/repository/memory-retrieval-index.test.ts
@@ -102,6 +102,37 @@ describe("memory retrieval indexes", () => {
}
});
+ it("indexes Skill retrieval metadata and can refresh a legacy FTS row in place", () => {
+ const root = mkdtempSync(join(tmpdir(), "mindock-skill-retrieval-index-"));
+ try {
+ const db = new MemoryDb({ path: join(root, "memory.sqlite") });
+ const repos = new Repositories(db.db);
+ const memory = retrievalSkillMemory();
+ repos.memories.insert(memory);
+
+ expect(repos.memories.searchFtsIds("\"retrievalneedle\"", { memoryLayer: "Skill" }, 5)
+ .map((hit) => hit.id)).toContain(memory.id);
+ expect(repos.memories.searchFtsIds("\"procedureonlyneedle\"", { memoryLayer: "Skill" }, 5)
+ .map((hit) => hit.id)).not.toContain(memory.id);
+
+ db.db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(memory.id);
+ db.db.prepare(
+ `INSERT INTO memories_fts (id, identifier, memory_value, tags) VALUES (?, ?, ?, ?)`
+ ).run(memory.id, memory.id, memory.memoryValue, memory.tags.join(" "));
+ expect(repos.memories.searchFtsIds("\"procedureonlyneedle\"", { memoryLayer: "Skill" }, 5)
+ .map((hit) => hit.id)).toContain(memory.id);
+
+ repos.memories.reindexFts(repos.memories.get(memory.id)!);
+ expect(repos.memories.searchFtsIds("\"retrievalneedle\"", { memoryLayer: "Skill" }, 5)
+ .map((hit) => hit.id)).toContain(memory.id);
+ expect(repos.memories.searchFtsIds("\"procedureonlyneedle\"", { memoryLayer: "Skill" }, 5)
+ .map((hit) => hit.id)).not.toContain(memory.id);
+ db.close();
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it.each([
{ layer: "L1", owner: "trace", fields: ["vec_summary", "vec_action"] },
{ layer: "L2", owner: "policy", fields: ["vec"] },
@@ -384,6 +415,43 @@ function authorityMemory(id: string, layer: MemoryLayer): MemoryRow {
};
}
+function retrievalSkillMemory(): MemoryRow {
+ const at = "2026-06-18T00:00:00.000Z";
+ return {
+ id: "skill_retrieval_indexed",
+ timeline: at,
+ userId: "skill-index-user",
+ memoryType: "SkillMemory",
+ status: "activated",
+ visibility: "private",
+ memoryKey: "skill:retrieval-indexed",
+ memoryValue: "# Skill retrieval\n\nprocedureonlyneedle",
+ tags: ["skill", "retrieval"],
+ info: {},
+ properties: {
+ internal_info: {
+ memory_layer: "Skill",
+ memory_kind: "skill",
+ skill: {
+ name: "Skill retrieval",
+ status: "active",
+ invocation_guide: "# Skill retrieval\n\nprocedureonlyneedle",
+ procedure_json: {
+ retrievalBlurb: "retrievalneedle",
+ triggerContext: "Use when retrieval metadata matches."
+ }
+ }
+ }
+ },
+ memoryLayer: "Skill",
+ contentHash: "skill-retrieval-indexed-hash",
+ version: 1,
+ createdAt: at,
+ updatedAt: at,
+ deletedAt: null
+ };
+}
+
function vectorTimestamps(db: MemoryDb, memoryId: string): Record {
const rows = db.db.prepare(
`SELECT vector_field, updated_at
diff --git a/Memory/tests/service/embedding/embedding-processing.test.ts b/Memory/tests/service/embedding/embedding-processing.test.ts
index f8c175748..fb840b27e 100644
--- a/Memory/tests/service/embedding/embedding-processing.test.ts
+++ b/Memory/tests/service/embedding/embedding-processing.test.ts
@@ -6,7 +6,14 @@ import {
type Embedder,
type MemoryRow
} from "../../../src/index.js";
-import { embeddingTextForMemory } from "../../../src/service/embedding/embedding-pipeline.js";
+import {
+ retrievalDocumentIsCurrent,
+ retrievalDocumentSourceHash
+} from "../../../src/algorithm/plugin-algorithms.js";
+import {
+ embeddingTextForMemory,
+ updateMemoryVectorField
+} from "../../../src/service/embedding/embedding-pipeline.js";
import { Repositories } from "../../../src/storage/repositories.js";
import {
createBatchReflectionLlm,
@@ -25,6 +32,50 @@ const {
afterEach(cleanup);
describe("MemoryService / embedding / processing", () => {
+ it("embeds Skill retrieval metadata instead of the full SKILL.md when short metadata exists", () => {
+ const text = embeddingTextForMemory(skillMemory({
+ retrievalBlurb: "Use for safe SQLite schema migrations.",
+ triggerContext: "Trigger when a task changes tables or indexes."
+ }));
+
+ expect(text).toContain("Use for safe SQLite schema migrations.");
+ expect(text).toContain("Trigger when a task changes tables or indexes.");
+ expect(text).not.toContain("PROCEDURE_ONLY_SENTINEL");
+ });
+
+ it("keeps legacy Skill memories searchable through their invocation guide", () => {
+ expect(embeddingTextForMemory(skillMemory())).toContain("PROCEDURE_ONLY_SENTINEL");
+ });
+
+ it("marks a replacement Skill vector with its retrieval document version and source hash", () => {
+ const memory = skillMemory({
+ retrievalBlurb: "Use for safe SQLite schema migrations.",
+ triggerContext: "Trigger when a task changes tables or indexes."
+ });
+ const sourceHash = retrievalDocumentSourceHash(memory);
+ const updated = updateMemoryVectorField(memory, "vec", [1, 0], {
+ provider: "test",
+ model: "test",
+ updatedAt: "2026-07-24T01:00:00.000Z",
+ sourceHash
+ });
+
+ expect(updated.properties.internal_info.retrieval_index).toEqual({
+ version: 2,
+ source_hash: sourceHash,
+ indexed_at: "2026-07-24T01:00:00.000Z"
+ });
+ expect(retrievalDocumentIsCurrent(updated)).toBe(true);
+ });
+
+ it("embeds L3 summary and structure without duplicating the rendered body", () => {
+ const text = embeddingTextForMemory(worldModelMemory());
+
+ expect(text).toContain("Schema migrations require staged verification.");
+ expect(text).toContain("Environment: SQLite database");
+ expect(text).not.toContain("BODY_ONLY_SENTINEL");
+ });
+
it("falls back to title when negative L2 title and trigger exceed 2048 mixed-language tokens", () => {
const title = "Avoid";
const triggerAtLimit = [
@@ -287,6 +338,79 @@ function negativePolicyMemory(title: string, trigger: string): MemoryRow {
};
}
+function skillMemory(short?: {
+ retrievalBlurb: string;
+ triggerContext: string;
+}): MemoryRow {
+ const now = "2026-07-24T00:00:00.000Z";
+ return {
+ id: "skill_retrieval_document",
+ timeline: now,
+ userId: "skill-retrieval-user",
+ memoryType: "SkillMemory",
+ status: "activated",
+ visibility: "private",
+ memoryKey: "skill:sqlite-migration",
+ memoryValue: "# SQLite migration\n\nPROCEDURE_ONLY_SENTINEL",
+ tags: ["sqlite", "migration"],
+ info: {},
+ properties: {
+ internal_info: {
+ memory_layer: "Skill",
+ memory_kind: "skill",
+ skill: {
+ name: "SQLite migration",
+ status: "active",
+ invocation_guide: "# SQLite migration\n\nPROCEDURE_ONLY_SENTINEL",
+ ...(short ? { procedure_json: short } : {})
+ }
+ }
+ },
+ memoryLayer: "Skill",
+ version: 1,
+ createdAt: now,
+ updatedAt: now
+ };
+}
+
+function worldModelMemory(): MemoryRow {
+ const now = "2026-07-24T00:00:00.000Z";
+ return {
+ id: "world_model_retrieval_document",
+ timeline: now,
+ userId: "world-retrieval-user",
+ memoryType: "LongTermMemory",
+ status: "activated",
+ visibility: "private",
+ memoryKey: "world-model:sqlite-migrations",
+ memoryValue: "# SQLite migrations\n\nBODY_ONLY_SENTINEL",
+ tags: ["sqlite", "migration"],
+ info: {},
+ properties: {
+ internal_info: {
+ memory_layer: "L3",
+ memory_kind: "world_model",
+ world_model: {
+ title: "SQLite migrations",
+ domain_key: "engineering|database",
+ domain_tags: ["sqlite", "migration"],
+ summary: "Schema migrations require staged verification.",
+ body: "# SQLite migrations\n\nBODY_ONLY_SENTINEL",
+ structure: {
+ environment: [{ label: "Environment", description: "SQLite database" }],
+ inference: [{ label: "Inference", description: "Verify focused paths first" }],
+ constraints: [{ label: "Constraint", description: "Preserve old readers" }]
+ }
+ }
+ }
+ },
+ memoryLayer: "L3",
+ version: 1,
+ createdAt: now,
+ updatedAt: now
+ };
+}
+
function createFlakyEmbedder(): Embedder {
let batchCalls = 0;
return {
diff --git a/Memory/tests/service/evolution/evolution-llm-stubs.ts b/Memory/tests/service/evolution/evolution-llm-stubs.ts
index 36d358b81..fa89d6fa3 100644
--- a/Memory/tests/service/evolution/evolution-llm-stubs.ts
+++ b/Memory/tests/service/evolution/evolution-llm-stubs.ts
@@ -63,7 +63,7 @@ export function createCapturingL2Llm(calls: Array<{
support_trace_ids: []
}) as unknown as T;
}
- if (options.operation === "l3.abstraction.v2") {
+ if (options.operation === "l3.abstraction.v3") {
return (l3AbstractionResponse ?? {
title: "Pytest sqlite migration environment",
domain_tags: ["pytest", "sqlite"],
@@ -163,7 +163,7 @@ export function createCapturingL2Llm(calls: Array<{
export function createNoToolSkillLlm(calls: Array<{
messages: Array<{ role: string; content: string }>;
options: { operation: string };
-}> = []): LlmClient {
+}> = [], l3AbstractionResponse?: Record): LlmClient {
const base = createCapturingL2Llm(calls, {
name: "memory_workflow_pytest_retry",
retrieval_blurb: "Use for python REST memory workflows and pytest retry workflows that require focused verification.",
@@ -178,7 +178,7 @@ export function createNoToolSkillLlm(calls: Array<{
}],
tools: [],
tags: ["pytest", "retry"]
- });
+ }, undefined, l3AbstractionResponse);
return {
...base,
async completeJson>(
diff --git a/Memory/tests/service/evolution/orchestration.test.ts b/Memory/tests/service/evolution/orchestration.test.ts
index 751b0aa5f..526db45c3 100644
--- a/Memory/tests/service/evolution/orchestration.test.ts
+++ b/Memory/tests/service/evolution/orchestration.test.ts
@@ -984,14 +984,14 @@ describe("MemoryService / evolution / orchestration", () => {
for (let i = 0; i < 16; i += 1) {
await service.runWorkerOnce(100);
if (
- calls.some((call) => call.options.operation === "l3.abstraction.v2") &&
+ calls.some((call) => call.options.operation === "l3.abstraction.v3") &&
calls.some((call) => call.options.operation === "skill.crystallize")
) {
break;
}
}
- const l3Call = calls.find((call) => call.options.operation === "l3.abstraction.v2");
+ const l3Call = calls.find((call) => call.options.operation === "l3.abstraction.v3");
if (l3Call) {
expect(l3Call.options.thinkingMode).toBe("enabled");
expect(l3Call.messages[0]!.content).toContain("declarative");
diff --git a/Memory/tests/service/evolution/world-model.test.ts b/Memory/tests/service/evolution/world-model.test.ts
index 6669b4168..15c3226d1 100644
--- a/Memory/tests/service/evolution/world-model.test.ts
+++ b/Memory/tests/service/evolution/world-model.test.ts
@@ -24,7 +24,14 @@ afterEach(cleanup);
describe("MemoryService / evolution / world model", () => {
it("merges L3 world models by policy overlap even when the domain key changes", async () => {
- const { db, service } = createTestService({ skillLlm: createNoToolSkillLlm() });
+ const calls: Array<{
+ messages: Array<{ role: string; content: string }>;
+ options: { operation: string };
+ }> = [];
+ const l3Response: Record = {};
+ const { db, service } = createTestService({
+ skillLlm: createNoToolSkillLlm(calls, l3Response)
+ });
const session = service.openSession({
namespace: {
source: "codex",
@@ -39,6 +46,24 @@ describe("MemoryService / evolution / world model", () => {
query: "python pytest l3 overlap merge",
answer: "Run pytest, inspect the failure, retry after fixing issue, then verify the result."
});
+ Object.assign(l3Response, {
+ title: "Pytest sqlite migration environment",
+ domain_tags: ["pytest", "sqlite"],
+ environment: [{
+ label: "verified evidence",
+ description: "The environment is supported by a policy and its source trace.",
+ evidenceIds: [
+ "policy_l3_policy_overlap",
+ complete.l1MemoryId,
+ "po_1",
+ "trace_missing"
+ ]
+ }],
+ inference: [],
+ constraints: [],
+ summary: "Pytest migration behavior is supported by verified evidence.",
+ confidence: 0.82
+ });
insertActivePolicyMemory(db, {
id: "policy_l3_policy_overlap",
userId: "user-l3-policy-overlap",
@@ -100,6 +125,12 @@ describe("MemoryService / evolution / world model", () => {
domain_tags?: string[];
confidence?: number;
body?: string;
+ structure?: {
+ environment?: Array<{
+ label?: string;
+ evidenceIds?: string[];
+ }>;
+ };
};
};
};
@@ -107,6 +138,15 @@ describe("MemoryService / evolution / world model", () => {
expect(world.internal_info?.world_model?.domain_tags).toEqual(expect.arrayContaining(["legacy", "pytest", "sqlite"]));
expect(world.internal_info?.world_model_confidence).toBeCloseTo(0.65);
expect(world.internal_info?.world_model?.confidence).toBeCloseTo(0.65);
+ expect(world.internal_info?.world_model?.structure?.environment
+ ?.find((entry) => entry.label === "verified evidence")?.evidenceIds).toEqual([
+ "policy_l3_policy_overlap",
+ complete.l1MemoryId
+ ]);
+ const l3Call = calls.find((call) => call.options.operation === "l3.abstraction.v3");
+ expect(l3Call?.messages[0]?.content).toContain("Never abbreviate, rewrite, or invent an evidence ID");
+ expect(l3Call?.messages[2]?.content).toContain("policy policy_l3_policy_overlap:");
+ expect(l3Call?.messages[2]?.content).toContain(`trace ${complete.l1MemoryId}`);
expect(worlds[0]!.memory_value).not.toContain("Merged policies:");
expect(world.internal_info?.body).not.toContain("Merged policies:");
expect(world.internal_info?.world_model?.body).not.toContain("Merged policies:");
@@ -507,12 +547,12 @@ describe("MemoryService / evolution / world model", () => {
);
for (let i = 0; i < 20; i += 1) {
await service.runWorkerOnce(100);
- if (calls.some((call) => call.options.operation === "l3.abstraction.v2")) {
+ if (calls.some((call) => call.options.operation === "l3.abstraction.v3")) {
break;
}
}
- expect(calls.some((call) => call.options.operation === "l3.abstraction.v2")).toBe(true);
+ expect(calls.some((call) => call.options.operation === "l3.abstraction.v3")).toBe(true);
const l3Count = db.db.prepare(
`SELECT COUNT(*) AS count
FROM memories
diff --git a/Memory/tests/service/feedback/decision-repair-llm-stub.ts b/Memory/tests/service/feedback/decision-repair-llm-stub.ts
index 0e5ddf87f..a413505fb 100644
--- a/Memory/tests/service/feedback/decision-repair-llm-stub.ts
+++ b/Memory/tests/service/feedback/decision-repair-llm-stub.ts
@@ -40,7 +40,7 @@ export function createDecisionRepairEvolutionLlm(): LlmClient {
support_trace_ids: []
} as unknown as T;
}
- if (options.operation === "l3.abstraction.v2") {
+ if (options.operation === "l3.abstraction.v3") {
return {
title: "SQLite migration repair environment",
domain_tags: ["sqlite", "migration"],
From 3074213133626def5045c4233f90b5295be17a90 Mon Sep 17 00:00:00 2001
From: jiang
Date: Mon, 3 Aug 2026 17:08:08 +0800
Subject: [PATCH 06/35] fix(memory): finalize episode rewards and processing
---
.../memory-client/http-memory-client.ts | 3 +-
.../adapters/outbound/memory-client/types.ts | 1 +
.../src/services/agent-source-scan-runner.ts | 17 ++-
.../src/services/agent-source-service.ts | 42 +++---
.../tests/agent-source-service.test.ts | 89 ++++++++++-
Memory/src/algorithm/plugin-algorithms.ts | 34 ++++-
Memory/src/server/http.ts | 17 +--
.../evolution/negative-experience-pipeline.ts | 74 ++++++---
.../src/service/evolution/reward-pipeline.ts | 49 +++++-
.../service/feedback/feedback-experience.ts | 6 +-
Memory/src/service/memory-service.ts | 5 +-
.../service/retrieval/retrieval-service.ts | 14 +-
.../service/session/session-turn-service.ts | 32 ----
Memory/src/service/worker/job-handlers.ts | 40 ++++-
Memory/src/service/worker/worker-runner.ts | 18 ++-
Memory/src/storage/repositories.ts | 142 +++++++++++-------
.../tests/algorithm/plugin-algorithms.test.ts | 49 ++++++
Memory/tests/http-startup.test.ts | 5 +-
.../embedding/embedding-processing.test.ts | 8 +-
.../evolution/negative-experience.test.ts | 52 ++++---
.../service/evolution/orchestration.test.ts | 69 ++++++---
.../evolution/policy-induction.test.ts | 35 ++++-
Memory/tests/service/evolution/reward.test.ts | 109 +++++++++-----
.../service/feedback/decision-repair.test.ts | 10 +-
.../tests/service/feedback/experience.test.ts | 17 ++-
.../service/import/import-processing.test.ts | 110 +++++++++++++-
.../service/session/episode-relation.test.ts | 90 ++++++++++-
.../tests/service/trials/skill-trial.test.ts | 33 ++--
28 files changed, 878 insertions(+), 292 deletions(-)
diff --git a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts
index 0ede0880e..ae07eebbd 100644
--- a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts
+++ b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts
@@ -191,7 +191,8 @@ export function createHttpMemoryClient(
return request("POST", "runWorker", WorkerRunOutputSchema, {
body: {
limit: input.limit,
- targetMemoryIds: input.targetMemoryIds
+ targetMemoryIds: input.targetMemoryIds,
+ priorityCohortOnly: input.priorityCohortOnly
},
signal: input.signal,
timeoutMs: input.timeoutMs
diff --git a/App/backend/src/adapters/outbound/memory-client/types.ts b/App/backend/src/adapters/outbound/memory-client/types.ts
index b3de43710..ba728dd91 100644
--- a/App/backend/src/adapters/outbound/memory-client/types.ts
+++ b/App/backend/src/adapters/outbound/memory-client/types.ts
@@ -55,6 +55,7 @@ export interface MemoryClient {
runWorker(input: {
limit: number;
targetMemoryIds?: string[];
+ priorityCohortOnly?: boolean;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise;
diff --git a/App/backend/src/services/agent-source-scan-runner.ts b/App/backend/src/services/agent-source-scan-runner.ts
index 05c10bc61..94556a209 100644
--- a/App/backend/src/services/agent-source-scan-runner.ts
+++ b/App/backend/src/services/agent-source-scan-runner.ts
@@ -134,15 +134,20 @@ export async function runAgentSourceScanJob(
}
callbacks.onResumeChanged({ phase: "summarize", results });
+ const failures = await agentSources.processImportSummaries(
+ results.flatMap((result) => result.memoryIds ?? []),
+ { ...scanOptions, progressSourceId: job.sourceId }
+ );
+ const resultByMemoryId = new Map();
for (const result of results) {
- const failures = await agentSources.processImportSummaries(result.memoryIds ?? [], {
- ...scanOptions,
- progressSourceId: result.sourceId
- });
- result.errors.push(...failures.map((failure) => ({
+ for (const memoryId of result.memoryIds ?? []) resultByMemoryId.set(memoryId, result);
+ }
+ for (const failure of failures) {
+ const result = resultByMemoryId.get(failure.memoryId);
+ result?.errors.push({
conversationId: failure.memoryId,
reason: failure.reason
- })));
+ });
}
if (job.controller.signal.aborted) {
return;
diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts
index 5766fe5d3..bbdf29273 100644
--- a/App/backend/src/services/agent-source-service.ts
+++ b/App/backend/src/services/agent-source-service.ts
@@ -47,9 +47,7 @@ import {
export type { ScanProgress } from "../adapters/outbound/agent-source/types.js";
const SCAN_MESSAGE_YIELD_INTERVAL = 100;
-const IMPORT_SUMMARY_PRIORITY_LIMIT = 100;
-const IMPORT_SUMMARY_PRIORITY_BATCH_SIZE = 20;
-const IMPORT_SUMMARY_STANDARD_BATCH_SIZE = 100;
+const IMPORT_WORKER_BATCH_SIZE = 4;
const IMPORT_WORKER_TIMEOUT_MS = 600_000;
const IMPORT_PROGRESS_POLL_INTERVAL_MS = 250;
const INITIAL_GLOBAL_MEMORY_LIMIT = 1_000;
@@ -122,13 +120,11 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption
async scanAll(scanOptions = {}) {
const collected = await this.collectAll(scanOptions);
const results = await this.ingestCollected(collected, scanOptions);
- for (const result of results) {
- const failures = await this.processImportSummaries(result.memoryIds ?? [], {
- ...scanOptions,
- progressSourceId: result.sourceId
- });
- appendProcessingFailures(result, failures);
- }
+ const failures = await this.processImportSummaries(
+ results.flatMap((result) => result.memoryIds ?? []),
+ { ...scanOptions, progressSourceId: "all" }
+ );
+ appendProcessingFailuresToResults(results, failures);
return results;
},
@@ -986,7 +982,6 @@ async function processPendingImportSummaries(
const failures: ProcessingFailure[] = [];
const progressSourceId = scanOptions.progressSourceId ?? "all";
let indexed = 0;
- let prioritySummaries = 0;
let lastProgressAt = Date.now();
emitProgress(scanOptions, {
sourceId: progressSourceId,
@@ -998,20 +993,13 @@ async function processPendingImportSummaries(
while (pendingMemoryIds.size > 0) {
scanOptions.signal?.throwIfAborted();
- const limit = prioritySummaries < IMPORT_SUMMARY_PRIORITY_LIMIT
- ? IMPORT_SUMMARY_PRIORITY_BATCH_SIZE
- : IMPORT_SUMMARY_STANDARD_BATCH_SIZE;
const result = await options.memoryClient.runWorker({
- limit,
- targetMemoryIds: [...pendingMemoryIds],
+ limit: IMPORT_WORKER_BATCH_SIZE,
+ priorityCohortOnly: true,
signal: scanOptions.signal,
timeoutMs: IMPORT_WORKER_TIMEOUT_MS
});
- prioritySummaries += result.jobs.filter((job) =>
- job.jobType === "import_summary" &&
- Boolean(job.targetMemoryId && pendingMemoryIds.has(job.targetMemoryId))
- ).length;
const refreshed = await options.memoryClient.getMemoryProcessingStatus([...pendingMemoryIds]);
const processingByMemoryId = new Map(refreshed.items.map((item) => [item.memoryId, item]));
const activeMemoryIds = new Set(refreshed.items
@@ -1067,6 +1055,20 @@ function appendProcessingFailures(result: ScanResult, failures: readonly Process
})));
}
+function appendProcessingFailuresToResults(
+ results: readonly ScanResult[],
+ failures: readonly ProcessingFailure[]
+): void {
+ const resultByMemoryId = new Map();
+ for (const result of results) {
+ for (const memoryId of result.memoryIds ?? []) resultByMemoryId.set(memoryId, result);
+ }
+ for (const failure of failures) {
+ const result = resultByMemoryId.get(failure.memoryId);
+ if (result) appendProcessingFailures(result, [failure]);
+ }
+}
+
async function* toAsyncIterable(messages: readonly ConversationMessage[]): AsyncIterable {
for (const message of messages) {
diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts
index db6a59e27..0f22629b2 100644
--- a/App/backend/src/services/tests/agent-source-service.test.ts
+++ b/App/backend/src/services/tests/agent-source-service.test.ts
@@ -466,9 +466,92 @@ describe("agent source service", () => {
expect(events).toEqual(["scan:cursor", "scan:custom", "ingest:cursor", "ingest:custom"]);
});
+ it("enqueues every scanned source into one global priority drain", async () => {
+ const baseMemoryClient = createMockMemoryClient();
+ const enqueueCalls: string[][] = [];
+ const workerCalls: Array<{
+ targetMemoryIds?: string[];
+ priorityCohortOnly?: boolean;
+ }> = [];
+ const service = createService({
+ adapters: [
+ createFakeAdapter("cursor", [createMessage("cursor", 1)]),
+ createFakeAdapter("custom", [createMessage("custom", 1)])
+ ],
+ ingestionService: {
+ async ingest(messages, ctx) {
+ for await (const _message of messages) {
+ // Consume the source stream before returning its durable memory id.
+ }
+ return {
+ attempted: 1,
+ written: 1,
+ deduped: 0,
+ failed: 0,
+ writtenMemories: 1,
+ dedupedMemories: 0,
+ failedMemories: 0,
+ memoryIds: [`memory-${ctx.sourceId}`],
+ conversations: 1,
+ completedConversationIds: [],
+ incompleteConversationIds: [],
+ failedConversationIds: [],
+ errors: []
+ };
+ }
+ },
+ memoryClient: {
+ ...baseMemoryClient,
+ async enqueueImportSummaries(memoryIds) {
+ enqueueCalls.push([...(memoryIds ?? [])]);
+ return {
+ enqueued: memoryIds?.length ?? 0,
+ memoryIds: memoryIds ?? [],
+ serverTime: "2026-05-28T10:00:00.000Z"
+ };
+ },
+ async runWorker(input) {
+ workerCalls.push(input);
+ return baseMemoryClient.runWorker(input);
+ },
+ async getMemoryProcessingStatus(memoryIds) {
+ return {
+ items: memoryIds.map((memoryId) => ({
+ memoryId,
+ state: "ready" as const,
+ stage: null,
+ activeJobId: null,
+ attemptCount: 1,
+ manualRetryCount: 0,
+ retryAction: "retry" as const,
+ errorCode: null,
+ errorMessage: null,
+ failedAt: null,
+ updatedAt: "2026-05-28T10:00:00.000Z"
+ })),
+ serverTime: "2026-05-28T10:00:00.000Z"
+ };
+ }
+ }
+ });
+
+ await service.scanAll();
+
+ expect(enqueueCalls).toEqual([["memory-cursor", "memory-custom"]]);
+ expect(workerCalls).toEqual([
+ expect.objectContaining({
+ limit: 4,
+ priorityCohortOnly: true
+ })
+ ]);
+ expect(workerCalls[0]?.targetMemoryIds).toBeUndefined();
+ });
+
it("reconciles summary progress when another worker finishes the scan memories", async () => {
const baseMemoryClient = createMockMemoryClient();
const workerTargets: string[][] = [];
+ const workerLimits: number[] = [];
+ const workerPriorityCohorts: Array = [];
let enqueueCalls = 0;
const memoryClient: MemoryClient = {
...baseMemoryClient,
@@ -500,6 +583,8 @@ describe("agent source service", () => {
},
async runWorker(input) {
workerTargets.push(input.targetMemoryIds ?? []);
+ workerLimits.push(input.limit);
+ workerPriorityCohorts.push(input.priorityCohortOnly);
return baseMemoryClient.runWorker(input);
}
};
@@ -515,7 +600,9 @@ describe("agent source service", () => {
}
})).resolves.toEqual([]);
- expect(workerTargets).toEqual([["memory-a", "memory-b"]]);
+ expect(workerTargets).toEqual([[]]);
+ expect(workerLimits).toEqual([4]);
+ expect(workerPriorityCohorts).toEqual([true]);
expect(progress).toEqual([
{ current: 0, total: 2 },
{ current: 2, total: 2 }
diff --git a/Memory/src/algorithm/plugin-algorithms.ts b/Memory/src/algorithm/plugin-algorithms.ts
index 5a3e02aac..5a6be7105 100644
--- a/Memory/src/algorithm/plugin-algorithms.ts
+++ b/Memory/src/algorithm/plugin-algorithms.ts
@@ -427,7 +427,10 @@ function detectFeedbackPreference(
};
}
if (/(prefer|instead|should use|下次用|改用|而不是)/.test(normalized)) {
- return { shape: "preference", confidence: 0.55 };
+ return {
+ shape: "preference",
+ confidence: feedbackMatchesAny(normalized, FEEDBACK_NEGATIVE_PATTERNS) ? 0.75 : 0.55
+ };
}
return null;
}
@@ -1049,7 +1052,8 @@ Fields:
- turnSummaries: chronological L1 summaries of the episode.
- finalExchange: exact trailing user and assistant text.
- execution: authoritative aggregate tool outcome.
-- feedback: explicit or implicit user signal; implicit feedback is weaker.
+- feedback: the latest explicit or implicit user signal; implicit feedback is weaker.
+- feedbackHistory: all captured user signals in chronological order.
- host: authoritative host-agent identity/model context. Do not project your
own identity, provider, policies, or capabilities onto the host agent.
@@ -1059,7 +1063,8 @@ Score three independent axes in [-1, 1]:
- user_satisfaction: -1 correction/frustration, 0 no signal, +1 acceptance.
Rules:
-- Judge goal achievement against mission, using turnSummaries in order.
+- Judge goal achievement against the active goal, using turnSummaries in order. If later user turns revise or replace the initial mission within the same episode, grade the latest active goal.
+- Treat feedback chronologically. A negative correction followed by demonstrated recovery or explicit acceptance is not a permanent failure.
- If execution.completedByTool is "no", goal_achievement must not exceed 0
unless a later summary shows a successful recovery.
- Explicit negative feedback without later recovery means goal_achievement <= 0.
@@ -3259,6 +3264,26 @@ export function policyMetaFromMemory(memory: MemoryRow): PolicyMemoryMeta | null
};
}
+export function failureAvoidancePolicyIsRetrievalEligible(policy: PolicyMemoryMeta): boolean {
+ if (policy.experienceType !== "failure_avoidance" && policy.evidencePolarity !== "negative") {
+ return true;
+ }
+ if (policy.confidence < 0.6 || !policy.trigger.trim()) return false;
+ const preferences = new Set(policy.decisionGuidance.preference.map(normalizeGuidanceForComparison).filter(Boolean));
+ const antiPatterns = new Set(policy.decisionGuidance.antiPattern.map(normalizeGuidanceForComparison).filter(Boolean));
+ if (preferences.size === 0 || antiPatterns.size === 0) return false;
+ return [...preferences].some((item) => !antiPatterns.has(item));
+}
+
+function normalizeGuidanceForComparison(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/^(?:avoid|prefer|safer behavior)\s*:\s*/i, "")
+ .replace(/[\s.。!!??,,;;::]+$/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
export function skillMetaFromMemory(memory: MemoryRow): SkillMemoryMeta | null {
if (memory.memoryLayer !== "Skill") return null;
const skill = getInternal>(memory, "skill");
@@ -4985,6 +5010,9 @@ function candidateFromMemory(
if (memory.memoryLayer === "L2" && policy?.status === "archived") {
return null;
}
+ if (memory.memoryLayer === "L2" && policy && !failureAvoidancePolicyIsRetrievalEligible(policy)) {
+ return null;
+ }
if (memory.memoryLayer === "L3" && (world?.confidence ?? 0) < options.config.minWorldModelConfidence) {
return null;
}
diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts
index 5c92de782..146263129 100644
--- a/Memory/src/server/http.ts
+++ b/Memory/src/server/http.ts
@@ -210,9 +210,7 @@ function createAutoWorkerDrain(
let startupTimer: ReturnType | undefined;
let delayedTimer: ReturnType | undefined;
const maxCycles = 40;
- const priorityJobLimit = 100;
- const priorityBatchSize = 20;
- const standardBatchSize = 100;
+ const workerBatchSize = 4;
async function drain(): Promise {
if (disposed) {
@@ -235,16 +233,13 @@ function createAutoWorkerDrain(
}
do {
requested = false;
- let prioritySummariesDuringDrain = 0;
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
- const limit = prioritySummariesDuringDrain < priorityJobLimit ? priorityBatchSize : standardBatchSize;
- const result = await service.runWorkerOnce(limit, {});
+ const result = await service.runWorkerOnce(workerBatchSize, {
+ priorityCohortOnly: true
+ });
if (result.leased === 0 && result.embeddingRetries.leased === 0) {
break;
}
- prioritySummariesDuringDrain += result.jobs.filter((job) =>
- job.jobType === "trace_summary" || job.jobType === "import_summary"
- ).length;
if (cycle === maxCycles - 1) {
continueSoon = true;
}
@@ -578,12 +573,14 @@ async function routeRequest(
const request = envelopeWithPrincipal(asObject(body, "worker.run"), principal) as RequestEnvelope & {
limit?: unknown;
targetMemoryIds?: unknown;
+ priorityCohortOnly?: unknown;
};
return service.runWorkerOnce(
parseNumberValue(request.limit) ?? parseNumber(url.searchParams.get("limit")) ?? 20,
{
...request,
- targetMemoryIds: parseOptionalStringArray(request.targetMemoryIds, "worker.run.targetMemoryIds")
+ targetMemoryIds: parseOptionalStringArray(request.targetMemoryIds, "worker.run.targetMemoryIds"),
+ priorityCohortOnly: request.priorityCohortOnly === true
}
);
}
diff --git a/Memory/src/service/evolution/negative-experience-pipeline.ts b/Memory/src/service/evolution/negative-experience-pipeline.ts
index 03f444f1b..983de91a6 100644
--- a/Memory/src/service/evolution/negative-experience-pipeline.ts
+++ b/Memory/src/service/evolution/negative-experience-pipeline.ts
@@ -1,4 +1,8 @@
-import { policyMetaFromMemory } from "../../algorithm/plugin-algorithms.js";
+import {
+ classifyFeedbackText,
+ traceMetaFromMemory,
+ type FeedbackTextShape
+} from "../../algorithm/plugin-algorithms.js";
import type { MemmyConfig } from "../../config/index.js";
import type {
DecisionRepairRecord,
@@ -10,6 +14,7 @@ import type {
import type { MemoryRow } from "../../types.js";
import { stableHash } from "../../utils/id.js";
import { isRecord } from "../../utils/json.js";
+import { clip } from "../../utils/text.js";
import {
profileIdFromMemory,
projectIdFromMemory
@@ -42,6 +47,8 @@ interface NegativeExperienceDraft {
verification: string;
confidence: number;
salience: number;
+ evidenceStrength: number;
+ feedbackShape?: FeedbackTextShape;
}
export interface NegativeExperiencePipelineDeps {
@@ -66,7 +73,10 @@ export class NegativeExperiencePipeline {
if (!draft || !isActionableNegativeExperience(draft)) return;
const config = this.deps.config.algorithm.negativeExperience;
- const sourceTraceIds = draft.episode.l1MemoryIds.slice(0, config.maxSourceIds);
+ const sourceTraceIds = (draft.sourceMemory
+ ? [draft.sourceMemory.id]
+ : draft.episode.l1MemoryIds.slice(0, 1))
+ .slice(0, config.maxSourceIds);
const signature = negativeExperienceSignature(draft);
const scopeIdentity = [
(draft.sourceMemory ? projectIdFromMemory(draft.sourceMemory) : undefined) ?? draft.episode.projectId ?? "",
@@ -139,6 +149,7 @@ export class NegativeExperiencePipeline {
gain: 0,
raw_gain: 0,
policy_confidence: draft.confidence,
+ evidence_strength: draft.evidenceStrength,
salience: draft.salience,
status: "candidate",
experience_type: "failure_avoidance",
@@ -180,6 +191,7 @@ export class NegativeExperiencePipeline {
gain: 0,
raw_gain: 0,
policy_confidence: draft.confidence,
+ evidence_strength: draft.evidenceStrength,
status: "candidate",
source_episode_ids: mergedEpisodeIds,
source_trace_ids: mergedTraceIds,
@@ -253,11 +265,15 @@ export class NegativeExperiencePipeline {
const repairId = text(job.payload.repairId);
const feedback = feedbackId ? this.deps.repos.runtime.getFeedback(feedbackId) : undefined;
const repair = repairId ? this.deps.repos.runtime.getDecisionRepair(repairId) : undefined;
- const sourceMemory = episode.l1MemoryIds
- .map((id) => this.deps.repos.memories.get(id))
- .find((memory): memory is MemoryRow => Boolean(memory));
+ const sourceMemory = feedback?.l1MemoryId
+ ? this.deps.repos.memories.get(feedback.l1MemoryId)
+ : [...episode.l1MemoryIds].reverse()
+ .map((id) => this.deps.repos.memories.get(id))
+ .find((memory): memory is MemoryRow => Boolean(memory));
+ const sourceTrace = sourceMemory ? traceMetaFromMemory(sourceMemory) : null;
const rawTurns = this.deps.repos.runtime.listRawTurnsByEpisode(episode.id);
const trigger = text(job.payload.triggerCondition)
+ ?? text(sourceTrace?.userText)
?? rawTurns.find((turn) => text(turn.userText))?.userText?.trim()
?? text(episode.title)
?? text(episode.summary)
@@ -266,8 +282,10 @@ export class NegativeExperiencePipeline {
?? text(episode.rewardDetail.reason)
?? text(isRecord(episode.meta.reward) ? episode.meta.reward.reason : undefined);
const issue = text(job.payload.issue) ?? repair?.issue;
+ const feedbackText = feedback?.rationale ?? issue ?? "";
+ const feedbackClassification = classifyFeedbackText(feedbackText);
const antiPattern = stripGuidanceLabel(text(job.payload.antiPattern)
- ?? feedback?.rationale
+ ?? text(sourceTrace?.agentText)
?? repair?.antiPattern
?? issue
?? rewardReason
@@ -278,8 +296,14 @@ export class NegativeExperiencePipeline {
?? feedback?.rationale
?? (rewardReason ? `Address and verify this failure before continuing: ${rewardReason}` : ""));
const sourceBasis = sourceBasisFor(source, feedback);
+ const feedbackConfidence = feedback?.polarity === "negative" && isOperationalSaferBehavior(feedbackText)
+ ? Math.max(0.65, feedbackClassification.confidence)
+ : feedbackClassification.confidence;
+ const repairConfidence = number(repair?.meta.confidence);
const rawConfidence = number(job.payload.confidence)
- ?? feedback?.magnitude
+ ?? (source === "negative_feedback" && feedback
+ ? Math.max(repairConfidence ?? 0, feedbackConfidence)
+ : repairConfidence)
?? (typeof episode.rTask === "number" ? Math.abs(episode.rTask) : 0);
const confidenceCap = sourceBasis === "implicit_failure_analysis"
? this.deps.config.algorithm.negativeExperience.implicitConfidenceCap
@@ -292,9 +316,9 @@ export class NegativeExperiencePipeline {
sourceMemory,
feedback,
repair,
- trigger,
- antiPattern,
- preference,
+ trigger: clip(trigger, 240),
+ antiPattern: clip(antiPattern, 360),
+ preference: clip(preference, 360),
verification: text(job.payload.verification)
?? "Check that the plan avoids the historical failure mode before acting.",
confidence: clamp(rawConfidence, 0, confidenceCap),
@@ -302,21 +326,15 @@ export class NegativeExperiencePipeline {
typeof episode.rTask === "number" ? Math.abs(episode.rTask) : 0,
feedback?.magnitude ?? 0,
number(repair?.meta.confidence) ?? 0
- ), 0, 1)
+ ), 0, 1),
+ evidenceStrength: clamp(feedback?.magnitude ?? Math.abs(episode.rTask ?? 0), 0, 1),
+ ...(feedback ? { feedbackShape: feedbackClassification.shape } : {})
};
}
private findExisting(draft: NegativeExperienceDraft, key: string): MemoryRow | undefined {
- const sameEpisode = this.deps.repos.memories
- .list({ memoryLayer: "L2" }, 1000)
- .find((memory) => {
- const policy = policyMetaFromMemory(memory);
- return policy?.experienceType === "failure_avoidance"
- && policy.evidencePolarity === "negative"
- && policy.sourceEpisodeIds.includes(draft.episode.id);
- });
- return sameEpisode
- ?? this.deps.repos.memories.getByKey("L2", key);
+ void draft;
+ return this.deps.repos.memories.getByKey("L2", key);
}
}
@@ -343,12 +361,26 @@ function sourceBasisFor(
function isActionableNegativeExperience(draft: NegativeExperienceDraft): boolean {
if (!draft.trigger.trim() || !draft.antiPattern.trim() || !draft.preference.trim()) return false;
+ const minConfidence = draft.sourceBasis === "tool_failure_burst" ? 0.4 : 0.6;
+ if (draft.confidence < minConfidence) return false;
+ if (normalizeSignatureText(draft.antiPattern) === normalizeSignatureText(draft.preference)) return false;
+ if (draft.sourceBasis === "user_corrective_feedback") {
+ if (!draft.feedbackShape || draft.feedbackShape === "confusion") {
+ return false;
+ }
+ if (!isOperationalSaferBehavior(draft.preference)) return false;
+ }
return !(
isGenericNegativeGuidance(draft.antiPattern)
&& isGenericNegativeGuidance(draft.preference)
);
}
+function isOperationalSaferBehavior(value: string): boolean {
+ return /\b(use|avoid|verify|check|confirm|must|should|instead|report|explain|cite|link)\b/i.test(value) ||
+ /(使用|改用|避免|不要|验证|检查|确认|必须|应该|说明|注明|引用|链接|先)/.test(value);
+}
+
function isGenericNegativeGuidance(value: string): boolean {
const normalized = value
.toLowerCase()
diff --git a/Memory/src/service/evolution/reward-pipeline.ts b/Memory/src/service/evolution/reward-pipeline.ts
index c64f2ad18..9c4b0f511 100644
--- a/Memory/src/service/evolution/reward-pipeline.ts
+++ b/Memory/src/service/evolution/reward-pipeline.ts
@@ -74,11 +74,21 @@ export class RewardPipeline {
const rewardSource = this.rewardSourceForJob(job);
if (!rewardSource) return;
const { source, trace } = rewardSource;
- const hasFeedbackSignal =
+ const episode = trace.episodeId ? this.deps.repos.runtime.getEpisode(trace.episodeId) : undefined;
+ if (episode && episode.status !== "closed") return;
+ const phase = episode ? "final" : "feedback";
+ const payloadHasFeedback =
typeof job.payload.polarity === "string" ||
typeof job.payload.magnitude === "number" ||
typeof job.payload.rationale === "string";
- const fallbackFeedback = heuristicHumanScore(hasFeedbackSignal
+ const episodeFeedback = episode?.feedbackIds
+ .map((id) => this.deps.repos.runtime.getFeedback(id))
+ .filter((item): item is NonNullable => Boolean(item)) ?? [];
+ const latestEpisodeFeedback = [...episodeFeedback].reverse().find((item) => item.channel === "explicit")
+ ?? episodeFeedback[episodeFeedback.length - 1];
+ const fallbackFeedback = heuristicHumanScore(latestEpisodeFeedback
+ ? [latestEpisodeFeedback]
+ : payloadHasFeedback
? [{
channel: job.payload.channel === "implicit" ? "implicit" : "explicit",
polarity: job.payload.polarity === "negative"
@@ -95,7 +105,7 @@ export class RewardPipeline {
.map((memory) => this.deps.traceMeta(memory))
.filter((item): item is TraceMeta => Boolean(item && item.episodeId === trace.episodeId))
.sort((a, b) => a.ts - b.ts);
- const skipReason = hasFeedbackSignal
+ const skipReason = episodeFeedback.length > 0 || payloadHasFeedback
? null
: rewardSkipReason(episodeTraces, this.deps.config.algorithm.reward);
if (skipReason && trace.episodeId) {
@@ -103,6 +113,7 @@ export class RewardPipeline {
const scoredAt = this.deps.nowIso();
const rewardDetail = {
rHuman: 0,
+ phase,
source: "heuristic",
axes: { goalAchievement: 0, processQuality: 0, userSatisfaction: 0 },
reason: skipReason,
@@ -119,7 +130,8 @@ export class RewardPipeline {
...(previousEpisode?.meta.closeReason === "finalized"
? {}
: { closeReason: "abandoned", abandonReason: skipReason }),
- reward: rewardDetail
+ reward: rewardDetail,
+ rewardDirty: null
}
});
if (savedEpisode) {
@@ -152,6 +164,7 @@ export class RewardPipeline {
const previousEpisode = this.deps.repos.runtime.getEpisode(trace.episodeId);
const rewardDetail = {
rHuman: feedback.rHuman,
+ phase,
source: feedback.source,
axes: feedback.axes,
reason: feedback.reason,
@@ -167,7 +180,7 @@ export class RewardPipeline {
const savedEpisode = this.deps.repos.runtime.updateEpisodeReward(trace.episodeId, {
rTask: feedback.rHuman,
rewardDetail,
- metaPatch: { reward: rewardDetail }
+ metaPatch: { reward: rewardDetail, rewardDirty: null }
});
rewardedEpisode = savedEpisode;
if (savedEpisode) {
@@ -188,10 +201,10 @@ export class RewardPipeline {
this.deps.config.algorithm.negativeExperience.enabled
&& feedback.rHuman <= this.deps.config.algorithm.negativeExperience.failureRTaskThreshold
) {
- const feedbackId = typeof job.payload.feedbackId === "string"
+ const feedbackId = job.payload.polarity === "negative" && typeof job.payload.feedbackId === "string"
? job.payload.feedbackId
: undefined;
- const repairId = typeof job.payload.repairId === "string"
+ const repairId = feedbackId && typeof job.payload.repairId === "string"
? job.payload.repairId
: undefined;
this.deps.enqueueJob({
@@ -346,6 +359,9 @@ export class RewardPipeline {
episode,
episodeTraces: input.episodeTraces,
feedbackPayload: input.payload,
+ feedbackHistory: episode?.feedbackIds
+ .map((id) => this.deps.repos.runtime.getFeedback(id))
+ .filter((item): item is NonNullable => Boolean(item)),
summaryMaxChars: this.deps.config.algorithm.reward.summaryMaxChars
}))
}
@@ -552,6 +568,12 @@ export interface RewardEpisodeInput {
magnitude: number;
rationale?: string;
};
+ feedbackHistory?: Array<{
+ channel: "explicit" | "implicit";
+ polarity: "positive" | "neutral" | "negative";
+ magnitude: number;
+ rationale?: string;
+ }>;
host?: {
agent?: string;
agentIdentity?: string;
@@ -567,6 +589,12 @@ export function buildRewardEpisodeInput(input: {
episode?: EpisodeRecord;
episodeTraces: readonly TraceMeta[];
feedbackPayload: Record;
+ feedbackHistory?: Array<{
+ channel: "explicit" | "implicit";
+ polarity: "positive" | "neutral" | "negative";
+ magnitude: number;
+ rationale?: string;
+ }>;
summaryMaxChars: number;
}): RewardEpisodeInput {
const traces = input.episodeTraces.length
@@ -579,6 +607,12 @@ export function buildRewardEpisodeInput(input: {
const first = traces[0] ?? input.trace;
const last = traces[traces.length - 1] ?? input.trace;
const feedback = rewardFeedbackInput(input.feedbackPayload);
+ const feedbackHistory = input.feedbackHistory?.map((item) => ({
+ channel: item.channel,
+ polarity: item.polarity,
+ magnitude: item.magnitude,
+ ...(item.rationale ? { rationale: rewardOneLine(item.rationale, 240) } : {})
+ }));
const host = rewardHostInput(input.source, input.episode);
return {
mission: rewardOneLine(rewardEpisodeMission(input.episode, first.userText), 400),
@@ -589,6 +623,7 @@ export function buildRewardEpisodeInput(input: {
},
execution: rewardExecutionOutcome(traces),
...(feedback ? { feedback } : {}),
+ ...(feedbackHistory?.length ? { feedbackHistory } : {}),
...(host ? { host } : {})
};
}
diff --git a/Memory/src/service/feedback/feedback-experience.ts b/Memory/src/service/feedback/feedback-experience.ts
index db5f50c0f..14a0d8b8c 100644
--- a/Memory/src/service/feedback/feedback-experience.ts
+++ b/Memory/src/service/feedback/feedback-experience.ts
@@ -271,7 +271,10 @@ async feedback(request: FeedbackRequest): Promise {
if (feedback.polarity !== "negative") {
jobs.push(...await this.maybeCreateFeedbackExperience(attributedRequest, feedback, context));
}
- if (attributedRequest.l1MemoryId || attributedRequest.episodeId) {
+ const rewardEpisode = attributedRequest.episodeId
+ ? this.deps.repos.runtime.getEpisode(attributedRequest.episodeId)
+ : undefined;
+ if ((attributedRequest.l1MemoryId || attributedRequest.episodeId) && rewardEpisode?.status !== "open") {
jobs.push(
this.deps.enqueueJob({
jobType: "reward",
@@ -286,6 +289,7 @@ async feedback(request: FeedbackRequest): Promise {
magnitude: feedback.magnitude,
rationale: feedback.rationale,
...(repair?.repairId ? { repairId: repair.repairId } : {}),
+ ...(rewardEpisode?.status === "closed" ? { phase: "final" } : {}),
trigger: feedback.channel === "implicit" ? "implicit_feedback" : "explicit_feedback"
}
})
diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts
index f49f251bb..3725b57ef 100644
--- a/Memory/src/service/memory-service.ts
+++ b/Memory/src/service/memory-service.ts
@@ -1646,7 +1646,10 @@ export class MemoryService {
runWorkerOnce(
limit = 100,
- request: RequestEnvelope & { targetMemoryIds?: string[] } = {}
+ request: RequestEnvelope & {
+ targetMemoryIds?: string[];
+ priorityCohortOnly?: boolean;
+ } = {}
): ReturnType {
return this.workerRunner.runWorkerOnce(limit, request);
}
diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts
index d0e5c1ada..3dc61015a 100644
--- a/Memory/src/service/retrieval/retrieval-service.ts
+++ b/Memory/src/service/retrieval/retrieval-service.ts
@@ -7,6 +7,7 @@ import { clip } from "../../utils/text.js";
import {
compileRetrievalQuery,
displayReflectionText,
+ failureAvoidancePolicyIsRetrievalEligible,
focusResearchRetrievalQuery,
isRepositoryRepairPrompt,
isResearchDomain,
@@ -915,7 +916,11 @@ function contextMemoriesForInjectedSources(memories: MemoryRow[], sourceMemoryId
if (visibleIds.has(memory.id)) return true;
if (memory.memoryLayer !== "L2") return false;
const policy = policyMetaFromMemory(memory);
- if (!policy || !policyHasDecisionGuidance(policy)) return false;
+ if (
+ !policy ||
+ !policyHasDecisionGuidance(policy) ||
+ !failureAvoidancePolicyIsRetrievalEligible(policy)
+ ) return false;
if (legacySkillSourcePolicyIds.has(memory.id)) return true;
return policy.sourceTraceIds.some((id) => visibleIds.has(id)) ||
policy.sourceEpisodeIds.some((id) => visibleEpisodeIds.has(id));
@@ -945,7 +950,11 @@ function contextMemoriesForRecallHits(hits: RecallHit[], memories: MemoryRow[]):
for (const memory of memories) {
if (memory.memoryLayer !== "L2") continue;
const policy = policyMetaFromMemory(memory);
- if (!policy || !policyHasDecisionGuidance(policy)) continue;
+ if (
+ !policy ||
+ !policyHasDecisionGuidance(policy) ||
+ !failureAvoidancePolicyIsRetrievalEligible(policy)
+ ) continue;
const traceOverlap = policy.sourceTraceIds.some((id) => hitTraceIds.has(id));
const episodeOverlap = policy.sourceEpisodeIds.some((id) => hitEpisodeIds.has(id));
const legacySkillFallback = legacySkillSourcePolicyIds.has(memory.id);
@@ -1024,6 +1033,7 @@ function failureAvoidanceSection(memories: MemoryRow[]): InjectedContext["sectio
const policy = policyMetaFromMemory(memory);
if (
!policy
+ || !failureAvoidancePolicyIsRetrievalEligible(policy)
|| (
policy.experienceType !== "failure_avoidance"
&& policy.evidencePolarity !== "negative"
diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts
index fe338776a..9ac6076d8 100644
--- a/Memory/src/service/session/session-turn-service.ts
+++ b/Memory/src/service/session/session-turn-service.ts
@@ -2488,22 +2488,6 @@ export class SessionTurnService {
});
this.deps.repos.runtime.appendEpisodeFeedback(episode.id, feedback.id, at);
this.deps.maybeCreateDecisionRepair(feedbackRequest, feedback, contextHash, this.deps.namespaceIdFromSession(session));
- this.deps.enqueueJob({
- jobType: "reward",
- userId: session.userId,
- sessionId: session.id,
- episodeId: episode.id,
- payload: {
- feedbackId: feedback.id,
- l1MemoryId: target.id,
- channel: feedback.channel,
- polarity: feedback.polarity,
- magnitude: feedback.magnitude,
- rationale: feedback.rationale,
- trigger: "implicit_turn_feedback"
- },
- createdAt: at
- });
for (const trial of this.deps.pendingTrialsForFeedback(feedback)) {
this.deps.enqueueJob({
jobType: "skill_trial_resolve",
@@ -2594,22 +2578,6 @@ export class SessionTurnService {
});
this.deps.repos.runtime.appendEpisodeFeedback(episode.id, feedback.id, at);
this.deps.maybeCreateDecisionRepair(feedbackRequest, feedback, contextHash, this.deps.namespaceIdFromSession(session));
- this.deps.enqueueJob({
- jobType: "reward",
- userId: session.userId,
- sessionId: session.id,
- episodeId: episode.id,
- payload: {
- feedbackId: feedback.id,
- l1MemoryId: target.id,
- channel: feedback.channel,
- polarity: feedback.polarity,
- magnitude: feedback.magnitude,
- rationale: feedback.rationale,
- trigger: "revision_feedback"
- },
- createdAt: at
- });
for (const trial of this.deps.pendingTrialsForFeedback(feedback)) {
this.deps.enqueueJob({
jobType: "skill_trial_resolve",
diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts
index c144cc439..a472e8398 100644
--- a/Memory/src/service/worker/job-handlers.ts
+++ b/Memory/src/service/worker/job-handlers.ts
@@ -330,12 +330,27 @@ export function enqueueEpisodeRewardAfterReflection(
episode.status !== "closed" ||
episodeHasRewardForReflection(episode) ||
episodeRewardWasSkipped(episode) ||
- deps.repos.runtime.hasEpisodeJob(episode.id, "reward", ["queued", "leased", "failed"])
+ (
+ deps.repos.runtime.hasEpisodeJob(episode.id, "reward", ["queued", "leased", "failed"])
+ && !episode.meta.rewardDirty
+ )
) return [];
const target = deps.feedbackTargetFromEpisode(episode);
if (!target) return [];
+ const feedback = [...episode.feedbackIds]
+ .reverse()
+ .map((id) => deps.repos.runtime.getFeedback(id))
+ .find((item) => Boolean(item));
const feedbackWindowSec = Math.max(1, deps.reward.feedbackWindowSec);
- const runAfter = new Date(Date.parse(at) + feedbackWindowSec * 1000).toISOString();
+ const runAfter = feedback
+ ? at
+ : new Date(Date.parse(at) + feedbackWindowSec * 1000).toISOString();
+ const repair = feedback
+ ? [...episode.decisionRepairIds]
+ .reverse()
+ .map((id) => deps.repos.runtime.getDecisionRepair(id))
+ .find((item) => item?.feedbackId === feedback.id)
+ : undefined;
return [enqueueJob(deps, {
jobType: "reward",
userId: episode.userId,
@@ -345,6 +360,15 @@ export function enqueueEpisodeRewardAfterReflection(
l1MemoryId: target.id,
trigger,
targetKind: "episode",
+ phase: "final",
+ ...(feedback ? {
+ feedbackId: feedback.id,
+ channel: feedback.channel,
+ polarity: feedback.polarity,
+ magnitude: feedback.magnitude,
+ rationale: feedback.rationale
+ } : {}),
+ ...(repair ? { repairId: repair.id } : {}),
runAfter
},
createdAt: at
@@ -405,7 +429,17 @@ export function enqueueImportSummaryIfMissing(
}
export function episodeHasRewardForReflection(episode: EpisodeRecord): boolean {
- return typeof episode.rTask === "number" && !episodeRewardWasSkipped(episode);
+ if (
+ episode.status !== "closed" ||
+ typeof episode.rTask !== "number" ||
+ episode.rewardDetail.phase !== "final" ||
+ episodeRewardWasSkipped(episode)
+ ) return false;
+ const traceIds = Array.isArray(episode.rewardDetail.traceIds)
+ ? episode.rewardDetail.traceIds.filter((id): id is string => typeof id === "string")
+ : [];
+ return traceIds.length === episode.l1MemoryIds.length &&
+ traceIds.every((id, index) => id === episode.l1MemoryIds[index]);
}
export function episodeRewardWasSkipped(episode: EpisodeRecord): boolean {
diff --git a/Memory/src/service/worker/worker-runner.ts b/Memory/src/service/worker/worker-runner.ts
index 32847214e..479c69571 100644
--- a/Memory/src/service/worker/worker-runner.ts
+++ b/Memory/src/service/worker/worker-runner.ts
@@ -302,7 +302,10 @@ export class WorkerRunner {
async runWorkerOnce(
limit = 100,
- request: RequestEnvelope & { targetMemoryIds?: string[] } = {}
+ request: RequestEnvelope & {
+ targetMemoryIds?: string[];
+ priorityCohortOnly?: boolean;
+ } = {}
): Promise {
if (!this.deps.memoryAddEnabled()) {
return this.deps.runWorkerNoWrite(request);
@@ -317,11 +320,13 @@ export class WorkerRunner {
for (const { before, after } of requeuedJobs) {
this.deps.appendJobChange(after, "queued", before);
}
- const jobs = this.deps.repos.runtime.leaseQueuedJobs(normalizedLimit, 60, targetMemoryIds);
+ const jobs = this.deps.repos.runtime.leaseQueuedJobs(
+ normalizedLimit,
+ 60,
+ targetMemoryIds,
+ request.priorityCohortOnly
+ );
const retryCapacity = Math.max(0, normalizedLimit - jobs.length);
- const embeddingRetries = retryCapacity > 0
- ? await this.runEmbeddingRetryOnce(retryCapacity, targetMemoryIds)
- : { leased: 0, succeeded: 0, failed: 0, items: [] };
const results: WorkerJobRunResult[] = [];
for (let index = 0; index < jobs.length;) {
const job = jobs[index]!;
@@ -347,6 +352,9 @@ export class WorkerRunner {
results.push(await this.runLeasedWorkerJob(job));
index += 1;
}
+ const embeddingRetries = retryCapacity > 0
+ ? await this.runEmbeddingRetryOnce(retryCapacity, targetMemoryIds)
+ : { leased: 0, succeeded: 0, failed: 0, items: [] };
const succeeded = results.reduce((sum, result) => sum + result.succeeded, 0);
const failed = results.reduce((sum, result) => sum + result.failed, 0);
diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts
index 37d1aae0c..f5ee88958 100644
--- a/Memory/src/storage/repositories.ts
+++ b/Memory/src/storage/repositories.ts
@@ -1342,8 +1342,9 @@ export class RuntimeRepository {
reopenEpisode(episodeId: string, metaPatch: Record = {}, at = nowIso()): EpisodeRecord | undefined {
const episode = this.getEpisode(episodeId);
if (!episode) return undefined;
+ const { reward: _staleReward, ...baseMeta } = episode.meta;
const meta = {
- ...episode.meta,
+ ...baseMeta,
...metaPatch
};
this.db
@@ -1351,6 +1352,8 @@ export class RuntimeRepository {
`UPDATE episodes
SET status = 'open',
closed_at = NULL,
+ r_task = NULL,
+ reward_detail_json = '{}',
meta_json = ?,
updated_at = ?
WHERE id = ?`
@@ -1360,6 +1363,8 @@ export class RuntimeRepository {
...episode,
status: "open",
closedAt: null,
+ rTask: undefined,
+ rewardDetail: {},
meta,
updatedAt: at
};
@@ -2215,7 +2220,8 @@ export class RuntimeRepository {
leaseQueuedJobs(
limit = 10,
leaseSeconds = 60,
- targetMemoryIds?: readonly string[]
+ targetMemoryIds?: readonly string[],
+ priorityCohortOnly = false
): EvolutionJobRecord[] {
if (targetMemoryIds?.length === 0) {
return [];
@@ -2226,9 +2232,9 @@ export class RuntimeRepository {
? `AND target_memory_id IN (${targetMemoryIds.map(() => "?").join(", ")})`
: "";
const transaction = this.db.transaction(() => {
- const rows = this.db
+ const candidates = this.db
.prepare(
- `SELECT *
+ `SELECT *, ${evolutionJobPrioritySql()} AS queue_priority
FROM evolution_jobs
WHERE (status = 'queued'
OR (status = 'leased' AND leased_until IS NOT NULL AND leased_until <= ?))
@@ -2266,7 +2272,13 @@ export class RuntimeRepository {
ORDER BY ${evolutionJobOrderSql()}
LIMIT ?`
)
- .all(at, at, ...(targetMemoryIds ?? []), limit) as SqlJobRow[];
+ .all(at, at, ...(targetMemoryIds ?? []), limit) as Array;
+ const queuePriority = candidates[0]?.queue_priority;
+ const rows = priorityCohortOnly && queuePriority !== undefined
+ ? candidates.filter((row) => row.queue_priority === queuePriority)
+ : candidates;
for (const row of rows) {
this.db
@@ -2517,7 +2529,7 @@ export class RuntimeRepository {
FROM embedding_retry_queue q
LEFT JOIN memories m ON m.id = q.target_id
${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
- ORDER BY q.next_attempt_at ASC, q.created_at ASC
+ ORDER BY ${embeddingRetryOrderSql()}
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as SqlEmbeddingRetryRow[];
@@ -2591,20 +2603,21 @@ export class RuntimeRepository {
}
const limit = Math.max(1, Math.min(200, Math.floor(input.limit ?? 25)));
const targetFilter = input.targetMemoryIds
- ? `AND target_id IN (${input.targetMemoryIds.map(() => "?").join(", ")})`
+ ? `AND q.target_id IN (${input.targetMemoryIds.map(() => "?").join(", ")})`
: "";
const transaction = this.db.transaction(() => {
const rows = this.db
.prepare(
- `SELECT *
- FROM embedding_retry_queue
+ `SELECT q.*
+ FROM embedding_retry_queue q
+ LEFT JOIN memories m ON m.id = q.target_id
WHERE (
- status = 'pending'
- OR (status = 'in_progress' AND lease_until IS NOT NULL AND lease_until <= ?)
+ q.status = 'pending'
+ OR (q.status = 'in_progress' AND q.lease_until IS NOT NULL AND q.lease_until <= ?)
)
- AND next_attempt_at <= ?
+ AND q.next_attempt_at <= ?
${targetFilter}
- ORDER BY next_attempt_at ASC, created_at ASC
+ ORDER BY ${embeddingRetryOrderSql()}
LIMIT ?`
)
.all(input.now, input.now, ...(input.targetMemoryIds ?? []), limit) as SqlEmbeddingRetryRow[];
@@ -4827,27 +4840,39 @@ function isSerializedBuffer(value: unknown): value is { __memmy_type: "buffer";
}
function evolutionJobOrderSql(): string {
- const summaryPlaceholderSql = importSummaryPlaceholderSql();
- const importIndexingSql = importIndexingSqlPredicate();
- return `CASE WHEN status = 'leased' THEN 0 ELSE 1 END ASC,
+ const memoryProcessingJob = `job_type IN ('trace_summary', 'import_summary', 'embedding')
+ AND target_memory_id IS NOT NULL`;
+ return `${evolutionJobPrioritySql()} ASC,
+ CASE WHEN ${memoryProcessingJob}
+ THEN COALESCE(
+ (SELECT created_at FROM memories WHERE memories.id = evolution_jobs.target_memory_id),
+ created_at
+ )
+ ELSE ''
+ END DESC,
CASE
+ WHEN job_type IN ('trace_summary', 'import_summary') THEN 0
+ WHEN job_type = 'embedding' THEN 1
+ ELSE 2
+ END ASC,
+ CASE WHEN status = 'leased' THEN 0 ELSE 1 END ASC,
+ created_at ASC,
+ rowid ASC`;
+}
+
+function evolutionJobPrioritySql(): string {
+ const importedTarget = targetMemoryMatchesSql(agentSourceMemorySql("memories"));
+ const interactiveL1Target = targetMemoryMatchesSql(
+ `memories.memory_layer = 'L1' AND NOT (${agentSourceMemorySql("memories")})`
+ );
+ return `CASE
WHEN json_extract(payload_json, '$.source') = 'memory.processing.manual_retry' THEN 0
- WHEN job_type = 'episode_idle_close' THEN 1
- WHEN job_type = 'embedding' AND EXISTS (
- SELECT 1
- FROM memories
- WHERE memories.id = evolution_jobs.target_memory_id
- AND ${importIndexingSql}
- ) THEN 4
- WHEN job_type = 'trace_summary' THEN 5
- WHEN job_type = 'import_summary' THEN 6
- WHEN job_type = 'embedding' AND EXISTS (
- SELECT 1
- FROM memories
- WHERE memories.id = evolution_jobs.target_memory_id
- AND ${summaryPlaceholderSql}
- ) THEN 7
- WHEN job_type = 'embedding' THEN 10
+ WHEN job_type = 'trace_summary'
+ OR (job_type = 'embedding' AND ${interactiveL1Target}) THEN 1
+ WHEN job_type = 'import_summary'
+ OR (job_type = 'embedding' AND ${importedTarget}) THEN 2
+ WHEN job_type = 'embedding' THEN 3
+ WHEN job_type = 'episode_idle_close' THEN 10
WHEN job_type = 'reflection' THEN 20
WHEN job_type = 'reward' THEN 30
WHEN job_type = 'span_big_turn' THEN 35
@@ -4857,38 +4882,41 @@ function evolutionJobOrderSql(): string {
WHEN job_type = 'skill_crystallization' THEN 70
WHEN job_type = 'skill_trial_resolve' THEN 80
ELSE 100
- END ASC,
- CASE
- WHEN job_type IN ('trace_summary', 'import_summary') OR (
- job_type = 'embedding' AND EXISTS (
- SELECT 1
- FROM memories
- WHERE memories.id = evolution_jobs.target_memory_id
- AND ${summaryPlaceholderSql}
- )
- )
- THEN COALESCE((SELECT updated_at FROM memories WHERE memories.id = evolution_jobs.target_memory_id), updated_at)
- ELSE ''
- END DESC,
- created_at ASC,
- rowid ASC`;
-}
-
-function importSummaryPlaceholderSql(): string {
- const summary = "COALESCE(json_extract(memories.info_json, '$.summary'), '')";
- const firstLine = `TRIM(REPLACE(REPLACE(CASE WHEN instr(${summary}, char(10)) > 0 THEN substr(${summary}, 1, instr(${summary}, char(10)) - 1) ELSE ${summary} END, '#', ''), char(13), ''))`;
- return `${firstLine} IN ('user', 'assistant', 'system', 'tool', 'developer', '摘要排队中', '摘要整理中')`;
+ END`;
}
-function importIndexingSqlPredicate(): string {
+function targetMemoryMatchesSql(predicate: string): string {
return `EXISTS (
SELECT 1
- FROM memory_processing_state
- WHERE memory_processing_state.memory_id = memories.id
- AND memory_processing_state.state IN ('embedding_pending', 'embedding')
+ FROM memories
+ WHERE memories.id = evolution_jobs.target_memory_id
+ AND ${predicate}
)`;
}
+function agentSourceMemorySql(alias: string): string {
+ return `(
+ json_extract(${alias}.properties_json, '$.internal_info.plugin_algorithm') LIKE 'memory.add.import_async.%'
+ OR EXISTS (
+ SELECT 1
+ FROM json_each(${alias}.tags_json)
+ WHERE lower(json_each.value) = 'agent-source'
+ )
+ )`;
+}
+
+function embeddingRetryOrderSql(): string {
+ const importedMemory = agentSourceMemorySql("m");
+ return `CASE
+ WHEN q.target_kind = 'trace' AND m.memory_layer = 'L1' AND NOT (${importedMemory}) THEN 0
+ WHEN q.target_kind = 'trace' AND m.memory_layer = 'L1' AND ${importedMemory} THEN 1
+ ELSE 2
+ END ASC,
+ m.created_at DESC,
+ q.next_attempt_at ASC,
+ q.created_at ASC`;
+}
+
export function jobToRef(job: EvolutionJobRecord): JobRef {
return {
jobId: job.id,
diff --git a/Memory/tests/algorithm/plugin-algorithms.test.ts b/Memory/tests/algorithm/plugin-algorithms.test.ts
index ff8447625..f06a47ff8 100644
--- a/Memory/tests/algorithm/plugin-algorithms.test.ts
+++ b/Memory/tests/algorithm/plugin-algorithms.test.ts
@@ -1644,6 +1644,55 @@ describe("plugin algorithm parity helpers", () => {
expect(result.hits.map((hit) => hit.id)).toEqual(["policy-active"]);
});
+ it("filters malformed failure-avoidance policies whose preference repeats the anti-pattern", () => {
+ const malformed = policyMemory(
+ "policy_5608950f4a75b91d2db4",
+ "黄金与比特币分析纠错",
+ "active",
+ [1, 0]
+ );
+ const malformedPolicy = malformed.properties.internal_info.policy as Record;
+ Object.assign(malformedPolicy, {
+ experience_type: "failure_avoidance",
+ evidence_polarity: "negative",
+ skill_eligible: false,
+ policy_confidence: 1,
+ decision_guidance: {
+ preference: ["我说的是黄金,不是比特币"],
+ anti_pattern: ["我说的是黄金,不是比特币"]
+ }
+ });
+ const actionable = policyMemory(
+ "policy-actionable-correction",
+ "TLS port correction",
+ "active",
+ [1, 0]
+ );
+ const actionablePolicy = actionable.properties.internal_info.policy as Record;
+ Object.assign(actionablePolicy, {
+ experience_type: "failure_avoidance",
+ evidence_polarity: "negative",
+ skill_eligible: false,
+ policy_confidence: 0.75,
+ decision_guidance: {
+ preference: ["Use port 443 and verify TLS before reporting completion"],
+ anti_pattern: ["Configure port 80 and skip TLS verification"]
+ }
+ });
+
+ const result = retrievePluginMemories({
+ query: "TLS port correction 黄金 比特币",
+ queryVector: [1, 0],
+ memories: [malformed, actionable],
+ layers: ["L2"],
+ limit: 5,
+ mode: "search",
+ now: Date.parse("2026-05-29T00:00:00.000Z")
+ });
+
+ expect(result.hits.map((hit) => hit.id)).toEqual(["policy-actionable-correction"]);
+ });
+
it("uses plugin Tier-2 experience salience for feedback-derived L2 policies", () => {
const plainPolicy = policyMemory("policy-plain", "python pytest policy", "active", [1, 0]);
const feedbackPolicy = policyMemory("policy-feedback", "python pytest policy", "active", [1, 0]);
diff --git a/Memory/tests/http-startup.test.ts b/Memory/tests/http-startup.test.ts
index 4c07fd070..9f0e4f242 100644
--- a/Memory/tests/http-startup.test.ts
+++ b/Memory/tests/http-startup.test.ts
@@ -51,8 +51,10 @@ describe("Memory HTTP startup", () => {
let runs = 0;
let timerFired = false;
let timerObservedBeforeSecondRun = false;
+ const limits: number[] = [];
const service = stubService(() => undefined);
- service.runWorkerOnce = async () => {
+ service.runWorkerOnce = async (limit) => {
+ limits.push(limit ?? 100);
runs += 1;
if (runs === 1) {
setTimeout(() => {
@@ -73,6 +75,7 @@ describe("Memory HTTP startup", () => {
await waitFor(() => runs >= 2);
expect(timerObservedBeforeSecondRun).toBe(true);
+ expect(limits).toEqual([4, 4]);
});
});
diff --git a/Memory/tests/service/embedding/embedding-processing.test.ts b/Memory/tests/service/embedding/embedding-processing.test.ts
index fb840b27e..1a91fab63 100644
--- a/Memory/tests/service/embedding/embedding-processing.test.ts
+++ b/Memory/tests/service/embedding/embedding-processing.test.ts
@@ -266,11 +266,13 @@ describe("MemoryService / embedding / processing", () => {
layers: ["L1"]
});
expect(recall.hits.some((hit) => hit.id === complete.l1MemoryId)).toBe(true);
- const openEpisodeRun = await service.runWorkerOnce(10);
- expect(openEpisodeRun.jobs.map((job) => job.jobType)).toEqual(["episode_idle_close", "trace_summary"]);
+ const openEpisodeRun = await service.runWorkerOnce(10, { priorityCohortOnly: true });
+ expect(openEpisodeRun.jobs.map((job) => job.jobType)).toEqual(["trace_summary"]);
expect(llmCalls.filter((call) => call.options.operation === "capture.summarize")).toHaveLength(1);
- const embeddingRun = await service.runWorkerOnce(10);
+ const embeddingRun = await service.runWorkerOnce(10, { priorityCohortOnly: true });
expect(embeddingRun.jobs.map((job) => job.jobType)).toEqual(["embedding"]);
+ const episodeRun = await service.runWorkerOnce(10, { priorityCohortOnly: true });
+ expect(episodeRun.jobs.map((job) => job.jobType)).toEqual(["episode_idle_close"]);
expect(embeddingTexts).toHaveLength(1);
expect(db.db.prepare(
`SELECT COUNT(*) AS count FROM evolution_jobs
diff --git a/Memory/tests/service/evolution/negative-experience.test.ts b/Memory/tests/service/evolution/negative-experience.test.ts
index 86e84a358..9dc3a1c3f 100644
--- a/Memory/tests/service/evolution/negative-experience.test.ts
+++ b/Memory/tests/service/evolution/negative-experience.test.ts
@@ -123,10 +123,18 @@ describe("MemoryService / evolution / negative experience", () => {
expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([]);
expect(feedback.jobs.map((job) => job.jobType)).not.toContain("negative_experience");
- expect(feedback.jobs.map((job) => job.jobType)).toContain("reward");
+ expect(feedback.jobs.map((job) => job.jobType)).not.toContain("reward");
+ service.closeSession(session.sessionId);
await service.runWorkerOnce(50);
expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([]);
+ expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ jobType: "reward" })
+ ])
+ );
+
+ await service.runWorkerOnce(50);
expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual(
expect.arrayContaining([
expect.objectContaining({ jobType: "negative_experience" })
@@ -161,10 +169,8 @@ describe("MemoryService / evolution / negative experience", () => {
});
expect(detail.body).toContain("Wrong port");
expect(detail.body).toContain("443");
- expect(operations).toEqual([
- "capture.summarize",
- "reward.reward.r_human.v7"
- ]);
+ expect(operations[0]).toBe("capture.summarize");
+ expect(operations.filter((operation) => operation === "reward.reward.r_human.v7")).toHaveLength(1);
const negativePolicy = (detail.metadata.properties as {
internal_info: {
policy: {
@@ -176,10 +182,9 @@ describe("MemoryService / evolution / negative experience", () => {
const initialVersion = policies[0]!.version;
await service.runWorkerOnce(50);
- expect(embeddedTexts).toEqual([
- [negativePolicy.title, negativePolicy.trigger].join("\n")
- ]);
- expect(embeddingRoles).toEqual(["query"]);
+ const policyEmbeddingText = [negativePolicy.title, negativePolicy.trigger].join("\n");
+ expect(embeddedTexts).toContain(policyEmbeddingText);
+ expect(embeddingRoles[embeddedTexts.indexOf(policyEmbeddingText)]).toBe("query");
expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([
expect.objectContaining({ id: policies[0]!.id, version: initialVersion })
]);
@@ -200,7 +205,7 @@ describe("MemoryService / evolution / negative experience", () => {
db.close();
});
- it("admits an episode exactly at the configured negative rTask boundary", async () => {
+ it("does not turn a weak negative score at the boundary into a policy", async () => {
const operations: string[] = [];
const llm = createCountingLlm(operations, {
goal_achievement: -0.15,
@@ -261,6 +266,13 @@ describe("MemoryService / evolution / negative experience", () => {
magnitude: 1
});
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(50);
+ expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ jobType: "reward" })
+ ])
+ );
await service.runWorkerOnce(50);
expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual(
expect.arrayContaining([
@@ -270,14 +282,9 @@ describe("MemoryService / evolution / negative experience", () => {
await service.runWorkerOnce(50);
const policies = service.panelItems({ namespace, layer: "L2" }).items;
- expect(policies).toHaveLength(1);
- expect(service.getMemory(policies[0]!.id, { namespace }).body).toContain(
- "TLS verification was skipped"
- );
- expect(operations).toEqual([
- "capture.summarize",
- "reward.reward.r_human.v7"
- ]);
+ expect(policies).toEqual([]);
+ expect(operations[0]).toBe("capture.summarize");
+ expect(operations.filter((operation) => operation === "reward.reward.r_human.v7")).toHaveLength(1);
db.close();
});
@@ -335,6 +342,8 @@ describe("MemoryService / evolution / negative experience", () => {
magnitude: 1,
rationale: "Wrong port: use 443 and verify TLS before reporting completion."
});
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(50);
await service.runWorkerOnce(50);
await service.runWorkerOnce(50);
const recall = await service.search({
@@ -383,6 +392,8 @@ describe("MemoryService / evolution / negative experience", () => {
magnitude: 1,
rationale: "Wrong port: use 443 and verify TLS before reporting completion."
});
+ service.closeSession(otherSession.sessionId);
+ await service.runWorkerOnce(50);
await service.runWorkerOnce(50);
await service.runWorkerOnce(50);
const otherRecall = await service.search({
@@ -450,6 +461,9 @@ describe("MemoryService / evolution / negative experience", () => {
rationale: "Be careful."
});
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(50);
+ await service.runWorkerOnce(50);
await service.runWorkerOnce(50);
expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([]);
@@ -503,10 +517,12 @@ describe("MemoryService / evolution / negative experience", () => {
magnitude: 1,
rationale: `TLS_ROTATION_GUARD_${index} verify certificate rotation before completion.`
});
+ service.closeSession(session.sessionId);
if (index === 24) targetSessionId = session.sessionId;
}
await service.runWorkerOnce(1000);
await service.runWorkerOnce(1000);
+ await service.runWorkerOnce(1000);
const crossUserPolicy = db.db.prepare(
`SELECT id
FROM memories
diff --git a/Memory/tests/service/evolution/orchestration.test.ts b/Memory/tests/service/evolution/orchestration.test.ts
index 526db45c3..e4136f4b7 100644
--- a/Memory/tests/service/evolution/orchestration.test.ts
+++ b/Memory/tests/service/evolution/orchestration.test.ts
@@ -81,6 +81,7 @@ describe("MemoryService / evolution / orchestration", () => {
makeTraceEligibleForL2(db, complete.l1MemoryId);
}
+ service.closeSession(session.sessionId);
let succeeded = 0;
for (let i = 0; i < 20; i += 1) {
succeeded += (await service.runWorkerOnce(100)).succeeded;
@@ -207,31 +208,45 @@ describe("MemoryService / evolution / orchestration", () => {
});
expect(searchedSkills.items.length).toBeGreaterThanOrEqual(1);
const skillId = skills.items[0]!.id;
+ const trialSession = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "jiang",
+ userId: "user-2",
+ sessionKey: "skill-trial"
+ }
+ });
+ const trialTurn = service.completeTurn("turn-skill-trial", {
+ sessionId: trialSession.sessionId,
+ episodeId: "episode-skill-trial",
+ query: "apply the recalled python REST memory workflow skill",
+ answer: "applied the recalled workflow"
+ });
const trial = service.useSkill(skillId, {
adapterId: "test-adapter",
requestId: "skill-use-1",
- sessionId: session.sessionId,
- episodeId: completes[0]!.episodeId,
- rawTurnId: completes[0]!.rawTurnId,
- turnId: completes[0]!.turnId
+ sessionId: trialSession.sessionId,
+ episodeId: trialTurn.episodeId,
+ rawTurnId: trialTurn.rawTurnId,
+ turnId: trialTurn.turnId
});
const duplicateTrial = service.useSkill(skillId, {
adapterId: "test-adapter",
requestId: "skill-use-1",
- sessionId: session.sessionId,
- episodeId: completes[0]!.episodeId,
- rawTurnId: completes[0]!.rawTurnId,
- turnId: completes[0]!.turnId
+ sessionId: trialSession.sessionId,
+ episodeId: trialTurn.episodeId,
+ rawTurnId: trialTurn.rawTurnId,
+ turnId: trialTurn.turnId
});
expect(duplicateTrial.trialId).toBe(trial.trialId);
expect(duplicateTrial.duplicate).toBe(true);
const duplicateEpisodeTrial = service.useSkill(skillId, {
adapterId: "test-adapter",
requestId: "skill-use-2",
- sessionId: session.sessionId,
- episodeId: completes[0]!.episodeId,
- rawTurnId: completes[0]!.rawTurnId,
- turnId: completes[0]!.turnId
+ sessionId: trialSession.sessionId,
+ episodeId: trialTurn.episodeId,
+ rawTurnId: trialTurn.rawTurnId,
+ turnId: trialTurn.turnId
});
expect(duplicateEpisodeTrial.trialId).toBe(trial.trialId);
expect(duplicateEpisodeTrial.duplicate).toBe(true);
@@ -241,7 +256,7 @@ describe("MemoryService / evolution / orchestration", () => {
WHERE skill_memory_id = ?
AND episode_id = ?
AND outcome = 'unknown'`
- ).get(skillId, completes[0]!.episodeId) as { count: number };
+ ).get(skillId, trialTurn.episodeId) as { count: number };
expect(pendingTrialCount.count).toBe(1);
const pendingTrial = db.db.prepare(
`SELECT status, outcome, l1_memory_id
@@ -250,7 +265,7 @@ describe("MemoryService / evolution / orchestration", () => {
).get(trial.trialId) as { status: string; outcome: string; l1_memory_id: string | null };
expect(pendingTrial.status).toBe("pending");
expect(pendingTrial.outcome).toBe("unknown");
- expect(pendingTrial.l1_memory_id).toBe(completes[0]!.l1MemoryId);
+ expect(pendingTrial.l1_memory_id).toBe(trialTurn.l1MemoryId);
const prematureResolveJobs = db.db.prepare(
`SELECT COUNT(*) AS count
FROM evolution_jobs
@@ -295,9 +310,9 @@ describe("MemoryService / evolution / orchestration", () => {
entity_id: trial.trialId
});
const skillFeedback = await service.feedback({
- sessionId: session.sessionId,
- episodeId: completes[0]!.episodeId,
- rawTurnId: completes[0]!.rawTurnId,
+ sessionId: trialSession.sessionId,
+ episodeId: trialTurn.episodeId,
+ rawTurnId: trialTurn.rawTurnId,
channel: "explicit",
polarity: "positive",
magnitude: 1,
@@ -314,7 +329,7 @@ describe("MemoryService / evolution / orchestration", () => {
target_memory_id: string | null;
payload_json: string;
};
- expect(trialResolveJobRow.episode_id).toBe(completes[0]!.episodeId);
+ expect(trialResolveJobRow.episode_id).toBe(trialTurn.episodeId);
expect(trialResolveJobRow.target_memory_id).toBeNull();
expect(JSON.parse(trialResolveJobRow.payload_json)).toMatchObject({
trialId: trial.trialId,
@@ -410,8 +425,8 @@ describe("MemoryService / evolution / orchestration", () => {
{
trialId: trial.trialId,
status: "pass",
- episodeId: completes[0]!.episodeId,
- reward: expect.any(Number)
+ episodeId: trialTurn.episodeId,
+ reward: undefined
}
]));
const episodeIndexes = db.db.prepare(
@@ -449,7 +464,7 @@ describe("MemoryService / evolution / orchestration", () => {
kind: "skill_trial",
op: "updated",
entity_id: trial.trialId,
- source: "worker.reward.updated"
+ source: "worker.skill_trial_resolve"
});
const recall = await service.search({
@@ -556,6 +571,9 @@ describe("MemoryService / evolution / orchestration", () => {
service.closeSession(session.sessionId);
await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
makeTraceEligibleForL2(db, second.l1MemoryId);
db.db.prepare(
`UPDATE evolution_jobs
@@ -595,7 +613,7 @@ describe("MemoryService / evolution / orchestration", () => {
payload_json: string;
}>;
expect(downstreamJobs.map((job) => job.job_type)).toEqual(["l3_abstraction", "skill_crystallization"]);
- expect(downstreamJobs.map((job) => job.status)).toEqual(["queued", "queued"]);
+ expect(downstreamJobs.map((job) => job.status)).toEqual(["succeeded", "succeeded"]);
expect(downstreamJobs.map((job) => job.episode_id)).toEqual([
"episode-l2-activation-2",
"episode-l2-activation-2"
@@ -608,13 +626,13 @@ describe("MemoryService / evolution / orchestration", () => {
targetKind: "policy_cluster",
seedPolicyId: "policy_l2_activation_downstream",
policyIds: ["policy_l2_activation_downstream"],
- previousStatus: "candidate",
+ previousStatus: "active",
status: "active"
});
expect(skillJob?.target_memory_id).toBe("policy_l2_activation_downstream");
expect(JSON.parse(skillJob!.payload_json)).toMatchObject({
reason: "l2.policy.updated",
- previousStatus: "candidate",
+ previousStatus: "active",
status: "active"
});
@@ -850,8 +868,9 @@ describe("MemoryService / evolution / orchestration", () => {
});
makeTraceEligibleForL2(db, complete.l1MemoryId);
}
+ service.closeSession(session.sessionId);
let policyCreated = false;
- for (let i = 0; i < 20; i += 1) {
+ for (let i = 0; i < 40; i += 1) {
await service.runWorkerOnce(1);
const l2Count = db.db.prepare(
`SELECT COUNT(*) AS count
diff --git a/Memory/tests/service/evolution/policy-induction.test.ts b/Memory/tests/service/evolution/policy-induction.test.ts
index dd8816ee0..a9cae88d3 100644
--- a/Memory/tests/service/evolution/policy-induction.test.ts
+++ b/Memory/tests/service/evolution/policy-induction.test.ts
@@ -148,8 +148,27 @@ describe("MemoryService / evolution / policy induction", () => {
service.closeSession(session.sessionId);
await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
makeTraceEligibleForL2(db, complete.l1MemoryId);
- db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded' WHERE job_type <> 'l2_association'`).run();
+ db.db.prepare(`DELETE FROM trace_policy_links WHERE l1_memory_id = ?`).run(complete.l1MemoryId);
+ db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded'`).run();
+ const associationAt = new Date().toISOString();
+ db.db.prepare(
+ `INSERT INTO evolution_jobs (
+ id, job_type, status, user_id, session_id, episode_id, target_memory_id,
+ payload_json, attempts, max_attempts, created_at, updated_at
+ ) VALUES (?, 'l2_association', 'queued', ?, ?, ?, ?, '{}', 0, 3, ?, ?)`
+ ).run(
+ "job_best_l2_association",
+ "user-best-l2-association",
+ session.sessionId,
+ complete.episodeId,
+ complete.l1MemoryId,
+ associationAt,
+ associationAt
+ );
await service.runWorkerOnce(20);
const links = db.db.prepare(
@@ -349,6 +368,9 @@ describe("MemoryService / evolution / policy induction", () => {
service.closeSession(profileA.sessionId);
service.closeSession(profileB.sessionId);
await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
makeTraceEligibleForL2(db, firstA.l1MemoryId);
makeTraceEligibleForL2(db, firstB.l1MemoryId);
db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded' WHERE job_type <> 'l2_induction'`).run();
@@ -379,6 +401,9 @@ describe("MemoryService / evolution / policy induction", () => {
makeTraceEligibleForL2(db, secondA.l1MemoryId);
service.closeSession(profileANext.sessionId);
await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
makeTraceEligibleForL2(db, secondA.l1MemoryId);
db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded' WHERE job_type <> 'l2_induction'`).run();
@@ -470,6 +495,9 @@ describe("MemoryService / evolution / policy induction", () => {
}
service.closeSession(profileA.sessionId);
await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
for (const turn of turnsA) {
setTraceSignatureAndVectorForTest(db, turn.l1MemoryId, signature, [1, 0, 0]);
}
@@ -490,6 +518,9 @@ describe("MemoryService / evolution / policy induction", () => {
}
service.closeSession(profileB.sessionId);
await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
+ await service.runWorkerOnce(20);
for (const turn of turnsB) {
setTraceSignatureAndVectorForTest(db, turn.l1MemoryId, signature, [1, 0, 0]);
}
@@ -907,6 +938,7 @@ describe("MemoryService / evolution / policy induction", () => {
magnitude: 1,
rationale: "the focused pytest migration workflow worked"
});
+ service.closeSession(session.sessionId);
makeTraceEligibleForL2(db, complete.l1MemoryId);
for (let i = 0; i < 8; i += 1) {
await service.runWorkerOnce(50);
@@ -1036,6 +1068,7 @@ describe("MemoryService / evolution / policy induction", () => {
magnitude: 1,
rationale: "the focused migration diagnosis worked"
});
+ service.closeSession(session.sessionId);
makeTraceEligibleForL2(db, complete.l1MemoryId);
for (let i = 0; i < 8; i += 1) {
await service.runWorkerOnce(50);
diff --git a/Memory/tests/service/evolution/reward.test.ts b/Memory/tests/service/evolution/reward.test.ts
index f77fc542f..6d87fa670 100644
--- a/Memory/tests/service/evolution/reward.test.ts
+++ b/Memory/tests/service/evolution/reward.test.ts
@@ -171,7 +171,7 @@ describe("MemoryService / evolution / reward", () => {
userId: "user-implicit-reward",
status: "queued"
}).items.map((job) => job.jobType);
- expect(queuedOrder.slice(0, 2)).toEqual(["episode_idle_close", "trace_summary"]);
+ expect(queuedOrder.slice(0, 2)).toEqual(["trace_summary", "episode_idle_close"]);
const run = await service.runWorkerOnce(20);
expect(run.changeSeq).toBeGreaterThan(0);
@@ -203,7 +203,7 @@ describe("MemoryService / evolution / reward", () => {
db.close();
});
- it("still reflects unscored L1 memories when an episode already has reward", async () => {
+ it("waits until episode close and scores every trace exactly once", async () => {
const calls: Array<{
messages: Array<{ role: string; content: string }>;
options: { operation: string };
@@ -264,32 +264,13 @@ describe("MemoryService / evolution / reward", () => {
rationale: "我不是只让你推荐一个吗"
});
await service.runWorkerOnce(20);
- const rewarded = db.db.prepare(
+ const openEpisode = db.db.prepare(
`SELECT r_task
FROM episodes
WHERE id = ?`
).get(first.episodeId) as { r_task: number | null };
- expect(typeof rewarded.r_task).toBe("number");
- const immediateRewardCall = calls.find((call) =>
- call.options.operation === "reward.reward.r_human.v7"
- );
- expect(immediateRewardCall).toBeTruthy();
- const immediateRewardInput = JSON.parse(
- immediateRewardCall!.messages.find((message) => message.role === "user")!.content
- ) as {
- turnSummaries: string[];
- finalExchange: { user: string; assistant: string };
- };
- expect(immediateRewardInput.turnSummaries[0]).toBe("LLM batch summary");
- expect(immediateRewardInput.turnSummaries[0]!.length).toBeLessThanOrEqual(200);
- expect(immediateRewardInput.turnSummaries[1]).toBe("LLM batch summary");
- expect(immediateRewardInput.finalExchange).toEqual({
- user: "水果中和西瓜比较相似有哪些,推荐一个",
- assistant: "我推荐哈密瓜。"
- });
- expect(calls.filter((call) =>
- call.options.operation === "reward.reward.r_human.v7"
- )).toHaveLength(1);
+ expect(openEpisode.r_task).toBeNull();
+ expect(calls.filter((call) => call.options.operation === "reward.reward.r_human.v7")).toEqual([]);
expect(calls.filter((call) => call.options.operation === "capture.summarize")).toHaveLength(2);
const third = service.completeTurn("turn-reward-before-reflection-3", {
@@ -308,6 +289,7 @@ describe("MemoryService / evolution / reward", () => {
).get(first.episodeId) as { count: number };
expect(queuedReflection.count).toBe(1);
+ await service.runWorkerOnce(20);
await service.runWorkerOnce(20);
const reflectedItems = service.panelItems({
userId: "user-reward-before-reflection",
@@ -316,6 +298,34 @@ describe("MemoryService / evolution / reward", () => {
expect(reflectedItems).toHaveLength(3);
expect(reflectedItems.every((item) => item.metrics?.reflectionDone)).toBe(true);
expect(calls.some((call) => call.options.operation === "capture.reflection.batch.v13")).toBe(true);
+ const rewardCalls = calls.filter((call) => call.options.operation === "reward.reward.r_human.v7");
+ expect(rewardCalls).toHaveLength(1);
+ const rewardInput = JSON.parse(
+ rewardCalls[0]!.messages.find((message) => message.role === "user")!.content
+ ) as {
+ turnSummaries: string[];
+ finalExchange: { user: string; assistant: string };
+ feedbackHistory: Array<{ polarity: string }>;
+ };
+ expect(rewardInput.turnSummaries).toHaveLength(3);
+ expect(rewardInput.finalExchange).toEqual({
+ user: "哈密瓜和西瓜谁的营养价值更高",
+ assistant: "综合营养密度上哈密瓜通常更高一点。"
+ });
+ expect(rewardInput.feedbackHistory).toEqual([
+ expect.objectContaining({ polarity: "negative" })
+ ]);
+ const rewarded = db.db.prepare(
+ `SELECT r_task, reward_detail_json
+ FROM episodes
+ WHERE id = ?`
+ ).get(first.episodeId) as { r_task: number | null; reward_detail_json: string };
+ expect(typeof rewarded.r_task).toBe("number");
+ expect(JSON.parse(rewarded.reward_detail_json)).toMatchObject({
+ phase: "final",
+ traceCount: 3,
+ traceIds: [first.l1MemoryId, second.l1MemoryId, third.l1MemoryId]
+ });
db.close();
});
@@ -344,6 +354,7 @@ describe("MemoryService / evolution / reward", () => {
});
service.closeSession(session.sessionId);
+ await service.runWorkerOnce(20);
await service.runWorkerOnce(20);
const memory = db.db.prepare(
@@ -638,6 +649,12 @@ describe("MemoryService / evolution / reward", () => {
magnitude: 1,
rationale: "accepted, but process was only partial"
},
+ feedbackHistory: [{
+ channel: "explicit",
+ polarity: "positive",
+ magnitude: 1,
+ rationale: "accepted, but process was only partial"
+ }],
host: {
agent: "codex"
}
@@ -732,23 +749,7 @@ describe("MemoryService / evolution / reward", () => {
rationale: "wrong, use port 443 instead and verify TLS"
});
- const rewardJob = feedback.jobs.find((job) => job.jobType === "reward");
- expect(rewardJob?.targetMemoryId).toBeUndefined();
- const rewardJobRow = db.db.prepare(
- `SELECT episode_id, target_memory_id, payload_json
- FROM evolution_jobs
- WHERE id = ?`
- ).get(rewardJob!.jobId) as {
- episode_id: string | null;
- target_memory_id: string | null;
- payload_json: string;
- };
- expect(rewardJobRow.episode_id).toBe(complete.episodeId);
- expect(rewardJobRow.target_memory_id).toBeNull();
- expect(JSON.parse(rewardJobRow.payload_json)).toMatchObject({
- l1MemoryId: complete.l1MemoryId,
- feedbackId: feedback.feedbackId
- });
+ expect(feedback.jobs.map((job) => job.jobType)).not.toContain("reward");
const feedbackRow = db.db.prepare(
`SELECT l1_memory_id, raw_turn_id, episode_id, session_id
FROM feedback
@@ -774,6 +775,32 @@ describe("MemoryService / evolution / reward", () => {
expect(JSON.parse(episodeIndexes.feedback_ids_json)).toContain(feedback.feedbackId);
expect(JSON.parse(episodeIndexes.decision_repair_ids_json)).toContain(feedback.repair?.repairId);
+ const beforeClose = JSON.parse((db.db.prepare(
+ `SELECT properties_json FROM memories WHERE id = ?`
+ ).get(complete.l1MemoryId) as { properties_json: string }).properties_json) as {
+ internal_info: { trace: { r_human?: number } };
+ };
+ expect(beforeClose.internal_info.trace.r_human).toBeUndefined();
+
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(50);
+ const rewardJobRow = db.db.prepare(
+ `SELECT episode_id, target_memory_id, payload_json
+ FROM evolution_jobs
+ WHERE job_type = 'reward'
+ AND episode_id = ?`
+ ).get(complete.episodeId) as {
+ episode_id: string | null;
+ target_memory_id: string | null;
+ payload_json: string;
+ };
+ expect(rewardJobRow.episode_id).toBe(complete.episodeId);
+ expect(rewardJobRow.target_memory_id).toBeNull();
+ expect(JSON.parse(rewardJobRow.payload_json)).toMatchObject({
+ phase: "final",
+ l1MemoryId: complete.l1MemoryId,
+ feedbackId: feedback.feedbackId
+ });
await service.runWorkerOnce(50);
const memory = db.db.prepare(
diff --git a/Memory/tests/service/feedback/decision-repair.test.ts b/Memory/tests/service/feedback/decision-repair.test.ts
index 5018a99de..9284241e9 100644
--- a/Memory/tests/service/feedback/decision-repair.test.ts
+++ b/Memory/tests/service/feedback/decision-repair.test.ts
@@ -118,6 +118,7 @@ describe("MemoryService / feedback / decision repair", () => {
});
makeTraceEligibleForL2(db, complete.l1MemoryId);
}
+ service.closeSession(session.sessionId);
for (let i = 0; i < 20; i += 1) {
await service.runWorkerOnce(100);
}
@@ -726,6 +727,9 @@ describe("MemoryService / feedback / decision repair", () => {
magnitude: 1,
rationale: "wrong, do not repeat the SQL query before inspecting the migration output"
});
+ service.closeSession(negativeSession.sessionId);
+ await service.runWorkerOnce(100);
+ await service.runWorkerOnce(100);
await service.runWorkerOnce(100);
const repair = db.db.prepare(
@@ -778,12 +782,6 @@ describe("MemoryService / feedback / decision repair", () => {
kind: "repair",
op: "created"
});
- expect(service.panelJobs({
- userId: negativeUserId,
- status: "queued"
- }).items).toEqual(expect.arrayContaining([
- expect.objectContaining({ jobType: "negative_experience" })
- ]));
await service.runWorkerOnce(100);
const policies = service.panelItems({
userId: negativeUserId,
diff --git a/Memory/tests/service/feedback/experience.test.ts b/Memory/tests/service/feedback/experience.test.ts
index 4f94895da..07a53324a 100644
--- a/Memory/tests/service/feedback/experience.test.ts
+++ b/Memory/tests/service/feedback/experience.test.ts
@@ -152,10 +152,12 @@ describe("MemoryService / feedback / experience", () => {
expect(beforeWorker).toHaveLength(1);
expect(beforeWorker[0]!.id).toBe(created[0]!.id);
expect(avoid.jobs.map((job) => job.jobType)).not.toContain("negative_experience");
- expect(avoid.jobs.map((job) => job.jobType)).toContain("reward");
+ expect(avoid.jobs.map((job) => job.jobType)).not.toContain("reward");
expect(avoid.jobs.map((job) => job.jobType)).not.toContain("l3_abstraction");
expect(avoid.jobs.map((job) => job.jobType)).not.toContain("skill_crystallization");
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(100);
await service.runWorkerOnce(100);
await service.runWorkerOnce(100);
@@ -194,7 +196,7 @@ describe("MemoryService / feedback / experience", () => {
evidence_polarity?: string;
skill_eligible?: boolean;
source_feedback_ids?: string[];
- decision_guidance?: { anti_pattern?: string[] };
+ decision_guidance?: { preference?: string[]; anti_pattern?: string[] };
};
};
}).internal_info.policy;
@@ -203,7 +205,8 @@ describe("MemoryService / feedback / experience", () => {
expect(negativePolicy.evidence_polarity).toBe("negative");
expect(negativePolicy.skill_eligible).toBe(false);
expect(negativePolicy.source_feedback_ids).toEqual([avoid.feedbackId]);
- expect(negativePolicy.decision_guidance?.anti_pattern?.join("\n")).toContain("filename");
+ expect(negativePolicy.decision_guidance?.anti_pattern?.join("\n")).toContain("validated the issuer field");
+ expect(negativePolicy.decision_guidance?.preference?.join("\n")).toContain("filename");
db.close();
});
@@ -267,7 +270,9 @@ describe("MemoryService / feedback / experience", () => {
expect(calls.find((call) => call.options.operation === "failure.experience.sink.v5")).toBeUndefined();
expect(feedbackResponse.jobs.map((job) => job.jobType)).not.toContain("negative_experience");
- expect(feedbackResponse.jobs.map((job) => job.jobType)).toContain("reward");
+ expect(feedbackResponse.jobs.map((job) => job.jobType)).not.toContain("reward");
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(100);
await service.runWorkerOnce(100);
await service.runWorkerOnce(100);
@@ -287,6 +292,7 @@ describe("MemoryService / feedback / experience", () => {
verification?: string;
decision_guidance?: { anti_pattern?: string[] };
policy_confidence?: number;
+ evidence_strength?: number;
};
};
}).internal_info.policy;
@@ -294,7 +300,8 @@ describe("MemoryService / feedback / experience", () => {
expect(policy.procedure).toContain("filename");
expect(policy.verification).toContain("historical failure mode");
expect(policy.decision_guidance?.anti_pattern?.join("\n")).toContain("filename");
- expect(policy.policy_confidence).toBeGreaterThanOrEqual(0.91);
+ expect(policy.policy_confidence).toBeGreaterThanOrEqual(0.6);
+ expect(policy.evidence_strength).toBe(1);
const skillRow = db.db.prepare(
`SELECT id, properties_json
diff --git a/Memory/tests/service/import/import-processing.test.ts b/Memory/tests/service/import/import-processing.test.ts
index 621d0a783..245e08752 100644
--- a/Memory/tests/service/import/import-processing.test.ts
+++ b/Memory/tests/service/import/import-processing.test.ts
@@ -1033,14 +1033,28 @@ describe("MemoryService / import / processing", () => {
userId: "user-import-order"
};
- const older = addAgentSourceImport(service, namespace, "older memory query", "order-old");
- const newer = addAgentSourceImport(service, namespace, "newer memory query", "order-new");
- db.db.prepare(`UPDATE memories SET updated_at = ? WHERE id = ?`).run("2026-06-10T10:00:00.000Z", older.id);
+ const older = addAgentSourceImport(
+ service,
+ namespace,
+ "older memory query",
+ "order-old",
+ "2026-06-10T10:00:00.000Z"
+ );
+ const newer = addAgentSourceImport(
+ service,
+ namespace,
+ "newer memory query",
+ "order-new",
+ "2026-06-10T12:00:00.000Z"
+ );
+ db.db.prepare(`UPDATE memories SET updated_at = ? WHERE id = ?`).run("2026-06-10T13:00:00.000Z", older.id);
db.db.prepare(`UPDATE memories SET updated_at = ? WHERE id = ?`).run("2026-06-10T12:00:00.000Z", newer.id);
- const run = await service.runWorkerOnce(10);
+ const summaryRun = await service.runWorkerOnce(10);
+ const embeddingRun = await service.runWorkerOnce(10);
- expect(run.jobs.map((job) => job.targetMemoryId)).toEqual([newer.id, older.id]);
+ expect(summaryRun.jobs.map((job) => job.targetMemoryId)).toEqual([newer.id, older.id]);
+ expect(embeddingRun.jobs.map((job) => job.targetMemoryId)).toEqual([newer.id, older.id]);
expect(llmCalls[0]?.messages.find((message) => message.role === "user")?.content).toContain("newer memory query");
db.close();
@@ -1069,7 +1083,13 @@ describe("MemoryService / import / processing", () => {
};
for (let index = 0; index < 25; index += 1) {
- addAgentSourceImport(service, namespace, `imported query ${index}`, `interleave-${index}`);
+ addAgentSourceImport(
+ service,
+ namespace,
+ `imported query ${index}`,
+ `interleave-${index}`,
+ new Date(Date.UTC(2026, 5, 10, 10, index)).toISOString()
+ );
}
const summaryRun = await service.runWorkerOnce(20);
@@ -1165,8 +1185,20 @@ describe("MemoryService / import / processing", () => {
userId: "user-import-placeholder-order"
};
- const older = addAgentSourceImport(service, namespace, "older user query", "placeholder-old");
- const newer = addAgentSourceImport(service, namespace, "newer assistant placeholder query", "placeholder-new");
+ const older = addAgentSourceImport(
+ service,
+ namespace,
+ "older user query",
+ "placeholder-old",
+ "2026-06-10T10:00:00.000Z"
+ );
+ const newer = addAgentSourceImport(
+ service,
+ namespace,
+ "newer assistant placeholder query",
+ "placeholder-new",
+ "2026-06-10T12:00:00.000Z"
+ );
db.db.prepare(`DELETE FROM evolution_jobs WHERE target_memory_id IN (?, ?)`).run(older.id, newer.id);
db.db.prepare(`UPDATE memories SET updated_at = ?, info_json = json_set(info_json, '$.summary', ?) WHERE id = ?`)
.run("2026-06-10T10:00:00.000Z", "## user", older.id);
@@ -1204,6 +1236,68 @@ describe("MemoryService / import / processing", () => {
db.close();
});
+ it("finishes a new Memmy chat memory before draining scanned-memory backlog", async () => {
+ const root = createTestRoot("mindock-memory-live-priority-");
+ const db = new MemoryDb({
+ path: join(root, "memory.sqlite")
+ });
+ const llmCalls: Array<{
+ messages: Array<{ role: string; content: string }>;
+ options: { operation: string };
+ }> = [];
+ const embeddingTexts: string[] = [];
+ const service = createTestMemoryService({
+ db,
+ mode: "dev",
+ llm: createBatchReflectionLlm(llmCalls),
+ embedder: createCapturingEmbedder(embeddingTexts)
+ });
+ const namespace = {
+ source: "memmy",
+ profileId: "jiang",
+ userId: "user-live-priority"
+ };
+
+ const oldImport = addAgentSourceImport(
+ service,
+ namespace,
+ "old scanned memory",
+ "live-priority-old",
+ "2026-06-10T10:00:00.000Z"
+ );
+ const recentImport = addAgentSourceImport(
+ service,
+ namespace,
+ "recent scanned memory",
+ "live-priority-recent",
+ "2026-06-10T12:00:00.000Z"
+ );
+ const session = service.openSession({ namespace });
+ const live = service.completeTurn("turn-live-priority", {
+ sessionId: session.sessionId,
+ query: "Remember the new interactive preference.",
+ answer: "The new interactive preference is dark mode."
+ });
+
+ const summaryRun = await service.runWorkerOnce(4, { priorityCohortOnly: true });
+ const embeddingRun = await service.runWorkerOnce(4, { priorityCohortOnly: true });
+ const scanRun = await service.runWorkerOnce(4, { priorityCohortOnly: true });
+
+ expect(summaryRun.jobs).toEqual([
+ expect.objectContaining({ jobType: "trace_summary", targetMemoryId: live.l1MemoryId })
+ ]);
+ expect(embeddingRun.jobs).toEqual([
+ expect.objectContaining({ jobType: "embedding", targetMemoryId: live.l1MemoryId })
+ ]);
+ expect(scanRun.jobs.map((job) => job.targetMemoryId)).toEqual([recentImport.id, oldImport.id]);
+ expect(scanRun.jobs.every((job) => job.jobType === "import_summary")).toBe(true);
+ expect(llmCalls[0]?.messages.find((message) => message.role === "user")?.content)
+ .toContain("new interactive preference");
+ expect(embeddingTexts).toHaveLength(1);
+
+ db.close();
+ });
+
it("guards imported trace embedding until a real summary job has run", async () => {
const root = createTestRoot("mindock-memory-import-embedding-guard-");
const db = new MemoryDb({
diff --git a/Memory/tests/service/session/episode-relation.test.ts b/Memory/tests/service/session/episode-relation.test.ts
index 1763d7794..4f00864ff 100644
--- a/Memory/tests/service/session/episode-relation.test.ts
+++ b/Memory/tests/service/session/episode-relation.test.ts
@@ -5,6 +5,7 @@ import {
MemoryDb,
type LlmClient
} from "../../../src/index.js";
+import { Repositories } from "../../../src/storage/repositories.js";
import {
accountRuntimeConfig,
createCapturingEmbedder,
@@ -955,7 +956,7 @@ describe("MemoryService / session / episode relation", () => {
db.close();
});
- it("turns revision relation messages into structured feedback and reward backprop", async () => {
+ it("records revision feedback immediately but defers reward backprop until episode close", async () => {
const { db, service } = createTestService();
const session = service.openSession({
namespace: {
@@ -1039,8 +1040,28 @@ describe("MemoryService / session / episode relation", () => {
change_type: "decision_repair_created"
});
- await service.runWorkerOnce(50);
+ const openMemory = db.db.prepare(
+ `SELECT properties_json
+ FROM memories
+ WHERE id = ?`
+ ).get(first.l1MemoryId) as { properties_json: string };
+ const openTrace = (JSON.parse(openMemory.properties_json) as {
+ internal_info: {
+ trace: {
+ r_human?: number;
+ source_feedback_ids?: string[];
+ };
+ };
+ }).internal_info.trace;
+ expect(openTrace.r_human).toBeUndefined();
+ expect(db.db.prepare(
+ `SELECT COUNT(*) AS count
+ FROM evolution_jobs
+ WHERE episode_id = ? AND job_type = 'reward'`
+ ).get(first.episodeId)).toEqual({ count: 0 });
+ service.closeSession(session.sessionId);
+ await runWorkerRounds(service, 2, 50);
const memory = db.db.prepare(
`SELECT properties_json
FROM memories
@@ -1060,6 +1081,59 @@ describe("MemoryService / session / episode relation", () => {
db.close();
});
+ it("clears a stale final reward when a closed episode is reopened", async () => {
+ const { db, service } = createTestService();
+ const session = service.openSession({
+ namespace: {
+ source: "codex",
+ profileId: "jiang",
+ userId: "user-reopen-stale-reward"
+ }
+ });
+ const first = service.completeTurn("turn-reopen-stale-reward-first", {
+ sessionId: session.sessionId,
+ query: "Configure nginx TLS for the service",
+ answer: "Use port 80 and skip certificate verification."
+ });
+ const repos = new Repositories(db.db);
+ const rewardDetail = {
+ phase: "final",
+ rHuman: -0.25,
+ traceIds: [first.l1MemoryId]
+ };
+ repos.runtime.updateEpisodeReward(first.episodeId, {
+ rTask: -0.25,
+ rewardDetail,
+ metaPatch: { reward: rewardDetail }
+ });
+ repos.runtime.closeEpisode(first.episodeId, { closeReason: "idle_timeout" });
+
+ await service.startTurn({
+ turnId: "turn-reopen-stale-reward-fix",
+ sessionId: session.sessionId,
+ query: "wrong, use port 443 instead and verify TLS"
+ });
+ const correction = service.completeTurn("turn-reopen-stale-reward-fix", {
+ sessionId: session.sessionId,
+ query: "wrong, use port 443 instead and verify TLS",
+ answer: "Corrected: use port 443 and verify TLS."
+ });
+
+ expect(correction.episodeId).toBe(first.episodeId);
+ expect(repos.runtime.getEpisode(first.episodeId)).toMatchObject({
+ status: "open",
+ rTask: undefined,
+ rewardDetail: {},
+ meta: {
+ rewardDirty: {
+ reason: "episode_reopened"
+ }
+ }
+ });
+ expect(repos.runtime.getEpisode(first.episodeId)?.meta).not.toHaveProperty("reward");
+ db.close();
+ });
+
it("records plugin-style implicit turn feedback before opening the next episode", async () => {
const { db, service } = createTestService();
const session = service.openSession({
@@ -1121,6 +1195,15 @@ describe("MemoryService / session / episode relation", () => {
classifierPolarity: "negative"
});
+ const rewardBeforeReflection = db.db.prepare(
+ `SELECT COUNT(*) AS count
+ FROM evolution_jobs
+ WHERE job_type = 'reward'
+ AND json_extract(payload_json, '$.feedbackId') = ?`
+ ).get(feedback.id) as { count: number };
+ expect(rewardBeforeReflection.count).toBe(0);
+
+ await service.runWorkerOnce(20);
const queuedReward = db.db.prepare(
`SELECT payload_json
FROM evolution_jobs
@@ -1130,7 +1213,8 @@ describe("MemoryService / session / episode relation", () => {
expect(JSON.parse(queuedReward!.payload_json)).toMatchObject({
feedbackId: feedback.id,
l1MemoryId: first.l1MemoryId,
- trigger: "implicit_turn_feedback"
+ phase: "final",
+ trigger: "implicit_fallback"
});
await runWorkerRounds(service, 2, 20);
diff --git a/Memory/tests/service/trials/skill-trial.test.ts b/Memory/tests/service/trials/skill-trial.test.ts
index 62351c374..9965d44d2 100644
--- a/Memory/tests/service/trials/skill-trial.test.ts
+++ b/Memory/tests/service/trials/skill-trial.test.ts
@@ -25,6 +25,7 @@ describe("MemoryService / trials / skill trial", () => {
answer: "applied the sqlite migration checklist and reported the neutral result"
});
await service.runWorkerOnce(100);
+ await service.runWorkerOnce(100);
const skillId = "skill_neutral_reward";
insertActiveSkillMemoryForTest(db, {
@@ -53,11 +54,10 @@ describe("MemoryService / trials / skill trial", () => {
magnitude: 1,
rationale: "skill result was inconclusive"
});
- expect(feedback.jobs.map((job) => job.jobType)).toEqual(expect.arrayContaining([
- "reward",
- "skill_trial_resolve"
- ]));
+ expect(feedback.jobs.map((job) => job.jobType)).toEqual(["skill_trial_resolve"]);
+ service.closeSession(session.sessionId);
+ await service.runWorkerOnce(100);
await service.runWorkerOnce(100);
const resolvedTrial = db.db.prepare(
@@ -90,13 +90,25 @@ describe("MemoryService / trials / skill trial", () => {
).get(trial.trialId) as { source: string };
expect(trialResolvedChange.source).toBe("worker.reward.updated");
+ const retrySession = service.openSession({
+ namespace: {
+ ...namespace,
+ sessionKey: "retry"
+ }
+ });
+ const retryTurn = service.completeTurn("turn-skill-neutral-reward-retry", {
+ sessionId: retrySession.sessionId,
+ episodeId: "episode-skill-neutral-reward-retry",
+ query: "retry the reusable sqlite migration checklist",
+ answer: "applied the checklist again"
+ });
const retryTrial = service.useSkill(skillId, {
adapterId: "test-adapter",
requestId: "skill-neutral-reward-2",
- sessionId: session.sessionId,
- episodeId: complete.episodeId,
- rawTurnId: complete.rawTurnId,
- turnId: complete.turnId
+ sessionId: retrySession.sessionId,
+ episodeId: retryTurn.episodeId,
+ rawTurnId: retryTurn.rawTurnId,
+ turnId: retryTurn.turnId
});
expect(retryTrial.trialId).not.toBe(trial.trialId);
expect(retryTrial.duplicate).toBeUndefined();
@@ -105,9 +117,8 @@ describe("MemoryService / trials / skill trial", () => {
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
COUNT(*) AS total
FROM skill_trials
- WHERE skill_memory_id = ?
- AND episode_id = ?`
- ).get(skillId, complete.episodeId) as { pending: number; total: number };
+ WHERE skill_memory_id = ?`
+ ).get(skillId) as { pending: number; total: number };
expect(trialCounts).toMatchObject({
pending: 1,
total: 2
From 94075296650afa1dda61d123eb91e0b9ea55f028 Mon Sep 17 00:00:00 2001
From: jiang
Date: Mon, 3 Aug 2026 17:14:26 +0800
Subject: [PATCH 07/35] chore: docs
---
README.md | 4 ++--
README.zh-CN.md | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 2f1535d58..1179de219 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@
## 🆓 Sign-up for Free Trial
-Get Memmy from [Official Website](https://memmy.bot/) or [GitHub Release](https://github.com/MemTensor/memmy-agent/releases).
+Get Memmy from [Official Website](https://memmy.bot/) or [GitHub Releases](https://github.com/MemTensor/memmy-agent/releases).
Sign up to get free tokens. Model routing is automatic — start exploring the full Memory + Agent Runtime with zero config.
@@ -109,7 +109,7 @@ Memmy is not just a chat interface — it is an AI Agent Runtime t
| 🔌 Integration Layer | Connect external ecosystems | Messaging channels, third-party tools, OpenAI-compatible API |
| 🖥️ User Interface | Provide entry points | Desktop App, CLI/TUI, Web API |
-### Repository Architecture
+### System Architecture

diff --git a/README.zh-CN.md b/README.zh-CN.md
index c2afcde93..9f2ce4d92 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -36,7 +36,7 @@
## 🚀 开始体验 Memmy
-点击进入[官网下载](https://memmy.cn/)或者 [GitHub Release](https://github.com/MemTensor/memmy-agent/releases)下载。
+点击进入[官网下载](https://memmy.cn/)或者 [GitHub Releases](https://github.com/MemTensor/memmy-agent/releases) 下载。
> [!TIP]
> 注册 Memmy 后,即可获得免费 AI 使用额度,系统会自动进行模型调度,帮助你体验完整的 Memory + Agent Runtime。
@@ -105,7 +105,7 @@ Memmy 不只是一个聊天界面,而是一套运行在本地的 AI Agent
| 🔌 Integration Layer | 连接外部生态 | 消息渠道、第三方工具、OpenAI 兼容 API |
| 🖥️ User Interface | 提供使用入口 | Desktop App、CLI/TUI、Web 接口 |
-### 仓库架构
+### 系统架构

From f8cf95557ddcee2a24e294c2821afe1690a26c33 Mon Sep 17 00:00:00 2001
From: jiang
Date: Mon, 3 Aug 2026 17:17:53 +0800
Subject: [PATCH 08/35] chore: readme
---
README.md | 2 +-
README.zh-CN.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 1179de219..b34550956 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@
## 🆓 Sign-up for Free Trial
-Get Memmy from [Official Website](https://memmy.bot/) or [GitHub Releases](https://github.com/MemTensor/memmy-agent/releases).
+Get Memmy from [Official Website](https://memmy.bot/) or [GitHub Release](https://github.com/MemTensor/memmy-agent/releases).
Sign up to get free tokens. Model routing is automatic — start exploring the full Memory + Agent Runtime with zero config.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 9f2ce4d92..97ad7c7a4 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -36,7 +36,7 @@
## 🚀 开始体验 Memmy
-点击进入[官网下载](https://memmy.cn/)或者 [GitHub Releases](https://github.com/MemTensor/memmy-agent/releases) 下载。
+点击进入[官网下载](https://memmy.cn/)或者 [GitHub Release](https://github.com/MemTensor/memmy-agent/releases) 下载。
> [!TIP]
> 注册 Memmy 后,即可获得免费 AI 使用额度,系统会自动进行模型调度,帮助你体验完整的 Memory + Agent Runtime。
From b0b4df242c8a284a1a844ba89660bd55ede4c976 Mon Sep 17 00:00:00 2001
From: ZongYue <52625187+ZongYue99@users.noreply.github.com>
Date: Mon, 3 Aug 2026 18:02:06 +0800
Subject: [PATCH 09/35] chore: show invitation token (#135)
---
App/frontend/desktop/src/i18n/messages.ts | 2 +
.../desktop/src/pages/settings-page.tsx | 13 +-
.../tests/settings-invitation-banner.test.tsx | 128 ++++++++++++++++++
3 files changed, 141 insertions(+), 2 deletions(-)
create mode 100644 App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx
diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts
index 297440f0c..9740d4175 100644
--- a/App/frontend/desktop/src/i18n/messages.ts
+++ b/App/frontend/desktop/src/i18n/messages.ts
@@ -1198,6 +1198,7 @@ export const zhCNMessages = {
"settings.token.remaining": "剩余 {count} Token",
"settings.token.invite.title": "邀请好友,享更多额度",
"settings.token.invite.body": "好友注册成功后,双方都会获得奖励 Token",
+ "settings.token.invite.bodyWithReward": "好友注册成功后,双方各获得 {count} Token",
"settings.token.invite.dailyLimit": "今日邀请已满,明天再来",
"settings.token.invite.codeLabel": "我的邀请码",
"settings.token.invite.copy": "复制",
@@ -2558,6 +2559,7 @@ export const enUSMessages: Record = {
"settings.token.remaining": "{count} tokens remaining",
"settings.token.invite.title": "Invite friends for more quota",
"settings.token.invite.body": "After a friend signs up, you both get bonus tokens",
+ "settings.token.invite.bodyWithReward": "After a friend signs up, you each receive {count} bonus tokens",
"settings.token.invite.dailyLimit": "Daily invite limit reached. Try again tomorrow",
"settings.token.invite.codeLabel": "Your invite code",
"settings.token.invite.copy": "Copy",
diff --git a/App/frontend/desktop/src/pages/settings-page.tsx b/App/frontend/desktop/src/pages/settings-page.tsx
index 5249fa532..02252d73f 100644
--- a/App/frontend/desktop/src/pages/settings-page.tsx
+++ b/App/frontend/desktop/src/pages/settings-page.tsx
@@ -27,6 +27,7 @@ import { appActions, type AppAction } from "../state/app-actions.js";
import type { AppState } from "../state/app-reducer.js";
import { useAppState } from "../state/app-state.js";
import { AppFrame } from "./app-frame.js";
+import { formatTokenGiftAmount } from "./token-gift.js";
import usageStyles from "./settings-token-usage.module.css";
import {
OptionalModelMissingWarningModal,
@@ -394,7 +395,15 @@ export function SettingsPageView(props: SettingsPageViewProps) {
const giftBarUsedTokens = agentQuota?.usedTokens ?? giftUsedTokens;
const { usagePercent, isTokenLow } = resolveGiftTokenUsage(giftBarUsedTokens, giftTotalTokens, giftRemainingTokens);
const showGiftQuota = !isByokMode;
- const invitationEnabled = bootstrap?.promotions?.invitation?.enabled === true;
+ const invitationPromotion = bootstrap?.promotions?.invitation;
+ const invitationEnabled = invitationPromotion?.enabled === true;
+ const invitationRewardBody = invitationPromotion
+ && invitationPromotion.inviterRewardTokens > 0
+ && invitationPromotion.inviterRewardTokens === invitationPromotion.inviteeRewardTokens
+ ? t("settings.token.invite.bodyWithReward", {
+ count: formatTokenGiftAmount(invitationPromotion.inviterRewardTokens)
+ })
+ : t("settings.token.invite.body");
const displayInviteCode = invitationInfo?.invitationCode ?? null;
const inviteDailyLimitReached = invitationInfo?.dailyLimitReached ?? false;
const showInvitationBanner = invitationEnabled
@@ -1715,7 +1724,7 @@ export function SettingsPageView(props: SettingsPageViewProps) {
? t("settings.token.invite.loading")
: inviteDailyLimitReached
? t("settings.token.invite.dailyLimit")
- : t("settings.token.invite.body")}
+ : invitationRewardBody}
{displayInviteCode ? (
diff --git a/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx b/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx
new file mode 100644
index 000000000..553ca10c3
--- /dev/null
+++ b/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx
@@ -0,0 +1,128 @@
+// @vitest-environment happy-dom
+
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "../../i18n/i18n-provider.js";
+import type { AccountClient } from "../../api/account-client.js";
+import { appActions } from "../../state/app-actions.js";
+import { appReducer, createInitialAppState } from "../../state/app-reducer.js";
+import { SettingsPageView } from "../settings-page.js";
+import { mockBootstrap } from "./fixtures/bootstrap.js";
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("SettingsPage invitation banner", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ Object.defineProperty(window, "localStorage", {
+ configurable: true,
+ value: createMemoryStorage()
+ });
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ document.body.replaceChildren();
+ });
+
+ it("shows the per-person reward amount delivered by the promotion bootstrap", async () => {
+ const invitationResponse = {
+ enabled: true,
+ invitationCode: "MEMMY-A1B2C3",
+ usedInviteSlotsToday: 0,
+ dailySuccessLimit: 5,
+ remainingInvitesToday: 5,
+ dailyLimitReached: false
+ };
+ const accountClient: AccountClient = {
+ sendCode: vi.fn(),
+ verifyCode: vi.fn(),
+ getInvitation: vi.fn(async () => invitationResponse),
+ updateProfile: vi.fn(),
+ markGuideFinished: vi.fn(),
+ logout: vi.fn(),
+ getSession: vi.fn()
+ };
+ const bootstrap = {
+ ...mockBootstrap,
+ app: {
+ ...mockBootstrap.app,
+ userMode: "account" as const,
+ language: "zh-CN" as const
+ },
+ promotions: {
+ loginBanner: true,
+ improvementGift: true,
+ improvementGiftRewardTokens: 1_000_000,
+ applyMore: true,
+ agentChatTokenTotal: 2_000_000,
+ invitation: {
+ enabled: true,
+ inviterRewardTokens: 765_432,
+ inviteeRewardTokens: 765_432,
+ dailySuccessLimit: 5
+ }
+ }
+ };
+ const bootstrapped = appReducer(
+ createInitialAppState(),
+ appActions.bootstrapLoaded(bootstrap, "/settings")
+ );
+ const state = appReducer(
+ bootstrapped,
+ appActions.accountUpdated({
+ email: "invite@example.com",
+ phoneNumber: null,
+ registeredAt: "2026-08-03T00:00:00.000Z"
+ })
+ );
+
+ await act(async () => {
+ root.render(
+
+ undefined)
+ }}
+ />
+
+ );
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain(
+ "好友注册成功后,双方各获得 765,432 Token"
+ );
+ expect(container.textContent).not.toContain(
+ "好友注册成功后,双方都会获得奖励 Token"
+ );
+ });
+});
+
+function createMemoryStorage(): Storage {
+ const values = new Map();
+ return {
+ get length() {
+ return values.size;
+ },
+ clear: () => values.clear(),
+ getItem: (key) => values.get(key) ?? null,
+ key: (index) => [...values.keys()][index] ?? null,
+ removeItem: (key) => values.delete(key),
+ setItem: (key, value) => values.set(key, value)
+ };
+}
From c388e5deb0549407e502a0e7151f0346903fa943 Mon Sep 17 00:00:00 2001
From: jiang
Date: Mon, 3 Aug 2026 18:09:41 +0800
Subject: [PATCH 10/35] feat(memory): link related memory details
---
.../desktop/src/pages/memory-page.tsx | 54 ++++++-
.../src/pages/memory/memories-sub-page.tsx | 37 +++--
.../pages/memory/memory-reference-tags.tsx | 50 ++++++
.../src/pages/memory/policies-sub-page.tsx | 145 ++++++++++--------
.../src/pages/memory/skills-sub-page.tsx | 84 +++++++---
.../src/pages/memory/tasks-sub-page.tsx | 32 ++++
...memory-reference-tags.interaction.test.tsx | 32 ++++
.../tests/memory-reference-tags.test.tsx | 34 ++++
.../memory/tests/skills-sub-page.test.tsx | 4 +
.../tests/sub-page-cache-hydration.test.tsx | 6 +-
.../tests/world-model-sub-page.test.tsx | 4 +
.../src/pages/memory/world-model-sub-page.tsx | 101 +++++++-----
App/frontend/desktop/src/styles.css | 13 ++
13 files changed, 453 insertions(+), 143 deletions(-)
create mode 100644 App/frontend/desktop/src/pages/memory/memory-reference-tags.tsx
create mode 100644 App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.interaction.test.tsx
create mode 100644 App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.test.tsx
diff --git a/App/frontend/desktop/src/pages/memory-page.tsx b/App/frontend/desktop/src/pages/memory-page.tsx
index 120b3807f..b782f8860 100644
--- a/App/frontend/desktop/src/pages/memory-page.tsx
+++ b/App/frontend/desktop/src/pages/memory-page.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { buildMemorySubPageViewEvent } from "../analytics/page-view.js";
import { useAnalytics } from "../analytics/use-analytics.js";
import { useApiClients } from "../app/providers.js";
@@ -10,6 +10,12 @@ import { useAppState } from "../state/app-state.js";
import { SidebarResizeHandle, useCodexResizableSidebar } from "./sidebar-resize.js";
import { AnalyticsSubPage } from "./memory/analytics-sub-page.js";
import { LogsSubPage } from "./memory/logs-sub-page.js";
+import {
+ resolveMemoryReferencePage,
+ type MemoryReferenceOpenRequest,
+ type MemoryReferencePage,
+ type OpenMemoryReference
+} from "./memory/memory-reference-tags.js";
import { MemoriesSubPage } from "./memory/memories-sub-page.js";
import { OverviewSubPage } from "./memory/overview-sub-page.js";
import { PoliciesSubPage } from "./memory/policies-sub-page.js";
@@ -91,13 +97,22 @@ export function MemoryPage(props: MemoryPageProps) {
const { dispatch } = useAppState();
const { track, ready: analyticsReady } = useAnalytics();
const prevSubPageRef = useRef(null);
+ const referenceRequestIdRef = useRef(0);
const [activePage, setActivePage] = useState(() => props.initialSubPage ?? readInitialMemorySubPage());
+ const [referenceRequest, setReferenceRequest] = useState<(MemoryReferenceOpenRequest & { page: MemoryReferencePage }) | null>(null);
const client = clients?.memoryRuntime ?? null;
function handleSubPageChange(page: MemorySubPageId) {
+ setReferenceRequest(null);
setActivePage(page);
}
+ const handleOpenMemoryReference = useCallback((id, fallbackPage) => {
+ const page = resolveMemoryReferencePage(id, fallbackPage);
+ setReferenceRequest({ id, page, requestId: ++referenceRequestIdRef.current });
+ setActivePage(page);
+ }, []);
+
useEffect(() => {
if (!analyticsReady) {
return;
@@ -115,20 +130,45 @@ export function MemoryPage(props: MemoryPageProps) {
const childByPage = useMemo>(
() => ({
overview: ,
- memories: dispatch(appActions.navigate("/settings"))} />,
- tasks: ,
- policies: ,
- "world-model": ,
- skills: ,
+ memories: (
+ dispatch(appActions.navigate("/settings"))}
+ />
+ ),
+ tasks: ,
+ policies: (
+
+ ),
+ "world-model": (
+
+ ),
+ skills: (
+
+ ),
analytics: ,
logs: ,
sources:
}),
- [client, dispatch]
+ [client, dispatch, handleOpenMemoryReference, referenceRequest]
);
useEffect(() => {
if (props.initialSubPage) {
+ setReferenceRequest(null);
setActivePage(props.initialSubPage);
}
}, [props.initialSubPage]);
diff --git a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
index 93c3e8e36..046ad0422 100644
--- a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx
@@ -30,6 +30,7 @@ import {
writeMemoryPanelCaches
} from "./memory-panel-cache.js";
import { MemoryPagination, normalizePage } from "./memory-pagination.js";
+import type { MemoryReferenceOpenRequest } from "./memory-reference-tags.js";
import { MemoryRefreshButton } from "./memory-refresh-button.js";
import { MemoryStateBox } from "./memory-state-box.js";
import { type RemoteData, toErrorMessage } from "./remote-state.js";
@@ -57,6 +58,7 @@ const MEMORIES_REFRESH_INTERVAL_MS = 5_000;
export interface MemoriesSubPageProps {
client: MemoryRuntimeClient | null;
+ openRequest?: MemoryReferenceOpenRequest;
onOpenSettings?: () => void;
}
@@ -204,9 +206,9 @@ export function MemoriesSubPage(props: MemoriesSubPageProps) {
setPage(normalizedPage);
}
- function openDetail(item: PanelItemsOutput["items"][number]) {
+ function openDetailById(id: string) {
const requestId = ++detailRequestIdRef.current;
- setSelectedMemoryId(item.id);
+ setSelectedMemoryId(id);
track(buildMemoryUiDetailOpenedEvent({
subPage: "memories",
filterLayer: memoriesFilterLayer(sourceAgent)
@@ -218,7 +220,7 @@ export function MemoriesSubPage(props: MemoriesSubPageProps) {
}
setDetail({ status: "loading" });
- void loadMemoryDetail(props.client, item)
+ void props.client.getMemory(id)
.then((data) => {
if (requestId === detailRequestIdRef.current) {
setDetail({ status: "ready", data });
@@ -231,6 +233,10 @@ export function MemoriesSubPage(props: MemoriesSubPageProps) {
});
}
+ function openDetail(item: PanelItemsOutput["items"][number]) {
+ openDetailById(item.id);
+ }
+
async function deleteMemoryDetail(id: string) {
if (!props.client) {
throw new Error(t("memory.clientNotReady"));
@@ -338,6 +344,13 @@ export function MemoriesSubPage(props: MemoriesSubPageProps) {
return () => window.clearTimeout(timeout);
}, [props.client, query, sourceAgent, page, t]);
+ useEffect(() => {
+ if (props.openRequest) {
+ openDetailById(props.openRequest.id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.openRequest?.requestId]);
+
useEffect(() => {
if (state.status !== "ready" || !state.data.items.some(memoryProcessingStatus)) {
return;
@@ -358,6 +371,7 @@ export function MemoriesSubPage(props: MemoriesSubPageProps) {
return (
| ({ status: "ready"; data: PanelItemsOutput; detail: DetailState });
+ detail?: DetailState;
query: string;
sourceAgent: string;
onQueryChange: (value: string) => void;
@@ -450,6 +465,14 @@ export function MemoriesSubPageView(props: MemoriesSubPageViewProps) {
+
);
}
@@ -517,14 +540,6 @@ function MemoryListState(input: { props: MemoriesSubPageViewProps }) {
})}
-
>
);
}
diff --git a/App/frontend/desktop/src/pages/memory/memory-reference-tags.tsx b/App/frontend/desktop/src/pages/memory/memory-reference-tags.tsx
new file mode 100644
index 000000000..73cf0994b
--- /dev/null
+++ b/App/frontend/desktop/src/pages/memory/memory-reference-tags.tsx
@@ -0,0 +1,50 @@
+export type MemoryReferencePage = "memories" | "tasks" | "policies" | "world-model" | "skills";
+
+export interface MemoryReferenceOpenRequest {
+ id: string;
+ requestId: number;
+}
+
+export type OpenMemoryReference = (id: string, fallbackPage: MemoryReferencePage) => void;
+
+export function resolveMemoryReferencePage(id: string, fallbackPage: MemoryReferencePage): MemoryReferencePage {
+ const localId = id.split("::").at(-1)?.toLowerCase() ?? id.toLowerCase();
+
+ if (/^(?:memory[-_])?episode[-_]/.test(localId)) return "tasks";
+ if (/^(?:memory[-_])?(?:trace|span)[-_]/.test(localId)) return "memories";
+ if (/^(?:memory[-_])?policy[-_]/.test(localId)) return "policies";
+ if (/^(?:memory[-_])?(?:world|world_model)[-_]/.test(localId)) return "world-model";
+ if (/^(?:memory[-_])?skill[-_]/.test(localId)) return "skills";
+
+ return fallbackPage;
+}
+
+export function MemoryReferenceTags(props: {
+ ids: string[];
+ fallbackPage: MemoryReferencePage;
+ onOpen: OpenMemoryReference;
+}) {
+ const ids = [...new Set(props.ids.filter(Boolean))];
+
+ return (
+
+ {ids.map((id) => (
+
+ ))}
+
+ );
+}
+
+function compactMemoryReferenceId(id: string): string {
+ const value = id.split("::").at(-1) ?? id;
+ return value.length > 22 ? `${value.slice(0, 18)}...` : value;
+}
diff --git a/App/frontend/desktop/src/pages/memory/policies-sub-page.tsx b/App/frontend/desktop/src/pages/memory/policies-sub-page.tsx
index aab65a3ab..0bc5479f9 100644
--- a/App/frontend/desktop/src/pages/memory/policies-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/policies-sub-page.tsx
@@ -14,6 +14,11 @@ import { ChevronRight, Search, Sparkles, X } from "./memory-prototype-icons.js";
import { MemoryDrawerDeleteAction } from "./memory-delete-action.js";
import { toMemoryDetailErrorMessage } from "./memory-detail-error.js";
import { cleanMemoryBody, displayMemoryTitle, drawerEyebrow } from "./memory-display.js";
+import {
+ MemoryReferenceTags,
+ type MemoryReferenceOpenRequest,
+ type OpenMemoryReference
+} from "./memory-reference-tags.js";
import {
clearMemoryPanelCache,
memoryPanelCacheKey,
@@ -52,6 +57,8 @@ interface ExperienceView {
export interface PoliciesSubPageProps {
client: MemoryRuntimeClient | null;
+ openRequest?: MemoryReferenceOpenRequest;
+ onOpenMemoryReference: OpenMemoryReference;
}
function policiesCacheKeys(query: string, page: number): string[] {
@@ -133,7 +140,7 @@ export function PoliciesSubPage(props: PoliciesSubPageProps) {
void refresh(normalizedPage).catch(() => undefined);
}
- function openDetail(item: MemoryListItem) {
+ function openDetailById(id: string) {
track(buildMemoryUiDetailOpenedEvent({
subPage: "policies",
filterLayer: policiesFilterLayer
@@ -145,11 +152,15 @@ export function PoliciesSubPage(props: PoliciesSubPageProps) {
setDetail({ status: "loading" });
void props.client
- .getMemory(item.id)
+ .getMemory(id)
.then((data) => setDetail({ status: "ready", data }))
.catch((error) => setDetail({ status: "error", message: toMemoryDetailErrorMessage(error, t("memory.detailUnavailable")) }));
}
+ function openDetail(item: MemoryListItem) {
+ openDetailById(item.id);
+ }
+
async function deleteDetail(id: string) {
if (!props.client) {
throw new Error(t("memory.clientNotReady"));
@@ -170,6 +181,13 @@ export function PoliciesSubPage(props: PoliciesSubPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.client, t]);
+ useEffect(() => {
+ if (props.openRequest) {
+ openDetailById(props.openRequest.id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.openRequest?.requestId]);
+
return (
@@ -215,6 +233,7 @@ export function PoliciesSubPage(props: PoliciesSubPageProps) {
onDeleteDetail={deleteDetail}
onCloseDetail={() => setDetail(null)}
onPageChange={changePage}
+ onOpenMemoryReference={props.onOpenMemoryReference}
/>
);
@@ -227,54 +246,60 @@ function ExperienceState(props: {
onDeleteDetail: (id: string) => Promise
;
onCloseDetail: () => void;
onPageChange: (page: number) => void;
+ onOpenMemoryReference: OpenMemoryReference;
}) {
const { t } = useTranslation();
- if (props.state.status === "loading") {
- return ;
- }
-
- if (props.state.status === "error") {
- return ;
- }
-
- if (props.state.data.items.length === 0) {
- return ;
- }
-
return (
<>
-
- {props.state.data.items.map((item) => (
-
- ))}
-
-
-
+ {props.state.status === "loading" && }
+ {props.state.status === "error" && }
+ {props.state.status === "ready" && props.state.data.items.length === 0 && }
+ {props.state.status === "ready" && props.state.data.items.length > 0 && (
+ <>
+
+ {props.state.data.items.map((item) => (
+
+ ))}
+
+
+ >
+ )}
+
>
);
}
-function ExperienceDrawer(props: { detail: DetailState; onClose: () => void; onDelete: (id: string) => Promise }) {
+function ExperienceDrawer(props: {
+ detail: DetailState;
+ onClose: () => void;
+ onDelete: (id: string) => Promise;
+ onOpenMemoryReference: OpenMemoryReference;
+}) {
const { t } = useTranslation();
if (!props.detail) {
@@ -306,7 +331,9 @@ function ExperienceDrawer(props: { detail: DetailState; onClose: () => void; onD
{props.detail.status === "loading" && }
{props.detail.status === "error" && }
- {props.detail.status === "ready" && }
+ {props.detail.status === "ready" && (
+
+ )}
{readyDetail && props.onDelete(readyDetail.item.id)} />}
@@ -314,7 +341,7 @@ function ExperienceDrawer(props: { detail: DetailState; onClose: () => void; onD
);
}
-function ExperienceDetail(props: { detail: GetMemoryOutput }) {
+function ExperienceDetail(props: { detail: GetMemoryOutput; onOpenMemoryReference: OpenMemoryReference }) {
const { t } = useTranslation();
const experience = experienceFromDetail(props.detail);
@@ -349,11 +376,15 @@ function ExperienceDetail(props: { detail: GetMemoryOutput }) {
title={t("memory.policies.sourceTasks")}
ids={experience.sourceEpisodes}
empty={t("memory.policies.noSourceTasks")}
+ fallbackPage="tasks"
+ onOpen={props.onOpenMemoryReference}
/>
0 ? experience.sourceTraces : props.detail.item.sourceMemoryIds}
empty={t("memory.policies.noSourceMemories")}
+ fallbackPage="memories"
+ onOpen={props.onOpenMemoryReference}
/>
>
);
@@ -404,20 +435,22 @@ function GuidanceList(props: { title: string; entries: string[]; tone: "prefer"
);
}
-function LinkedIdsSection(props: { title: string; ids: string[]; empty: string }) {
- const uniqueIds = uniqueStrings(props.ids);
+function LinkedIdsSection(props: {
+ title: string;
+ ids: string[];
+ empty: string;
+ fallbackPage: "memories" | "tasks";
+ onOpen: OpenMemoryReference;
+}) {
+ const hasIds = props.ids.some(Boolean);
return (
{props.title}
- {uniqueIds.length === 0 ? (
+ {!hasIds ? (
{props.empty}
) : (
-
- {uniqueIds.map((id) => (
- {compactId(id)}
- ))}
-
+
)}
);
@@ -544,10 +577,6 @@ function stringArray(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
}
-function uniqueStrings(values: string[]): string[] {
- return [...new Set(values.filter(Boolean))];
-}
-
function formatNumber(value: number | undefined, digits: number): string {
return value === undefined ? "-" : value.toFixed(digits);
}
@@ -560,9 +589,3 @@ function formatDateTime(value: string | undefined): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
-
-function compactId(id: string): string {
- const parts = id.split("::");
- const value = parts[parts.length - 1] ?? id;
- return value.length > 22 ? `${value.slice(0, 18)}...` : value;
-}
diff --git a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx
index 02d764cb1..1c1a76aad 100644
--- a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx
@@ -15,6 +15,12 @@ import { MemoryDrawerDeleteAction } from "./memory-delete-action.js";
import { toMemoryDetailErrorMessage } from "./memory-detail-error.js";
import { cleanMemoryBody, cleanMemoryText, drawerEyebrow } from "./memory-display.js";
import { displayMemoryId } from "./memory-id.js";
+import {
+ MemoryReferenceTags,
+ type MemoryReferenceOpenRequest,
+ type MemoryReferencePage,
+ type OpenMemoryReference
+} from "./memory-reference-tags.js";
import {
clearMemoryPanelCache,
memoryPanelCacheKey,
@@ -64,7 +70,6 @@ interface SkillView {
decisionGuidance: SkillDecisionGuidance;
evidenceAnchors: string[];
sourcePolicyIds: string[];
- sourceWorldModelIds: string[];
eta?: number;
support?: number;
gain?: number;
@@ -76,6 +81,8 @@ interface SkillView {
export interface SkillsSubPageProps {
client: MemoryRuntimeClient | null;
+ openRequest?: MemoryReferenceOpenRequest;
+ onOpenMemoryReference: OpenMemoryReference;
}
export function loadSkillsData(client: MemoryRuntimeClient, query = ""): Promise {
@@ -259,9 +266,17 @@ export function SkillsSubPage(props: SkillsSubPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.client, t, demoEnabled]);
+ useEffect(() => {
+ if (props.openRequest) {
+ openSkill(props.openRequest.id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.openRequest?.requestId]);
+
return (
);
}
export interface SkillsSubPageViewProps {
state: RemoteData | ({ status: "ready"; data: PanelItemsOutput; detail: SkillDetailState });
+ detail?: SkillDetailState;
selectedSkillId?: string | null;
query: string;
onQueryChange: (value: string) => void;
@@ -295,6 +312,7 @@ export interface SkillsSubPageViewProps {
onOpenSkill: (skillId: string) => void;
onDeleteSkill: (id: string) => Promise;
onCloseSkill: () => void;
+ onOpenMemoryReference: OpenMemoryReference;
}
export function SkillsSubPageView(props: SkillsSubPageViewProps) {
@@ -359,9 +377,14 @@ export function SkillsSubPageView(props: SkillsSubPageViewProps) {
))}
-
>
)}
+
);
}
@@ -373,7 +396,12 @@ export function SkillsSubPageView(props: SkillsSubPageViewProps) {
* @param props.onClose The close callback.
* @returns The skill detail node.
*/
-function SkillDrawer(props: { detail: SkillDetailState; onClose: () => void; onDelete: (id: string) => Promise }) {
+function SkillDrawer(props: {
+ detail: SkillDetailState;
+ onClose: () => void;
+ onDelete: (id: string) => Promise;
+ onOpenMemoryReference: OpenMemoryReference;
+}) {
const { t } = useTranslation();
if (!props.detail) {
@@ -405,7 +433,13 @@ function SkillDrawer(props: { detail: SkillDetailState; onClose: () => void; onD
{props.detail.status === "loading" && }
{props.detail.status === "error" && }
- {props.detail.status === "ready" && }
+ {props.detail.status === "ready" && (
+
+ )}
{readyDetail && props.onDelete(readyDetail.detail.item.id)} />}
@@ -413,7 +447,11 @@ function SkillDrawer(props: { detail: SkillDetailState; onClose: () => void; onD
);
}
-function SkillDetail(props: { detail: GetMemoryOutput; timeline: SkillTimelineEntry[] }) {
+function SkillDetail(props: {
+ detail: GetMemoryOutput;
+ timeline: SkillTimelineEntry[];
+ onOpenMemoryReference: OpenMemoryReference;
+}) {
const { t } = useTranslation();
const skill = skillFromDetail(props.detail);
const hasDecisionGuidance = skill.decisionGuidance.preference.length > 0 || skill.decisionGuidance.antiPattern.length > 0;
@@ -461,9 +499,16 @@ function SkillDetail(props: { detail: GetMemoryOutput; timeline: SkillTimelineEn
title={t("memory.skills.sourceExperience")}
ids={skill.sourcePolicyIds.length > 0 ? skill.sourcePolicyIds : props.detail.item.sourceMemoryIds}
empty={t("memory.skills.noSourceExperience")}
+ fallbackPage="policies"
+ onOpen={props.onOpenMemoryReference}
+ />
+
-
-
>
);
}
@@ -499,20 +544,22 @@ function GuidanceList(props: { title: string; entries: string[]; tone: "prefer"
);
}
-function LinkedIdsSection(props: { title: string; ids: string[]; empty: string }) {
- const ids = uniqueStrings(props.ids);
+function LinkedIdsSection(props: {
+ title: string;
+ ids: string[];
+ empty: string;
+ fallbackPage: MemoryReferencePage;
+ onOpen: OpenMemoryReference;
+}) {
+ const hasIds = props.ids.some(Boolean);
return (
{props.title}
- {ids.length === 0 ? (
+ {!hasIds ? (
{props.empty}
) : (
-
- {ids.map((id) => (
- {compactId(id)}
- ))}
-
+
)}
);
@@ -709,7 +756,6 @@ function skillFromDetail(detail: GetMemoryOutput): SkillView {
decisionGuidance,
evidenceAnchors: readEvidenceAnchors(firstDefined(skill.evidenceAnchors, skill.evidence_anchors, internalInfo.evidenceAnchors, internalInfo.evidence_anchors)),
sourcePolicyIds: stringArray(firstDefined(skill.sourcePolicyIds, skill.source_policy_ids, internalInfo.sourcePolicyIds, internalInfo.source_policy_ids)),
- sourceWorldModelIds: stringArray(firstDefined(skill.sourceWorldModelIds, skill.source_world_model_ids, internalInfo.sourceWorldModelIds, internalInfo.source_world_model_ids)),
eta: numberValue(firstDefined(skill.eta, internalInfo.eta, info.eta)),
support: numberValue(firstDefined(skill.support, internalInfo.support, info.support)),
gain: numberValue(firstDefined(skill.gain, internalInfo.gain, info.gain)),
@@ -909,9 +955,3 @@ function formatDateTime(value: string | undefined): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
-
-function compactId(id: string): string {
- const parts = id.split("::");
- const value = parts[parts.length - 1] ?? id;
- return value.length > 22 ? `${value.slice(0, 18)}...` : value;
-}
diff --git a/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx b/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx
index 0f3f6a350..ee23d8af3 100644
--- a/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx
+++ b/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx
@@ -20,12 +20,14 @@ import {
writeMemoryPanelCaches
} from "./memory-panel-cache.js";
import { type MemoryPageInfo, MemoryPagination, normalizePage } from "./memory-pagination.js";
+import type { MemoryReferenceOpenRequest } from "./memory-reference-tags.js";
import { MemoryRefreshButton } from "./memory-refresh-button.js";
import { MemoryStateBox } from "./memory-state-box.js";
import { type RemoteData, toErrorMessage } from "./remote-state.js";
export interface TasksSubPageProps {
client: MemoryRuntimeClient | null;
+ openRequest?: MemoryReferenceOpenRequest;
}
export interface MemoryTasksOutput extends MemoryPageInfo {
@@ -204,6 +206,29 @@ export function TasksSubPage(props: TasksSubPageProps) {
setSelectedTask(task);
}
+ function openTaskById(id: string) {
+ if (!props.client) {
+ setState({ status: "error", message: t("memory.clientNotReady") });
+ return;
+ }
+
+ const localId = id.split("::").at(-1) ?? id;
+ void loadTasksData(props.client, localId, 1, t)
+ .then((data) => {
+ const task = data.tasks.find((item) => item.id === id || item.id.split("::").at(-1) === localId);
+ if (!task) {
+ setState({ status: "error", message: t("memory.detailUnavailable") });
+ return;
+ }
+
+ setQuery(localId);
+ setPage(1);
+ setState({ status: "ready", data });
+ openTask(task);
+ })
+ .catch((error) => setState({ status: "error", message: toErrorMessage(error) }));
+ }
+
function changePage(nextPage: number) {
const normalizedPage = normalizePage(nextPage);
if (normalizedPage === page) {
@@ -235,6 +260,13 @@ export function TasksSubPage(props: TasksSubPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.client, query, page, t, language]);
+ useEffect(() => {
+ if (props.openRequest) {
+ openTaskById(props.openRequest.id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.openRequest?.requestId]);
+
useEffect(() => {
if (!props.client) {
return undefined;
diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.interaction.test.tsx
new file mode 100644
index 000000000..867e9f92f
--- /dev/null
+++ b/App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.interaction.test.tsx
@@ -0,0 +1,32 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MemoryReferenceTags } from "../memory-reference-tags.js";
+
+describe("MemoryReferenceTags interaction", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it("passes the complete id and semantic fallback when clicked", () => {
+ const onOpen = vi.fn();
+ act(() => {
+ root.render();
+ });
+
+ act(() => container.querySelector("button")?.click());
+
+ expect(onOpen).toHaveBeenCalledWith("codex::policy_1", "policies");
+ });
+});
diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.test.tsx
new file mode 100644
index 000000000..72daa171e
--- /dev/null
+++ b/App/frontend/desktop/src/pages/memory/tests/memory-reference-tags.test.tsx
@@ -0,0 +1,34 @@
+import { renderToString } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { MemoryReferenceTags, resolveMemoryReferencePage } from "../memory-reference-tags.js";
+
+describe("memory reference tags", () => {
+ it.each([
+ ["episode_1", "tasks"],
+ ["codex::trace_1", "memories"],
+ ["memory-policy-1", "policies"],
+ ["world_model_1", "world-model"],
+ ["skill_1", "skills"]
+ ] as const)("routes %s to %s", (id, page) => {
+ expect(resolveMemoryReferencePage(id, "memories")).toBe(page);
+ });
+
+ it("uses the field meaning for an unrecognized legacy id", () => {
+ expect(resolveMemoryReferencePage("legacy_1", "policies")).toBe("policies");
+ });
+
+ it("renders a clickable tag and keeps the complete id in its title", () => {
+ const html = renderToString(
+
+ );
+
+ expect(html).toContain("
))}
-
-
-
+
+
>
)}
+
);
}
-function WorldModelDrawer(props: { detail: WorldModelDetailState; onClose: () => void; onDelete: (id: string) => Promise }) {
+function WorldModelDrawer(props: {
+ detail: WorldModelDetailState;
+ onClose: () => void;
+ onDelete: (id: string) => Promise;
+ onOpenMemoryReference: OpenMemoryReference;
+}) {
const { t } = useTranslation();
if (!props.detail) {
@@ -339,7 +369,9 @@ function WorldModelDrawer(props: { detail: WorldModelDetailState; onClose: () =>
{props.detail.status === "loading" && }
{props.detail.status === "error" && }
- {props.detail.status === "ready" && }
+ {props.detail.status === "ready" && (
+
+ )}
{readyDetail && props.onDelete(readyDetail.item.id)} />}
@@ -347,7 +379,7 @@ function WorldModelDrawer(props: { detail: WorldModelDetailState; onClose: () =>
);
}
-function WorldModelDetail(props: { detail: GetMemoryOutput }) {
+function WorldModelDetail(props: { detail: GetMemoryOutput; onOpenMemoryReference: OpenMemoryReference }) {
const { t } = useTranslation();
const worldModel = worldModelFromDetail(props.detail);
const hasStructuredCognition = worldModel.structure.environment.length > 0 ||
@@ -363,7 +395,6 @@ function WorldModelDetail(props: { detail: GetMemoryOutput }) {
-
{worldModel.source && (
@@ -375,10 +406,15 @@ function WorldModelDetail(props: { detail: GetMemoryOutput }) {
{worldModel.summary &&
}
{hasStructuredCognition
- ?
+ ?
:
}
-
-
+
>
);
}
@@ -401,7 +437,7 @@ function DetailTextSection(props: { title: string; body?: string }) {
);
}
-function StructureSection(props: { structure: WorldModelStructure }) {
+function StructureSection(props: { structure: WorldModelStructure; onOpenMemoryReference: OpenMemoryReference }) {
const { t } = useTranslation();
const sections = [
{ title: t("memory.worldModel.environmentTopology"), entries: props.structure.environment },
@@ -426,11 +462,7 @@ function StructureSection(props: { structure: WorldModelStructure }) {
{entry.label}
{entry.description ? ` - ${entry.description}` : ""}
{entry.evidenceIds.length > 0 && (
-
- {entry.evidenceIds.map((id) => (
- {compactId(id)}
- ))}
-
+
)}
))}
@@ -441,20 +473,22 @@ function StructureSection(props: { structure: WorldModelStructure }) {
);
}
-function LinkedIdsSection(props: { title: string; ids: string[]; empty: string }) {
- const ids = uniqueStrings(props.ids);
+function LinkedIdsSection(props: {
+ title: string;
+ ids: string[];
+ empty: string;
+ fallbackPage: MemoryReferencePage;
+ onOpen: OpenMemoryReference;
+}) {
+ const hasIds = props.ids.some(Boolean);
return (
{props.title}
- {ids.length === 0 ? (
+ {!hasIds ? (
{props.empty}
) : (
-
- {ids.map((id) => (
- {compactId(id)}
- ))}
-
+
)}
);
@@ -510,7 +544,6 @@ function worldModelFromDetail(detail: GetMemoryOutput): WorldModelView {
body: cleanMemoryBody(detail.item.body),
summary: cleanWorldModelText(firstString(layerWorldModel.summary, worldModel.summary, internalInfo.summary)),
policyIds: stringArray(firstDefined(worldModel.policyIds, worldModel.policy_ids, internalInfo.policyIds, internalInfo.policy_ids)),
- sourceMemoryIds: detail.item.sourceMemoryIds,
structure
};
}
@@ -651,10 +684,6 @@ function stringArray(value: unknown): string[] {
.filter((item): item is string => Boolean(item));
}
-function uniqueStrings(values: string[]): string[] {
- return [...new Set(values.filter(Boolean))];
-}
-
function formatDateTime(value: string | undefined): string {
if (!value) {
return "-";
@@ -663,9 +692,3 @@ function formatDateTime(value: string | undefined): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
-
-function compactId(id: string): string {
- const parts = id.split("::");
- const value = parts[parts.length - 1] ?? id;
- return value.length > 22 ? `${value.slice(0, 18)}...` : value;
-}
diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css
index 30688f84d..df014538e 100644
--- a/App/frontend/desktop/src/styles.css
+++ b/App/frontend/desktop/src/styles.css
@@ -4752,6 +4752,19 @@ code {
padding: 4px 8px;
}
+.memory-policy-id--link {
+ cursor: pointer;
+ transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
+}
+
+.memory-policy-id--link:hover,
+.memory-policy-id--link:focus-visible {
+ border-color: color-mix(in srgb, var(--color-action-sky) 52%, var(--color-border-stone));
+ background: color-mix(in srgb, var(--color-action-sky) 12%, var(--color-background-paper));
+ color: var(--color-text-ink);
+ outline: none;
+}
+
.memory-source-list {
display: flex;
flex-wrap: wrap;
From 9857a059543f65a162c495992bc08077231ed163 Mon Sep 17 00:00:00 2001
From: antalike <527949167@qq.com>
Date: Mon, 3 Aug 2026 19:08:12 +0800
Subject: [PATCH 11/35] feat(analytics): track desktop memory adds during
agent-source scans (#139)
Co-authored-by: antalike <>
Co-authored-by: Cursor
---
.../src/analytics/memory-add-analytics.ts | 122 ++++++++++++
.../tests/memory-add-analytics.test.ts | 94 ++++++++++
.../src/services/agent-source-scan-worker.ts | 35 ++--
.../src/services/agent-source-service.ts | 4 +-
App/backend/src/services/index.ts | 43 +++--
App/backend/src/services/ingestion-service.ts | 36 ++++
.../services/tests/ingestion-service.test.ts | 173 +++++++++++++++++-
.../desktop/src/pages/onboarding-page.tsx | 1 +
.../tests/onboarding-page-source.test.ts | 1 +
.../analytics/memory-lifecycle-analytics.ts | 2 +
10 files changed, 477 insertions(+), 34 deletions(-)
create mode 100644 App/backend/src/analytics/memory-add-analytics.ts
create mode 100644 App/backend/src/analytics/tests/memory-add-analytics.test.ts
diff --git a/App/backend/src/analytics/memory-add-analytics.ts b/App/backend/src/analytics/memory-add-analytics.ts
new file mode 100644
index 000000000..5e1c9828d
--- /dev/null
+++ b/App/backend/src/analytics/memory-add-analytics.ts
@@ -0,0 +1,122 @@
+import { createHash } from "node:crypto";
+import {
+ compactAnalyticsParams,
+ createQueuedAnalytics,
+ errorCodeFromUnknown,
+ readAnalyticsClientId,
+ type AnalyticsAppEdition,
+ type AnalyticsAppEnv,
+ type AnalyticsParams,
+} from "./analytics-transport.js";
+
+/** Matches Desktop memory lifecycle event names (`memory_desktop_*`). */
+export const MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS = {
+ addStarted: "memory_desktop_add_started",
+ addSucceeded: "memory_desktop_add_succeeded",
+ addFailed: "memory_desktop_add_failed",
+} as const;
+
+export const MEMORY_DESKTOP_ADD_ENTRYPOINT = "memmy-desktop";
+export const MEMORY_DESKTOP_ADD_STORAGE_BACKEND = "memmy-memory";
+export const MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN = "agent_source_scan";
+export const MEMORY_DESKTOP_ADD_LAYER_L1 = "L1";
+
+export type MemoryDesktopAddScanMode = "initial_subset" | "incremental" | "full";
+
+const MEMORY_ADD_ANALYTICS_SOURCE = "memmy-agent";
+
+export type MemoryDesktopAddAnalytics = {
+ trackAddStarted: (input: MemoryDesktopScanAddBaseInput) => void;
+ trackAddSucceeded: (input: MemoryDesktopScanAddBaseInput & {
+ durationMs: number;
+ storedCount: number;
+ }) => void;
+ trackAddFailed: (input: MemoryDesktopScanAddBaseInput & {
+ durationMs: number;
+ error?: unknown;
+ errorCode?: string;
+ }) => void;
+ flush: () => Promise;
+};
+
+export type MemoryDesktopScanAddBaseInput = {
+ adapterId: string;
+ /** Present for agent-source scan/import paths; omitted when unavailable. */
+ scanMode?: MemoryDesktopAddScanMode;
+ conversationId?: string | null;
+ turnId?: string | null;
+};
+
+export function hashAnalyticsId(value: string | null | undefined): string | undefined {
+ if (typeof value !== "string" || !value.trim()) return undefined;
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
+}
+
+export function buildMemoryDesktopScanAddParams(input: MemoryDesktopScanAddBaseInput): AnalyticsParams {
+ const sessionIdHash = hashAnalyticsId(input.conversationId);
+ const turnIdHash = hashAnalyticsId(input.turnId);
+ return compactAnalyticsParams({
+ entrypoint: MEMORY_DESKTOP_ADD_ENTRYPOINT,
+ adapter_id: input.adapterId,
+ storage_backend: MEMORY_DESKTOP_ADD_STORAGE_BACKEND,
+ mode: MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN,
+ layer: MEMORY_DESKTOP_ADD_LAYER_L1,
+ ...(input.scanMode ? { scan_mode: input.scanMode } : {}),
+ ...(sessionIdHash ? { session_id_hash: sessionIdHash } : {}),
+ ...(turnIdHash ? { turn_id_hash: turnIdHash } : {}),
+ });
+}
+
+export function createMemoryDesktopAddAnalytics(options: {
+ getClientId?: () => string | null | undefined;
+ getUserId?: () => string | null | undefined;
+ getUserMode?: () => string | null | undefined;
+ appEnv?: AnalyticsAppEnv | null;
+ appEdition?: AnalyticsAppEdition | null;
+ debugMode?: boolean | null;
+ fetchImpl?: typeof fetch;
+ baseUrl?: string | null;
+} = {}): MemoryDesktopAddAnalytics {
+ const queued = createQueuedAnalytics({
+ source: MEMORY_ADD_ANALYTICS_SOURCE,
+ getClientId: options.getClientId ?? (() => readAnalyticsClientId()),
+ getUserId: options.getUserId,
+ getUserMode: options.getUserMode,
+ appEnv: options.appEnv,
+ appEdition: options.appEdition,
+ debugMode: options.debugMode,
+ fetchImpl: options.fetchImpl,
+ baseUrl: options.baseUrl,
+ });
+
+ return {
+ trackAddStarted(input) {
+ queued.track(MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addStarted, buildMemoryDesktopScanAddParams(input));
+ },
+ trackAddSucceeded(input) {
+ queued.track(
+ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addSucceeded,
+ compactAnalyticsParams({
+ ...buildMemoryDesktopScanAddParams(input),
+ duration_ms: Math.max(0, Math.trunc(input.durationMs)),
+ success: true,
+ stored_count: Math.max(0, Math.trunc(input.storedCount)),
+ }),
+ );
+ },
+ trackAddFailed(input) {
+ queued.track(
+ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addFailed,
+ compactAnalyticsParams({
+ ...buildMemoryDesktopScanAddParams(input),
+ duration_ms: Math.max(0, Math.trunc(input.durationMs)),
+ success: false,
+ error_code: input.errorCode ?? errorCodeFromUnknown(input.error),
+ }),
+ );
+ },
+ flush() {
+ return queued.flush();
+ },
+ };
+}
diff --git a/App/backend/src/analytics/tests/memory-add-analytics.test.ts b/App/backend/src/analytics/tests/memory-add-analytics.test.ts
new file mode 100644
index 000000000..a3ca0872a
--- /dev/null
+++ b/App/backend/src/analytics/tests/memory-add-analytics.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS,
+ MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN,
+ buildMemoryDesktopScanAddParams,
+ createMemoryDesktopAddAnalytics,
+ hashAnalyticsId,
+} from "../memory-add-analytics.js";
+
+describe("memory-add-analytics", () => {
+ it("hashes ids and builds scan add params with agent_source_scan mode and scan_mode", () => {
+ expect(hashAnalyticsId("conv-1")).toHaveLength(16);
+ expect(buildMemoryDesktopScanAddParams({
+ adapterId: "agent-source:cursor",
+ scanMode: "initial_subset",
+ conversationId: "conv-1",
+ turnId: "cursor:abc",
+ })).toEqual({
+ entrypoint: "memmy-desktop",
+ adapter_id: "agent-source:cursor",
+ storage_backend: "memmy-memory",
+ mode: MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN,
+ scan_mode: "initial_subset",
+ layer: "L1",
+ session_id_hash: hashAnalyticsId("conv-1"),
+ turn_id_hash: hashAnalyticsId("cursor:abc"),
+ });
+ });
+
+ it("tracks started/succeeded/failed desktop add events", async () => {
+ const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
+ const analytics = createMemoryDesktopAddAnalytics({
+ getClientId: () => "client-1",
+ getUserId: () => "user-1",
+ getUserMode: () => "account",
+ appEnv: "dev",
+ appEdition: "cn",
+ debugMode: false,
+ baseUrl: "https://example.test",
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ });
+
+ analytics.trackAddStarted({
+ adapterId: "agent-source:cursor",
+ scanMode: "full",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ });
+ analytics.trackAddSucceeded({
+ adapterId: "agent-source:cursor",
+ scanMode: "full",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ durationMs: 12,
+ storedCount: 1,
+ });
+ analytics.trackAddFailed({
+ adapterId: "agent-source:cursor",
+ scanMode: "incremental",
+ conversationId: "conv-1",
+ turnId: "turn-2",
+ durationMs: 3,
+ error: new Error("boom"),
+ });
+ await analytics.flush();
+
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body));
+ const names = body.events.map((event: { eventName: string }) => event.eventName);
+ expect(names).toEqual([
+ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addStarted,
+ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addSucceeded,
+ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addFailed,
+ ]);
+ expect(body.events[0]?.params).toMatchObject({
+ mode: MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN,
+ scan_mode: "full",
+ adapter_id: "agent-source:cursor",
+ source: "memmy-agent",
+ });
+ expect(body.events[1]?.params).toMatchObject({
+ success: true,
+ stored_count: 1,
+ duration_ms: 12,
+ scan_mode: "full",
+ });
+ expect(body.events[2]?.params).toMatchObject({
+ success: false,
+ error_code: "boom",
+ duration_ms: 3,
+ scan_mode: "incremental",
+ });
+ });
+});
diff --git a/App/backend/src/services/agent-source-scan-worker.ts b/App/backend/src/services/agent-source-scan-worker.ts
index 2182c9ebe..0af7f2556 100644
--- a/App/backend/src/services/agent-source-scan-worker.ts
+++ b/App/backend/src/services/agent-source-scan-worker.ts
@@ -12,6 +12,7 @@ import {
createAgentSourceLifecycleAnalytics,
resolveLoggedInAnalyticsUserId,
} from "../analytics/agent-source-analytics.js";
+import { createMemoryDesktopAddAnalytics } from "../analytics/memory-add-analytics.js";
import { createAgentSourceService } from "./agent-source-service.js";
import { createBuiltinAgentSourceRegistry } from "./builtin-agent-source-registry.js";
import { createIngestionService } from "./ingestion-service.js";
@@ -84,12 +85,28 @@ async function runWorker(): Promise {
function createAgentSources(appStateStore: AppStateStore, memoryClient: MemoryClient) {
const sourceRegistry = createBuiltinAgentSourceRegistry();
+ const accountSessionRepository = appStateStore.repositories.accountSession;
+ const resolveAnalyticsUserId = () => {
+ const session = accountSessionRepository.get();
+ if (!session.authenticated) return null;
+ return resolveLoggedInAnalyticsUserId({
+ cloudUuid: accountSessionRepository.getCloudUuid(),
+ userId: session.profile.userId,
+ });
+ };
+ const resolveAnalyticsUserMode = () => {
+ const mode = appStateStore.repositories.bootstrap.getAppSettings().userMode;
+ return mode === "account" || mode === "byok" ? mode : null;
+ };
const ingestionService = createIngestionService({
memoryClient,
- agentSourceRepository: appStateStore.repositories.agentSources
+ agentSourceRepository: appStateStore.repositories.agentSources,
+ memoryAddAnalytics: createMemoryDesktopAddAnalytics({
+ getUserId: resolveAnalyticsUserId,
+ getUserMode: resolveAnalyticsUserMode,
+ }),
});
- const accountSessionRepository = appStateStore.repositories.accountSession;
return createAgentSourceService({
sourceRegistry,
agentSourceRepository: appStateStore.repositories.agentSources,
@@ -97,18 +114,8 @@ function createAgentSources(appStateStore: AppStateStore, memoryClient: MemoryCl
memoryClient,
skillDistributionService: createUnavailableSkillDistributionService(),
agentSourceAnalytics: createAgentSourceLifecycleAnalytics({
- getUserId: () => {
- const session = accountSessionRepository.get();
- if (!session.authenticated) return null;
- return resolveLoggedInAnalyticsUserId({
- cloudUuid: accountSessionRepository.getCloudUuid(),
- userId: session.profile.userId,
- });
- },
- getUserMode: () => {
- const mode = appStateStore.repositories.bootstrap.getAppSettings().userMode;
- return mode === "account" || mode === "byok" ? mode : null;
- },
+ getUserId: resolveAnalyticsUserId,
+ getUserMode: resolveAnalyticsUserMode,
}),
});
}
diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts
index bbdf29273..278dd5fb3 100644
--- a/App/backend/src/services/agent-source-service.ts
+++ b/App/backend/src/services/agent-source-service.ts
@@ -205,7 +205,8 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption
sourceId,
memorySource: source.displayName,
deferProcessing: true,
- totalMessages: messages.length
+ totalMessages: messages.length,
+ scanMode: input.mode
});
const processingFailures = await processPendingImportSummaries(options, stats.memoryIds, {
progressSourceId: sourceId
@@ -644,6 +645,7 @@ async function ingestCollectedSource(
signal: scanOptions.signal,
deferProcessing: true,
totalMessages: ingestMessages.length,
+ scanMode: collected.scanMode ?? scanOptions.mode,
onProgress(progress) {
emitProgress(scanOptions, {
sourceId: progress.sourceId,
diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts
index 901795569..36f36c876 100644
--- a/App/backend/src/services/index.ts
+++ b/App/backend/src/services/index.ts
@@ -24,6 +24,7 @@ import {
createAgentSourceLifecycleAnalytics,
resolveLoggedInAnalyticsUserId,
} from "../analytics/agent-source-analytics.js";
+import { createMemoryDesktopAddAnalytics } from "../analytics/memory-add-analytics.js";
import { createAgentSourceService, type AgentSourceService } from "./agent-source-service.js";
import { createAgentSourceAutoInjectService, type AgentSourceAutoInjectService } from "./agent-source-auto-inject-service.js";
import { createBuiltinAgentSourceRegistry } from "./builtin-agent-source-registry.js";
@@ -114,12 +115,6 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
const sourceRegistry =
options.sourceRegistry ??
createBuiltinAgentSourceRegistry();
- const ingestionService =
- options.ingestionService ??
- createIngestionService({
- memoryClient: options.memoryClient,
- agentSourceRepository: options.appStateStore.repositories.agentSources
- });
const skillTargetRegistry =
options.skillTargetRegistry ??
createSkillTargetRegistry([
@@ -141,6 +136,28 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret });
const memmyConfigWriter = options.memmyConfigWriter ?? createUnavailableMemmyConfigWriter();
const accountSessionRepository = options.appStateStore.repositories.accountSession;
+ const resolveAnalyticsUserId = () => {
+ const session = accountSessionRepository.get();
+ if (!session.authenticated) return null;
+ return resolveLoggedInAnalyticsUserId({
+ cloudUuid: accountSessionRepository.getCloudUuid(),
+ userId: session.profile.userId,
+ });
+ };
+ const resolveAnalyticsUserMode = () => {
+ const mode = options.appStateStore.repositories.bootstrap.getAppSettings().userMode;
+ return mode === "account" || mode === "byok" ? mode : null;
+ };
+ const ingestionService =
+ options.ingestionService ??
+ createIngestionService({
+ memoryClient: options.memoryClient,
+ agentSourceRepository: options.appStateStore.repositories.agentSources,
+ memoryAddAnalytics: createMemoryDesktopAddAnalytics({
+ getUserId: resolveAnalyticsUserId,
+ getUserMode: resolveAnalyticsUserMode,
+ }),
+ });
const agentSources = createAgentSourceService({
sourceRegistry,
agentSourceRepository: options.appStateStore.repositories.agentSources,
@@ -149,18 +166,8 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
skillDistributionService,
getScanPermission: () => options.permissionManager.getScanPermission(),
agentSourceAnalytics: createAgentSourceLifecycleAnalytics({
- getUserId: () => {
- const session = accountSessionRepository.get();
- if (!session.authenticated) return null;
- return resolveLoggedInAnalyticsUserId({
- cloudUuid: accountSessionRepository.getCloudUuid(),
- userId: session.profile.userId,
- });
- },
- getUserMode: () => {
- const mode = options.appStateStore.repositories.bootstrap.getAppSettings().userMode;
- return mode === "account" || mode === "byok" ? mode : null;
- },
+ getUserId: resolveAnalyticsUserId,
+ getUserMode: resolveAnalyticsUserMode,
}),
});
diff --git a/App/backend/src/services/ingestion-service.ts b/App/backend/src/services/ingestion-service.ts
index a9db8d956..c49e88904 100644
--- a/App/backend/src/services/ingestion-service.ts
+++ b/App/backend/src/services/ingestion-service.ts
@@ -3,6 +3,10 @@ import { createHash } from "node:crypto";
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
import type { ConversationMessage } from "../adapters/outbound/agent-source/types.js";
import type { MemoryClient } from "../adapters/outbound/memory-client/index.js";
+import type {
+ MemoryDesktopAddAnalytics,
+ MemoryDesktopAddScanMode
+} from "../analytics/memory-add-analytics.js";
import type { AgentSourceRepository } from "../infrastructure/agent-source-store/index.js";
const INGESTION_TURN_YIELD_INTERVAL = 50;
@@ -29,6 +33,7 @@ export interface IngestionContext {
signal?: AbortSignal;
deferProcessing?: boolean;
totalMessages?: number;
+ scanMode?: MemoryDesktopAddScanMode;
onProgress?: (progress: IngestionProgress) => void;
}
@@ -60,6 +65,10 @@ export interface IngestionStats {
export interface CreateIngestionServiceOptions {
memoryClient: Pick;
agentSourceRepository: Pick;
+ memoryAddAnalytics?: Pick<
+ MemoryDesktopAddAnalytics,
+ "trackAddStarted" | "trackAddSucceeded" | "trackAddFailed"
+ >;
warn?: (warning: IngestionWarning) => void;
}
@@ -204,6 +213,19 @@ async function processConversation(
const dedupKeys = turn.messages.map((message) => createDedupKey(ctx.sourceId, message.messageId));
const allSeen = dedupKeys.every((dedupKey) => options.agentSourceRepository.hasSeen(dedupKey));
+ // Skip analytics for already-seen turns: addMemory still runs for idempotent replay,
+ // but those calls do not create new memories and would flood scan telemetry.
+ const shouldTrackAddAnalytics = !allSeen;
+ const addAnalyticsBase = {
+ adapterId: request.adapterId,
+ conversationId: turn.conversationId,
+ turnId: request.turnId,
+ ...(ctx.scanMode ? { scanMode: ctx.scanMode } : {})
+ };
+ if (shouldTrackAddAnalytics) {
+ options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase);
+ }
+ const addStartedAt = Date.now();
try {
const added = await options.memoryClient.addMemory(request);
@@ -215,6 +237,13 @@ async function processConversation(
stats.writtenMemories += 1;
}
stats.memoryIds.push(added.id);
+ if (shouldTrackAddAnalytics) {
+ options.memoryAddAnalytics?.trackAddSucceeded({
+ ...addAnalyticsBase,
+ durationMs: Date.now() - addStartedAt,
+ storedCount: 1
+ });
+ }
for (const dedupKey of dedupKeys) {
options.agentSourceRepository.markSeen(dedupKey, ctx.sourceId);
@@ -228,6 +257,13 @@ async function processConversation(
conversationId: turn.conversationId,
reason: error instanceof Error ? error.message : "ingestion failed"
});
+ if (shouldTrackAddAnalytics) {
+ options.memoryAddAnalytics?.trackAddFailed({
+ ...addAnalyticsBase,
+ durationMs: Date.now() - addStartedAt,
+ error
+ });
+ }
emitIngestionProgress(ctx, stats);
}
}
diff --git a/App/backend/src/services/tests/ingestion-service.test.ts b/App/backend/src/services/tests/ingestion-service.test.ts
index 12e6c3741..947a49966 100644
--- a/App/backend/src/services/tests/ingestion-service.test.ts
+++ b/App/backend/src/services/tests/ingestion-service.test.ts
@@ -540,12 +540,182 @@ describe("ingestion service", () => {
)
).rejects.toBeInstanceOf(IngestionAssertionError);
});
+
+ it("emits memory_desktop add analytics for each new addMemory call", async () => {
+ const events: Array<{ name: string; payload: Record }> = [];
+ const service = createService(
+ {},
+ {},
+ undefined,
+ {
+ trackAddStarted(input) {
+ events.push({ name: "started", payload: { ...input } });
+ },
+ trackAddSucceeded(input) {
+ events.push({ name: "succeeded", payload: { ...input } });
+ },
+ trackAddFailed(input) {
+ events.push({ name: "failed", payload: { ...input } });
+ }
+ }
+ );
+
+ await service.ingest(
+ toAsyncIterable([
+ createMessage("conv-a", 1),
+ createMessage("conv-a", 2),
+ createMessage("conv-b", 3),
+ createMessage("conv-b", 4)
+ ]),
+ { sourceId: "cursor", scanMode: "initial_subset" }
+ );
+
+ expect(events.map((event) => event.name)).toEqual(["started", "succeeded", "started", "succeeded"]);
+ expect(events[0]?.payload).toMatchObject({
+ adapterId: "agent-source:cursor",
+ conversationId: "conv-a",
+ scanMode: "initial_subset"
+ });
+ expect(events[1]?.payload).toMatchObject({
+ adapterId: "agent-source:cursor",
+ conversationId: "conv-a",
+ scanMode: "initial_subset",
+ storedCount: 1
+ });
+ expect(typeof events[0]?.payload.turnId).toBe("string");
+ expect(typeof events[1]?.payload.durationMs).toBe("number");
+ });
+
+ it("forwards scanMode into add analytics payloads", async () => {
+ const events: Array<{ name: string; payload: Record }> = [];
+ const service = createService(
+ {},
+ {},
+ undefined,
+ {
+ trackAddStarted(input) {
+ events.push({ name: "started", payload: { ...input } });
+ },
+ trackAddSucceeded(input) {
+ events.push({ name: "succeeded", payload: { ...input } });
+ },
+ trackAddFailed(input) {
+ events.push({ name: "failed", payload: { ...input } });
+ }
+ }
+ );
+
+ await service.ingest(
+ toAsyncIterable([createMessage("conv-a", 1), createMessage("conv-a", 2)]),
+ { sourceId: "cursor", scanMode: "full" }
+ );
+
+ expect(events).toHaveLength(2);
+ expect(events[0]?.payload).toMatchObject({
+ adapterId: "agent-source:cursor",
+ scanMode: "full"
+ });
+ expect(events[1]?.payload).toMatchObject({
+ scanMode: "full",
+ storedCount: 1
+ });
+ });
+
+ it("skips memory_desktop add analytics for already-seen turns", async () => {
+ const events: Array<{ name: string; payload: Record }> = [];
+ const calls: string[] = [];
+ const service = createService(
+ {
+ async addMemory() {
+ calls.push("add");
+ return {
+ id: "memory-existing",
+ kind: "trace",
+ memoryLayer: "L1",
+ status: "activated",
+ title: "Existing memory",
+ summary: "Existing memory",
+ tags: [],
+ createdAt: now(),
+ serverTime: now()
+ };
+ }
+ },
+ {
+ hasSeen: () => true
+ },
+ undefined,
+ {
+ trackAddStarted(input) {
+ events.push({ name: "started", payload: { ...input } });
+ },
+ trackAddSucceeded(input) {
+ events.push({ name: "succeeded", payload: { ...input } });
+ },
+ trackAddFailed(input) {
+ events.push({ name: "failed", payload: { ...input } });
+ }
+ }
+ );
+
+ const stats = await service.ingest(
+ toAsyncIterable([createMessage("conv-a", 1), createMessage("conv-a", 2)]),
+ { sourceId: "cursor" }
+ );
+
+ expect(calls).toEqual(["add"]);
+ expect(stats.dedupedMemories).toBe(1);
+ expect(events).toEqual([]);
+ });
+
+ it("emits add_failed analytics when addMemory throws", async () => {
+ const events: Array<{ name: string; payload: Record }> = [];
+ const service = createService(
+ {
+ async addMemory() {
+ throw new Error("write failed");
+ }
+ },
+ {},
+ undefined,
+ {
+ trackAddStarted(input) {
+ events.push({ name: "started", payload: { ...input } });
+ },
+ trackAddSucceeded(input) {
+ events.push({ name: "succeeded", payload: { ...input } });
+ },
+ trackAddFailed(input) {
+ events.push({ name: "failed", payload: { ...input } });
+ }
+ }
+ );
+
+ const stats = await service.ingest(
+ toAsyncIterable([createMessage("conv-a", 1), createMessage("conv-a", 2)]),
+ { sourceId: "cursor", scanMode: "incremental" }
+ );
+
+ expect(stats.failedMemories).toBe(1);
+ expect(events.map((event) => event.name)).toEqual(["started", "failed"]);
+ expect(events[1]?.payload).toMatchObject({
+ adapterId: "agent-source:cursor",
+ conversationId: "conv-a",
+ scanMode: "incremental"
+ });
+ expect(events[1]?.payload.error).toBeInstanceOf(Error);
+ });
});
function createService(
memoryClientPatch: Partial,
repositoryPatch: Partial = {},
- warn?: (warning: IngestionWarning) => void
+ warn?: (warning: IngestionWarning) => void,
+ memoryAddAnalytics?: {
+ trackAddStarted: (input: Record) => void;
+ trackAddSucceeded: (input: Record) => void;
+ trackAddFailed: (input: Record) => void;
+ }
): IngestionService {
return createIngestionService({
memoryClient: {
@@ -556,6 +726,7 @@ function createService(
...createRepository(),
...repositoryPatch
},
+ memoryAddAnalytics: memoryAddAnalytics as never,
warn
});
}
diff --git a/App/frontend/desktop/src/pages/onboarding-page.tsx b/App/frontend/desktop/src/pages/onboarding-page.tsx
index 52c03b3f8..6abab9c31 100644
--- a/App/frontend/desktop/src/pages/onboarding-page.tsx
+++ b/App/frontend/desktop/src/pages/onboarding-page.tsx
@@ -341,6 +341,7 @@ export function OnboardingPage() {
await startAgentSourceScan({
clients,
dispatch,
+ mode: "initial_subset",
queuedMessage: t("memory.scanQueued"),
formatError: (error) => formatAgentSourceScanRequestError(error, undefined, t),
scheduleFallback: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
diff --git a/App/frontend/desktop/src/pages/tests/onboarding-page-source.test.ts b/App/frontend/desktop/src/pages/tests/onboarding-page-source.test.ts
index d1ab17e3f..813220000 100644
--- a/App/frontend/desktop/src/pages/tests/onboarding-page-source.test.ts
+++ b/App/frontend/desktop/src/pages/tests/onboarding-page-source.test.ts
@@ -115,6 +115,7 @@ describe("OnboardingPage source", () => {
expect(source).toContain("const activeFirstScanStep = guidanceCompleted ? null : (firstScanStep ?? resumedFirstScanStep);");
expect(source).toContain("const guidanceCompleted = readGuidanceCompleted(");
expect(source).toContain("startAgentSourceScan({");
+ expect(source).toContain('mode: "initial_subset"');
expect(source).toContain(".updateOnboarding(patch)");
expect(source).toContain("startFirstReport([]);");
expect(source).toContain("void startFirstScanInBackground().catch((error)");
diff --git a/App/memmy-agent/src/analytics/memory-lifecycle-analytics.ts b/App/memmy-agent/src/analytics/memory-lifecycle-analytics.ts
index 0200779da..d5e46e699 100644
--- a/App/memmy-agent/src/analytics/memory-lifecycle-analytics.ts
+++ b/App/memmy-agent/src/analytics/memory-lifecycle-analytics.ts
@@ -25,6 +25,8 @@ export const MEMORY_OP_MODES = {
turnStart: "turn_start",
tool: "tool",
turnComplete: "turn_complete",
+ /** Agent-source scan ingestion via memory add (Desktop local backend). */
+ agentSourceScan: "agent_source_scan",
} as const;
export type MemoryOpMode = (typeof MEMORY_OP_MODES)[keyof typeof MEMORY_OP_MODES];
From ca31449f448b37cff7f415dde61ba9a22aad2385 Mon Sep 17 00:00:00 2001
From: Hustzdy <67457465+wustzdy@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:47:16 +0800
Subject: [PATCH 12/35] feat: merge pkg && optimize pkg (#140)
* feat: merge pkg shell
* feat: merge pkg shell
* feat: merge pkg shell
* feat: merge pkg shell
---
.../desktop/electron-builder.unsigned.yml | 6 +-
.../desktop/electron-builder.win.unsigned.yml | 6 +-
App/shell/desktop/electron-builder.win.yml | 6 +-
App/shell/desktop/electron-builder.yml | 6 +-
.../tests/packaged-runtime-boundary.test.ts | 103 +++++-----
package-lock.json | 1 +
package.json | 24 ++-
scripts/auto-release-mac.sh | 4 +-
scripts/internal/package-mac-dmg.sh | 38 ++--
scripts/package-mac-arm64-cn-signed.sh | 8 -
scripts/package-mac-arm64-cn-unsigned.sh | 8 -
scripts/package-mac-arm64-intl-signed.sh | 8 -
scripts/package-mac-arm64-intl-unsigned.sh | 8 -
scripts/package-mac-x64-cn-signed.sh | 8 -
scripts/package-mac-x64-cn-unsigned.sh | 8 -
scripts/package-mac-x64-intl-signed.sh | 8 -
scripts/package-mac-x64-intl-unsigned.sh | 8 -
scripts/package-mac.sh | 194 ++++++++++++++++++
scripts/package-win-x64-cn-signed.sh | 9 -
scripts/package-win-x64-cn-unsigned.sh | 9 -
scripts/package-win-x64-intl-signed.sh | 9 -
scripts/package-win-x64-intl-unsigned.sh | 9 -
scripts/package-win.sh | 165 +++++++++++++++
23 files changed, 466 insertions(+), 187 deletions(-)
delete mode 100755 scripts/package-mac-arm64-cn-signed.sh
delete mode 100755 scripts/package-mac-arm64-cn-unsigned.sh
delete mode 100755 scripts/package-mac-arm64-intl-signed.sh
delete mode 100755 scripts/package-mac-arm64-intl-unsigned.sh
delete mode 100755 scripts/package-mac-x64-cn-signed.sh
delete mode 100755 scripts/package-mac-x64-cn-unsigned.sh
delete mode 100755 scripts/package-mac-x64-intl-signed.sh
delete mode 100755 scripts/package-mac-x64-intl-unsigned.sh
create mode 100755 scripts/package-mac.sh
delete mode 100755 scripts/package-win-x64-cn-signed.sh
delete mode 100755 scripts/package-win-x64-cn-unsigned.sh
delete mode 100755 scripts/package-win-x64-intl-signed.sh
delete mode 100755 scripts/package-win-x64-intl-unsigned.sh
create mode 100755 scripts/package-win.sh
diff --git a/App/shell/desktop/electron-builder.unsigned.yml b/App/shell/desktop/electron-builder.unsigned.yml
index 5c26ab431..4c4b462f7 100644
--- a/App/shell/desktop/electron-builder.unsigned.yml
+++ b/App/shell/desktop/electron-builder.unsigned.yml
@@ -9,8 +9,10 @@ directories:
files:
- dist/**/*
- package.json
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
- "!**/node_modules/**/*.{test,spec}.*"
- "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
diff --git a/App/shell/desktop/electron-builder.win.unsigned.yml b/App/shell/desktop/electron-builder.win.unsigned.yml
index 3b57c9297..b26011147 100644
--- a/App/shell/desktop/electron-builder.win.unsigned.yml
+++ b/App/shell/desktop/electron-builder.win.unsigned.yml
@@ -9,8 +9,10 @@ directories:
files:
- dist/**/*
- package.json
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
- "!**/node_modules/**/*.{test,spec}.*"
- "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
diff --git a/App/shell/desktop/electron-builder.win.yml b/App/shell/desktop/electron-builder.win.yml
index 453e1d5ec..f526ba473 100644
--- a/App/shell/desktop/electron-builder.win.yml
+++ b/App/shell/desktop/electron-builder.win.yml
@@ -9,8 +9,10 @@ directories:
files:
- dist/**/*
- package.json
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
- "!**/node_modules/**/*.{test,spec}.*"
- "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
diff --git a/App/shell/desktop/electron-builder.yml b/App/shell/desktop/electron-builder.yml
index ca761e1fb..3cae82bf0 100644
--- a/App/shell/desktop/electron-builder.yml
+++ b/App/shell/desktop/electron-builder.yml
@@ -9,8 +9,10 @@ directories:
files:
- dist/**/*
- package.json
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
- - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"
+ - "!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"
- "!**/node_modules/**/*.{test,spec}.*"
- "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts
index d5bc8e9bc..980278868 100644
--- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts
+++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts
@@ -9,23 +9,13 @@ const runtimeServicesPath = fileURLToPath(new URL("../src/main/runtime-services.
const devStartPath = fileURLToPath(new URL("../../../../scripts/dev-start.sh", import.meta.url));
const devMemorySupervisorPath = fileURLToPath(new URL("../../../../scripts/internal/dev-memory-supervisor.mjs", import.meta.url));
const clearAllPath = fileURLToPath(new URL("../../../../scripts/clear-all.sh", import.meta.url));
+const packageMacPath = fileURLToPath(new URL("../../../../scripts/package-mac.sh", import.meta.url));
const packageMacDmgPath = fileURLToPath(new URL("../../../../scripts/internal/package-mac-dmg.sh", import.meta.url));
const signedMacArm64PackagePath = fileURLToPath(
new URL("../../../../scripts/internal/package-mac-arm64-signed-base.sh", import.meta.url)
);
+const packageWinPath = fileURLToPath(new URL("../../../../scripts/package-win.sh", import.meta.url));
const packageWinX64Path = fileURLToPath(new URL("../../../../scripts/internal/package-win-x64.sh", import.meta.url));
-const winX64CnUnsignedPackagePath = fileURLToPath(
- new URL("../../../../scripts/package-win-x64-cn-unsigned.sh", import.meta.url)
-);
-const winX64CnSignedPackagePath = fileURLToPath(
- new URL("../../../../scripts/package-win-x64-cn-signed.sh", import.meta.url)
-);
-const winX64IntlUnsignedPackagePath = fileURLToPath(
- new URL("../../../../scripts/package-win-x64-intl-unsigned.sh", import.meta.url)
-);
-const winX64IntlSignedPackagePath = fileURLToPath(
- new URL("../../../../scripts/package-win-x64-intl-signed.sh", import.meta.url)
-);
const winUnsignedBuilderPath = fileURLToPath(new URL("../electron-builder.win.unsigned.yml", import.meta.url));
const winUnsignedInstallerIncludePath = fileURLToPath(new URL("../build/installer-win-unsigned.nsh", import.meta.url));
const desktopInterfacePath = fileURLToPath(new URL("../interface/src/index.ts", import.meta.url));
@@ -234,7 +224,7 @@ describe("desktop packaged runtime boundaries", () => {
}
});
- it("excludes dependency tests and docs from every desktop app archive", () => {
+ it("excludes dependency root tests and docs from every desktop app archive", () => {
for (const configPath of [
electronBuilderPath,
unsignedElectronBuilderPath,
@@ -247,12 +237,15 @@ describe("desktop packaged runtime boundaries", () => {
const files = config.files ?? [];
expect(files).toContain("dist/**/*");
- expect(files).toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}");
- expect(files).toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*");
+ expect(files).toContain("!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}");
+ expect(files).toContain("!**/node_modules/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*");
+ expect(files).toContain("!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}");
+ expect(files).toContain("!**/node_modules/@*/*/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*");
expect(files).toContain("!**/node_modules/**/*.{test,spec}.*");
expect(files).toContain(
"!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}"
);
+ expect(files).not.toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}");
expect(files).not.toContain("!**/node_modules/**/*.md");
}
});
@@ -987,24 +980,26 @@ describe("desktop packaged runtime boundaries", () => {
expect(source).not.toContain("npm run package:mac -- --arm64");
});
- it("builds Windows x64 editions through one shared packaging script", () => {
- const wrappers = [
- [readFileSync(winX64CnUnsignedPackagePath, "utf8"), "phone", "cn", true],
- [readFileSync(winX64CnSignedPackagePath, "utf8"), "phone", "cn", false],
- [readFileSync(winX64IntlUnsignedPackagePath, "utf8"), "email", "intl", true],
- [readFileSync(winX64IntlSignedPackagePath, "utf8"), "email", "intl", false]
- ] as const;
-
- for (const [source, accountChannel, edition, unsigned] of wrappers) {
- expect(source).toContain(`export MEMMY_ACCOUNT_CHANNEL=${accountChannel}`);
- expect(source).toContain(`export MEMMY_APP_EDITION=${edition}`);
- expect(source).toContain('scripts/internal/package-win-x64.sh');
- if (unsigned) {
- expect(source).toContain("export MEMMY_SKIP_CODESIGN=1");
- } else {
- expect(source).toContain("unset MEMMY_SKIP_CODESIGN");
- }
- }
+ it("routes Windows x64 package variants through one public win entrypoint", () => {
+ const packageWinSource = readFileSync(packageWinPath, "utf8");
+ const rootPackage = readJson(rootPackagePath);
+ const scripts = rootPackage.scripts ?? {};
+
+ expect(packageWinSource).toContain("Usage: package-win.sh --version --arch --edition --sign ");
+ expect(packageWinSource).toContain("--version is required. Example: --version 0.0.1");
+ expect(packageWinSource).toContain('export MEMMY_DESKTOP_VERSION="$VERSION"');
+ expect(packageWinSource).toContain("export MEMMY_ACCOUNT_CHANNEL=phone");
+ expect(packageWinSource).toContain("export MEMMY_ACCOUNT_CHANNEL=email");
+ expect(packageWinSource).toContain("export MEMMY_SKIP_CODESIGN=1");
+ expect(packageWinSource).toContain("unset MEMMY_SKIP_CODESIGN");
+ expect(packageWinSource).toContain('scripts/internal/package-win-x64.sh');
+
+ expect(scripts["package:win:x64"]).toBe("bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign signed");
+ expect(scripts["package:win:x64:unsigned"]).toBe("bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign unsigned");
+ expect(scripts["package:win:x64:cn:signed"]).toBe("bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign signed");
+ expect(scripts["package:win:x64:cn:unsigned"]).toBe("bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign unsigned");
+ expect(scripts["package:win:x64:intl:signed"]).toBe("bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition intl --sign signed");
+ expect(scripts["package:win:x64:intl:unsigned"]).toBe("bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition intl --sign unsigned");
});
it("validates the bundled browser runtime during Windows packaging", () => {
@@ -1047,8 +1042,10 @@ describe("desktop packaged runtime boundaries", () => {
expect(source).toContain("prune_node_modules_non_runtime_files");
expect(source).toContain('prune_node_modules_non_runtime_files "$RUNTIME_DIR"');
- expect(source).toContain("-name tests");
- expect(source).toContain("-name docs");
+ expect(source).toContain('"$package_dir/tests"');
+ expect(source).toContain('"$package_dir/docs"');
+ expect(source).not.toContain("-name docs");
+ expect(source).not.toContain("-name doc");
expect(source).toContain('-iname "README*.md"');
expect(source).toContain('-iname "README*.mdown"');
expect(source).toContain('-iname "CHANGELOG*.md"');
@@ -1065,19 +1062,29 @@ describe("desktop packaged runtime boundaries", () => {
);
});
- it("sets an explicit edition in macOS package wrappers", () => {
- for (const [name, accountChannel, edition] of [
- ["cn-unsigned", "phone", "cn"],
- ["cn-signed", "phone", "cn"],
- ["intl-unsigned", "email", "intl"],
- ["intl-signed", "email", "intl"]
- ] as const) {
- const path = fileURLToPath(new URL(`../../../../scripts/package-mac-arm64-${name}.sh`, import.meta.url));
- const source = readFileSync(path, "utf8");
-
- expect(source).toContain(`export MEMMY_ACCOUNT_CHANNEL=${accountChannel}`);
- expect(source).toContain(`export MEMMY_APP_EDITION=${edition}`);
- }
+ it("routes macOS package variants through one public mac entrypoint", () => {
+ const packageMacSource = readFileSync(packageMacPath, "utf8");
+ const rootPackage = readJson(rootPackagePath);
+ const scripts = rootPackage.scripts ?? {};
+
+ expect(packageMacSource).toContain("Usage: package-mac.sh --version --arch --edition --sign ");
+ expect(packageMacSource).toContain("--version is required. Example: --version 0.0.1");
+ expect(packageMacSource).toContain('export MEMMY_DESKTOP_VERSION="$VERSION"');
+ expect(packageMacSource).toContain("export MEMMY_ACCOUNT_CHANNEL=phone");
+ expect(packageMacSource).toContain("export MEMMY_ACCOUNT_CHANNEL=email");
+ expect(packageMacSource).toContain("export MEMMY_SKIP_CODESIGN=1");
+ expect(packageMacSource).toContain("unset MEMMY_SKIP_CODESIGN");
+ expect(packageMacSource).toContain('BASE_SCRIPT="$ROOT_DIR/scripts/internal/package-mac-$ARCH-$SIGN-base.sh"');
+ expect(packageMacSource).toContain('bash "$BASE_SCRIPT" "${PASSTHROUGH_ARGS[@]}"');
+
+ expect(scripts["package:mac:arm64:cn:signed"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition cn --sign signed");
+ expect(scripts["package:mac:arm64:cn:unsigned"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition cn --sign unsigned");
+ expect(scripts["package:mac:arm64:intl:signed"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition intl --sign signed");
+ expect(scripts["package:mac:arm64:intl:unsigned"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition intl --sign unsigned");
+ expect(scripts["package:mac:x64:cn:signed"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition cn --sign signed");
+ expect(scripts["package:mac:x64:cn:unsigned"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition cn --sign unsigned");
+ expect(scripts["package:mac:x64:intl:signed"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition intl --sign signed");
+ expect(scripts["package:mac:x64:intl:unsigned"]).toBe("bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition intl --sign unsigned");
});
it("supports Windows signing through PFX files and SimplySign certificate store thumbprints", () => {
diff --git a/package-lock.json b/package-lock.json
index 5f53867b2..e53ec1f11 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -226,6 +226,7 @@
"dependencies": {
"@huggingface/transformers": "^3.8.0",
"better-sqlite3": "^12.6.3",
+ "dotenv": "^16.6.1",
"sqlite-vec": "0.1.9",
"yaml": "^2.9.0"
},
diff --git a/package.json b/package.json
index 1ffb4cfa2..6d84f13aa 100644
--- a/package.json
+++ b/package.json
@@ -31,16 +31,20 @@
"serve:dev": "npm run memory:serve:dev",
"package:mac": "bash scripts/internal/package-mac-dmg.sh",
"package:mac:unsigned": "MEMMY_SKIP_CODESIGN=1 bash scripts/internal/package-mac-dmg.sh",
- "package:mac:x64:cn:unsigned": "bash scripts/package-mac-x64-cn-unsigned.sh",
- "package:mac:x64:cn:signed": "bash scripts/package-mac-x64-cn-signed.sh",
- "package:mac:x64:intl:unsigned": "bash scripts/package-mac-x64-intl-unsigned.sh",
- "package:mac:x64:intl:signed": "bash scripts/package-mac-x64-intl-signed.sh",
- "package:win:x64": "bash scripts/internal/package-win-x64.sh",
- "package:win:x64:unsigned": "MEMMY_SKIP_CODESIGN=1 bash scripts/internal/package-win-x64.sh",
- "package:win:x64:cn:unsigned": "bash scripts/package-win-x64-cn-unsigned.sh",
- "package:win:x64:cn:signed": "bash scripts/package-win-x64-cn-signed.sh",
- "package:win:x64:intl:unsigned": "bash scripts/package-win-x64-intl-unsigned.sh",
- "package:win:x64:intl:signed": "bash scripts/package-win-x64-intl-signed.sh",
+ "package:mac:arm64:cn:unsigned": "bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition cn --sign unsigned",
+ "package:mac:arm64:cn:signed": "bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition cn --sign signed",
+ "package:mac:arm64:intl:unsigned": "bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition intl --sign unsigned",
+ "package:mac:arm64:intl:signed": "bash scripts/package-mac.sh --version $npm_package_version --arch arm64 --edition intl --sign signed",
+ "package:mac:x64:cn:unsigned": "bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition cn --sign unsigned",
+ "package:mac:x64:cn:signed": "bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition cn --sign signed",
+ "package:mac:x64:intl:unsigned": "bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition intl --sign unsigned",
+ "package:mac:x64:intl:signed": "bash scripts/package-mac.sh --version $npm_package_version --arch x64 --edition intl --sign signed",
+ "package:win:x64": "bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign signed",
+ "package:win:x64:unsigned": "bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign unsigned",
+ "package:win:x64:cn:unsigned": "bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign unsigned",
+ "package:win:x64:cn:signed": "bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition cn --sign signed",
+ "package:win:x64:intl:unsigned": "bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition intl --sign unsigned",
+ "package:win:x64:intl:signed": "bash scripts/package-win.sh --version $npm_package_version --arch x64 --edition intl --sign signed",
"worker:run": "npm run memory:worker:run",
"memory:build": "npm run build -w @memmy/memory",
"memory:package": "npm run package:npm -w @memmy/memory",
diff --git a/scripts/auto-release-mac.sh b/scripts/auto-release-mac.sh
index 07b734501..6642c1f80 100644
--- a/scripts/auto-release-mac.sh
+++ b/scripts/auto-release-mac.sh
@@ -147,7 +147,7 @@ upload_pkg() {
CURRENT_STEP="Build and upload Mac domestic signed package"
log "$CURRENT_STEP"
set_cloud_service "$CN_CLOUD_SERVICE"
-bash scripts/package-mac-arm64-cn-signed.sh
+bash scripts/package-mac.sh --version "$NEW_VERSION" --arch arm64 --edition cn --sign signed
upload_pkg "$RELEASE_DIR/Memmy-$NEW_VERSION-darwin-arm64-cn-signed.dmg" "darwin-arm64-cn-signed"
# ============================================================
@@ -156,7 +156,7 @@ upload_pkg "$RELEASE_DIR/Memmy-$NEW_VERSION-darwin-arm64-cn-signed.dmg" "darwin-
CURRENT_STEP="Build and upload Mac international signed package"
log "$CURRENT_STEP"
set_cloud_service "$INTL_CLOUD_SERVICE"
-bash scripts/package-mac-arm64-intl-signed.sh
+bash scripts/package-mac.sh --version "$NEW_VERSION" --arch arm64 --edition intl --sign signed
upload_pkg "$RELEASE_DIR/Memmy-$NEW_VERSION-darwin-arm64-intl-signed.dmg" "darwin-arm64-intl-signed"
# ============================================================
diff --git a/scripts/internal/package-mac-dmg.sh b/scripts/internal/package-mac-dmg.sh
index 82dbc5462..435afb3bd 100755
--- a/scripts/internal/package-mac-dmg.sh
+++ b/scripts/internal/package-mac-dmg.sh
@@ -444,25 +444,25 @@ prune_node_modules_non_runtime_files() {
continue
fi
- local disposable_list
- disposable_list="$(mktemp)"
- find "$modules_dir" -depth -type d \( \
- -name test -o \
- -name tests -o \
- -name __tests__ -o \
- -name doc -o \
- -name docs -o \
- -name example -o \
- -name examples -o \
- -name coverage -o \
- -name .github \
- \) > "$disposable_list"
-
- local disposable_dir
- while IFS= read -r disposable_dir; do
- rm -rf "$disposable_dir"
- done < "$disposable_list"
- rm -f "$disposable_list"
+ local package_dir disposable_dir
+ for package_dir in "$modules_dir"/* "$modules_dir"/@*/*; do
+ if [ ! -d "$package_dir" ]; then
+ continue
+ fi
+
+ for disposable_dir in \
+ "$package_dir/test" \
+ "$package_dir/tests" \
+ "$package_dir/__tests__" \
+ "$package_dir/doc" \
+ "$package_dir/docs" \
+ "$package_dir/example" \
+ "$package_dir/examples" \
+ "$package_dir/coverage" \
+ "$package_dir/.github"; do
+ rm -rf "$disposable_dir"
+ done
+ done
if [ ! -d "$modules_dir" ]; then
continue
diff --git a/scripts/package-mac-arm64-cn-signed.sh b/scripts/package-mac-arm64-cn-signed.sh
deleted file mode 100755
index 50f21a7aa..000000000
--- a/scripts/package-mac-arm64-cn-signed.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=phone
-export MEMMY_APP_EDITION=cn
-bash "$ROOT_DIR/scripts/internal/package-mac-arm64-signed-base.sh" "$@"
diff --git a/scripts/package-mac-arm64-cn-unsigned.sh b/scripts/package-mac-arm64-cn-unsigned.sh
deleted file mode 100755
index 646e5f6a2..000000000
--- a/scripts/package-mac-arm64-cn-unsigned.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=phone
-export MEMMY_APP_EDITION=cn
-bash "$ROOT_DIR/scripts/internal/package-mac-arm64-unsigned-base.sh" "$@"
diff --git a/scripts/package-mac-arm64-intl-signed.sh b/scripts/package-mac-arm64-intl-signed.sh
deleted file mode 100755
index 5469da096..000000000
--- a/scripts/package-mac-arm64-intl-signed.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=email
-export MEMMY_APP_EDITION=intl
-bash "$ROOT_DIR/scripts/internal/package-mac-arm64-signed-base.sh" "$@"
diff --git a/scripts/package-mac-arm64-intl-unsigned.sh b/scripts/package-mac-arm64-intl-unsigned.sh
deleted file mode 100755
index 6b16d31d5..000000000
--- a/scripts/package-mac-arm64-intl-unsigned.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=email
-export MEMMY_APP_EDITION=intl
-bash "$ROOT_DIR/scripts/internal/package-mac-arm64-unsigned-base.sh" "$@"
diff --git a/scripts/package-mac-x64-cn-signed.sh b/scripts/package-mac-x64-cn-signed.sh
deleted file mode 100755
index f3f7af827..000000000
--- a/scripts/package-mac-x64-cn-signed.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=phone
-export MEMMY_APP_EDITION=cn
-bash "$ROOT_DIR/scripts/internal/package-mac-x64-signed-base.sh" "$@"
diff --git a/scripts/package-mac-x64-cn-unsigned.sh b/scripts/package-mac-x64-cn-unsigned.sh
deleted file mode 100755
index e87c7c5cc..000000000
--- a/scripts/package-mac-x64-cn-unsigned.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=phone
-export MEMMY_APP_EDITION=cn
-bash "$ROOT_DIR/scripts/internal/package-mac-x64-unsigned-base.sh" "$@"
diff --git a/scripts/package-mac-x64-intl-signed.sh b/scripts/package-mac-x64-intl-signed.sh
deleted file mode 100755
index 6a83b692c..000000000
--- a/scripts/package-mac-x64-intl-signed.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=email
-export MEMMY_APP_EDITION=intl
-bash "$ROOT_DIR/scripts/internal/package-mac-x64-signed-base.sh" "$@"
diff --git a/scripts/package-mac-x64-intl-unsigned.sh b/scripts/package-mac-x64-intl-unsigned.sh
deleted file mode 100755
index 91e36d536..000000000
--- a/scripts/package-mac-x64-intl-unsigned.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=email
-export MEMMY_APP_EDITION=intl
-bash "$ROOT_DIR/scripts/internal/package-mac-x64-unsigned-base.sh" "$@"
diff --git a/scripts/package-mac.sh b/scripts/package-mac.sh
new file mode 100755
index 000000000..2c58b9b26
--- /dev/null
+++ b/scripts/package-mac.sh
@@ -0,0 +1,194 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+ARCH=""
+VERSION=""
+EDITION="cn"
+SIGN="unsigned"
+PASSTHROUGH_ARGS=()
+
+usage() {
+ cat <<'USAGE'
+Usage: package-mac.sh --version --arch --edition --sign [electron-builder args...]
+
+Examples:
+ bash scripts/package-mac.sh --version 0.0.1 --arch arm64 --edition cn --sign signed
+ bash scripts/package-mac.sh --version 0.0.1 --arch arm64 --edition intl --sign unsigned
+ bash scripts/package-mac.sh --version 0.0.1 --arch x64 --edition cn --sign signed
+
+Defaults:
+ --arch current machine arch
+ --edition cn
+ --sign unsigned
+
+Required:
+ --version package version, for example 0.0.1
+USAGE
+}
+
+infer_arch() {
+ case "$(uname -m)" in
+ arm64|aarch64)
+ printf '%s\n' "arm64"
+ ;;
+ x86_64|amd64)
+ printf '%s\n' "x64"
+ ;;
+ *)
+ echo "Cannot infer macOS package arch from uname -m. Pass --arch arm64 or --arch x64." >&2
+ exit 1
+ ;;
+ esac
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --version)
+ if [ "$#" -lt 2 ]; then
+ echo "--version requires a version value" >&2
+ exit 1
+ fi
+ VERSION="$2"
+ shift 2
+ ;;
+ --version=*)
+ VERSION="${1#--version=}"
+ shift
+ ;;
+ --arch)
+ if [ "$#" -lt 2 ]; then
+ echo "--arch requires arm64 or x64" >&2
+ exit 1
+ fi
+ ARCH="$2"
+ shift 2
+ ;;
+ --arch=*)
+ ARCH="${1#--arch=}"
+ shift
+ ;;
+ --arm64|arm64)
+ ARCH="arm64"
+ shift
+ ;;
+ --x64|x64)
+ ARCH="x64"
+ shift
+ ;;
+ --edition)
+ if [ "$#" -lt 2 ]; then
+ echo "--edition requires cn or intl" >&2
+ exit 1
+ fi
+ EDITION="$2"
+ shift 2
+ ;;
+ --edition=*)
+ EDITION="${1#--edition=}"
+ shift
+ ;;
+ --cn|cn)
+ EDITION="cn"
+ shift
+ ;;
+ --intl|intl)
+ EDITION="intl"
+ shift
+ ;;
+ --sign|--signing)
+ if [ "$#" -lt 2 ]; then
+ echo "--sign requires signed or unsigned" >&2
+ exit 1
+ fi
+ SIGN="$2"
+ shift 2
+ ;;
+ --sign=*|--signing=*)
+ SIGN="${1#*=}"
+ shift
+ ;;
+ --signed|signed)
+ SIGN="signed"
+ shift
+ ;;
+ --unsigned|unsigned)
+ SIGN="unsigned"
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ PASSTHROUGH_ARGS+=("$@")
+ break
+ ;;
+ *)
+ PASSTHROUGH_ARGS+=("$1")
+ shift
+ ;;
+ esac
+done
+
+if [ -z "$VERSION" ]; then
+ echo "--version is required. Example: --version 0.0.1" >&2
+ usage >&2
+ exit 1
+fi
+
+if [ -z "$ARCH" ]; then
+ ARCH="$(infer_arch)"
+fi
+
+case "$ARCH" in
+ arm64|x64)
+ ;;
+ *)
+ echo "Unsupported macOS package arch: $ARCH" >&2
+ exit 1
+ ;;
+esac
+
+case "$EDITION" in
+ cn)
+ export MEMMY_ACCOUNT_CHANNEL=phone
+ export MEMMY_APP_EDITION=cn
+ ;;
+ intl)
+ export MEMMY_ACCOUNT_CHANNEL=email
+ export MEMMY_APP_EDITION=intl
+ ;;
+ *)
+ echo "Unsupported macOS package edition: $EDITION" >&2
+ exit 1
+ ;;
+esac
+
+case "$SIGN" in
+ signed)
+ unset MEMMY_SKIP_CODESIGN
+ ;;
+ unsigned)
+ export MEMMY_SKIP_CODESIGN=1
+ ;;
+ *)
+ echo "Unsupported macOS signing mode: $SIGN" >&2
+ exit 1
+ ;;
+esac
+
+BASE_SCRIPT="$ROOT_DIR/scripts/internal/package-mac-$ARCH-$SIGN-base.sh"
+if [ ! -f "$BASE_SCRIPT" ]; then
+ echo "Missing macOS package base script: $BASE_SCRIPT" >&2
+ exit 1
+fi
+
+export MEMMY_DESKTOP_VERSION="$VERSION"
+if [ "${#PASSTHROUGH_ARGS[@]}" -gt 0 ]; then
+ bash "$BASE_SCRIPT" "${PASSTHROUGH_ARGS[@]}"
+else
+ bash "$BASE_SCRIPT"
+fi
diff --git a/scripts/package-win-x64-cn-signed.sh b/scripts/package-win-x64-cn-signed.sh
deleted file mode 100755
index 7e38100a7..000000000
--- a/scripts/package-win-x64-cn-signed.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=phone
-export MEMMY_APP_EDITION=cn
-unset MEMMY_SKIP_CODESIGN
-bash "$ROOT_DIR/scripts/internal/package-win-x64.sh" "$@"
diff --git a/scripts/package-win-x64-cn-unsigned.sh b/scripts/package-win-x64-cn-unsigned.sh
deleted file mode 100755
index 3d027e98e..000000000
--- a/scripts/package-win-x64-cn-unsigned.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=phone
-export MEMMY_APP_EDITION=cn
-export MEMMY_SKIP_CODESIGN=1
-bash "$ROOT_DIR/scripts/internal/package-win-x64.sh" "$@"
diff --git a/scripts/package-win-x64-intl-signed.sh b/scripts/package-win-x64-intl-signed.sh
deleted file mode 100755
index e646cc78c..000000000
--- a/scripts/package-win-x64-intl-signed.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=email
-export MEMMY_APP_EDITION=intl
-unset MEMMY_SKIP_CODESIGN
-bash "$ROOT_DIR/scripts/internal/package-win-x64.sh" "$@"
diff --git a/scripts/package-win-x64-intl-unsigned.sh b/scripts/package-win-x64-intl-unsigned.sh
deleted file mode 100755
index 38e5848ff..000000000
--- a/scripts/package-win-x64-intl-unsigned.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-export MEMMY_ACCOUNT_CHANNEL=email
-export MEMMY_APP_EDITION=intl
-export MEMMY_SKIP_CODESIGN=1
-bash "$ROOT_DIR/scripts/internal/package-win-x64.sh" "$@"
diff --git a/scripts/package-win.sh b/scripts/package-win.sh
new file mode 100755
index 000000000..cb564a392
--- /dev/null
+++ b/scripts/package-win.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+ARCH="x64"
+VERSION=""
+EDITION="cn"
+SIGN="unsigned"
+PASSTHROUGH_ARGS=()
+
+usage() {
+ cat <<'USAGE'
+Usage: package-win.sh --version --arch --edition --sign [electron-builder args...]
+
+Examples:
+ bash scripts/package-win.sh --version 0.0.1 --arch x64 --edition cn --sign signed
+ bash scripts/package-win.sh --version 0.0.1 --arch x64 --edition intl --sign unsigned
+ bash scripts/package-win.sh --version 0.0.1 --edition cn --sign signed
+
+Defaults:
+ --arch x64
+ --edition cn
+ --sign unsigned
+
+Required:
+ --version package version, for example 0.0.1
+USAGE
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --version)
+ if [ "$#" -lt 2 ]; then
+ echo "--version requires a version value" >&2
+ exit 1
+ fi
+ VERSION="$2"
+ shift 2
+ ;;
+ --version=*)
+ VERSION="${1#--version=}"
+ shift
+ ;;
+ --arch)
+ if [ "$#" -lt 2 ]; then
+ echo "--arch requires x64" >&2
+ exit 1
+ fi
+ ARCH="$2"
+ shift 2
+ ;;
+ --arch=*)
+ ARCH="${1#--arch=}"
+ shift
+ ;;
+ --x64|x64)
+ ARCH="x64"
+ shift
+ ;;
+ --edition)
+ if [ "$#" -lt 2 ]; then
+ echo "--edition requires cn or intl" >&2
+ exit 1
+ fi
+ EDITION="$2"
+ shift 2
+ ;;
+ --edition=*)
+ EDITION="${1#--edition=}"
+ shift
+ ;;
+ --cn|cn)
+ EDITION="cn"
+ shift
+ ;;
+ --intl|intl)
+ EDITION="intl"
+ shift
+ ;;
+ --sign|--signing)
+ if [ "$#" -lt 2 ]; then
+ echo "--sign requires signed or unsigned" >&2
+ exit 1
+ fi
+ SIGN="$2"
+ shift 2
+ ;;
+ --sign=*|--signing=*)
+ SIGN="${1#*=}"
+ shift
+ ;;
+ --signed|signed)
+ SIGN="signed"
+ shift
+ ;;
+ --unsigned|unsigned)
+ SIGN="unsigned"
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ PASSTHROUGH_ARGS+=("$@")
+ break
+ ;;
+ *)
+ PASSTHROUGH_ARGS+=("$1")
+ shift
+ ;;
+ esac
+done
+
+if [ -z "$VERSION" ]; then
+ echo "--version is required. Example: --version 0.0.1" >&2
+ usage >&2
+ exit 1
+fi
+
+case "$ARCH" in
+ x64)
+ ;;
+ *)
+ echo "Unsupported Windows package arch: $ARCH" >&2
+ exit 1
+ ;;
+esac
+
+case "$EDITION" in
+ cn)
+ export MEMMY_ACCOUNT_CHANNEL=phone
+ export MEMMY_APP_EDITION=cn
+ ;;
+ intl)
+ export MEMMY_ACCOUNT_CHANNEL=email
+ export MEMMY_APP_EDITION=intl
+ ;;
+ *)
+ echo "Unsupported Windows package edition: $EDITION" >&2
+ exit 1
+ ;;
+esac
+
+case "$SIGN" in
+ signed)
+ unset MEMMY_SKIP_CODESIGN
+ ;;
+ unsigned)
+ export MEMMY_SKIP_CODESIGN=1
+ ;;
+ *)
+ echo "Unsupported Windows signing mode: $SIGN" >&2
+ exit 1
+ ;;
+esac
+
+export MEMMY_DESKTOP_VERSION="$VERSION"
+if [ "${#PASSTHROUGH_ARGS[@]}" -gt 0 ]; then
+ bash "$ROOT_DIR/scripts/internal/package-win-x64.sh" "${PASSTHROUGH_ARGS[@]}"
+else
+ bash "$ROOT_DIR/scripts/internal/package-win-x64.sh"
+fi
From 91e881d8984e104a4eaaac6964ff1e99d5f4897f Mon Sep 17 00:00:00 2001
From: shsfcx
Date: Mon, 3 Aug 2026 20:20:05 +0800
Subject: [PATCH 13/35] feat: tighten onboarding activation with cross-agent
relay and product tour
Ship the first-chat relay/opt-in cards, memory verification loop, and a
memory-first product tour without local mock switches or test-only scaffolding.
Co-authored-by: Cursor
---
.../services/onboarding-insight-service.ts | 44 ++-
.../desktop/src/analytics/analytics-events.ts | 18 ++
.../desktop/src/app/product-tour-layout.ts | 251 ++++++++++++++++--
App/frontend/desktop/src/app/product-tour.tsx | 169 +++++++++---
App/frontend/desktop/src/app/router.tsx | 101 ++++++-
App/frontend/desktop/src/i18n/messages.ts | 129 ++++++---
App/frontend/desktop/src/pages/app-frame.tsx | 66 +----
.../pages/first-encounter-relay-challenge.tsx | 98 +++++--
.../src/pages/first-encounter-report.tsx | 168 ++++++------
App/frontend/desktop/src/pages/home-page.tsx | 87 +++++-
.../desktop/src/pages/memory-page.tsx | 26 +-
.../desktop/src/pages/memory-sources-page.tsx | 29 +-
.../src/pages/memory/logs-sub-page.tsx | 64 +++--
.../src/pages/memory/overview-sub-page.tsx | 7 +-
.../desktop/src/pages/onboarding-page.tsx | 232 ++++++++++++----
docs/cn/memory/sources.mdx | 5 +-
docs/en/memory/sources.mdx | 5 +-
17 files changed, 1106 insertions(+), 393 deletions(-)
diff --git a/App/backend/src/services/onboarding-insight-service.ts b/App/backend/src/services/onboarding-insight-service.ts
index 20ee42040..467aeb69c 100644
--- a/App/backend/src/services/onboarding-insight-service.ts
+++ b/App/backend/src/services/onboarding-insight-service.ts
@@ -751,13 +751,11 @@ function renderFallbackReport(profile: OnboardingInsightProfileSignals, locale:
function renderEmptyHistoryReport(locale: "zh-CN" | "en-US"): string {
return locale === "en-US" ? [
- "There are no records on this device that Memmy can read yet. From now on, though, Memmy will keep capturing the experience, decisions, and context that emerge from your conversations with Agents. The next time you start a new conversation or switch Agents, Memmy can inject the relevant memories directly, so you do not have to explain the background all over again.",
- "That includes project naming conventions, your preferred implementation style, pitfalls you have already encountered, and the root cause uncovered by a debugging session—things that recur in daily work but should not need to be explained repeatedly. They will become reusable long-term memory.",
- "If you switch between Agents such as Cursor and Codex, Memmy can also connect the context scattered across them. What moves is not merely a chat log, but a working task state that can be continued. Starting with this conversation, Memmy is officially on the job."
+ "There is no readable Agent history on this device yet, so there is nothing useful to pretend I already know.",
+ "Tell Memmy about one real task. It will preserve the useful background, decisions, and next step so a new conversation—or another Agent such as Cursor or Codex—can continue without making you explain it again."
].join("\n\n") : [
- "这台设备上还没有 Memmy 可以读取的记录,不过从现在开始,你和 Agent 对话中产生的经验、决策和上下文,Memmy 会帮你持续沉淀下来。下一次开新对话或者切换 Agent 时,Memmy 可以直接注入相关记忆,不用你每次重新解释背景。",
- "比如项目里的命名约定、你偏好的实现方式、某个问题踩过的坑、一次排查最终定位到的原因——这些在日常工作中反复出现却不该反复解释的东西,之后都会变成可复用的长期记忆。",
- "如果你在 Cursor、Codex 等不同 Agent 之间切换工作,Memmy 也能把分散的上下文串起来——迁移的不是聊天记录,而是可以继续执行的任务现场。从这次对话开始,Memmy 就正式上班了。"
+ "这台设备上还没有可读取的 Agent 历史,所以我不会假装已经了解你。",
+ "先告诉 Memmy 一件你正在做的真实任务。它会记住有用的背景、决策和下一步;之后新开对话,或换到 Cursor、Codex,也不用再从头解释。"
].join("\n\n");
}
@@ -1096,8 +1094,8 @@ function buildAction(
if (type === "cross_agent_synthesis") {
return {
type,
- buttonLabel: "Alright, pull it together",
- description: agents.length > 1 ? `Merge related threads from ${agents.join(", ")}` : "Merge recent related threads",
+ buttonLabel: "Recover and merge this task",
+ description: agents.length > 1 ? `Recover one task across ${agents.join(", ")}` : "Recover the task context and unfinished work",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1108,8 +1106,8 @@ function buildAction(
if (type === "problem_diagnosis") {
return {
type,
- buttonLabel: "Continue debugging",
- description: "Pick up the recent error, build, or debugging context",
+ buttonLabel: "Recall how this was debugged",
+ description: "Recap what was tried and continue from the last useful result",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1120,8 +1118,8 @@ function buildAction(
if (type === "decision_doc") {
return {
type,
- buttonLabel: "Summarize the decisions",
- description: "Turn recent tradeoffs into a clean decision record",
+ buttonLabel: "Recover the key decisions",
+ description: "Turn previous options and tradeoffs into a usable decision record",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1131,8 +1129,8 @@ function buildAction(
return {
type: "continue_task",
- buttonLabel: "Continue this task",
- description: "Pick up the current work from recent conversations",
+ buttonLabel: "Continue the unfinished task",
+ description: "Recover the latest state and take the next concrete step",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1143,8 +1141,8 @@ function buildAction(
if (type === "cross_agent_synthesis") {
return {
type,
- buttonLabel: "好,帮我整合",
- description: agents.length > 1 ? `整合 ${agents.join("、")} 中的相关讨论` : "整合最近的相关讨论",
+ buttonLabel: "找回并合并这项任务",
+ description: agents.length > 1 ? `找回 ${agents.join("、")} 里的同一项任务` : "找回任务背景、结论和未完成项",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1155,8 +1153,8 @@ function buildAction(
if (type === "problem_diagnosis") {
return {
type,
- buttonLabel: "继续排查问题",
- description: "接续最近的报错、构建或调试上下文",
+ buttonLabel: "复盘上次怎么解决",
+ description: "找回已尝试的方法和最后一个有效结果",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1167,8 +1165,8 @@ function buildAction(
if (type === "decision_doc") {
return {
type,
- buttonLabel: "整理技术决策",
- description: "把最近讨论过的方案和取舍整理成决策记录",
+ buttonLabel: "找回之前的关键决策",
+ description: "把讨论过的方案、取舍和结论整理成记录",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -1178,8 +1176,8 @@ function buildAction(
return {
type: "continue_task",
- buttonLabel: "继续这个任务",
- description: "基于最近对话接续当前工作",
+ buttonLabel: "继续最近未完成的任务",
+ description: "找回最近进度并执行一个明确的下一步",
contextSummary,
relatedAgents: agents,
topicKeywords: keywords,
@@ -2105,7 +2103,7 @@ function isActionCopyParagraph(paragraph: string): boolean {
/^(主按钮|主要按钮|次级按钮|备选按钮|也可以|其他选项|可选项|行动按钮|按钮文案)\b/.test(normalized) ||
/\b(main button|also available|button label|keep moving)\b/.test(normalized) ||
/(?:主按钮|次级按钮|按钮文案)/.test(normalized) ||
- /^(好,帮我整合|继续这个任务|整理技术决策)\s*[::-]?\s*$/.test(normalized);
+ /^(好,帮我整合|继续这个任务|整理技术决策|找回并合并这项任务|继续最近未完成的任务|找回之前的关键决策|复盘上次怎么解决)\s*[::-]?\s*$/.test(normalized);
}
function chatCompletionsUrl(baseUrl: string): string {
diff --git a/App/frontend/desktop/src/analytics/analytics-events.ts b/App/frontend/desktop/src/analytics/analytics-events.ts
index cd5795d81..70a6c2300 100644
--- a/App/frontend/desktop/src/analytics/analytics-events.ts
+++ b/App/frontend/desktop/src/analytics/analytics-events.ts
@@ -89,6 +89,23 @@ export interface OnboardingCompletedEvent {
consentTier: "basic";
}
+export interface OnboardingActivationEvent {
+ name:
+ | "onboarding_report_viewed"
+ | "onboarding_report_action_clicked"
+ | "onboarding_first_task_completed"
+ | "onboarding_relay_clicked"
+ | "onboarding_external_memory_verified";
+ params: {
+ page_path: string;
+ action?: string;
+ source_id?: string;
+ empty_history?: boolean;
+ duration_ms?: number;
+ };
+ consentTier: "basic";
+}
+
export interface FirstEntryEvent {
name: "first_entry";
params: { page_location: string };
@@ -185,6 +202,7 @@ export type AnalyticsEvent =
| ByokCompletedEvent
| OnboardingStepCompletedEvent
| OnboardingCompletedEvent
+ | OnboardingActivationEvent
| TokenUsageSnapshotEvent
| ImprovementLogEvent
| MemoryUiAnalyticsEvent;
diff --git a/App/frontend/desktop/src/app/product-tour-layout.ts b/App/frontend/desktop/src/app/product-tour-layout.ts
index ee8abf53e..8b455c4d7 100644
--- a/App/frontend/desktop/src/app/product-tour-layout.ts
+++ b/App/frontend/desktop/src/app/product-tour-layout.ts
@@ -4,12 +4,41 @@ import type { CSSProperties } from "react";
/** Definition for product tour memory nav anchor. */
export const PRODUCT_TOUR_MEMORY_NAV_ANCHOR = "product-tour-memory-nav";
+/** Definition for the main Agent workspace anchor. */
+export const PRODUCT_TOUR_CHAT_CONTENT_ANCHOR = "product-tour-chat-content";
+
/** Definition for product tour tools nav anchor. */
export const PRODUCT_TOUR_TOOLS_NAV_ANCHOR = "product-tour-tools-nav";
/** Definition for product tour tools content anchor. */
export const PRODUCT_TOUR_TOOLS_CONTENT_ANCHOR = "product-tour-tools-content";
+/** Memory call-log list highlighted during onboarding feature dig. */
+export const PRODUCT_TOUR_MEMORY_LOGS_LIST_ANCHOR = "product-tour-memory-logs-list";
+
+/** Logs item in the memory sidebar. */
+export const PRODUCT_TOUR_MEMORY_LOGS_NAV_ANCHOR = "product-tour-memory-logs-nav";
+
+/** Overview item in the memory sidebar. */
+export const PRODUCT_TOUR_MEMORY_OVERVIEW_NAV_ANCHOR = "product-tour-memory-overview-nav";
+
+/** Cross-agent sources item in the memory sidebar. */
+export const PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR = "product-tour-memory-sources-nav";
+
+/** Discovered-agent list on the cross-agent sources page. */
+export const PRODUCT_TOUR_MEMORY_AGENTS_LIST_ANCHOR = "product-tour-memory-agents-list";
+
+/** Scan-behavior preferences block on the cross-agent sources page. */
+export const PRODUCT_TOUR_MEMORY_SCAN_PREFERENCES_ANCHOR = "product-tour-memory-scan-preferences";
+
+/** Four-layer memory count cards on the overview page. */
+export const PRODUCT_TOUR_MEMORY_OVERVIEW_COUNTS_ANCHOR = "product-tour-memory-overview-counts";
+
+/** Approximate bubble box used for collision checks (matches `w-72` card + mascot headroom). */
+const PRODUCT_TOUR_BUBBLE_WIDTH = 288;
+const PRODUCT_TOUR_BUBBLE_HEIGHT = 200;
+const PRODUCT_TOUR_VIEWPORT_PADDING = 16;
+
/** Contract for product tour rect. */
export interface ProductTourRect {
top: number;
@@ -49,6 +78,14 @@ export interface ProductTourRightBubblePlacement {
gap: number;
}
+/** Contract for product tour below bubble placement. */
+export interface ProductTourBelowBubblePlacement {
+ anchorId: string;
+ side: "below";
+ align: "start" | "center";
+ gap: number;
+}
+
/** Contract for product tour inside bubble placement. */
export interface ProductTourInsideBubblePlacement {
anchorId: string;
@@ -60,7 +97,13 @@ export interface ProductTourInsideBubblePlacement {
}
/** Type definition for product tour bubble placement. */
-export type ProductTourBubblePlacement = ProductTourRightBubblePlacement | ProductTourInsideBubblePlacement;
+export type ProductTourBubblePlacement =
+ | ProductTourRightBubblePlacement
+ | ProductTourBelowBubblePlacement
+ | ProductTourInsideBubblePlacement;
+
+/** Arrow direction resolved with the bubble. */
+export type ProductTourArrowDirection = "left" | "right" | "top" | "bottom";
/** Contract for product tour anchor lookup. */
export interface ProductTourAnchorLookup {
@@ -73,6 +116,7 @@ export interface ProductTourResolvedLayout {
highlight: ProductTourHighlightStyle;
extraHighlights: ProductTourHighlightStyle[];
bubblePosition: CSSProperties;
+ arrow: ProductTourArrowDirection;
}
/** Handles resolve product tour step layout. */
@@ -83,12 +127,18 @@ export function resolveProductTourStepLayout(
extraHighlights?: readonly ProductTourHighlightSpec[]
): ProductTourResolvedLayout | null {
const highlightRect = lookup.getAnchorRect(highlight.anchorId);
- const bubbleRect = lookup.getAnchorRect(bubble.anchorId);
- if (!highlightRect || !bubbleRect) {
+ const bubbleAnchorRect = lookup.getAnchorRect(bubble.anchorId);
+ if (!highlightRect || !bubbleAnchorRect) {
return null;
}
const viewport = lookup.getViewport();
+ const resolvedHighlight = toHighlightStyle(highlightRect, highlight, viewport);
+ const avoidRect =
+ highlight.anchorId === bubble.anchorId
+ ? null
+ : toNumericHighlightRect(highlightRect, highlight, viewport);
+
const resolvedExtras: ProductTourHighlightStyle[] = [];
for (const extra of extraHighlights ?? []) {
const rect = lookup.getAnchorRect(extra.anchorId);
@@ -97,10 +147,13 @@ export function resolveProductTourStepLayout(
}
}
+ const placed = resolveProductTourBubblePlacement(bubble, bubbleAnchorRect, viewport, avoidRect);
+
return {
- highlight: toHighlightStyle(highlightRect, highlight, viewport),
+ highlight: resolvedHighlight,
extraHighlights: resolvedExtras,
- bubblePosition: resolveProductTourBubblePlacement(bubble, bubbleRect, viewport)
+ bubblePosition: placed.style,
+ arrow: placed.arrow
};
}
@@ -125,34 +178,172 @@ export function createDomProductTourAnchorLookup(ownerDocument: Document): Produ
function resolveProductTourBubblePlacement(
bubble: ProductTourBubblePlacement,
anchorRect: ProductTourRect,
- viewport: ProductTourViewport
-): CSSProperties {
+ viewport: ProductTourViewport,
+ avoidRect: ProductTourRect | null
+): { style: CSSProperties; arrow: ProductTourArrowDirection } {
if (bubble.side === "inside") {
const offsetX = bubble.offsetX ?? 0;
const offsetY = bubble.offsetY ?? 0;
return {
- [bubble.blockAlign === "start" ? "top" : "bottom"]:
- `${bubble.blockAlign === "start" ? anchorRect.top + offsetY : viewport.height - anchorRect.bottom - offsetY}px`,
- [bubble.inlineAlign === "start" ? "left" : "right"]:
- `${bubble.inlineAlign === "start" ? anchorRect.left + offsetX : viewport.width - anchorRect.right + offsetX}px`
+ // Bubble on the right edge points left into the content; on the left edge points right.
+ arrow: bubble.inlineAlign === "end" ? "left" : "right",
+ style: {
+ [bubble.blockAlign === "start" ? "top" : "bottom"]:
+ `${bubble.blockAlign === "start" ? anchorRect.top + offsetY : viewport.height - anchorRect.bottom - offsetY}px`,
+ [bubble.inlineAlign === "start" ? "left" : "right"]:
+ `${bubble.inlineAlign === "start" ? anchorRect.left + offsetX : viewport.width - anchorRect.right + offsetX}px`
+ }
+ };
+ }
+
+ if (bubble.side === "below") {
+ const left = bubble.align === "center"
+ ? clamp(
+ anchorRect.left + anchorRect.width / 2 - PRODUCT_TOUR_BUBBLE_WIDTH / 2,
+ PRODUCT_TOUR_VIEWPORT_PADDING,
+ Math.max(PRODUCT_TOUR_VIEWPORT_PADDING, viewport.width - PRODUCT_TOUR_BUBBLE_WIDTH - PRODUCT_TOUR_VIEWPORT_PADDING)
+ )
+ : clamp(
+ anchorRect.left,
+ PRODUCT_TOUR_VIEWPORT_PADDING,
+ Math.max(PRODUCT_TOUR_VIEWPORT_PADDING, viewport.width - PRODUCT_TOUR_BUBBLE_WIDTH - PRODUCT_TOUR_VIEWPORT_PADDING)
+ );
+ const top = clamp(
+ anchorRect.bottom + bubble.gap,
+ PRODUCT_TOUR_VIEWPORT_PADDING,
+ Math.max(PRODUCT_TOUR_VIEWPORT_PADDING, viewport.height - PRODUCT_TOUR_BUBBLE_HEIGHT - PRODUCT_TOUR_VIEWPORT_PADDING)
+ );
+ return {
+ arrow: "top",
+ style: {
+ top: `${top}px`,
+ left: `${left}px`
+ }
};
}
- if (bubble.align === "center") {
- const centerY = anchorRect.top + anchorRect.height / 2;
+ const preferredLeft = anchorRect.right + bubble.gap;
+ const preferredTop =
+ bubble.align === "center"
+ ? anchorRect.top + anchorRect.height / 2 - PRODUCT_TOUR_BUBBLE_HEIGHT / 2
+ : anchorRect.top;
+ const preferred: ProductTourRect = {
+ left: preferredLeft,
+ top: preferredTop,
+ width: PRODUCT_TOUR_BUBBLE_WIDTH,
+ height: PRODUCT_TOUR_BUBBLE_HEIGHT,
+ right: preferredLeft + PRODUCT_TOUR_BUBBLE_WIDTH,
+ bottom: preferredTop + PRODUCT_TOUR_BUBBLE_HEIGHT
+ };
+
+ if (!avoidRect || !rectsOverlap(preferred, avoidRect)) {
+ if (bubble.align === "center") {
+ return {
+ arrow: "left",
+ style: {
+ top: `${anchorRect.top + anchorRect.height / 2}px`,
+ left: `${preferredLeft}px`,
+ transform: "translateY(-50%)"
+ }
+ };
+ }
return {
- top: `${centerY}px`,
- left: `${anchorRect.right + bubble.gap}px`,
- transform: "translateY(-50%)"
+ arrow: "left",
+ style: {
+ top: `${anchorRect.top}px`,
+ left: `${preferredLeft}px`
+ }
};
}
+ const gap = bubble.gap;
+ const leftNearAnchor = clamp(
+ Math.max(preferredLeft, avoidRect.left),
+ PRODUCT_TOUR_VIEWPORT_PADDING,
+ Math.max(PRODUCT_TOUR_VIEWPORT_PADDING, viewport.width - PRODUCT_TOUR_BUBBLE_WIDTH - PRODUCT_TOUR_VIEWPORT_PADDING)
+ );
+
+ const candidates: Array<{ rect: ProductTourRect; arrow: ProductTourArrowDirection }> = [
+ {
+ arrow: "top",
+ rect: box(leftNearAnchor, avoidRect.bottom + gap)
+ },
+ {
+ arrow: "bottom",
+ rect: box(leftNearAnchor, avoidRect.top - gap - PRODUCT_TOUR_BUBBLE_HEIGHT)
+ },
+ {
+ arrow: "left",
+ rect: box(
+ avoidRect.right + gap,
+ clamp(
+ preferredTop,
+ PRODUCT_TOUR_VIEWPORT_PADDING,
+ Math.max(PRODUCT_TOUR_VIEWPORT_PADDING, viewport.height - PRODUCT_TOUR_BUBBLE_HEIGHT - PRODUCT_TOUR_VIEWPORT_PADDING)
+ )
+ )
+ }
+ ];
+
+ for (const candidate of candidates) {
+ if (fitsViewport(candidate.rect, viewport) && !rectsOverlap(candidate.rect, avoidRect)) {
+ return {
+ arrow: candidate.arrow,
+ style: {
+ top: `${candidate.rect.top}px`,
+ left: `${candidate.rect.left}px`
+ }
+ };
+ }
+ }
+
+ // Last resort: park below the highlight, clamped into the viewport.
+ const fallbackTop = clamp(
+ avoidRect.bottom + gap,
+ PRODUCT_TOUR_VIEWPORT_PADDING,
+ Math.max(PRODUCT_TOUR_VIEWPORT_PADDING, viewport.height - PRODUCT_TOUR_BUBBLE_HEIGHT - PRODUCT_TOUR_VIEWPORT_PADDING)
+ );
return {
- top: `${anchorRect.top}px`,
- left: `${anchorRect.right + bubble.gap}px`
+ arrow: "top",
+ style: {
+ top: `${fallbackTop}px`,
+ left: `${leftNearAnchor}px`
+ }
};
}
+/** Builds a bubble-sized box at a top-left origin. */
+function box(left: number, top: number): ProductTourRect {
+ return {
+ left,
+ top,
+ width: PRODUCT_TOUR_BUBBLE_WIDTH,
+ height: PRODUCT_TOUR_BUBBLE_HEIGHT,
+ right: left + PRODUCT_TOUR_BUBBLE_WIDTH,
+ bottom: top + PRODUCT_TOUR_BUBBLE_HEIGHT
+ };
+}
+
+/** Checks whether two rects overlap. */
+function rectsOverlap(a: ProductTourRect, b: ProductTourRect): boolean {
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
+}
+
+/** Checks whether a rect fits inside the viewport with padding. */
+function fitsViewport(rect: ProductTourRect, viewport: ProductTourViewport): boolean {
+ return (
+ rect.left >= PRODUCT_TOUR_VIEWPORT_PADDING
+ && rect.top >= PRODUCT_TOUR_VIEWPORT_PADDING
+ && rect.right <= viewport.width - PRODUCT_TOUR_VIEWPORT_PADDING
+ && rect.bottom <= viewport.height - PRODUCT_TOUR_VIEWPORT_PADDING
+ );
+}
+
+/** Clamps a number into [min, max]. */
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
/**
* Converts a numeric rectangle into CSS styles.
*
@@ -164,6 +355,21 @@ function toHighlightStyle(
highlight: ProductTourHighlightSpec,
viewport: ProductTourViewport
): ProductTourHighlightStyle {
+ const numeric = toNumericHighlightRect(rect, highlight, viewport);
+ return {
+ top: `${numeric.top}px`,
+ left: `${numeric.left}px`,
+ width: `${numeric.width}px`,
+ height: `${numeric.height}px`
+ };
+}
+
+/** Resolves a padded highlight rectangle in viewport coordinates. */
+function toNumericHighlightRect(
+ rect: ProductTourRect,
+ highlight: ProductTourHighlightSpec,
+ viewport: ProductTourViewport
+): ProductTourRect {
const padding = highlight.padding ?? {};
const top = Math.max(0, rect.top - (padding.top ?? 0));
const left = Math.max(0, rect.left - (padding.left ?? 0));
@@ -171,13 +377,10 @@ function toHighlightStyle(
const bottom = highlight.viewportBottom == null
? rect.bottom + (padding.bottom ?? 0)
: viewport.height - highlight.viewportBottom;
+ const width = Math.max(0, right - left);
+ const height = Math.max(0, bottom - top);
- return {
- top: `${top}px`,
- left: `${left}px`,
- width: `${Math.max(0, right - left)}px`,
- height: `${Math.max(0, bottom - top)}px`
- };
+ return { top, left, right: left + width, bottom: top + height, width, height };
}
/**
diff --git a/App/frontend/desktop/src/app/product-tour.tsx b/App/frontend/desktop/src/app/product-tour.tsx
index 7ec3ea06c..7a83c399e 100644
--- a/App/frontend/desktop/src/app/product-tour.tsx
+++ b/App/frontend/desktop/src/app/product-tour.tsx
@@ -1,12 +1,19 @@
/** Product tour module. */
-import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { FileText, PlugZap, Settings2 } from "lucide-react";
import { Memmy, type MemmyPose } from "../components/mascot/memmy.js";
import { zhCNMessages, type MessageKey } from "../i18n/messages.js";
import { useTranslation } from "../i18n/use-translation.js";
import { BrainCircuit, Link2 } from "../pages/memory/memory-prototype-icons.js";
import {
createDomProductTourAnchorLookup,
- PRODUCT_TOUR_MEMORY_NAV_ANCHOR,
+ PRODUCT_TOUR_MEMORY_AGENTS_LIST_ANCHOR,
+ PRODUCT_TOUR_MEMORY_LOGS_LIST_ANCHOR,
+ PRODUCT_TOUR_MEMORY_LOGS_NAV_ANCHOR,
+ PRODUCT_TOUR_MEMORY_OVERVIEW_COUNTS_ANCHOR,
+ PRODUCT_TOUR_MEMORY_OVERVIEW_NAV_ANCHOR,
+ PRODUCT_TOUR_MEMORY_SCAN_PREFERENCES_ANCHOR,
+ PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR,
PRODUCT_TOUR_TOOLS_CONTENT_ANCHOR,
PRODUCT_TOUR_TOOLS_NAV_ANCHOR,
resolveProductTourStepLayout,
@@ -16,7 +23,7 @@ import {
import { readProductTourStep, writeProductTourStep, type AppRoutePath } from "./routes.js";
/** Type definition for product tour tab. */
-export type ProductTourTab = "chat" | "tools" | "memory" | "settings";
+export type ProductTourTab = "logs" | "agents" | "agentsScan" | "overview" | "tools" | "chat" | "settings";
/** Handles product tour tab route. */
export function productTourTabRoute(tab: ProductTourTab): AppRoutePath {
@@ -25,13 +32,33 @@ export function productTourTabRoute(tab: ProductTourTab): AppRoutePath {
return "/tools";
case "settings":
return "/settings";
- case "memory":
+ case "agents":
+ case "agentsScan":
+ return "/memory-sources";
+ case "logs":
+ case "overview":
+ return "/memory";
case "chat":
default:
return "/main";
}
}
+/** Memory sub-page keyed by tour tab, when the route is /memory. */
+export function productTourMemorySubPage(tab: ProductTourTab): "logs" | "overview" | "sources" | null {
+ switch (tab) {
+ case "logs":
+ return "logs";
+ case "overview":
+ return "overview";
+ case "agents":
+ case "agentsScan":
+ return "sources";
+ default:
+ return null;
+ }
+}
+
type ArrowDirection = "left" | "right" | "top" | "bottom";
/** Contract for product tour step. */
@@ -47,29 +74,96 @@ export interface ProductTourStep {
extraHighlights?: ProductTourHighlightSpec[];
}
-const PRODUCT_TOUR_BUBBLE_GAP_PX = 16;
-
export const productTourSteps: ProductTourStep[] = createProductTourSteps((key) => zhCNMessages[key]);
/** Creates create product tour steps. */
export function createProductTourSteps(t: (key: MessageKey) => string): ProductTourStep[] {
return [
{
- tab: "memory",
- title: t("productTour.memory.title"),
+ tab: "logs",
+ title: t("onboarding.featureDig.logs.title"),
+ icon: ,
+ pose: "brain",
+ description: t("onboarding.featureDig.logs.description"),
+ arrow: "top",
+ bubblePlacement: {
+ // Sit just under the lit log rows and point up at them.
+ anchorId: PRODUCT_TOUR_MEMORY_LOGS_LIST_ANCHOR,
+ side: "below",
+ align: "start",
+ gap: 12
+ },
+ highlight: {
+ anchorId: PRODUCT_TOUR_MEMORY_LOGS_LIST_ANCHOR,
+ padding: { top: 4, right: 6, bottom: 4, left: 6 }
+ },
+ extraHighlights: [
+ { anchorId: PRODUCT_TOUR_MEMORY_LOGS_NAV_ANCHOR, padding: { top: 4, right: 4, bottom: 4, left: 4 } }
+ ]
+ },
+ {
+ tab: "agents",
+ title: t("onboarding.featureDig.agents.title"),
+ icon: ,
+ pose: "chat",
+ description: t("onboarding.featureDig.agents.description"),
+ arrow: "left",
+ bubblePlacement: {
+ anchorId: PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR,
+ side: "right",
+ align: "center",
+ gap: 12
+ },
+ highlight: {
+ anchorId: PRODUCT_TOUR_MEMORY_AGENTS_LIST_ANCHOR,
+ padding: { top: 8, right: 8, bottom: 8, left: 8 },
+ viewportBottom: 24
+ },
+ extraHighlights: [
+ { anchorId: PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR, padding: { top: 4, right: 4, bottom: 4, left: 4 } }
+ ]
+ },
+ {
+ tab: "agentsScan",
+ title: t("onboarding.featureDig.agentsScan.title"),
+ icon: ,
+ pose: "chat",
+ description: t("onboarding.featureDig.agentsScan.description"),
+ arrow: "left",
+ bubblePlacement: {
+ anchorId: PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR,
+ side: "right",
+ align: "center",
+ gap: 12
+ },
+ highlight: {
+ anchorId: PRODUCT_TOUR_MEMORY_SCAN_PREFERENCES_ANCHOR,
+ padding: { top: 8, right: 8, bottom: 8, left: 8 }
+ },
+ extraHighlights: [
+ { anchorId: PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR, padding: { top: 4, right: 4, bottom: 4, left: 4 } }
+ ]
+ },
+ {
+ tab: "overview",
+ title: t("onboarding.featureDig.memory.title"),
icon: ,
pose: "brain",
- description: t("productTour.memory.description"),
+ description: t("onboarding.featureDig.memory.description"),
arrow: "left",
bubblePlacement: {
- anchorId: PRODUCT_TOUR_MEMORY_NAV_ANCHOR,
+ anchorId: PRODUCT_TOUR_MEMORY_OVERVIEW_NAV_ANCHOR,
side: "right",
align: "center",
- gap: PRODUCT_TOUR_BUBBLE_GAP_PX
+ gap: 12
},
highlight: {
- anchorId: PRODUCT_TOUR_MEMORY_NAV_ANCHOR
- }
+ anchorId: PRODUCT_TOUR_MEMORY_OVERVIEW_COUNTS_ANCHOR,
+ padding: { top: 8, right: 8, bottom: 8, left: 8 }
+ },
+ extraHighlights: [
+ { anchorId: PRODUCT_TOUR_MEMORY_OVERVIEW_NAV_ANCHOR, padding: { top: 4, right: 4, bottom: 4, left: 4 } }
+ ]
},
{
tab: "tools",
@@ -77,14 +171,14 @@ export function createProductTourSteps(t: (key: MessageKey) => string): ProductT
icon: ,
pose: "chat",
description: t("productTour.tools.description"),
- arrow: "bottom",
+ arrow: "left",
bubblePlacement: {
anchorId: PRODUCT_TOUR_TOOLS_CONTENT_ANCHOR,
side: "inside",
blockAlign: "start",
inlineAlign: "end",
- offsetX: 4,
- offsetY: 4
+ offsetX: 16,
+ offsetY: 16
},
highlight: {
anchorId: PRODUCT_TOUR_TOOLS_CONTENT_ANCHOR,
@@ -92,7 +186,7 @@ export function createProductTourSteps(t: (key: MessageKey) => string): ProductT
viewportBottom: 16
},
extraHighlights: [
- { anchorId: PRODUCT_TOUR_TOOLS_NAV_ANCHOR }
+ { anchorId: PRODUCT_TOUR_TOOLS_NAV_ANCHOR, padding: { top: 4, right: 4, bottom: 4, left: 4 } }
]
}
];
@@ -115,9 +209,12 @@ export function ProductTourGuide(props: ProductTourGuideProps) {
const current = steps[Math.min(step, steps.length - 1)]!;
const [layout, setLayout] = useState(() => null as ReturnType);
+ const onTabChangeRef = useRef(onTabChange);
+ onTabChangeRef.current = onTabChange;
+
useEffect(() => {
- onTabChange(current.tab);
- }, [current, onTabChange]);
+ onTabChangeRef.current(current.tab);
+ }, [current.tab]);
useEffect(() => {
if (typeof document === "undefined" || typeof window === "undefined") {
@@ -162,6 +259,9 @@ export function ProductTourGuide(props: ProductTourGuideProps) {
subtree: true
});
+ const highlightElement = document.querySelector(`[data-tour-anchor="${current.highlight.anchorId}"]`);
+ highlightElement?.scrollIntoView({ block: "center", behavior: "smooth" });
+
setLayout(null);
scheduleMeasurement();
window.addEventListener("resize", scheduleMeasurement);
@@ -185,7 +285,8 @@ export function ProductTourGuide(props: ProductTourGuideProps) {
/** Handles go next. */
function goNext() {
if (isLast) {
- onTabChange("chat");
+ // Dismiss owns navigation to /main; calling onTabChange("chat") first races
+ // with the still-mounted tools step and can bounce back to /tools.
onDismiss();
return;
}
@@ -199,14 +300,14 @@ export function ProductTourGuide(props: ProductTourGuideProps) {
/** Handles handle dismiss. */
function handleDismiss() {
- onTabChange("chat");
onDismiss();
}
+ const arrow = layout.arrow;
const animationClass =
- current.arrow === "left" || current.arrow === "right"
+ arrow === "left" || arrow === "right"
? "animate-in fade-in slide-in-from-left-2"
- : current.arrow === "bottom"
+ : arrow === "bottom"
? "animate-in fade-in slide-in-from-bottom-2"
: "animate-in fade-in slide-in-from-top-2";
@@ -272,34 +373,34 @@ export function ProductTourGuide(props: ProductTourGuideProps) {
onClick={goNext}
className="px-4 py-1.5 text-xs font-normal text-white bg-action-sky rounded-btn hover:bg-action-sky-hover cursor-pointer transition-all shadow-sm"
>
- {isLast ? t("productTour.start") : t("productTour.next")}
+ {isLast ? t("onboarding.featureDig.startChat") : t("productTour.next")}
- {current.arrow === "left" && (
+ {arrow === "left" && (
)}
- {current.arrow === "right" && (
+ {arrow === "right" && (
)}
- {current.arrow === "top" && (
+ {arrow === "top" && (
)}
- {current.arrow === "bottom" && (
+ {arrow === "bottom" && (
)}
diff --git a/App/frontend/desktop/src/app/router.tsx b/App/frontend/desktop/src/app/router.tsx
index 92661de37..e222c1c08 100644
--- a/App/frontend/desktop/src/app/router.tsx
+++ b/App/frontend/desktop/src/app/router.tsx
@@ -12,20 +12,39 @@ import {
type MainWindowActionResolution,
type PetGuideChoice
} from "./pet-guide.js";
-import { productTourTabRoute, type ProductTourTab } from "./product-tour.js";
+import { ProductTourGuide, productTourMemorySubPage, productTourTabRoute, type ProductTourTab } from "./product-tour.js";
import { GlobalUpdateDialog } from "./update-coordinator.js";
-import { readCurrentRoute, readLaunchModeOverride, readTokenExhaustedDismissed, shouldShowTokenExhaustedModal, writeCurrentRoute, writeTokenExhaustedDismissed, type AppRoutePath } from "./routes.js";
+import {
+ clearDeferredGuidanceStep,
+ clearProductTourStep,
+ readCurrentRoute,
+ readDeferredGuidanceStep,
+ readLaunchModeOverride,
+ readTokenExhaustedDismissed,
+ shouldShowTokenExhaustedModal,
+ writeCurrentRoute,
+ writeDeferredGuidanceStep,
+ writeGuidanceCompleted,
+ writeTokenExhaustedDismissed,
+ type AppRoutePath,
+ type DeferredGuidanceStep
+} from "./routes.js";
import { emitTokenExhaustedApplyMoreRequest, writeTokenExhaustedApplyMoreRequest } from "./token-exhausted-apply-more.js";
+import { persistNickname } from "./nickname.js";
+import { useOptionalApiClients } from "./providers.js";
import { useAppState } from "../state/app-state.js";
import { useAnalytics } from "../analytics/use-analytics.js";
import { buildRoutePageViewEvent, shouldDeferRoutePageView } from "../analytics/page-view.js";
import { appActions } from "../state/app-actions.js";
+import { NicknameModal } from "../components/nickname-modal.js";
+import { randomNickname } from "../lib/nickname.js";
+import { useTranslation } from "../i18n/use-translation.js";
import { ApiKeyPage } from "../pages/api-key-page.js";
import { ApiKeyOptionalPage } from "../pages/api-key-optional-page.js";
import { ModelPage } from "../pages/model-page.js";
import { HomePage } from "../pages/home-page.js";
import { LoginPage } from "../pages/login-page.js";
-import { MemoryPage } from "../pages/memory-page.js";
+import { MemoryPage, writeMemorySubPage } from "../pages/memory-page.js";
import { OnboardingPage } from "../pages/onboarding-page.js";
import { PetPage } from "../pages/pet-page.js";
import { SettingsPage } from "../pages/settings-page.js";
@@ -34,11 +53,23 @@ import { TokenDetailPage } from "../pages/token-detail-page.js";
import { TokenExhaustedModal } from "../pages/token-exhausted-modal.js";
import { ToolsPage } from "../pages/tools-page.js";
import { WelcomePage } from "../pages/welcome-page.js";
+
+function readWorkspaceGuidanceOverlay(storage: Storage | undefined): Extract | null {
+ const step = readDeferredGuidanceStep(storage);
+ return step === "product_tour" || step === "nickname" ? step : null;
+}
+
/** Handles app router. */
export function AppRouter(props: { onRetry: () => void }) {
const { state, dispatch } = useAppState();
+ const { clients } = useOptionalApiClients();
const { track, ready: analyticsReady } = useAnalytics();
+ const { language } = useTranslation();
const prevPathRef = useRef(null);
+ const [workspaceGuidanceStep, setWorkspaceGuidanceStep] = useState(() =>
+ readWorkspaceGuidanceOverlay(typeof window === "undefined" ? undefined : window.sessionStorage)
+ );
+ const [deferredNickname, setDeferredNickname] = useState("");
const [hasDismissedTokenExhaustedModal, setHasDismissedTokenExhaustedModal] = useState(() =>
readTokenExhaustedDismissed(typeof window === "undefined" ? undefined : window.sessionStorage)
);
@@ -116,6 +147,15 @@ export function AppRouter(props: { onRetry: () => void }) {
}, [state.navigation.currentPath, state.startup.status]);
const currentPath = state.navigation.currentPath;
+ useEffect(() => {
+ // Memory/Tools pages are not always wrapped by AppFrame; keep the product tour mounted at router level.
+ const step = readWorkspaceGuidanceOverlay(typeof window === "undefined" ? undefined : window.sessionStorage);
+ setWorkspaceGuidanceStep(step);
+ if (step === "nickname") {
+ setDeferredNickname((current) => current || randomNickname(language));
+ }
+ }, [currentPath, language, state.startup.status]);
+
useEffect(() => {
if (!analyticsReady) return;
if (currentPath === prevPathRef.current) return;
@@ -128,6 +168,32 @@ export function AppRouter(props: { onRetry: () => void }) {
track(buildRoutePageViewEvent(currentPath, referrer));
}, [currentPath, track, analyticsReady]);
+ function dismissProductTour() {
+ const storage = typeof window === "undefined" ? undefined : window.sessionStorage;
+ clearProductTourStep(storage);
+ // Persist nickname step before navigating so the path-change effect does not
+ // re-read stale `product_tour` and remount the tour on /tools.
+ writeDeferredGuidanceStep(storage, "nickname");
+ setDeferredNickname(randomNickname(language));
+ setWorkspaceGuidanceStep("nickname");
+ dispatch(appActions.navigate("/main"));
+ }
+
+ function submitDeferredNickname() {
+ void persistNickname({
+ rawNickname: deferredNickname,
+ language,
+ isByok: state.bootstrap?.app.userMode === "byok",
+ storage: typeof window === "undefined" ? undefined : window.localStorage,
+ current: state.account,
+ updateProfile: (nickname) => clients?.account.updateProfile({ nickname }) ?? Promise.resolve(null)
+ }).then((update) => dispatch(appActions.accountUpdated(update)));
+ track({ name: "onboarding_step_completed", params: { step: "nickname", step_index: 0 }, consentTier: "basic" });
+ writeGuidanceCompleted(typeof window === "undefined" ? undefined : window.localStorage);
+ clearDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage);
+ setWorkspaceGuidanceStep(null);
+ }
+
if (state.startup.status === "loading" || state.startup.status === "idle") {
return (
<>
@@ -150,6 +216,27 @@ export function AppRouter(props: { onRetry: () => void }) {
<>
{renderRoute(state.navigation.currentPath)}
{windowDragRegion}
+ {workspaceGuidanceStep === "product_tour" && (
+ {
+ const memorySubPage = productTourMemorySubPage(tab);
+ if (memorySubPage) {
+ writeMemorySubPage(typeof window === "undefined" ? undefined : window.sessionStorage, memorySubPage);
+ }
+ dispatch(appActions.navigate(productTourTabRoute(tab)));
+ }}
+ />
+ )}
+ {workspaceGuidanceStep === "nickname" && (
+ setDeferredNickname(randomNickname(language))}
+ onSubmit={submitDeferredNickname}
+ />
+ )}
{petGuideRequest && }
{tokenModalOpen && (
void }) {
/>
)}
>
);
diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts
index d3c6284cf..86e4f74f5 100644
--- a/App/frontend/desktop/src/i18n/messages.ts
+++ b/App/frontend/desktop/src/i18n/messages.ts
@@ -308,12 +308,12 @@ export const zhCNMessages = {
"apiKey.modelPage.reusePrevious": "沿用上一步的 Agent 任务模型",
"apiKey.modelPage.reuseAgent": "沿用 Agent 任务模型",
"onboarding.permission.title": "Memmy 需要你的授权",
- "onboarding.permission.subtitle": "为了让所有 AI 都记住同一个你",
+ "onboarding.permission.subtitle": "首次导入历史,之后让每个 AI 自动接上上下文",
"onboarding.permission.scanTitle": "扫描已有 Agent 对话",
- "onboarding.permission.scanBody": "读取 Cursor / Codex / WorkBuddy 等本地历史对话,生成你的记忆",
- "onboarding.permission.writeTitle": "允许其他Agent使用Memmy的记忆",
- "onboarding.permission.writeBody": "将会通过插件 / CLI / 修改 AGENTS.md 等方式引导消费记忆",
- "onboarding.permission.notice": "你可以随时在「记忆管理 -> 接入源管理」中调整授权",
+ "onboarding.permission.scanBody": "首次读取 Cursor / Codex / WorkBuddy 等本地对话历史,转成可复用记忆",
+ "onboarding.permission.writeTitle": "让常用 Agent 自动接入 Memmy 记忆",
+ "onboarding.permission.writeBody": "继续使用原来的 Agent,切换工具或新开对话时,自动同步与注入相关背景",
+ "onboarding.permission.notice": "你可以随时在「记忆管理 -> 跨 Agent 接入」中调整授权",
"onboarding.permission.none": "不允许",
"onboarding.permission.scan": "仅允许扫描",
"onboarding.permission.all": "全部允许",
@@ -323,9 +323,9 @@ export const zhCNMessages = {
"onboarding.scan.title.report": "正在生成初见报告",
"onboarding.scan.title.reportError": "初见报告生成失败",
"onboarding.scan.subtitle.discovering": "查找已安装的 Agent,确认可迁移的上下文来源",
- "onboarding.scan.subtitle.analyzing": "读取本地对话记录,用于生成首次使用报告",
- "onboarding.scan.subtitle.ready": "报告正在生成,请稍候",
- "onboarding.scan.subtitle.report": "正在整理可接续的任务上下文",
+ "onboarding.scan.subtitle.analyzing": "首次导入历史任务,之后开新对话会自动召回",
+ "onboarding.scan.subtitle.ready": "正在找出一个可以直接接着做的任务",
+ "onboarding.scan.subtitle.report": "正在把历史线索整理成可继续执行的任务",
"onboarding.scan.subtitle.reportError": "未使用模拟报告,请稍后重试或检查本地服务状态。",
"onboarding.scan.conversationCount": "{count} 条对话",
"onboarding.scan.agentPending": "扫描中",
@@ -333,13 +333,34 @@ export const zhCNMessages = {
"onboarding.scan.privacy": "对话记录仅在本机读取,扫描完成后可在设置中管理来源。",
"onboarding.scan.skipStep": "跳过此步骤",
"onboarding.report.title": "初见报告",
- "onboarding.report.subtitle": "基于你的 AI 对话历史生成",
- "onboarding.report.disclaimer": "以上基于对话历史判断,随时可以纠正我",
+ "onboarding.report.subtitle": "先看最近一个可接续的项目 / bug / 关键词",
+ "onboarding.report.userPrompt": "梳理我最近的一个项目 / bug / 关键词,整理成初见报告,并列出可行待办",
+ "onboarding.report.disclaimer": "线索来自本机对话历史,判断不对可以随时纠正",
"onboarding.report.skip": "跳过",
"onboarding.report.alternatives": "或者从这些开始:",
- "onboarding.report.firstConversation": "开始第一次对话",
- "onboarding.report.firstConversationDescription": "随便聊点什么,Memmy 会自动记住有用的部分",
+ "onboarding.report.firstConversation": "建立第一段可接续上下文",
+ "onboarding.report.firstConversationDescription": "告诉 Memmy 你正在做什么,之后换个 AI 也不用重讲背景",
+ "onboarding.report.imported": "这些线索已导入 Memmy,换个 AI 也能接着做。",
+ "onboarding.report.sourceLabel": "线索来自",
+ "onboarding.report.viewMemories": "查看已导入记忆",
+ "onboarding.report.connectAgents": "连接常用 Agent",
+ "onboarding.report.continue": "下一步",
"onboarding.report.errorFallback": "初见报告生成失败,请稍后重试。",
+ "onboarding.featureDig.title": "挖掘更多功能",
+ "onboarding.featureDig.subtitle": "先看清 Memmy 还能帮你做什么",
+ "onboarding.featureDig.next": "下一步",
+ "onboarding.featureDig.skip": "跳过",
+ "onboarding.featureDig.startChat": "进入首次对话",
+ "onboarding.featureDig.logs.title": "记忆日志",
+ "onboarding.featureDig.logs.description": "记录所有 Agent 调用的记忆。刚才的初见中,已经完成过写入、召回,你可以在列表里查看被引用的记忆。",
+ "onboarding.featureDig.agents.title": "连接更多 Agent",
+ "onboarding.featureDig.agents.description": "这里可以接入本机支持的所有 Agent。安装 Hook 或 Skill 后,Memmy 能自动接入,持续同步与注入记忆。",
+ "onboarding.featureDig.agentsScan.title": "连接更多 AGENT",
+ "onboarding.featureDig.agentsScan.description": "这里还支持更多自动化配置,自动接入 Agent、自动扫描对话,无需用户再手动安装与同步。",
+ "onboarding.featureDig.memory.title": "记忆管理",
+ "onboarding.featureDig.memory.description": "记忆分为四层:L1原始对话记忆、L2 经验与偏好、L3 场域认知、L4 可复用技能,方便按层次查看和管理。",
+ "onboarding.featureDig.tools.title": "工具连接",
+ "onboarding.featureDig.tools.description": "绑定消息渠道和外部工具,让 Agent 跨平台、跨工具继续为你推进任务。",
"onboarding.relay.title": "换个 AI,继续刚才的对话",
"onboarding.relay.body": "Memmy 会自动整合不同 AI 的记忆,切换工具时,自动接续任务上下文。",
"onboarding.relay.openAgent": "在 {agent} 中继续",
@@ -351,8 +372,12 @@ export const zhCNMessages = {
"onboarding.relay.copyPrompt": "复制指令",
"onboarding.relay.copiedPrompt": "已复制",
"onboarding.relay.copyPromptFailed": "未能复制指令,请重试。",
+ "onboarding.relay.waiting": "等待 {agent} 调用 Memmy 记忆…",
+ "onboarding.relay.verified": "{agent} 已通过 Memmy 找回这段上下文",
+ "onboarding.relay.verifyTimeout": "还没检测到记忆调用;发送指令后可在「记忆管理 → 调用日志」查看。",
"onboarding.relay.optInTitle": "换个 AI,也能接着聊",
"onboarding.relay.optInBody": "安装 Memmy 到你使用的 Agent 工具,切换工具时,不再遗忘上下文。",
+ "onboarding.relay.optInHint": "在「记忆管理 - 跨 Agent 接入」中授权,即可让所有 AI 接入 Memmy 记忆。",
"onboarding.relay.optInAction": "去安装",
"onboarding.improvement.title": "帮我们变得更好",
"onboarding.improvement.body": "获取崩溃和报错信息,帮我们快速定位问题,针对性改进",
@@ -369,6 +394,8 @@ export const zhCNMessages = {
"onboarding.pluginConflict.skillOnly": "仅 Skill",
"onboarding.pluginConflict.replace": "替换插件",
"onboarding.complete.error": "新手引导保存失败,请稍后重试",
+ "productTour.chat.title": "继续任务,不必重讲背景",
+ "productTour.chat.description": "在这里直接分配任务,Memmy 会自动召回本机记忆;你也可以继续使用 Cursor、Codex 等常用 Agent,共享同一份上下文。",
"productTour.memory.title": "记忆管理",
"productTour.memory.description": "查看和管理你的所有记忆,以及各 Agent 的接入状态。扫描完成后你会在这里看到结果",
"productTour.tools.title": "连接与工具",
@@ -676,13 +703,11 @@ export const zhCNMessages = {
"memory.cliNotInstalled": "未安装",
"memory.daemonRunning": "运行中",
"memory.daemonStopped": "已停止",
- "memory.preferences": "扫描行为",
- "memory.autoScan": "自动扫描已知 Agent",
- "memory.autoScanDescription": "启动时扫描 Cursor / Codex / Claude Code / WorkBuddy 等已安装 Agent 的新对话",
- "memory.watchFiles": "自动增量同步",
- "memory.watchFilesDescription": "自动跟进 Agent 会话文件的新增内容",
- "memory.autoInject": "新发现 Agent 自动安装 Hook/插件",
- "memory.autoInjectDescription": "自动为新发现的 Agent 安装对应的 Hook 或插件,Skill 会随接入一并安装;关闭后,新发现的 Agent 仅出现在下方列表中等待你手动安装",
+ "memory.preferences": "自动同步",
+ "memory.autoScan": "自动同步会话",
+ "memory.autoScanDescription": "自动从已接入的 Agent 采集新对话,无需手动点「同步新增」",
+ "memory.autoInject": "发现新 Agent 时自动接入",
+ "memory.autoInjectDescription": "自动安装接入组件;关闭后只出现在下方列表,由你手动接入",
"memory.scan": "同步新增",
"memory.syncNew": "同步新增",
"memory.syncCompleted": "同步完成",
@@ -1668,12 +1693,12 @@ export const enUSMessages: Record = {
"apiKey.modelPage.reusePrevious": "Reuse the Agent task model from the previous step",
"apiKey.modelPage.reuseAgent": "Reuse Agent task model",
"onboarding.permission.title": "Memmy needs your authorization",
- "onboarding.permission.subtitle": "Let all AI remember the same you",
+ "onboarding.permission.subtitle": "Import history once, then let every AI pick up the context",
"onboarding.permission.scanTitle": "Scan existing Agent conversations",
- "onboarding.permission.scanBody": "Read local Cursor / Codex / WorkBuddy histories and generate your memory",
- "onboarding.permission.writeTitle": "Allow other Agents to use Memmy memory",
- "onboarding.permission.writeBody": "Guide memory consumption through plugins / CLI / modifying AGENTS.md, etc.",
- "onboarding.permission.notice": "You can change this later in Memory -> Sources",
+ "onboarding.permission.scanBody": "First read of local Cursor / Codex / WorkBuddy chat history, turned into reusable memory",
+ "onboarding.permission.writeTitle": "Let your usual Agents connect to Memmy memory",
+ "onboarding.permission.writeBody": "Keep using your Agents; when you switch tools or start a new chat, relevant context is synced and injected automatically",
+ "onboarding.permission.notice": "You can change this later in Memory -> Cross-Agent access",
"onboarding.permission.none": "Deny",
"onboarding.permission.scan": "Scan only",
"onboarding.permission.all": "Allow all",
@@ -1683,9 +1708,9 @@ export const enUSMessages: Record = {
"onboarding.scan.title.report": "Generating your first report",
"onboarding.scan.title.reportError": "First report failed",
"onboarding.scan.subtitle.discovering": "Finding installed Agents and reusable context sources",
- "onboarding.scan.subtitle.analyzing": "Reading local conversation records for your first report",
- "onboarding.scan.subtitle.ready": "Generating your report",
- "onboarding.scan.subtitle.report": "Organizing task context you can continue from.",
+ "onboarding.scan.subtitle.analyzing": "Importing past tasks once so new conversations can recall them",
+ "onboarding.scan.subtitle.ready": "Finding one task you can continue right away",
+ "onboarding.scan.subtitle.report": "Turning history signals into a task you can continue",
"onboarding.scan.subtitle.reportError": "No mock report was used. Please try again later or check the local service.",
"onboarding.scan.conversationCount": "{count} conversations",
"onboarding.scan.agentPending": "Scanning",
@@ -1693,13 +1718,34 @@ export const enUSMessages: Record = {
"onboarding.scan.privacy": "Conversation records are read locally. You can manage sources in Settings after scanning.",
"onboarding.scan.skipStep": "Skip this step",
"onboarding.report.title": "First report",
- "onboarding.report.subtitle": "Generated from your AI conversation history",
- "onboarding.report.disclaimer": "These are guesses from conversation history. You can correct me anytime.",
+ "onboarding.report.subtitle": "Start from your latest project, bug, or keyword",
+ "onboarding.report.userPrompt": "Organize my latest project / bug / keyword into a first report, and list actionable todos",
+ "onboarding.report.disclaimer": "These signals come from local conversation history. Correct me anytime.",
"onboarding.report.skip": "Skip",
"onboarding.report.alternatives": "Or start from:",
- "onboarding.report.firstConversation": "Start your first conversation",
- "onboarding.report.firstConversationDescription": "Chat about anything. Memmy will remember the useful parts automatically.",
+ "onboarding.report.firstConversation": "Create your first portable context",
+ "onboarding.report.firstConversationDescription": "Tell Memmy what you are working on, then switch AI without repeating the background",
+ "onboarding.report.imported": "These signals are now in Memmy, so another AI can continue from here.",
+ "onboarding.report.sourceLabel": "Signals from",
+ "onboarding.report.viewMemories": "View imported memories",
+ "onboarding.report.connectAgents": "Connect your Agents",
+ "onboarding.report.continue": "Next",
"onboarding.report.errorFallback": "Failed to generate the first report. Please try again later.",
+ "onboarding.featureDig.title": "Discover more",
+ "onboarding.featureDig.subtitle": "See what else Memmy can do for you",
+ "onboarding.featureDig.next": "Next",
+ "onboarding.featureDig.skip": "Skip",
+ "onboarding.featureDig.startChat": "Enter first chat",
+ "onboarding.featureDig.logs.title": "Memory logs",
+ "onboarding.featureDig.logs.description": "Tracks memory calls from every Agent. Your first encounter already wrote and recalled memory — you can review the referenced entries in this list.",
+ "onboarding.featureDig.agents.title": "Connect more Agents",
+ "onboarding.featureDig.agents.description": "Connect every Agent supported on this device. After installing a Hook or Skill, Memmy can auto-connect and keep syncing and injecting memory.",
+ "onboarding.featureDig.agentsScan.title": "Connect more AGENTS",
+ "onboarding.featureDig.agentsScan.description": "More automation lives here: auto-connect Agents and auto-scan conversations, so you don’t have to install or sync by hand.",
+ "onboarding.featureDig.memory.title": "Memory management",
+ "onboarding.featureDig.memory.description": "Memory has four layers: L1 raw conversation memory, L2 experiences and preferences, L3 field cognition, and L4 reusable skills — easy to browse and manage by layer.",
+ "onboarding.featureDig.tools.title": "Tool connections",
+ "onboarding.featureDig.tools.description": "Bind messaging channels and external tools so Agents can keep pushing work across platforms.",
"onboarding.relay.title": "Switch AI and continue the conversation",
"onboarding.relay.body": "Memmy organizes memory across AI tools and automatically retrieves task context when you switch.",
"onboarding.relay.openAgent": "Continue in {agent}",
@@ -1711,8 +1757,12 @@ export const enUSMessages: Record = {
"onboarding.relay.copyPrompt": "Copy prompt",
"onboarding.relay.copiedPrompt": "Copied",
"onboarding.relay.copyPromptFailed": "Couldn't copy the prompt. Try again.",
+ "onboarding.relay.waiting": "Waiting for {agent} to use Memmy memory…",
+ "onboarding.relay.verified": "{agent} recovered this context through Memmy",
+ "onboarding.relay.verifyTimeout": "No memory call detected yet. After sending the prompt, check Memory → Logs.",
"onboarding.relay.optInTitle": "Switch AI and keep going",
"onboarding.relay.optInBody": "Install Memmy in the Agent tools you use, so switching tools no longer loses context.",
+ "onboarding.relay.optInHint": "Authorize in Memory → Cross-Agent connections so every AI can use Memmy memory.",
"onboarding.relay.optInAction": "Install",
"onboarding.improvement.title": "Join the Memmy improvement program",
"onboarding.improvement.body": "Opt in to help us improve Memmy; upload scope follows the privacy policy.",
@@ -1729,6 +1779,8 @@ export const enUSMessages: Record = {
"onboarding.pluginConflict.skillOnly": "Skill only",
"onboarding.pluginConflict.replace": "Replace plugin",
"onboarding.complete.error": "Could not save onboarding. Try again later.",
+ "productTour.chat.title": "Continue without repeating the background",
+ "productTour.chat.description": "Assign a task here and Memmy recalls local memory automatically. Or keep using Cursor, Codex, and your other Agents with the same shared context.",
"productTour.memory.title": "Memory",
"productTour.memory.description": "View and manage all your memories and each Agent's connection status. Scan results appear here when complete.",
"productTour.tools.title": "Connections & Tools",
@@ -2036,13 +2088,11 @@ export const enUSMessages: Record = {
"memory.cliNotInstalled": "Not installed",
"memory.daemonRunning": "Running",
"memory.daemonStopped": "Stopped",
- "memory.preferences": "Scan behavior",
- "memory.autoScan": "Auto-scan known Agents",
- "memory.autoScanDescription": "Scan new conversations from installed Agents such as Cursor / Codex / Claude Code / WorkBuddy on startup",
- "memory.watchFiles": "Auto incremental sync",
- "memory.watchFilesDescription": "Automatically follow newly written Agent conversation files",
- "memory.autoInject": "Auto-install Hooks/plugins for new Agents",
- "memory.autoInjectDescription": "Automatically install the matching Hook or plugin for each new Agent; the Skill is installed with the integration. When disabled, newly found Agents stay in the list until you install them manually",
+ "memory.preferences": "Auto sync",
+ "memory.autoScan": "Auto-sync conversations",
+ "memory.autoScanDescription": "Automatically collect new conversations from connected Agents—no need to click Sync new",
+ "memory.autoInject": "Auto-connect newly found Agents",
+ "memory.autoInjectDescription": "Install the integration automatically; when off, new Agents only appear in the list below for you to connect manually",
"memory.scan": "Sync new",
"memory.syncNew": "Sync new",
"memory.syncCompleted": "Synced",
@@ -2744,6 +2794,9 @@ export function resolveLanguage(language: Language | string | undefined): Resolv
}
export function formatMessage(template: string, values: MessageValues = {}): string {
+ if (typeof template !== "string") {
+ return "";
+ }
return Object.entries(values).reduce(
(result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
template
diff --git a/App/frontend/desktop/src/pages/app-frame.tsx b/App/frontend/desktop/src/pages/app-frame.tsx
index ff094719b..ad41f74ff 100644
--- a/App/frontend/desktop/src/pages/app-frame.tsx
+++ b/App/frontend/desktop/src/pages/app-frame.tsx
@@ -1,8 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
-import { PRODUCT_TOUR_MEMORY_NAV_ANCHOR, PRODUCT_TOUR_TOOLS_NAV_ANCHOR } from "../app/product-tour-layout.js";
+import { PRODUCT_TOUR_CHAT_CONTENT_ANCHOR, PRODUCT_TOUR_MEMORY_NAV_ANCHOR, PRODUCT_TOUR_TOOLS_NAV_ANCHOR } from "../app/product-tour-layout.js";
import type { AppRoutePath } from "../app/routes.js";
-import { clearDeferredGuidanceStep, clearFocusedAgentTarget, clearProductTourStep, readDeferredGuidanceStep, readGuidanceCompleted, routeTable, writeDeferredGuidanceStep, writeGuidanceCompleted } from "../app/routes.js";
+import { clearFocusedAgentTarget, clearProductTourStep, readDeferredGuidanceStep, readGuidanceCompleted, routeTable, writeDeferredGuidanceStep } from "../app/routes.js";
import { useAnalytics } from "../analytics/use-analytics.js";
import {
useOptionalAgentRuntimeBridge,
@@ -29,10 +29,7 @@ import { maskAccountIdentifier } from "../utils/mask-account-identifier.js";
import { openExternalUrl } from "../utils/open-url.js";
import { isComposingKeyboardEvent } from "../utils/keyboard.js";
import { ImprovementProgramModal } from "./improvement-program-modal.js";
-import { NicknameModal } from "../components/nickname-modal.js";
-import { randomNickname } from "../lib/nickname.js";
-import { ProductTourGuide, productTourTabRoute, type ProductTourTab } from "../app/product-tour.js";
-import { persistNickname } from "../app/nickname.js";
+import { writeMemorySubPage } from "./memory-page.js";
import { SearchPalette } from "../components/search-palette.js";
import { SidebarResizeHandle, useCodexResizableSidebar } from "./sidebar-resize.js";
import {
@@ -267,7 +264,6 @@ export function AppFrame(props: AppFrameProps) {
const [deferredGuidanceStep, setDeferredGuidanceStep] = useState(() =>
readDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage)
);
- const [deferredNickname, setDeferredNickname] = useState("");
const [sidebarHidden, setSidebarHidden] = useState(false);
const communityMenuRef = useRef(null);
const taskScrollRef = useRef(null);
@@ -538,36 +534,27 @@ export function AppFrame(props: AppFrameProps) {
if (deferredGuidanceStep !== "armed") {
return;
}
+ const storage = typeof window === "undefined" ? undefined : window.sessionStorage;
const firstStep = state.bootstrap?.app.userMode !== "byok" && state.bootstrap?.onboarding.improvementProgram === "unset" ? "improvement" : "product_tour";
if (firstStep === "product_tour") {
- clearProductTourStep(typeof window === "undefined" ? undefined : window.sessionStorage);
+ clearProductTourStep(storage);
+ writeMemorySubPage(storage, "logs");
+ dispatch(appActions.navigate("/memory"));
}
- writeDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage, firstStep);
+ writeDeferredGuidanceStep(storage, firstStep);
setDeferredGuidanceStep(firstStep);
}
- function submitDeferredNickname() {
- void persistNickname({
- rawNickname: deferredNickname,
- language,
- isByok: state.bootstrap?.app.userMode === "byok",
- storage: typeof window === "undefined" ? undefined : window.localStorage,
- current: state.account,
- updateProfile: (nickname) => clients?.account.updateProfile({ nickname }) ?? Promise.resolve(null)
- }).then((update) => dispatch(appActions.accountUpdated(update)));
- track({ name: "onboarding_step_completed", params: { step: "nickname", step_index: 0 }, consentTier: "basic" });
- writeGuidanceCompleted(typeof window === "undefined" ? undefined : window.localStorage);
- clearDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage);
- setDeferredGuidanceStep(null);
- }
-
function chooseDeferredImprovementProgram(accepted: boolean) {
const onboardingPatch = { improvementProgram: accepted ? "accepted" : "declined" } as const;
const privacyPatch = { allowMemoryImprovementUpload: accepted };
+ const storage = typeof window === "undefined" ? undefined : window.sessionStorage;
- clearProductTourStep(typeof window === "undefined" ? undefined : window.sessionStorage);
- writeDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage, "product_tour");
+ clearProductTourStep(storage);
+ writeMemorySubPage(storage, "logs");
+ writeDeferredGuidanceStep(storage, "product_tour");
setDeferredGuidanceStep("product_tour");
+ dispatch(appActions.navigate("/memory"));
dispatch(appActions.onboardingUpdated(onboardingPatch));
dispatch(appActions.privacyUpdated(privacyPatch));
track({ name: "onboarding_step_completed", params: { step: "improvement_program", step_index: 2, choice: accepted ? "accepted" : "declined" }, consentTier: "basic" });
@@ -1438,6 +1425,7 @@ export function AppFrame(props: AppFrameProps) {
)}
)}
- {deferredGuidanceStep === "product_tour" && (
- {
- // Increment 3: after the product tour ends, enter the final DGS step — the nickname modal (set for both account and BYOK).
- // The tour has ended; clear the persisted step index so the next tour doesn't resume from a mid-tour step.
- clearProductTourStep(typeof window === "undefined" ? undefined : window.sessionStorage);
- setDeferredNickname(randomNickname(language));
- writeDeferredGuidanceStep(typeof window === "undefined" ? undefined : window.sessionStorage, "nickname");
- setDeferredGuidanceStep("nickname");
- }}
- onTabChange={(tab: ProductTourTab) => {
- // The memory step maps to /main (stay on the main workspace and highlight the memory entry icon) rather than the standalone /memory page —
- // /memory doesn't host the tour overlay, so navigating there would lose the tour and strand the user on the memory page. See productTourTabRoute for the mapping.
- dispatch(appActions.navigate(productTourTabRoute(tab)));
- }}
- />
- )}
- {deferredGuidanceStep === "nickname" && (
- setDeferredNickname(randomNickname(language))}
- onSubmit={submitDeferredNickname}
- />
- )}
Promise;
onCopyPrompt?: (prompt: string) => Promise;
+ onVerifyMemory?: (sourceId: string, startedAt: string) => Promise;
+ onLifecycle?: (event: "relay_clicked" | "memory_verified", sourceId: string, action: string) => void;
}
export interface FirstEncounterRelayOptInProps {
- onOpenConnections: () => void;
+ /** When omitted, the card stays informational without a connect CTA. */
+ onOpenConnections?: () => void;
}
const RELAY_AGENT_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]);
@@ -29,19 +34,27 @@ type RelayFeedback =
| { kind: "opened_and_copied"; agent: RelayAgentOption }
| { kind: "opened_copy_failed"; agent: RelayAgentOption }
| { kind: "copy_fallback"; agent: RelayAgentOption }
- | { kind: "failed"; agent: RelayAgentOption };
+ | { kind: "failed"; agent: RelayAgentOption }
+ | { kind: "waiting"; agent: RelayAgentOption }
+ | { kind: "verified"; agent: RelayAgentOption }
+ | { kind: "verify_timeout"; agent: RelayAgentOption };
export function FirstEncounterRelayChallenge(props: FirstEncounterRelayChallengeProps) {
const { t } = useTranslation();
const [feedback, setFeedback] = useState(null);
const [launchingSourceId, setLaunchingSourceId] = useState(null);
const feedbackTimerRef = useRef(null);
+ const mountedRef = useRef(true);
const agents = useMemo(() => relayAgentOptions(props.agents), [props.agents]);
- useEffect(() => () => {
- if (feedbackTimerRef.current !== null) {
- window.clearTimeout(feedbackTimerRef.current);
- }
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ if (feedbackTimerRef.current !== null) {
+ window.clearTimeout(feedbackTimerRef.current);
+ }
+ };
}, []);
function showTemporaryFeedback(nextFeedback: RelayFeedback) {
@@ -60,6 +73,7 @@ export function FirstEncounterRelayChallenge(props: FirstEncounterRelayChallenge
return;
}
setLaunchingSourceId(agent.sourceId);
+ const startedAt = new Date().toISOString();
try {
const prompt = t("onboarding.relay.prompt");
const outcome = await launchFirstEncounterRelay({
@@ -68,7 +82,14 @@ export function FirstEncounterRelayChallenge(props: FirstEncounterRelayChallenge
openAgent: props.onOpenAgent,
copyPrompt: props.onCopyPrompt
});
- if (outcome.opened) {
+ props.onLifecycle?.(
+ "relay_clicked",
+ agent.sourceId,
+ outcome.opened ? "opened" : outcome.copied ? "copied_fallback" : "failed"
+ );
+ if (outcome.copied && props.onVerifyMemory) {
+ beginMemoryVerification(agent, startedAt);
+ } else if (outcome.opened) {
showTemporaryFeedback({ agent, kind: outcome.copied ? "opened_and_copied" : "opened_copy_failed" });
} else {
showTemporaryFeedback({ agent, kind: outcome.copied ? "copy_fallback" : "failed" });
@@ -80,9 +101,31 @@ export function FirstEncounterRelayChallenge(props: FirstEncounterRelayChallenge
}
}
+ function beginMemoryVerification(agent: RelayAgentOption, startedAt: string) {
+ if (feedbackTimerRef.current !== null) {
+ window.clearTimeout(feedbackTimerRef.current);
+ feedbackTimerRef.current = null;
+ }
+ setFeedback({ kind: "waiting", agent });
+ void props.onVerifyMemory?.(agent.sourceId, startedAt).then((verified) => {
+ if (!mountedRef.current) {
+ return;
+ }
+ setFeedback({ kind: verified ? "verified" : "verify_timeout", agent });
+ if (verified) {
+ props.onLifecycle?.("memory_verified", agent.sourceId, "memory_search");
+ }
+ }).catch(() => {
+ if (mountedRef.current) {
+ setFeedback({ kind: "verify_timeout", agent });
+ }
+ });
+ }
+
async function copyInstruction() {
try {
await (props.onCopyPrompt ?? copyRelayPrompt)(t("onboarding.relay.prompt"));
+ props.onLifecycle?.("relay_clicked", "", "copy_prompt");
showTemporaryFeedback({ kind: "copied" });
} catch {
showTemporaryFeedback({ kind: "copy_failed" });
@@ -154,6 +197,7 @@ export function FirstEncounterRelayChallenge(props: FirstEncounterRelayChallenge
export function FirstEncounterRelayOptIn(props: FirstEncounterRelayOptInProps) {
const { t } = useTranslation();
+ const showAction = Boolean(props.onOpenConnections);
return (
@@ -162,16 +206,20 @@ export function FirstEncounterRelayOptIn(props: FirstEncounterRelayOptInProps) {
{t("onboarding.relay.optInTitle")}
-
{t("onboarding.relay.optInBody")}
+
+ {t(showAction ? "onboarding.relay.optInBody" : "onboarding.relay.optInHint")}
+
-
+ {showAction ? (
+
+ ) : null}
);
@@ -181,7 +229,9 @@ export function relayAgentOptions(agents: RelayAgentOption[]): RelayAgentOption[
const seen = new Set();
return agents.filter((agent) => {
const sourceId = normalizeAgentSourceId(agent.sourceId);
- if (!agent.available || agent.status === "not_connected" || !RELAY_AGENT_IDS.has(sourceId) || seen.has(sourceId)) {
+ // Same presence gate as the onboarding scan list; only keep agents we can launch.
+ const present = agent.available && (agent.builtin || agent.messageCount > 0);
+ if (!present || !RELAY_AGENT_IDS.has(sourceId) || seen.has(sourceId)) {
return false;
}
seen.add(sourceId);
@@ -189,9 +239,13 @@ export function relayAgentOptions(agents: RelayAgentOption[]): RelayAgentOption[
});
}
-/** True when at least one supported agent binary/history source was detected on this machine. */
+/** True when at least one launchable agent source is present on this machine. */
export function hasDetectedRelayAgents(agents: RelayAgentOption[]): boolean {
- return agents.some((agent) => agent.available && RELAY_AGENT_IDS.has(normalizeAgentSourceId(agent.sourceId)));
+ return agents.some((agent) => (
+ agent.available
+ && (agent.builtin || agent.messageCount > 0)
+ && RELAY_AGENT_IDS.has(normalizeAgentSourceId(agent.sourceId))
+ ));
}
export function firstEncounterFollowUpMode(permission: ScanPermission): "relay" | "connect" | null {
@@ -220,6 +274,12 @@ function relayFeedbackStatus(
}
const agent = relayAgentName(feedback.agent);
switch (feedback.kind) {
+ case "waiting":
+ return { tone: "info", text: t("onboarding.relay.waiting", { agent }) };
+ case "verified":
+ return { tone: "info", text: t("onboarding.relay.verified", { agent }) };
+ case "verify_timeout":
+ return { tone: "info", text: t("onboarding.relay.verifyTimeout") };
case "opened_and_copied":
return { tone: "info", text: t("onboarding.relay.openedCopied", { agent }) };
case "copy_fallback":
diff --git a/App/frontend/desktop/src/pages/first-encounter-report.tsx b/App/frontend/desktop/src/pages/first-encounter-report.tsx
index 8fe53e796..952d3dd83 100644
--- a/App/frontend/desktop/src/pages/first-encounter-report.tsx
+++ b/App/frontend/desktop/src/pages/first-encounter-report.tsx
@@ -1,17 +1,25 @@
import { useEffect, useLayoutEffect, useRef, useState, type UIEvent } from "react";
-import { MessageCircle, Sparkles } from "lucide-react";
+import { ArrowRight, Sparkles } from "lucide-react";
import { Memmy } from "../components/mascot/memmy.js";
import { useTranslation } from "../i18n/use-translation.js";
import { AgentMessageContent } from "./agent-message-content.js";
-import type { FirstEncounterReportPayload, FirstEncounterTaskAction } from "./first-encounter-protocol.js";
+import type { FirstEncounterReportPayload } from "./first-encounter-protocol.js";
+import {
+ FirstEncounterRelayChallenge,
+ FirstEncounterRelayOptIn,
+ type RelayAgentOption
+} from "./first-encounter-relay-challenge.js";
export interface FirstEncounterReportProps {
payload: FirstEncounterReportPayload;
isStreaming: boolean;
simulateStreaming: boolean;
- onTaskClick: (action: FirstEncounterTaskAction) => void;
- onStartConversation: () => void;
- onSkip: () => void;
+ followUpMode: "relay" | "connect" | null;
+ agents: RelayAgentOption[];
+ onOpenAgent?: (sourceId: string, prompt: string) => Promise;
+ onVerifyMemory?: (sourceId: string, startedAt: string) => Promise;
+ onRelayLifecycle?: (event: "relay_clicked" | "memory_verified", sourceId: string, action: string) => void;
+ onContinue: () => void;
}
const PUNCTUATION = new Set(["。", "!", "?", ",", "、", ";", ":", ".", "!", "?", ",", ";", ":", "\n"]);
@@ -26,36 +34,24 @@ function isReportContentAtBottom(element: Pick(null);
const shouldAutoScrollReportRef = useRef(true);
const isProgrammaticReportScrollRef = useRef(false);
const userScrollIntentUntilRef = useRef(0);
const report = props.payload.body;
- const emptyHistory = props.payload.emptyHistory;
- const primaryAction = props.payload.actions[0] ?? null;
- const secondaryActions = props.payload.actions.slice(1, 3);
- const mainAction = emptyHistory ? {
- buttonLabel: t("onboarding.report.firstConversation"),
- description: t("onboarding.report.firstConversationDescription"),
- onClick: props.onStartConversation
- } : primaryAction ? {
- buttonLabel: primaryAction.buttonLabel,
- description: primaryAction.description,
- onClick: () => props.onTaskClick(primaryAction)
- } : null;
- const contentIsStreaming = props.isStreaming || (props.simulateStreaming && !showActions);
+ const contentIsStreaming = props.isStreaming || (props.simulateStreaming && !showFollowUps);
useEffect(() => {
if (props.isStreaming) {
setDisplayedText(report);
- setShowActions(false);
+ setShowFollowUps(false);
return;
}
if (!props.simulateStreaming) {
setDisplayedText(report);
- setShowActions(true);
+ setShowFollowUps(true);
return;
}
@@ -66,11 +62,11 @@ export function FirstEncounterReport(props: FirstEncounterReportProps) {
let index = 0;
let timer: number | undefined;
setDisplayedText("");
- setShowActions(false);
+ setShowFollowUps(false);
const tick = () => {
if (index >= report.length) {
- timer = window.setTimeout(() => setShowActions(true), 300);
+ timer = window.setTimeout(() => setShowFollowUps(true), 300);
return;
}
@@ -86,13 +82,13 @@ export function FirstEncounterReport(props: FirstEncounterReportProps) {
window.clearTimeout(timer);
}
};
- }, [primaryAction, props.isStreaming, props.simulateStreaming, report]);
+ }, [props.isStreaming, props.simulateStreaming, report]);
useLayoutEffect(() => {
if (shouldAutoScrollReportRef.current) {
- scrollReportToBottom(showActions ? "smooth" : "auto");
+ scrollReportToBottom(showFollowUps ? "smooth" : "auto");
}
- }, [displayedText, showActions]);
+ }, [displayedText, showFollowUps]);
function scrollReportToBottom(behavior: ScrollBehavior = "auto") {
const target = scrollRef.current;
@@ -131,84 +127,72 @@ export function FirstEncounterReport(props: FirstEncounterReportProps) {
className="my-8 flex max-h-[calc(100vh-64px)] flex-col"
style={{ width: "min(calc(100vw - 48px), clamp(600px, 64vw, 760px))" }}
>
-
-
-
-
-
-
{t("onboarding.report.title")}
+
+
+
+
+ {t("onboarding.report.userPrompt")}
+
-
{t("onboarding.report.subtitle")}
-
-
-
+
+
+
+
+
+
+
+
{t("onboarding.report.title")}
+
+
+
+ {showFollowUps && props.followUpMode === "relay" && (
+
+
+
+ )}
- {showActions && mainAction && (
-
-
- {!emptyHistory && secondaryActions.length > 0 && (
-
-
{t("onboarding.report.alternatives")}
-
- {secondaryActions.map((secondaryAction) => (
-
- ))}
-
+ {showFollowUps && props.followUpMode === "connect" && (
+
+ {/* scan_only: keep the value card, omit the connect button — this screen cannot install Agents. */}
+
)}
- )}
-
- {showActions && !emptyHistory && (
-
-
{t("onboarding.report.disclaimer")}
-
+ {showFollowUps && (
+
+
{t("onboarding.report.disclaimer")}
+
+
+ )}
- )}
+
);
}
-
-function ReportPrimaryAction(props: { buttonLabel: string; description: string; onClick: () => void }) {
- return (
-
- );
-}
diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx
index 027dafec1..8ccffc2ca 100644
--- a/App/frontend/desktop/src/pages/home-page.tsx
+++ b/App/frontend/desktop/src/pages/home-page.tsx
@@ -63,6 +63,7 @@ import { AppFrame } from "./app-frame.js";
import { mergeVoiceTranscript, useAsrRecorder } from "./asr-recorder.js";
import { FirstEncounterRelayChallenge, FirstEncounterRelayOptIn, firstEncounterFollowUpMode, hasDetectedRelayAgents, relayAgentOptions } from "./first-encounter-relay-challenge.js";
import {
+ armFirstEncounterRelayChat,
consumeFirstEncounterRelayArm,
consumePendingFirstEncounterTaskLaunch,
readFirstEncounterRelayChat,
@@ -95,6 +96,8 @@ const SLASH_COMMAND_RETRY_DELAYS_MS = [300, 1000, 2500];
* immediately, regardless of what triggered that scroll event.
*/
const AGENT_CONVERSATION_USER_SCROLL_INTENT_MS = 600;
+const FIRST_ENCOUNTER_MEMORY_VERIFY_TIMEOUT_MS = 60_000;
+const FIRST_ENCOUNTER_MEMORY_VERIFY_INTERVAL_MS = 2_000;
/** Definition for stop confirmation grace ms. */
export const STOP_CONFIRMATION_GRACE_MS = 8000;
const TRANSLATABLE_AGENT_ERROR_KEYS = new Set
([
@@ -764,6 +767,8 @@ export function HomePage() {
sourceId: source.sourceId,
displayName: source.displayName,
available: source.available,
+ builtin: source.builtin,
+ messageCount: source.messageCount,
status: source.status
}));
const relayAgents = relayAgentOptions(agentSourceOptions);
@@ -797,6 +802,17 @@ export function HomePage() {
firstEncounterRelayChatId
);
setFirstEncounterRelayReadyChatId(firstEncounterRelayChatId);
+ const completedAt = state.bootstrap?.onboarding.completedAt
+ ? Date.parse(state.bootstrap.onboarding.completedAt)
+ : Number.NaN;
+ track({
+ name: "onboarding_first_task_completed",
+ params: {
+ page_path: "/main",
+ ...(Number.isFinite(completedAt) ? { duration_ms: Math.max(0, Date.now() - completedAt) } : {})
+ },
+ consentTier: "basic"
+ });
}
}, [
firstEncounterRelayAnswerMessageId,
@@ -805,7 +821,9 @@ export function HomePage() {
isCurrentAgentRunning,
isFirstEncounterFollowUpChat,
state.agent.lastTaskCompletion?.chatId,
- state.agent.messages
+ state.agent.messages,
+ state.bootstrap?.onboarding.completedAt,
+ track
]);
const openFirstEncounterRelayAgent = useCallback(async (sourceId: string, prompt: string): Promise => {
@@ -817,13 +835,65 @@ export function HomePage() {
}
}, []);
+ const verifyFirstEncounterRelayMemory = useCallback(async (sourceId: string, startedAt: string): Promise => {
+ const client = clients?.memoryRuntime;
+ if (!client) {
+ return false;
+ }
+ const startedAtMs = Date.parse(startedAt);
+ const deadline = Date.now() + FIRST_ENCOUNTER_MEMORY_VERIFY_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ try {
+ const output = await client.listMemoryLogs({
+ tools: ["memory_search"],
+ sourceAgent: sourceId,
+ limit: 20,
+ offset: 0
+ });
+ if (output.logs.some((log) => log.success && Date.parse(log.calledAt) >= startedAtMs)) {
+ return true;
+ }
+ } catch {
+ // The logs route may be unavailable while the local Memory service is starting.
+ }
+ await new Promise((resolve) => window.setTimeout(resolve, FIRST_ENCOUNTER_MEMORY_VERIFY_INTERVAL_MS));
+ }
+ return false;
+ }, [clients?.memoryRuntime]);
+
+ const trackFirstEncounterRelayLifecycle = useCallback((
+ event: "relay_clicked" | "memory_verified",
+ sourceId: string,
+ action: string
+ ) => {
+ track({
+ name: event === "memory_verified"
+ ? "onboarding_external_memory_verified"
+ : "onboarding_relay_clicked",
+ params: {
+ page_path: "/main",
+ action,
+ ...(sourceId ? { source_id: sourceId } : {})
+ },
+ consentTier: "basic"
+ });
+ }, [track]);
+
const openFirstEncounterRelayConnections = useCallback(() => {
dispatch(appActions.navigate("/memory-sources"));
}, [dispatch]);
- const firstEncounterRelayContent = firstEncounterRelayAnchorMessageId && hasDetectedAgents
- ? firstEncounterFollowUp === "relay"
- ?
+ // scan_and_write_skill → relay list; scan_only → opt-in install card.
+ const firstEncounterRelayContent = firstEncounterRelayAnchorMessageId
+ ? firstEncounterFollowUp === "relay" && hasDetectedAgents
+ ? (
+
+ )
: firstEncounterFollowUp === "connect"
?
: null
@@ -972,7 +1042,8 @@ export function HomePage() {
}
const memmyAgent = clients.memmyAgent;
- const pendingPrompt = consumePendingFirstEncounterTaskLaunch(typeof window === "undefined" ? undefined : window.sessionStorage);
+ const storage = typeof window === "undefined" ? undefined : window.sessionStorage;
+ const pendingPrompt = consumePendingFirstEncounterTaskLaunch(storage);
if (!pendingPrompt) {
return;
}
@@ -1003,10 +1074,7 @@ export function HomePage() {
}
}).then((sent) => {
if (!sent) {
- writePendingFirstEncounterTaskLaunch(
- typeof window === "undefined" ? undefined : window.sessionStorage,
- pendingPrompt
- );
+ writePendingFirstEncounterTaskLaunch(storage, pendingPrompt);
}
});
}, [
@@ -1493,7 +1561,6 @@ export function HomePage() {
}
}
-
/**
* Automatically shrinks or expands the input box height.
*
diff --git a/App/frontend/desktop/src/pages/memory-page.tsx b/App/frontend/desktop/src/pages/memory-page.tsx
index 120b3807f..611f41c91 100644
--- a/App/frontend/desktop/src/pages/memory-page.tsx
+++ b/App/frontend/desktop/src/pages/memory-page.tsx
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { buildMemorySubPageViewEvent } from "../analytics/page-view.js";
import { useAnalytics } from "../analytics/use-analytics.js";
import { useApiClients } from "../app/providers.js";
-import { PRODUCT_TOUR_MEMORY_NAV_ANCHOR } from "../app/product-tour-layout.js";
+import {
+ PRODUCT_TOUR_MEMORY_LOGS_NAV_ANCHOR,
+ PRODUCT_TOUR_MEMORY_NAV_ANCHOR,
+ PRODUCT_TOUR_MEMORY_OVERVIEW_NAV_ANCHOR,
+ PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR
+} from "../app/product-tour-layout.js";
import type { MessageKey } from "../i18n/messages.js";
import { useTranslation } from "../i18n/use-translation.js";
import { appActions } from "../state/app-actions.js";
@@ -130,6 +135,11 @@ export function MemoryPage(props: MemoryPageProps) {
useEffect(() => {
if (props.initialSubPage) {
setActivePage(props.initialSubPage);
+ return;
+ }
+ const stored = typeof window === "undefined" ? null : readMemorySubPage(window.sessionStorage);
+ if (stored) {
+ setActivePage(stored);
}
}, [props.initialSubPage]);
@@ -169,6 +179,19 @@ export function writeMemorySubPage(storage: Storage | undefined, page: MemorySub
storage?.setItem(MEMORY_SUB_PAGE_STORAGE_KEY, page);
}
+function resolveMemoryNavTourAnchor(page: MemorySubPageId): string | undefined {
+ switch (page) {
+ case "logs":
+ return PRODUCT_TOUR_MEMORY_LOGS_NAV_ANCHOR;
+ case "overview":
+ return PRODUCT_TOUR_MEMORY_OVERVIEW_NAV_ANCHOR;
+ case "sources":
+ return PRODUCT_TOUR_MEMORY_SOURCES_NAV_ANCHOR;
+ default:
+ return undefined;
+ }
+}
+
export interface MemoryPageViewProps {
activePage: MemorySubPageId;
onActivePageChange: (page: MemorySubPageId) => void;
@@ -230,6 +253,7 @@ export function MemoryPageView(props: MemoryPageViewProps) {