recallnest 2.6.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +131 -14
- package/README_CN.md +121 -17
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +7 -5
- package/src/__tests__/access-tracker-novelty.test.ts +22 -2
- package/src/__tests__/activity-counter.test.ts +38 -38
- package/src/__tests__/age-label.test.ts +33 -0
- package/src/__tests__/cluster-consolidation.test.ts +313 -22
- package/src/__tests__/consolidation-engine.test.ts +90 -0
- package/src/__tests__/contracts.test.ts +2 -2
- package/src/__tests__/data-checkup.test.ts +31 -3
- package/src/__tests__/dream-pipeline.test.ts +41 -1
- package/src/__tests__/dream-scope-isolation.test.ts +386 -0
- package/src/__tests__/dream-write-batching.test.ts +211 -0
- package/src/__tests__/env-config-invariant.test.ts +80 -0
- package/src/__tests__/env-config.test.ts +60 -0
- package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
- package/src/__tests__/maintenance-locks.test.ts +8 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
- package/src/__tests__/memory-output.test.ts +9 -0
- package/src/__tests__/memory-utility.test.ts +256 -0
- package/src/__tests__/openai-contract.test.ts +513 -0
- package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
- package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
- package/src/__tests__/source-heartbeat.test.ts +6 -2
- package/src/__tests__/store-patch-metadata.test.ts +30 -0
- package/src/__tests__/synthesis-contract.test.ts +252 -0
- package/src/__tests__/synthesis-promotion.test.ts +369 -0
- package/src/__tests__/trusted-memory.test.ts +28 -0
- package/src/__tests__/utility-weight.test.ts +57 -0
- package/src/__tests__/verbatim-self-recall.test.ts +279 -0
- package/src/__tests__/version-manager.test.ts +35 -0
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +20 -5
- package/src/env-config.ts +53 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +23 -5
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +12 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/scope-policy.ts +22 -1
- package/src/session-output.ts +4 -3
- package/src/skill-promotion.ts +152 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
package/src/ingest.ts
CHANGED
|
@@ -1731,10 +1731,24 @@ export async function ingestCCTranscripts(
|
|
|
1731
1731
|
store: MemoryStore,
|
|
1732
1732
|
embedder: Embedder,
|
|
1733
1733
|
sourcePath: string,
|
|
1734
|
-
options: {
|
|
1734
|
+
options: {
|
|
1735
|
+
limit?: number;
|
|
1736
|
+
verbose?: boolean;
|
|
1737
|
+
noDedup?: boolean;
|
|
1738
|
+
llm?: LLMClient | null;
|
|
1739
|
+
recentHours?: number;
|
|
1740
|
+
/**
|
|
1741
|
+
* scope 前缀与来源标签。默认 "cc" —— 这个默认值是承重的:
|
|
1742
|
+
* memory-boundaries.ts 的 TRANSCRIPT_SCOPE_PREFIXES 靠前缀把会话碎片降权到
|
|
1743
|
+
* evidence 层,前缀写错等于让某个端的 shell 输出被当成"用户说过的话"。
|
|
1744
|
+
* 新增来源时必须同时在那份清单里登记,别只在这里传个新字符串。
|
|
1745
|
+
*/
|
|
1746
|
+
scopePrefix?: string;
|
|
1747
|
+
} = {},
|
|
1735
1748
|
): Promise<IngestResult> {
|
|
1749
|
+
const scopePrefix = options.scopePrefix ?? "cc";
|
|
1736
1750
|
const result: IngestResult = {
|
|
1737
|
-
source:
|
|
1751
|
+
source: scopePrefix,
|
|
1738
1752
|
filesProcessed: 0,
|
|
1739
1753
|
chunksIngested: 0,
|
|
1740
1754
|
chunksSkipped: 0,
|
|
@@ -1838,7 +1852,7 @@ export async function ingestCCTranscripts(
|
|
|
1838
1852
|
// Without LLM: queue raw chunks for later extraction instead of storing garbage
|
|
1839
1853
|
if (!options.llm) {
|
|
1840
1854
|
queueForLaterExtraction(
|
|
1841
|
-
dedupedChunks.map(c => ({ text: c.text, scope:
|
|
1855
|
+
dedupedChunks.map(c => ({ text: c.text, scope: `${scopePrefix}:${c.sessionId.slice(0, 8)}` }))
|
|
1842
1856
|
);
|
|
1843
1857
|
result.chunksSkipped += dedupedTexts.length;
|
|
1844
1858
|
continue;
|
|
@@ -1853,8 +1867,12 @@ export async function ingestCCTranscripts(
|
|
|
1853
1867
|
const chunk = dedupedChunks[j];
|
|
1854
1868
|
const ext = extractions[j];
|
|
1855
1869
|
toStore.push(buildIngestedEntry({
|
|
1856
|
-
|
|
1857
|
-
|
|
1870
|
+
// 必须跟 scope 前缀一致:memory-boundaries 有两道闸,isTranscriptScope 看 scope、
|
|
1871
|
+
// isTranscriptSource 看这个字段。写死 "cc" 时降权仍然生效(cc 也在清单里),
|
|
1872
|
+
// 所以错了不会报错、只会让「按来源过滤」静默失真——2026-08-20 接 minis 源时漏改这处,
|
|
1873
|
+
// 库里出现 10 条 scope=minis: 而 source=cc 的记录。
|
|
1874
|
+
source: scopePrefix,
|
|
1875
|
+
scope: `${scopePrefix}:${chunk.sessionId.slice(0, 8)}`,
|
|
1858
1876
|
text: dedupedTexts[j],
|
|
1859
1877
|
vector: dedupedVectors[j],
|
|
1860
1878
|
extraction: ext,
|
package/src/llm-client.ts
CHANGED
|
@@ -16,6 +16,19 @@
|
|
|
16
16
|
import OpenAI from "openai";
|
|
17
17
|
import { logInfo } from "./stderr-log.js";
|
|
18
18
|
import { isEmotionScoringEnabled } from "./memory-schema.js";
|
|
19
|
+
import { synthesisModel } from "./env-config.js";
|
|
20
|
+
import {
|
|
21
|
+
CLUSTER_INSIGHT_PROMPT,
|
|
22
|
+
CLUSTER_PATTERN_PROMPT,
|
|
23
|
+
allocateClusterBudget,
|
|
24
|
+
numberClusterTexts,
|
|
25
|
+
parseSynthesisJson,
|
|
26
|
+
validateSynthesis,
|
|
27
|
+
type SynthesisVerdict,
|
|
28
|
+
} from "./synthesis-contract.js";
|
|
29
|
+
|
|
30
|
+
/** dream 合成单次调用的超时。离线批处理,宽松换稳定,见 `runSynthesis` 处的注释。 */
|
|
31
|
+
const SYNTHESIS_TIMEOUT_MS = 60_000;
|
|
19
32
|
|
|
20
33
|
// ============================================================================
|
|
21
34
|
// LME-9: Circuit Breaker — LLM 降级策略
|
|
@@ -691,31 +704,64 @@ export class LLMClient {
|
|
|
691
704
|
}
|
|
692
705
|
}
|
|
693
706
|
|
|
707
|
+
// --------------------------------------------------------------------------
|
|
708
|
+
// dream 合成(契约见 synthesis-contract.ts)
|
|
709
|
+
//
|
|
710
|
+
// 这两个方法一起取代了原来的「`generateL0` 当 insight 用 + `extractPattern` 找性格
|
|
711
|
+
// 模式」。三点结构性差别,不是提示词微调:
|
|
712
|
+
// 1. 有弃权路径 —— 炼不出结论时返回 `abstained`,不再硬凑一句进库;
|
|
713
|
+
// 2. 强制 evidence 编号 —— 说不出出处的结论不写库;
|
|
714
|
+
// 3. 输入按成员等额分配预算 —— 不再让簇的前 2,000 字吃光整个上下文。
|
|
715
|
+
// 三者都在 `SynthesisVerdict` 里有形状,调用方据此分开计数,不再退化成一个 null。
|
|
716
|
+
// --------------------------------------------------------------------------
|
|
717
|
+
|
|
694
718
|
/**
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
* @returns Pattern text if found, null if no pattern or LLM failure.
|
|
719
|
+
* 从一簇相关记忆里提炼一条**可复用结论**(规则/因果/决策/踩坑/判断转折)。
|
|
720
|
+
* 提炼不出来返回 `abstained` —— 这条路径是新的,老 `generateL0` 只会在 LLM 失败时
|
|
721
|
+
* 不产出,「这簇不值得留」从来没有表达方式。
|
|
700
722
|
*/
|
|
701
|
-
async
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
723
|
+
async synthesizeClusterInsight(clusterTexts: string[]): Promise<SynthesisVerdict> {
|
|
724
|
+
return this.runSynthesis(clusterTexts, CLUSTER_INSIGHT_PROMPT, "conclusion", 1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 从一簇相关记忆里提炼**跨条目才成立**的结论。
|
|
729
|
+
* evidence ≥2 是定义性要求:只有一条支撑的东西按定义不是跨条目模式。
|
|
730
|
+
*/
|
|
731
|
+
async synthesizeClusterPattern(clusterTexts: string[]): Promise<SynthesisVerdict> {
|
|
732
|
+
if (clusterTexts.length < 3) return { status: "abstained" };
|
|
733
|
+
return this.runSynthesis(clusterTexts, CLUSTER_PATTERN_PROMPT, "pattern", 2);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private async runSynthesis(
|
|
737
|
+
clusterTexts: string[],
|
|
738
|
+
system: string,
|
|
739
|
+
textField: string,
|
|
740
|
+
minEvidence: number,
|
|
741
|
+
): Promise<SynthesisVerdict> {
|
|
742
|
+
if (clusterTexts.length === 0) return { status: "abstained" };
|
|
743
|
+
|
|
744
|
+
const budgeted = allocateClusterBudget(clusterTexts);
|
|
745
|
+
const user = `记忆条目(共 ${clusterTexts.length} 条):\n${numberClusterTexts(budgeted)}`;
|
|
746
|
+
// max_tokens 放宽到 400:默认的 500 是给整段回复的,而这里只要一个小 JSON;
|
|
747
|
+
// 给足余量是为了避免 evidence 数组被截断——截断的 JSON 会整条判 unparsable,
|
|
748
|
+
// 一条本可用的结论就这么丢了。
|
|
749
|
+
// max_tokens 沿用默认 500,不另设:2026-08-23 实测 60 次合成调用 finish_reason
|
|
750
|
+
// 全为 stop、completion_tokens 最大仅 132,输出侧根本够不到上限。
|
|
751
|
+
const raw = await this.chatRaw(system, user, {
|
|
752
|
+
model: synthesisModel(),
|
|
753
|
+
// 默认 15s 是给 2,000 字输入定的。等额预算后单次输入可达 12,000 字,更强的
|
|
754
|
+
// 合成模型也更慢;沿用 15s 会把「模型正在正常工作」误判成失败,而 dream 是
|
|
755
|
+
// 离线批处理,等一会儿不影响任何人。
|
|
756
|
+
timeoutMs: SYNTHESIS_TIMEOUT_MS,
|
|
757
|
+
});
|
|
716
758
|
|
|
717
|
-
|
|
718
|
-
|
|
759
|
+
return validateSynthesis({
|
|
760
|
+
parsed: parseSynthesisJson(raw),
|
|
761
|
+
textField,
|
|
762
|
+
memberCount: clusterTexts.length,
|
|
763
|
+
minEvidence,
|
|
764
|
+
});
|
|
719
765
|
}
|
|
720
766
|
|
|
721
767
|
/**
|
|
@@ -843,24 +889,39 @@ export class LLMClient {
|
|
|
843
889
|
// --------------------------------------------------------------------------
|
|
844
890
|
|
|
845
891
|
private async chat(system: string, user: string): Promise<string | null> {
|
|
892
|
+
return this.chatRaw(system, user);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* `chat()` 的可调版:允许按调用点覆盖模型与 token 上限,其余行为(熔断、超时、
|
|
897
|
+
* 温度、异常语义)完全一致。
|
|
898
|
+
*
|
|
899
|
+
* 加这一层而不是给 `chat()` 加参数,是为了让「默认调用」的形状一个字不变 ——
|
|
900
|
+
* 现有十来个调用点不该因为合成这一处的需要而被动改签名。
|
|
901
|
+
*/
|
|
902
|
+
private async chatRaw(
|
|
903
|
+
system: string,
|
|
904
|
+
user: string,
|
|
905
|
+
opts?: { model?: string; maxTokens?: number; timeoutMs?: number },
|
|
906
|
+
): Promise<string | null> {
|
|
846
907
|
// LME-9: Circuit breaker — short-circuit when LLM is down
|
|
847
908
|
if (!this.breaker.canAttempt()) {
|
|
848
909
|
return null;
|
|
849
910
|
}
|
|
850
911
|
|
|
851
912
|
const controller = new AbortController();
|
|
852
|
-
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
913
|
+
const timeout = setTimeout(() => controller.abort(), opts?.timeoutMs ?? this.timeoutMs);
|
|
853
914
|
|
|
854
915
|
try {
|
|
855
916
|
const response = await this.client.chat.completions.create(
|
|
856
917
|
{
|
|
857
|
-
model: this.model,
|
|
918
|
+
model: opts?.model || this.model,
|
|
858
919
|
messages: [
|
|
859
920
|
{ role: "system", content: system },
|
|
860
921
|
{ role: "user", content: user },
|
|
861
922
|
],
|
|
862
923
|
temperature: this.temperature,
|
|
863
|
-
max_tokens: 500,
|
|
924
|
+
max_tokens: opts?.maxTokens ?? 500,
|
|
864
925
|
},
|
|
865
926
|
{ signal: controller.signal },
|
|
866
927
|
);
|
package/src/mcp-server.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Tool tiers:
|
|
10
10
|
* - core: Always exposed (7 tools)
|
|
11
11
|
* - advanced: Exposed by default, includes core (30 tools)
|
|
12
|
-
* - full: All tools including governance (
|
|
12
|
+
* - full: All tools including governance (44 tools)
|
|
13
13
|
*
|
|
14
14
|
* Control: RECALLNEST_MCP_TIER=core|advanced|full
|
|
15
15
|
*/
|
|
@@ -79,6 +79,7 @@ const TOOL_TIERS: Record<string, ToolTier> = {
|
|
|
79
79
|
distill_session: "advanced",
|
|
80
80
|
scan_skill_promotions: "governance",
|
|
81
81
|
promote_scan: "governance",
|
|
82
|
+
promote_synthesis: "governance",
|
|
82
83
|
manage_alias: "governance",
|
|
83
84
|
forget_memory: "advanced",
|
|
84
85
|
|
|
@@ -633,10 +633,11 @@ registerTool(
|
|
|
633
633
|
"dream",
|
|
634
634
|
"Run a full memory consolidation cycle (Orient, Gather, Consolidate, Prune). Side effect: may archive low-value entries and generate insight memories. Use when memory count is high and you need periodic maintenance.",
|
|
635
635
|
{
|
|
636
|
-
scope: z.string().min(1).max(160).optional().describe("Scope to consolidate, e.g. 'project:myapp'. Omit to consolidate across all scopes"),
|
|
636
|
+
scope: z.string().min(1).max(160).optional().describe("Scope to consolidate, e.g. 'project:myapp'. Matched exactly by default — a scope name is treated as itself, not as a prefix family. Omit to consolidate across all scopes"),
|
|
637
637
|
force: z.boolean().default(false).describe("Set to true to force consolidation even if recent write count is below the automatic threshold"),
|
|
638
|
+
familyMatch: z.boolean().default(false).describe("Set to true to also consolidate child scopes sharing this prefix (e.g. 'memory' would additionally pull in 'memory:pivot'). Off by default: merging sibling scopes into one clustering round mixes unrelated material."),
|
|
638
639
|
},
|
|
639
|
-
async ({ scope, force }) => {
|
|
640
|
+
async ({ scope, force, familyMatch }) => {
|
|
640
641
|
const resolvedScope = scope || "project:default";
|
|
641
642
|
const components = getComponents();
|
|
642
643
|
const result = await runDream({
|
|
@@ -646,6 +647,10 @@ registerTool(
|
|
|
646
647
|
scope: resolvedScope,
|
|
647
648
|
force,
|
|
648
649
|
kgStore: getKGStore(),
|
|
650
|
+
// MCP 调用方给的都是具体 scope 名,family 语义在这里是意外面:
|
|
651
|
+
// `dream({scope: "memory"})` 会连 `memory:pivot` 一起卷进去(2026-08-16 实测)。
|
|
652
|
+
// 默认改精确是**行为变更**,要 family 得显式要。
|
|
653
|
+
scopeMatch: familyMatch ? "family" : "exact",
|
|
649
654
|
});
|
|
650
655
|
return {
|
|
651
656
|
content: [{ type: "text" as const, text: formatDreamResult(result) }],
|
package/src/mcp-tools-core.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { buildSessionCheckpointResult } from "./session-engine.js";
|
|
|
13
13
|
import { formatCheckpointSaved, formatCheckpointSummary, formatResumeContext } from "./session-output.js";
|
|
14
14
|
import { matchesTemporalConstraint, type TemporalConstraint } from "./temporal-parser.js";
|
|
15
15
|
import { buildManagedCheckpointObservation, buildManagedResumeObservation } from "./workflow-observation-managed.js";
|
|
16
|
+
import { PROCESS_READER_ID, recentRecallHits } from "./recall-ledger.js";
|
|
16
17
|
import { buildWorkflowObservationRecord } from "./workflow-observation-engine.js";
|
|
17
18
|
import type { ToolRegistryDeps } from "./mcp-tool-deps.js";
|
|
18
19
|
import type { RetrievalResult } from "./retriever.js";
|
|
@@ -65,7 +66,16 @@ export function registerCoreTools(deps: ToolRegistryDeps): void {
|
|
|
65
66
|
|
|
66
67
|
async function saveManagedObservation(observation: Parameters<typeof buildWorkflowObservationRecord>[0]): Promise<void> {
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
+
// P0 join key:managed observation 是最该带它的一类——`resume_context` 本身
|
|
70
|
+
// 就是「读了一批记忆」的动作,它的成败与那批记忆直接相关。
|
|
71
|
+
// (api-server 那份同名函数刻意不加:HTTP server 一个进程服务多个客户端,
|
|
72
|
+
// 「一进程 ≈ 一会话」的前提在那里不成立,加了只会 join 出假配对。)
|
|
73
|
+
const recalledIds = recentRecallHits();
|
|
74
|
+
const record = buildWorkflowObservationRecord({
|
|
75
|
+
readerId: PROCESS_READER_ID,
|
|
76
|
+
...(recalledIds.length > 0 ? { recalledIds } : {}),
|
|
77
|
+
...(observation as Record<string, unknown>),
|
|
78
|
+
});
|
|
69
79
|
await workflowObservationStore.save(record);
|
|
70
80
|
} catch (error) {
|
|
71
81
|
console.error("[RecallNest MCP] Failed to persist managed workflow observation:", error);
|
|
@@ -8,13 +8,20 @@ import { formatConflictAudit, formatConflictClusters, formatConflictEscalation,
|
|
|
8
8
|
import { ConflictStatusSchema } from "./conflict-schema.js";
|
|
9
9
|
import { ConsolidationEngine, formatConsolidationResult } from "./consolidation-engine.js";
|
|
10
10
|
import { archiveDirtyBriefAsset, listDirtyBriefAssets } from "./memory-assets.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
scanMemoryPromotions,
|
|
13
|
+
scanSynthesisPromotions,
|
|
14
|
+
buildPromoteScanDeps,
|
|
15
|
+
formatPromoteScanResult,
|
|
16
|
+
formatSynthesisPromoteResult,
|
|
17
|
+
} from "./memory-promotion.js";
|
|
12
18
|
import { aliasMapFilePath, expandQuery, explainUserAliases, listUserAliases, removeUserAlias, upsertUserAlias } from "./query-expander.js";
|
|
13
19
|
import { recordSkillOutcome } from "./skill-engine.js";
|
|
14
20
|
import { scanForPromotions, formatPromotionResult } from "./skill-promotion.js";
|
|
15
21
|
import { buildWorkflowEvidence, buildWorkflowObservationRecord, inspectWorkflowDashboard, inspectWorkflowHealth } from "./workflow-observation-engine.js";
|
|
16
22
|
import { formatWorkflowEvidencePack, formatWorkflowHealthDashboard, formatWorkflowHealthReport, formatWorkflowObservationSaved } from "./workflow-observation-output.js";
|
|
17
23
|
import { WorkflowObservationOutcomeSchema } from "./workflow-observation-schema.js";
|
|
24
|
+
import { PROCESS_READER_ID, recentRecallHits } from "./recall-ledger.js";
|
|
18
25
|
import type { ToolRegistryDeps } from "./mcp-tool-deps.js";
|
|
19
26
|
import { withLock } from "./distill-lock.js";
|
|
20
27
|
|
|
@@ -36,8 +43,14 @@ registerTool(
|
|
|
36
43
|
tools: z.array(z.string().min(1).max(60)).max(6).default([]).describe("Optional tools involved"),
|
|
37
44
|
skillId: z.string().min(1).max(128).optional().describe("Optional skill id to bump. When present and outcome is success, the skill's successCount increments; otherwise failureCount increments. Missing/corrupt skills are skipped silently (does not block observation write)."),
|
|
38
45
|
idempotencyKey: z.string().min(1).max(160).optional().describe("Optional stable request key; repeated saves with the same key replace the prior observation"),
|
|
46
|
+
recalledIds: z.array(z.string().min(1).max(128)).max(64).optional().describe("Optional memory ids this task actually used. Omit to auto-fill from what this session retrieved in the last 30 minutes — that auto-fill is what makes (memory, outcome) value back-propagation possible."),
|
|
39
47
|
},
|
|
40
|
-
async ({ workflowId, outcome, summary, scope, source, signal, task, tags, tools, skillId, idempotencyKey }) => {
|
|
48
|
+
async ({ workflowId, outcome, summary, scope, source, signal, task, tags, tools, skillId, idempotencyKey, recalledIds }) => {
|
|
49
|
+
// P0 join key:不传就用本进程的检索账本自动补。这一步是价值回溯的前提——
|
|
50
|
+
// 少了它,库里就只剩「有结果没参与者」和「有参与者没结果」两堆对不上的数据。
|
|
51
|
+
const resolvedRecalledIds = recalledIds && recalledIds.length > 0
|
|
52
|
+
? recalledIds
|
|
53
|
+
: recentRecallHits();
|
|
41
54
|
const record = buildWorkflowObservationRecord({
|
|
42
55
|
workflowId,
|
|
43
56
|
outcome,
|
|
@@ -50,6 +63,8 @@ registerTool(
|
|
|
50
63
|
tools,
|
|
51
64
|
skillId,
|
|
52
65
|
idempotencyKey,
|
|
66
|
+
readerId: PROCESS_READER_ID,
|
|
67
|
+
...(resolvedRecalledIds.length > 0 ? { recalledIds: resolvedRecalledIds } : {}),
|
|
53
68
|
});
|
|
54
69
|
const stored = await workflowObservationStore.save(record);
|
|
55
70
|
|
|
@@ -332,6 +347,7 @@ registerTool(
|
|
|
332
347
|
getById: store.getById.bind(store),
|
|
333
348
|
vectorSearch: store.vectorSearch.bind(store),
|
|
334
349
|
update: async () => null, // no-op in dry-run
|
|
350
|
+
patchMetadataBatch: async () => 0, // no-op in dry-run(3a 批量写通道,2026-08-14)
|
|
335
351
|
};
|
|
336
352
|
const engine = new ConsolidationEngine(readOnlyStore, { clusterThreshold, mergeThreshold, maxEntriesPerRun: maxEntries }, getKGStore());
|
|
337
353
|
const result = await engine.run(scope);
|
|
@@ -377,11 +393,13 @@ registerTool(
|
|
|
377
393
|
{
|
|
378
394
|
scope: z.string().min(1).max(160).describe("Project scope to scan for promotion candidates"),
|
|
379
395
|
minOccurrences: z.number().min(2).max(20).default(3).describe("Minimum similar cases to trigger a promotion suggestion"),
|
|
396
|
+
minDistinctSources: z.number().min(1).max(10).default(2).describe("How many DIFFERENT sources (episodes) a cluster must span. Counting similar entries is not the same as counting distinct experiences — the same pothole hit three times is one experience, not a rule. Set 1 to disable this check. Abstains (never rejects) when the entries carry no source pointer."),
|
|
380
397
|
},
|
|
381
|
-
async ({ scope, minOccurrences }) => {
|
|
398
|
+
async ({ scope, minOccurrences, minDistinctSources }) => {
|
|
382
399
|
const { store } = getComponents();
|
|
383
400
|
const result = await scanForPromotions(store, scope, {
|
|
384
401
|
minCaseOccurrences: minOccurrences,
|
|
402
|
+
minDistinctSources,
|
|
385
403
|
});
|
|
386
404
|
|
|
387
405
|
return {
|
|
@@ -472,4 +490,28 @@ registerTool(
|
|
|
472
490
|
};
|
|
473
491
|
},
|
|
474
492
|
);
|
|
493
|
+
|
|
494
|
+
registerTool(
|
|
495
|
+
"promote_synthesis",
|
|
496
|
+
"Scan dream-synthesized conclusions (cluster insights and cross-memory patterns, which are written on the evidence layer and therefore excluded from stable memory) and promote the well-supported ones into a durable copy. Eligibility comes from the conclusion's own validated evidence set, not from repetition. Default dry-run (no writes); when dryRun=false it writes durable entries through the same promote path as promote_memory, so canonicalKey dedup makes re-runs a revision rather than a duplicate. The synthesized row itself is never modified.",
|
|
497
|
+
{
|
|
498
|
+
scope: z.string().min(1).max(160).describe("Scope to scan, e.g. memory"),
|
|
499
|
+
dryRun: z.boolean().default(true).describe("When true (default), only report candidates without writing"),
|
|
500
|
+
minDistinctEvidence: z.number().int().min(2).max(20).default(2).describe("Min distinct, still-active evidence memories behind the conclusion"),
|
|
501
|
+
minImportance: z.number().min(0).max(1).default(0.6).describe("Min importance of the synthesized conclusion itself"),
|
|
502
|
+
},
|
|
503
|
+
async ({ scope, dryRun, minDistinctEvidence, minImportance }) => {
|
|
504
|
+
const { store, embedder } = getComponents();
|
|
505
|
+
const kgExtractor = getKGExtractor();
|
|
506
|
+
const deps = buildPromoteScanDeps({ store, embedder, conflictStore, kgExtractor });
|
|
507
|
+
const result = await scanSynthesisPromotions(deps, scope, { dryRun, minDistinctEvidence, minImportance });
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
content: [{
|
|
511
|
+
type: "text" as const,
|
|
512
|
+
text: formatSynthesisPromoteResult(result),
|
|
513
|
+
}],
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
);
|
|
475
517
|
}
|
package/src/memory-evolution.ts
CHANGED
|
@@ -115,6 +115,22 @@ export function patchEvolution(
|
|
|
115
115
|
return JSON.stringify(parsed);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* patchEvolution 的对象版:给 patchMetadataBatch 的 patchFn 用(那条通道进出都是
|
|
120
|
+
* 已解析的 metadata 对象,不该为了复用字符串版而反复 stringify/parse)。
|
|
121
|
+
* 就地修改并返回同一对象,语义与 patchEvolution 一致(浅合并 evolution 子对象)。
|
|
122
|
+
*/
|
|
123
|
+
export function patchEvolutionOnMeta(
|
|
124
|
+
meta: Record<string, unknown>,
|
|
125
|
+
patch: Partial<EvolutionMetadata>,
|
|
126
|
+
): Record<string, unknown> {
|
|
127
|
+
const existing = meta.evolution !== null && typeof meta.evolution === "object" && !Array.isArray(meta.evolution)
|
|
128
|
+
? (meta.evolution as Partial<EvolutionMetadata>)
|
|
129
|
+
: undefined;
|
|
130
|
+
meta.evolution = { ...existing, ...patch };
|
|
131
|
+
return meta;
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
// ---------------------------------------------------------------------------
|
|
119
135
|
// Predicates
|
|
120
136
|
// ---------------------------------------------------------------------------
|
package/src/memory-output.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatAgeLabel } from "./age-label.js";
|
|
1
2
|
import type { MemoryEntry } from "./store.js";
|
|
2
3
|
import type { RetrievalResult } from "./retriever.js";
|
|
3
4
|
import type { RetrievalProfileName } from "./retrieval-profiles.js";
|
|
@@ -41,6 +42,7 @@ export interface DistilledEvidence {
|
|
|
41
42
|
source: string;
|
|
42
43
|
scope: string;
|
|
43
44
|
date: string;
|
|
45
|
+
age: string;
|
|
44
46
|
retrievalPath: string;
|
|
45
47
|
snippet: string;
|
|
46
48
|
}
|
|
@@ -332,6 +334,7 @@ function buildSearchRow(index: number, query: string, result: RetrievalResult):
|
|
|
332
334
|
getTierLabel(result).padEnd(10),
|
|
333
335
|
getSourceLabel(result).padEnd(7),
|
|
334
336
|
getDateLabel(result.entry.timestamp),
|
|
337
|
+
formatAgeLabel(result.entry.timestamp).padEnd(5),
|
|
335
338
|
getRetrievalPath(result).padEnd(20),
|
|
336
339
|
getFileLabel(result),
|
|
337
340
|
cleanSnippet(pickBestSnippet(query, result.entry.text), 120),
|
|
@@ -374,13 +377,13 @@ export function formatFullResults(
|
|
|
374
377
|
`Profile : ${context.profile}`,
|
|
375
378
|
`Hits : ${results.length}`,
|
|
376
379
|
"",
|
|
377
|
-
"# ID Score Category Tier Source Date Retrieval Path File / Snippet",
|
|
378
|
-
"-- -------- ----- ------------ ---------- ------- ---------- -------------------- --------------",
|
|
380
|
+
"# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
|
|
381
|
+
"-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
|
|
379
382
|
];
|
|
380
383
|
|
|
381
384
|
for (let i = 0; i < results.length; i++) {
|
|
382
385
|
const row = buildSearchRow(i, context.query, results[i]);
|
|
383
|
-
lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} | ${row[
|
|
386
|
+
lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
|
|
384
387
|
lines.push(` prov : ${getProvenanceSummary(results[i])}`);
|
|
385
388
|
// Full mode: append metadata details
|
|
386
389
|
const meta = parseMetadata(results[i].entry);
|
|
@@ -424,13 +427,13 @@ export function formatSearchResults(
|
|
|
424
427
|
`Profile : ${context.profile}`,
|
|
425
428
|
`Hits : ${results.length}`,
|
|
426
429
|
"",
|
|
427
|
-
"# ID Score Category Tier Source Date Retrieval Path File / Snippet",
|
|
428
|
-
"-- -------- ----- ------------ ---------- ------- ---------- -------------------- --------------",
|
|
430
|
+
"# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
|
|
431
|
+
"-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
|
|
429
432
|
];
|
|
430
433
|
|
|
431
434
|
for (let i = 0; i < results.length; i++) {
|
|
432
435
|
const row = buildSearchRow(i, context.query, results[i]);
|
|
433
|
-
lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} | ${row[
|
|
436
|
+
lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
|
|
434
437
|
lines.push(` prov : ${getProvenanceSummary(results[i])}`);
|
|
435
438
|
// Freshness: only shown for memories that declared dependsOn (opt-in, cheap check).
|
|
436
439
|
const fresh = evaluateEntryFreshness(results[i].entry.metadata, freshnessCache);
|
|
@@ -529,7 +532,7 @@ export function formatExplainResults(
|
|
|
529
532
|
const session = getSessionLabel(result);
|
|
530
533
|
const why = buildWhyMatched(context.query, result);
|
|
531
534
|
|
|
532
|
-
lines.push(`${i + 1}. ${result.entry.id.slice(0, 8)} | ${score} | ${getSourceLabel(result)} | ${getDateLabel(result.entry.timestamp)}`);
|
|
535
|
+
lines.push(`${i + 1}. ${result.entry.id.slice(0, 8)} | ${score} | ${getSourceLabel(result)} | ${getDateLabel(result.entry.timestamp)} (${formatAgeLabel(result.entry.timestamp)})`);
|
|
533
536
|
lines.push(` category: ${getCategoryLabel(result)}`);
|
|
534
537
|
lines.push(` tier : ${getTierLabel(result)}`);
|
|
535
538
|
lines.push(` path : ${retrieval}`);
|
|
@@ -592,6 +595,7 @@ export function summarizeResults(
|
|
|
592
595
|
source: getSourceLabel(result),
|
|
593
596
|
scope: result.entry.scope,
|
|
594
597
|
date: getDateLabel(result.entry.timestamp),
|
|
598
|
+
age: formatAgeLabel(result.entry.timestamp),
|
|
595
599
|
retrievalPath: getRetrievalPath(result),
|
|
596
600
|
snippet: pickBestSnippet(context.query, normalizeRecallText(result)),
|
|
597
601
|
});
|
|
@@ -654,7 +658,7 @@ export function distillResults(
|
|
|
654
658
|
|
|
655
659
|
lines.push("", "Evidence");
|
|
656
660
|
summary.evidence.forEach((item, index) => {
|
|
657
|
-
lines.push(`${index + 1}. ${item.source} | ${item.date} | ${item.retrievalPath} | ${item.snippet}`);
|
|
661
|
+
lines.push(`${index + 1}. ${item.source} | ${item.date} (${item.age}) | ${item.retrievalPath} | ${item.snippet}`);
|
|
658
662
|
});
|
|
659
663
|
|
|
660
664
|
lines.push("", "Reusable Memory Candidates");
|