recallnest 1.4.1
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/.claude-plugin/CLAUDE.md +29 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.env.example +3 -0
- package/.github/workflows/ci.yml +28 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +51 -0
- package/CLAUDE.md +42 -0
- package/DEMO.md +94 -0
- package/EVAL.md +89 -0
- package/FAILURES.md +75 -0
- package/LICENSE +21 -0
- package/OPERATIONS.md +59 -0
- package/README.md +427 -0
- package/README_CN.md +425 -0
- package/ROADMAP.md +198 -0
- package/assets/recallnest-workbench.png +0 -0
- package/assets/ui/app.js +807 -0
- package/assets/ui/index.html +154 -0
- package/assets/ui/styles.css +605 -0
- package/assets/wechat-qr.jpg +0 -0
- package/config.json.example +38 -0
- package/daily-recall.sh +87 -0
- package/daily-reflections/2026-03-05.md +15 -0
- package/daily-reflections/2026-03-06.md +15 -0
- package/daily-reflections/2026-03-07.md +7 -0
- package/daily-reflections/2026-03-08.md +34 -0
- package/daily-reflections/2026-03-09.md +35 -0
- package/daily-reflections/2026-03-10.md +36 -0
- package/daily-reflections/2026-03-11.md +35 -0
- package/daily-reflections/2026-03-12.md +36 -0
- package/daily-reflections/2026-03-13.md +35 -0
- package/daily-reflections/2026-03-14.md +35 -0
- package/daily-reflections/2026-03-15.md +35 -0
- package/daily-reflections/2026-03-16.md +41 -0
- package/daily-reflections/2026-03-17.md +10 -0
- package/daily-reflections/2026-03-18.md +10 -0
- package/daily-reflections/2026-03-19.md +10 -0
- package/daily-reflections/2026-03-20.md +10 -0
- package/daily-reflections/2026-03-21.md +10 -0
- package/daily-reflections/2026-03-22.md +10 -0
- package/daily-reflections/2026-03-23.md +10 -0
- package/daily-reflections/2026-03-24.md +10 -0
- package/daily-reflections/2026-03-25.md +32 -0
- package/daily-reflections/2026-03-26.md +37 -0
- package/daily-reflections/2026-03-27.md +35 -0
- package/daily-reflections/2026-03-28.md +35 -0
- package/daily-reflections/2026-03-29.md +35 -0
- package/daily-reflections/2026-03-30.md +36 -0
- package/daily-reflections/2026-03-31.md +35 -0
- package/daily-reflections/2026-04-01.md +35 -0
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/memory-boundary-contract.md +226 -0
- package/docs/memory-categories.md +119 -0
- package/docs/pipeline-audit-ultramemory.md +177 -0
- package/docs/plans/2026-03-08-query-expansion.md +270 -0
- package/docs/plans/2026-03-16-continuity-layer.md +196 -0
- package/docs/plans/mcp-tiering-plan.md +126 -0
- package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
- package/docs/self-evolution.md +198 -0
- package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
- package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
- package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
- package/eval/cases.json +85 -0
- package/eval/continuity/case-seeds.json +90 -0
- package/eval/continuity/cases.json +302 -0
- package/eval/continuity/memory-seeds.json +26 -0
- package/eval/continuity/pattern-seeds.json +100 -0
- package/eval/continuity/results-2026-03-16.md +117 -0
- package/eval/continuity/results-2026-03-19.md +453 -0
- package/eval/continuity/runbook.md +110 -0
- package/eval/continuity/seed-cases.md +54 -0
- package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
- package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
- package/eval/reports/2026-03-06-baseline.md +82 -0
- package/eval/reports/query-expansion.md +138 -0
- package/integrations/README.md +57 -0
- package/integrations/claude-code/README.md +133 -0
- package/integrations/claude-code/claude-md-snippet.md +31 -0
- package/integrations/claude-code/setup.sh +66 -0
- package/integrations/claude-code/smoke-continuity.sh +452 -0
- package/integrations/codex/README.md +49 -0
- package/integrations/codex/agents-md-snippet.md +31 -0
- package/integrations/codex/setup.sh +58 -0
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
- package/integrations/examples/langchain/memory-chain.py +111 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
- package/integrations/gemini-cli/README.md +57 -0
- package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
- package/integrations/gemini-cli/setup.sh +64 -0
- package/integrations/shared/install-managed-block.sh +51 -0
- package/lm +19 -0
- package/package.json +50 -0
- package/plan-v1.2-distributable.md +105 -0
- package/scripts/archive-distilled-junk.ts +116 -0
- package/scripts/backfill-l0l1l2.ts +98 -0
- package/scripts/blind-scan.ts +588 -0
- package/scripts/cleanup-step1-exact-dups.ts +117 -0
- package/scripts/cleanup-step2-template-dups.ts +113 -0
- package/scripts/cleanup-step3-stale-content.ts +149 -0
- package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
- package/scripts/delete-distilled-junk.ts +88 -0
- package/scripts/distill-facts.ts +305 -0
- package/scripts/health-check.ts +343 -0
- package/scripts/incremental-ingest.sh +71 -0
- package/scripts/mlp-health-check.ts +382 -0
- package/scripts/pull-claude-desktop.ts +515 -0
- package/scripts/re-ingest-cc.sh +66 -0
- package/scripts/rebalance-memory-health.ts +181 -0
- package/scripts/run-desktop-ingest.sh +11 -0
- package/scripts/start-server.sh +12 -0
- package/scripts/viz-dashboard.html +423 -0
- package/scripts/viz-export.ts +703 -0
- package/src/__tests__/access-tracker-novelty.test.ts +228 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/capture-engine.test.ts +1142 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +33 -0
- package/src/__tests__/confidence-tracker.test.ts +196 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +374 -0
- package/src/__tests__/consolidation-engine.test.ts +181 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/doctor.test.ts +303 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/hotness-blend.test.ts +76 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/kg-extractor.test.ts +184 -0
- package/src/__tests__/kg-store.test.ts +198 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/memory-boundaries.test.ts +237 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +230 -0
- package/src/__tests__/preference-slots.test.ts +106 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4743 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/session-checkpoint.test.ts +259 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-output-compressor.test.ts +133 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation.test.ts +400 -0
- package/src/access-tracker.ts +316 -0
- package/src/adaptive-retrieval.ts +64 -0
- package/src/api-server.ts +667 -0
- package/src/asset-sync.ts +79 -0
- package/src/auto-consolidation.ts +133 -0
- package/src/auto-gc.ts +124 -0
- package/src/auto-recall.ts +95 -0
- package/src/capture-engine.ts +931 -0
- package/src/capture-heuristic.ts +177 -0
- package/src/cascade-forget.ts +135 -0
- package/src/chunker.ts +253 -0
- package/src/cli.ts +1917 -0
- package/src/confidence-tracker.ts +166 -0
- package/src/conflict-advisor.ts +433 -0
- package/src/conflict-engine.ts +181 -0
- package/src/conflict-escalation.ts +138 -0
- package/src/conflict-lifecycle.ts +67 -0
- package/src/conflict-output.ts +218 -0
- package/src/conflict-schema.ts +130 -0
- package/src/conflict-store.ts +127 -0
- package/src/consolidation-engine.ts +256 -0
- package/src/context-composer-pins.ts +183 -0
- package/src/context-composer-queries.ts +121 -0
- package/src/context-composer-scope.ts +56 -0
- package/src/context-composer-stable-selection.ts +200 -0
- package/src/context-composer-stable.ts +217 -0
- package/src/context-composer-task-fallbacks.ts +36 -0
- package/src/context-composer-task-ranking.ts +197 -0
- package/src/context-composer-task-results.ts +137 -0
- package/src/context-composer-task-selection.ts +342 -0
- package/src/context-composer-text.ts +57 -0
- package/src/context-composer.ts +300 -0
- package/src/context-renderer.ts +179 -0
- package/src/decay-engine.ts +189 -0
- package/src/doctor.ts +543 -0
- package/src/embedder.ts +557 -0
- package/src/entity-resolver.ts +131 -0
- package/src/eval.ts +632 -0
- package/src/event-segmenter.ts +216 -0
- package/src/ingest.ts +1822 -0
- package/src/kg-extractor.ts +217 -0
- package/src/kg-store.ts +353 -0
- package/src/legacy-scope-review.ts +53 -0
- package/src/llm-client.ts +629 -0
- package/src/llm-consolidation.ts +168 -0
- package/src/mcp-server.ts +1337 -0
- package/src/memory-assets.ts +475 -0
- package/src/memory-boundaries.ts +354 -0
- package/src/memory-health-rebalance.ts +220 -0
- package/src/memory-output.ts +474 -0
- package/src/memory-schema.ts +184 -0
- package/src/multi-vector.ts +234 -0
- package/src/noise-filter.ts +155 -0
- package/src/ppr-traversal.ts +279 -0
- package/src/preference-matcher.ts +174 -0
- package/src/preference-slots.ts +259 -0
- package/src/prospective-memory.ts +198 -0
- package/src/query-entity-detector.ts +207 -0
- package/src/query-expander.ts +98 -0
- package/src/result-synthesizer.ts +114 -0
- package/src/retrieval-profiles.ts +101 -0
- package/src/retrieval-trace.ts +94 -0
- package/src/retriever.ts +1338 -0
- package/src/retroactive-boost.ts +154 -0
- package/src/rif.ts +73 -0
- package/src/runtime-config.ts +172 -0
- package/src/schema-utils.ts +56 -0
- package/src/scope-inventory.ts +310 -0
- package/src/scope-policy.ts +142 -0
- package/src/session-engine.ts +168 -0
- package/src/session-output.ts +96 -0
- package/src/session-schema.ts +60 -0
- package/src/session-store.ts +293 -0
- package/src/stderr-log.ts +16 -0
- package/src/store.ts +903 -0
- package/src/temporal-parser.ts +318 -0
- package/src/term-registry.ts +791 -0
- package/src/tool-output-compressor.ts +115 -0
- package/src/tracker.ts +57 -0
- package/src/ui-server.ts +430 -0
- package/src/version-manager.ts +172 -0
- package/src/workflow-observation-engine.ts +316 -0
- package/src/workflow-observation-managed.ts +83 -0
- package/src/workflow-observation-output.ts +86 -0
- package/src/workflow-observation-schema.ts +87 -0
- package/src/workflow-observation-scope-review.ts +409 -0
- package/src/workflow-observation-store.ts +69 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { MemoryCategorySchema, MemoryImportanceSchema, MemoryScopeSchema, MemoryTextSchema, StoreMemorySourceSchema, DurableMemoryCategorySchema, CanonicalKeySchema } from "./memory-schema.js";
|
|
4
|
+
import { MEMORY_AUTHORITIES, MEMORY_CONFLICT_POLICIES, MEMORY_LAYERS } from "./memory-boundaries.js";
|
|
5
|
+
import { identifierSchema, optionalBoundedStringSchema } from "./schema-utils.js";
|
|
6
|
+
|
|
7
|
+
const BoundaryMetadataSchema = z.object({
|
|
8
|
+
layer: z.enum(MEMORY_LAYERS),
|
|
9
|
+
authority: z.enum(MEMORY_AUTHORITIES),
|
|
10
|
+
conflictPolicy: z.enum(MEMORY_CONFLICT_POLICIES),
|
|
11
|
+
originalCategory: DurableMemoryCategorySchema.optional(),
|
|
12
|
+
downgradedFrom: DurableMemoryCategorySchema.optional(),
|
|
13
|
+
note: optionalBoundedStringSchema(240),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const ConflictReasonSchema = z.enum([
|
|
17
|
+
"promotion_conflicts_with_existing_durable",
|
|
18
|
+
"canonical_key_conflicts_with_existing_durable",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export const ConflictStatusSchema = z.enum([
|
|
22
|
+
"open",
|
|
23
|
+
"accepted-incoming",
|
|
24
|
+
"kept-existing",
|
|
25
|
+
"merged",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const ConflictExistingMemorySchema = z.object({
|
|
29
|
+
memoryId: identifierSchema("memoryId", 128),
|
|
30
|
+
text: MemoryTextSchema,
|
|
31
|
+
category: MemoryCategorySchema,
|
|
32
|
+
scope: identifierSchema("scope", 160),
|
|
33
|
+
importance: MemoryImportanceSchema,
|
|
34
|
+
metadata: z.string().min(1).max(8000),
|
|
35
|
+
boundary: BoundaryMetadataSchema.nullable().optional(),
|
|
36
|
+
canonicalKey: CanonicalKeySchema,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const ConflictIncomingMemorySchema = z.object({
|
|
40
|
+
text: MemoryTextSchema,
|
|
41
|
+
category: DurableMemoryCategorySchema,
|
|
42
|
+
scope: identifierSchema("scope", 160),
|
|
43
|
+
importance: MemoryImportanceSchema,
|
|
44
|
+
metadata: z.string().min(1).max(8000),
|
|
45
|
+
source: StoreMemorySourceSchema,
|
|
46
|
+
sourceMemoryId: identifierSchema("sourceMemoryId", 128).optional(),
|
|
47
|
+
sourceCategory: MemoryCategorySchema.optional(),
|
|
48
|
+
sourceBoundary: BoundaryMetadataSchema.nullable().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const ConflictCandidateInputSchema = z.object({
|
|
52
|
+
canonicalKey: z.string().min(1).max(120),
|
|
53
|
+
category: DurableMemoryCategorySchema,
|
|
54
|
+
fingerprint: z.string().min(1).max(240),
|
|
55
|
+
reason: ConflictReasonSchema,
|
|
56
|
+
existing: ConflictExistingMemorySchema,
|
|
57
|
+
incoming: ConflictIncomingMemorySchema,
|
|
58
|
+
createdAt: z.string().datetime().default(() => new Date().toISOString()),
|
|
59
|
+
updatedAt: z.string().datetime().default(() => new Date().toISOString()),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const ConflictCandidateRecordSchema = ConflictCandidateInputSchema.extend({
|
|
63
|
+
conflictId: identifierSchema("conflictId", 128),
|
|
64
|
+
status: ConflictStatusSchema.default("open"),
|
|
65
|
+
reopenCount: z.number().int().min(0).default(0),
|
|
66
|
+
escalationCount: z.number().int().min(0).default(0),
|
|
67
|
+
lastReopenedAt: z.string().datetime().optional(),
|
|
68
|
+
lastEscalatedAt: z.string().datetime().optional(),
|
|
69
|
+
lastEscalationAttention: z.enum(["stale", "escalated"]).optional(),
|
|
70
|
+
resolvedAt: z.string().datetime().optional(),
|
|
71
|
+
resolutionNotes: optionalBoundedStringSchema(320),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const ResolveConflictInputSchema = z.object({
|
|
75
|
+
conflictId: identifierSchema("conflictId", 128),
|
|
76
|
+
resolution: z.enum(["accept_incoming", "keep_existing", "merge"]),
|
|
77
|
+
mergedText: MemoryTextSchema.optional(),
|
|
78
|
+
notes: optionalBoundedStringSchema(320),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const ConflictResolutionResultSchema = z.object({
|
|
82
|
+
conflictId: identifierSchema("conflictId", 128),
|
|
83
|
+
status: ConflictStatusSchema,
|
|
84
|
+
updatedAt: z.string().datetime(),
|
|
85
|
+
resolvedAt: z.string().datetime(),
|
|
86
|
+
updatedMemoryId: identifierSchema("updatedMemoryId", 128).optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const EscalateConflictsInputSchema = z.object({
|
|
90
|
+
attention: z.enum(["stale", "escalated"]).optional(),
|
|
91
|
+
canonicalKey: CanonicalKeySchema.optional(),
|
|
92
|
+
limit: z.number().int().min(1).max(500).default(100),
|
|
93
|
+
top: z.number().int().min(1).max(20).default(10),
|
|
94
|
+
apply: z.boolean().default(false),
|
|
95
|
+
notes: optionalBoundedStringSchema(320),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const ConflictEscalationResultItemSchema = z.object({
|
|
99
|
+
conflictId: identifierSchema("conflictId", 128),
|
|
100
|
+
canonicalKey: CanonicalKeySchema,
|
|
101
|
+
attention: z.enum(["stale", "escalated"]),
|
|
102
|
+
openAgeDays: z.number().int().min(0),
|
|
103
|
+
reopenCount: z.number().int().min(0),
|
|
104
|
+
escalationCount: z.number().int().min(0),
|
|
105
|
+
suggestedResolution: z.enum(["keep_existing", "accept_incoming", "manual_review"]),
|
|
106
|
+
confidence: z.enum(["low", "medium", "high"]),
|
|
107
|
+
action: z.enum(["pending", "escalated", "already-escalated"]),
|
|
108
|
+
clusterKey: z.string().min(1).max(240),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const EscalateConflictsResultSchema = z.object({
|
|
112
|
+
apply: z.boolean(),
|
|
113
|
+
scanned: z.number().int().min(0),
|
|
114
|
+
eligible: z.number().int().min(0),
|
|
115
|
+
escalated: z.number().int().min(0),
|
|
116
|
+
skipped: z.number().int().min(0),
|
|
117
|
+
items: z.array(ConflictEscalationResultItemSchema),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export type ConflictReason = z.infer<typeof ConflictReasonSchema>;
|
|
121
|
+
export type ConflictStatus = z.infer<typeof ConflictStatusSchema>;
|
|
122
|
+
export type ConflictExistingMemory = z.infer<typeof ConflictExistingMemorySchema>;
|
|
123
|
+
export type ConflictIncomingMemory = z.infer<typeof ConflictIncomingMemorySchema>;
|
|
124
|
+
export type ConflictCandidateInput = z.infer<typeof ConflictCandidateInputSchema>;
|
|
125
|
+
export type ConflictCandidateRecord = z.infer<typeof ConflictCandidateRecordSchema>;
|
|
126
|
+
export type ResolveConflictInput = z.infer<typeof ResolveConflictInputSchema>;
|
|
127
|
+
export type ConflictResolutionResult = z.infer<typeof ConflictResolutionResultSchema>;
|
|
128
|
+
export type EscalateConflictsInput = z.infer<typeof EscalateConflictsInputSchema>;
|
|
129
|
+
export type ConflictEscalationResultItem = z.infer<typeof ConflictEscalationResultItemSchema>;
|
|
130
|
+
export type EscalateConflictsResult = z.infer<typeof EscalateConflictsResultSchema>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ConflictCandidateRecord, ConflictStatus } from "./conflict-schema.js";
|
|
5
|
+
import { ConflictCandidateRecordSchema } from "./conflict-schema.js";
|
|
6
|
+
|
|
7
|
+
export interface ConflictCandidateQuery {
|
|
8
|
+
status?: ConflictStatus;
|
|
9
|
+
canonicalKey?: string;
|
|
10
|
+
limit?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ensureDir(dir: string): string {
|
|
14
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sortNewestFirst(records: ConflictCandidateRecord[]): ConflictCandidateRecord[] {
|
|
19
|
+
return [...records].sort((a, b) => {
|
|
20
|
+
const timeDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
|
21
|
+
if (timeDiff !== 0) return timeDiff;
|
|
22
|
+
return b.conflictId.localeCompare(a.conflictId);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ConflictCandidateStore {
|
|
27
|
+
constructor(private readonly dir = resolve(import.meta.dir, "../data/conflict-candidates")) {}
|
|
28
|
+
|
|
29
|
+
get dataDir(): string {
|
|
30
|
+
return ensureDir(this.dir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async save(record: ConflictCandidateRecord): Promise<ConflictCandidateRecord> {
|
|
34
|
+
const parsed = ConflictCandidateRecordSchema.parse(record);
|
|
35
|
+
const timestampToken = parsed.createdAt.replace(/[:.]/g, "-");
|
|
36
|
+
const path = join(this.dataDir, `${timestampToken}-${parsed.conflictId}.json`);
|
|
37
|
+
writeFileSync(path, JSON.stringify(parsed, null, 2) + "\n");
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async replace(record: ConflictCandidateRecord): Promise<ConflictCandidateRecord> {
|
|
42
|
+
const parsed = ConflictCandidateRecordSchema.parse(record);
|
|
43
|
+
const existingPath = this.findPathById(parsed.conflictId);
|
|
44
|
+
const timestampToken = parsed.createdAt.replace(/[:.]/g, "-");
|
|
45
|
+
const path = existingPath || join(this.dataDir, `${timestampToken}-${parsed.conflictId}.json`);
|
|
46
|
+
writeFileSync(path, JSON.stringify(parsed, null, 2) + "\n");
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async listRecent(query: ConflictCandidateQuery = {}): Promise<ConflictCandidateRecord[]> {
|
|
51
|
+
const { status, canonicalKey, limit = 20 } = query;
|
|
52
|
+
const files = readdirSync(this.dataDir)
|
|
53
|
+
.filter((name) => name.endsWith(".json"))
|
|
54
|
+
.map((name) => join(this.dataDir, name));
|
|
55
|
+
|
|
56
|
+
const items: ConflictCandidateRecord[] = [];
|
|
57
|
+
for (const path of files) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = ConflictCandidateRecordSchema.parse(
|
|
60
|
+
JSON.parse(readFileSync(path, "utf-8")),
|
|
61
|
+
);
|
|
62
|
+
if (status && parsed.status !== status) continue;
|
|
63
|
+
if (canonicalKey && parsed.canonicalKey !== canonicalKey) continue;
|
|
64
|
+
items.push(parsed);
|
|
65
|
+
} catch {
|
|
66
|
+
// Skip corrupt conflict files.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return sortNewestFirst(items).slice(0, limit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async getById(conflictId: string): Promise<ConflictCandidateRecord | null> {
|
|
74
|
+
const path = this.findPathById(conflictId);
|
|
75
|
+
if (!path) return null;
|
|
76
|
+
try {
|
|
77
|
+
return ConflictCandidateRecordSchema.parse(
|
|
78
|
+
JSON.parse(readFileSync(path, "utf-8")),
|
|
79
|
+
);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getOpenByFingerprint(fingerprint: string): Promise<ConflictCandidateRecord | null> {
|
|
86
|
+
const items = await this.listRecent({ status: "open", limit: 200 });
|
|
87
|
+
return items.find((item) => item.fingerprint === fingerprint) || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getLatestByFingerprint(fingerprint: string): Promise<ConflictCandidateRecord | null> {
|
|
91
|
+
const items = await this.listRecent({ limit: 200 });
|
|
92
|
+
return items.find((item) => item.fingerprint === fingerprint) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private findPathById(conflictId: string): string | null {
|
|
96
|
+
const exact = readdirSync(this.dataDir)
|
|
97
|
+
.filter((name) => name.endsWith(`-${conflictId}.json`))
|
|
98
|
+
.map((name) => join(this.dataDir, name));
|
|
99
|
+
if (exact.length > 0) {
|
|
100
|
+
return exact[0] || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const files = readdirSync(this.dataDir)
|
|
104
|
+
.filter((name) => name.endsWith(".json"))
|
|
105
|
+
.map((name) => join(this.dataDir, name));
|
|
106
|
+
|
|
107
|
+
const matches: string[] = [];
|
|
108
|
+
for (const path of files) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = ConflictCandidateRecordSchema.parse(
|
|
111
|
+
JSON.parse(readFileSync(path, "utf-8")),
|
|
112
|
+
);
|
|
113
|
+
if (parsed.conflictId.startsWith(conflictId)) {
|
|
114
|
+
matches.push(path);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Skip corrupt conflict files.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (matches.length > 1) {
|
|
122
|
+
throw new Error(`Ambiguous conflict prefix "${conflictId}" matches ${matches.length} conflicts. Use a longer prefix or full ID.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return matches[0] || null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Consolidation Engine
|
|
3
|
+
*
|
|
4
|
+
* Borrowed from UltraMemory's consolidation-engine.ts, adapted for RecallNest:
|
|
5
|
+
* - Uses RecallNest's store.vectorSearch() + store.update() (no patchMetadata)
|
|
6
|
+
* - Integrates with RecallNest's existing conflict-engine for conflict creation
|
|
7
|
+
* - Uses RecallNest's metadata structure (boundary.layer, canonicalKey, etc.)
|
|
8
|
+
*
|
|
9
|
+
* Algorithm:
|
|
10
|
+
* 1. List entries in scope, filter active ones, group by category
|
|
11
|
+
* 2. For each category: cluster entries by vector similarity (>= clusterThreshold)
|
|
12
|
+
* 3. Within each cluster:
|
|
13
|
+
* - Merge near-duplicates (>= mergeThreshold) → archive weaker entry
|
|
14
|
+
* - Link related entries (cluster but below merge) → add clustered_with
|
|
15
|
+
* - Detect contradictions via heuristic
|
|
16
|
+
*
|
|
17
|
+
* LLM-free — deterministic clustering and merge only.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { MemoryEntry, MemorySearchResult, MemoryStore } from "./store.js";
|
|
21
|
+
import { createVersionGroup } from "./version-manager.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Config & Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface ConsolidationConfig {
|
|
28
|
+
/** Minimum cosine similarity to form a cluster (default 0.82) */
|
|
29
|
+
clusterThreshold: number;
|
|
30
|
+
/** Minimum cosine similarity to merge (archive the weaker entry) (default 0.92) */
|
|
31
|
+
mergeThreshold: number;
|
|
32
|
+
/** Maximum entries to scan per consolidation run (default 500) */
|
|
33
|
+
maxEntriesPerRun: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_CONSOLIDATION_CONFIG: ConsolidationConfig = {
|
|
37
|
+
clusterThreshold: 0.82,
|
|
38
|
+
mergeThreshold: 0.92,
|
|
39
|
+
maxEntriesPerRun: 500,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface ConflictEvent {
|
|
43
|
+
memoryA: string;
|
|
44
|
+
memoryB: string;
|
|
45
|
+
type: "heuristic_contradiction";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConsolidationResult {
|
|
49
|
+
originalCount: number;
|
|
50
|
+
clustersFound: number;
|
|
51
|
+
mergedCount: number;
|
|
52
|
+
relationsAdded: number;
|
|
53
|
+
conflictsDetected: ConflictEvent[];
|
|
54
|
+
scope: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function parseMetadata(entry: MemoryEntry): Record<string, unknown> {
|
|
62
|
+
if (!entry.metadata) return {};
|
|
63
|
+
try { return JSON.parse(entry.metadata); } catch { return {}; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isActive(entry: MemoryEntry): boolean {
|
|
67
|
+
const meta = parseMetadata(entry);
|
|
68
|
+
return meta.state !== "archived" && meta.state !== "superseded";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function canonicalScore(entry: MemoryEntry): number {
|
|
72
|
+
const meta = parseMetadata(entry);
|
|
73
|
+
const accessCount = typeof meta.accessCount === "number" ? meta.accessCount : 0;
|
|
74
|
+
return entry.importance * (1 + Math.log(accessCount + 1));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Simple heuristic contradiction: negation pattern check between two texts. */
|
|
78
|
+
function detectHeuristicContradiction(textA: string, textB: string): boolean {
|
|
79
|
+
const a = textA.toLowerCase();
|
|
80
|
+
const b = textB.toLowerCase();
|
|
81
|
+
|
|
82
|
+
// Pattern: one says "X is Y" and the other says "X is not Y" (or vice versa)
|
|
83
|
+
const negationPairs = [
|
|
84
|
+
[/\bnot\b/, /\b(?:always|must|should|is|are|was|were)\b/],
|
|
85
|
+
[/\bnever\b/, /\b(?:always|every|each)\b/],
|
|
86
|
+
[/\bdisable/, /\benable/],
|
|
87
|
+
[/不要|不用|别/, /必须|一定|总是/],
|
|
88
|
+
[/从不/, /每次|总是|一直/],
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
for (const [negRe, posRe] of negationPairs) {
|
|
92
|
+
if ((negRe.test(a) && posRe.test(b)) || (negRe.test(b) && posRe.test(a))) {
|
|
93
|
+
// Check they share at least one significant term (to avoid false positives)
|
|
94
|
+
const wordsA = new Set(a.split(/\s+/).filter(w => w.length > 3));
|
|
95
|
+
const wordsB = new Set(b.split(/\s+/).filter(w => w.length > 3));
|
|
96
|
+
for (const w of wordsA) {
|
|
97
|
+
if (wordsB.has(w)) return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Store interface (duck-typed to avoid hard dependency)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
type ConsolidationStore = Pick<MemoryStore, "list" | "getById" | "vectorSearch" | "update">;
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Engine
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export class ConsolidationEngine {
|
|
115
|
+
constructor(
|
|
116
|
+
private store: ConsolidationStore,
|
|
117
|
+
private config: ConsolidationConfig = DEFAULT_CONSOLIDATION_CONFIG,
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
async run(scope: string): Promise<ConsolidationResult> {
|
|
121
|
+
const { clusterThreshold, mergeThreshold, maxEntriesPerRun } = this.config;
|
|
122
|
+
|
|
123
|
+
// 1. Fetch entries in scope
|
|
124
|
+
const entries = await this.store.list([scope], undefined, maxEntriesPerRun, 0);
|
|
125
|
+
const active = entries.filter(isActive);
|
|
126
|
+
|
|
127
|
+
if (active.length === 0) {
|
|
128
|
+
return { originalCount: 0, clustersFound: 0, mergedCount: 0, relationsAdded: 0, conflictsDetected: [], scope };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. Group by category
|
|
132
|
+
const byCategory = new Map<string, MemoryEntry[]>();
|
|
133
|
+
for (const e of active) {
|
|
134
|
+
const cat = e.category;
|
|
135
|
+
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
|
136
|
+
byCategory.get(cat)!.push(e);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let clustersFound = 0;
|
|
140
|
+
let mergedCount = 0;
|
|
141
|
+
let relationsAdded = 0;
|
|
142
|
+
const conflictsDetected: ConflictEvent[] = [];
|
|
143
|
+
|
|
144
|
+
// 3. Cluster within each category
|
|
145
|
+
for (const [, catEntries] of byCategory) {
|
|
146
|
+
if (catEntries.length < 2) continue;
|
|
147
|
+
|
|
148
|
+
const clustered = new Set<string>();
|
|
149
|
+
const clusters = new Map<string, string[]>(); // seed ID → member IDs
|
|
150
|
+
|
|
151
|
+
for (const entry of catEntries) {
|
|
152
|
+
if (clustered.has(entry.id)) continue;
|
|
153
|
+
|
|
154
|
+
const full = await this.store.getById(entry.id);
|
|
155
|
+
if (!full?.vector?.length) continue;
|
|
156
|
+
|
|
157
|
+
const similar = await this.store.vectorSearch(
|
|
158
|
+
full.vector, 10, clusterThreshold, [scope],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const members = similar.filter(
|
|
162
|
+
s => s.entry.id !== entry.id && !clustered.has(s.entry.id) && s.entry.category === entry.category,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (members.length === 0) continue;
|
|
166
|
+
|
|
167
|
+
const memberIds = [entry.id, ...members.map(s => s.entry.id)];
|
|
168
|
+
clusters.set(entry.id, memberIds);
|
|
169
|
+
for (const id of memberIds) clustered.add(id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
clustersFound += clusters.size;
|
|
173
|
+
|
|
174
|
+
// 4. Process each cluster
|
|
175
|
+
for (const [, memberIds] of clusters) {
|
|
176
|
+
const memberEntries: MemoryEntry[] = [];
|
|
177
|
+
for (const id of memberIds) {
|
|
178
|
+
const full = await this.store.getById(id);
|
|
179
|
+
if (full) memberEntries.push(full);
|
|
180
|
+
}
|
|
181
|
+
if (memberEntries.length < 2) continue;
|
|
182
|
+
|
|
183
|
+
// Determine canonical: highest canonicalScore
|
|
184
|
+
const sorted = [...memberEntries].sort((a, b) => canonicalScore(b) - canonicalScore(a));
|
|
185
|
+
const canonical = sorted[0];
|
|
186
|
+
|
|
187
|
+
if (!canonical.vector?.length) continue;
|
|
188
|
+
|
|
189
|
+
// Get similarity scores relative to canonical
|
|
190
|
+
const pairResults = await this.store.vectorSearch(
|
|
191
|
+
canonical.vector, memberEntries.length + 5, clusterThreshold, [scope],
|
|
192
|
+
);
|
|
193
|
+
const scoreMap = new Map<string, number>();
|
|
194
|
+
for (const r of pairResults) scoreMap.set(r.entry.id, r.score);
|
|
195
|
+
|
|
196
|
+
for (const member of sorted.slice(1)) {
|
|
197
|
+
const sim = scoreMap.get(member.id) ?? clusterThreshold;
|
|
198
|
+
|
|
199
|
+
if (sim >= mergeThreshold) {
|
|
200
|
+
// Tier 3.3: Version coexistence instead of SUPERSEDE.
|
|
201
|
+
// Both entries stay active but are grouped — retrieval picks the best.
|
|
202
|
+
await createVersionGroup(this.store, canonical, member, scope);
|
|
203
|
+
mergedCount++;
|
|
204
|
+
} else {
|
|
205
|
+
// Link: add clustered_with relation
|
|
206
|
+
const memberMeta = parseMetadata(member);
|
|
207
|
+
memberMeta.clustered_with = canonical.id;
|
|
208
|
+
await this.store.update(member.id, { metadata: JSON.stringify(memberMeta) }, [scope]);
|
|
209
|
+
|
|
210
|
+
const canonMeta = parseMetadata(canonical);
|
|
211
|
+
if (!Array.isArray(canonMeta.cluster_members)) canonMeta.cluster_members = [];
|
|
212
|
+
if (!(canonMeta.cluster_members as string[]).includes(member.id)) {
|
|
213
|
+
(canonMeta.cluster_members as string[]).push(member.id);
|
|
214
|
+
}
|
|
215
|
+
await this.store.update(canonical.id, { metadata: JSON.stringify(canonMeta) }, [scope]);
|
|
216
|
+
relationsAdded++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Conflict detection within cluster
|
|
221
|
+
for (let i = 0; i < memberEntries.length; i++) {
|
|
222
|
+
for (let j = i + 1; j < memberEntries.length; j++) {
|
|
223
|
+
if (detectHeuristicContradiction(memberEntries[i].text, memberEntries[j].text)) {
|
|
224
|
+
conflictsDetected.push({
|
|
225
|
+
memoryA: memberEntries[i].id,
|
|
226
|
+
memoryB: memberEntries[j].id,
|
|
227
|
+
type: "heuristic_contradiction",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { originalCount: active.length, clustersFound, mergedCount, relationsAdded, conflictsDetected, scope };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Format a ConsolidationResult for display. */
|
|
240
|
+
export function formatConsolidationResult(result: ConsolidationResult): string {
|
|
241
|
+
const lines = [
|
|
242
|
+
`Consolidation complete for scope: ${result.scope}`,
|
|
243
|
+
`Scanned: ${result.originalCount} active entries`,
|
|
244
|
+
`Clusters found: ${result.clustersFound}`,
|
|
245
|
+
`Merged (versioned): ${result.mergedCount}`,
|
|
246
|
+
`Relations added: ${result.relationsAdded}`,
|
|
247
|
+
`Conflicts detected: ${result.conflictsDetected.length}`,
|
|
248
|
+
];
|
|
249
|
+
if (result.conflictsDetected.length > 0) {
|
|
250
|
+
lines.push("", "Conflicts:");
|
|
251
|
+
for (const c of result.conflictsDetected) {
|
|
252
|
+
lines.push(` ${c.type}: ${c.memoryA.slice(0, 8)} ↔ ${c.memoryB.slice(0, 8)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return lines.join("\n");
|
|
256
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { PinAsset } from "./memory-assets.js";
|
|
2
|
+
import { extractBoundaryMetadata, extractCanonicalKey } from "./memory-boundaries.js";
|
|
3
|
+
import { buildProjectScopeCueTerms, normalizeScopedValue } from "./context-composer-scope.js";
|
|
4
|
+
import { cleanText, dedupeText, stripConversationMarkers } from "./context-composer-text.js";
|
|
5
|
+
import {
|
|
6
|
+
TASK_CUE_EXTRACTION_LIMIT,
|
|
7
|
+
buildTaskHintTerms,
|
|
8
|
+
containsLowSignalStableTerm,
|
|
9
|
+
extractTerms,
|
|
10
|
+
looksLikeLowSignalTaskResult,
|
|
11
|
+
looksLikePlanishTaskResult,
|
|
12
|
+
looksLikeStableInstruction,
|
|
13
|
+
normalizeText,
|
|
14
|
+
} from "./term-registry.js";
|
|
15
|
+
|
|
16
|
+
function isUsefulPinnedTaskTerm(term: string): boolean {
|
|
17
|
+
const normalized = normalizeText(term);
|
|
18
|
+
if (!normalized) return false;
|
|
19
|
+
if (looksLikeStableInstruction(normalized)) return false;
|
|
20
|
+
if (containsLowSignalStableTerm(normalized)) return false;
|
|
21
|
+
if (normalized.includes("刚才")) return false;
|
|
22
|
+
if (normalized.endsWith("那个") || normalized === "那个") return false;
|
|
23
|
+
if (["窗口", "window", "系统", "system", "项目", "project"].includes(normalized)) return false;
|
|
24
|
+
|
|
25
|
+
if (/[\p{Script=Han}]/u.test(term)) {
|
|
26
|
+
return normalized.length >= 3;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return normalized.length >= 4;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scorePin(asset: PinAsset, taskTerms: string[], scope?: string): number {
|
|
33
|
+
let score = 0;
|
|
34
|
+
const haystack = `${asset.title} ${asset.summary} ${asset.tags.join(" ")}`.toLowerCase();
|
|
35
|
+
for (const term of taskTerms) {
|
|
36
|
+
if (haystack.includes(term)) score += 2;
|
|
37
|
+
}
|
|
38
|
+
if (scope && asset.source.scope === scope) score += 3;
|
|
39
|
+
return score;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatPinnedContext(asset: PinAsset, taskTerms: string[]): string {
|
|
43
|
+
const title = asset.title.trim();
|
|
44
|
+
const summary = asset.summary.trim();
|
|
45
|
+
const hasStandaloneTitle = title.length > 0 && !title.startsWith("[");
|
|
46
|
+
const base = hasStandaloneTitle && summary && !normalizeText(summary).includes(normalizeText(title))
|
|
47
|
+
? `Pinned: ${title}: ${summary}`
|
|
48
|
+
: `Pinned: ${summary || title}`;
|
|
49
|
+
|
|
50
|
+
const combined = `${title} ${summary} ${asset.tags.join(" ")}`.toLowerCase();
|
|
51
|
+
const snippet = cleanText(stripConversationMarkers(asset.snippet), 120);
|
|
52
|
+
const shouldAppendSnippet = Boolean(
|
|
53
|
+
snippet &&
|
|
54
|
+
taskTerms.some((term) => {
|
|
55
|
+
const normalized = normalizeText(term);
|
|
56
|
+
return normalized.length >= 2 && snippet.toLowerCase().includes(normalized) && !combined.includes(normalized);
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return cleanText(
|
|
61
|
+
shouldAppendSnippet
|
|
62
|
+
? `${base} Snippet: ${snippet}`
|
|
63
|
+
: base,
|
|
64
|
+
220,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isDurablePinAsset(asset: PinAsset): boolean {
|
|
69
|
+
if (asset.type === "pinned-memory") return true;
|
|
70
|
+
|
|
71
|
+
const metadata = JSON.stringify(asset.source.metadata || {});
|
|
72
|
+
const boundary = extractBoundaryMetadata(metadata);
|
|
73
|
+
if (boundary?.layer === "durable") return true;
|
|
74
|
+
if (extractCanonicalKey(metadata)) return true;
|
|
75
|
+
return asset.source.scope.startsWith("memory:") || asset.source.scope.startsWith("asset:");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function looksLikeConversationalPinnedText(text: string): boolean {
|
|
79
|
+
const normalized = normalizeText(text);
|
|
80
|
+
if (!normalized) return true;
|
|
81
|
+
if (looksLikeStableInstruction(normalized)) return true;
|
|
82
|
+
if (looksLikePlanishTaskResult(normalized)) return true;
|
|
83
|
+
if (looksLikeLowSignalTaskResult(normalized)) return true;
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
"现在看",
|
|
87
|
+
"现在查",
|
|
88
|
+
"现在更新",
|
|
89
|
+
"现在修改",
|
|
90
|
+
"现在修",
|
|
91
|
+
"现在补",
|
|
92
|
+
"现在排查",
|
|
93
|
+
"现在确认",
|
|
94
|
+
"现在检查",
|
|
95
|
+
"现在同步",
|
|
96
|
+
"现在处理",
|
|
97
|
+
"刚才",
|
|
98
|
+
"刚刚",
|
|
99
|
+
"i'll",
|
|
100
|
+
"i will",
|
|
101
|
+
"let me",
|
|
102
|
+
"going to",
|
|
103
|
+
].some((prefix) => normalized.startsWith(prefix));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isConversationalPinnedAsset(asset: PinAsset): boolean {
|
|
107
|
+
const rawLead = `${asset.title} ${asset.summary}`.trim();
|
|
108
|
+
if (!/^\[(用户|助手|Pinned Asset|Memory Brief)\]/i.test(rawLead)) return false;
|
|
109
|
+
if (looksLikeConversationalPinnedText(stripConversationMarkers(rawLead))) return true;
|
|
110
|
+
return !isDurablePinAsset(asset) || !asset.source.scope.startsWith("memory:");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isUsefulPinnedAsset(asset: PinAsset): boolean {
|
|
114
|
+
if (isConversationalPinnedAsset(asset)) return false;
|
|
115
|
+
|
|
116
|
+
const stripped = stripConversationMarkers(`${asset.title} ${asset.summary}`);
|
|
117
|
+
const normalized = normalizeText(stripped);
|
|
118
|
+
if (!normalized || normalized.length < 8) return false;
|
|
119
|
+
if (looksLikeStableInstruction(normalized)) return false;
|
|
120
|
+
if (containsLowSignalStableTerm(normalized)) return false;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isRelevantToScopedPinnedContext(asset: PinAsset, scope?: string): boolean {
|
|
125
|
+
if (!scope) return true;
|
|
126
|
+
|
|
127
|
+
const requestScope = normalizeScopedValue(scope);
|
|
128
|
+
const assetScope = normalizeScopedValue(asset.source.scope);
|
|
129
|
+
if (assetScope === requestScope) return true;
|
|
130
|
+
|
|
131
|
+
const requestIsProject = requestScope.startsWith("project:");
|
|
132
|
+
const assetIsProject = assetScope.startsWith("project:");
|
|
133
|
+
if (!requestIsProject || !assetIsProject) return true;
|
|
134
|
+
|
|
135
|
+
const cueTerms = buildProjectScopeCueTerms(scope);
|
|
136
|
+
if (cueTerms.length === 0) return false;
|
|
137
|
+
|
|
138
|
+
const haystack = normalizeText(stripConversationMarkers(`${asset.title} ${asset.summary} ${asset.tags.join(" ")}`));
|
|
139
|
+
return cueTerms.some((term) => haystack.includes(term));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function selectPinnedContext(
|
|
143
|
+
assets: Array<PinAsset & { path: string }>,
|
|
144
|
+
params: {
|
|
145
|
+
taskSeed?: string;
|
|
146
|
+
scope?: string;
|
|
147
|
+
limit: number;
|
|
148
|
+
styleFocused?: boolean;
|
|
149
|
+
skipForStyleTask?: boolean;
|
|
150
|
+
},
|
|
151
|
+
): string[] {
|
|
152
|
+
const { taskSeed, scope, limit, styleFocused, skipForStyleTask } = params;
|
|
153
|
+
if (styleFocused && skipForStyleTask) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const taskTerms = dedupeText([
|
|
158
|
+
...buildTaskHintTerms(taskSeed),
|
|
159
|
+
...extractTerms(taskSeed, TASK_CUE_EXTRACTION_LIMIT).filter((term) => isUsefulPinnedTaskTerm(term)),
|
|
160
|
+
...(styleFocused ? ["写作", "文章", "风格", "语气", "口语化", "不端着", "自嘲", "style", "tone", "voice"] : []),
|
|
161
|
+
], 24);
|
|
162
|
+
const requirePositiveMatch = taskTerms.length > 0 || Boolean(scope);
|
|
163
|
+
const ranked = assets
|
|
164
|
+
.filter((asset) => isUsefulPinnedAsset(asset) && isRelevantToScopedPinnedContext(asset, scope))
|
|
165
|
+
.map((asset, index) => ({
|
|
166
|
+
asset,
|
|
167
|
+
score: scorePin(asset, taskTerms, scope)
|
|
168
|
+
+ ((styleFocused && isDurablePinAsset(asset)) ? 2 : 0)
|
|
169
|
+
- index * 0.01,
|
|
170
|
+
}))
|
|
171
|
+
.filter((item) =>
|
|
172
|
+
(!requirePositiveMatch || item.score > 0) &&
|
|
173
|
+
(!styleFocused || isDurablePinAsset(item.asset)),
|
|
174
|
+
)
|
|
175
|
+
.sort((a, b) => b.score - a.score);
|
|
176
|
+
|
|
177
|
+
return dedupeText(
|
|
178
|
+
ranked
|
|
179
|
+
.slice(0, Math.max(limit * 2, 4))
|
|
180
|
+
.map(({ asset }) => formatPinnedContext(asset, taskTerms)),
|
|
181
|
+
limit,
|
|
182
|
+
);
|
|
183
|
+
}
|