cap-pro 1.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/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
// @cap-feature(feature:F-038) Neural Cluster Detection — single-linkage clustering over thread nodes, divergence-based decay, dormant node management, and auto-labeling
|
|
2
|
+
// @cap-decision Pure logic module — zero I/O, zero external dependencies. All functions accept data and return structured results.
|
|
3
|
+
// @cap-decision Single-linkage clustering chosen for simplicity and interpretability — MAX affinity between any members of two clusters determines merge eligibility.
|
|
4
|
+
// @cap-decision Divergence-based decay uses MAX of three drift metrics (file-drift, keyword-drift, cluster-drift) as the combined decay factor, applied with a configurable damping rate (default 0.3) to prevent abrupt weight drops.
|
|
5
|
+
// @cap-decision No time-based decay — only measured divergence reduces scores. A thread from 6 months ago with still-relevant keywords keeps full affinity.
|
|
6
|
+
// @cap-decision Cluster IDs are stable hashes of sorted member thread IDs — deterministic as long as membership does not change.
|
|
7
|
+
// @cap-constraint Zero external dependencies — uses only Node.js built-ins (crypto).
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
|
|
13
|
+
// --- Constants ---
|
|
14
|
+
|
|
15
|
+
// @cap-todo(ac:F-038/AC-1) Configurable linkage threshold (default 0.40)
|
|
16
|
+
/** Default linkage threshold for cluster merging. */
|
|
17
|
+
const DEFAULT_LINKAGE_THRESHOLD = 0.40;
|
|
18
|
+
|
|
19
|
+
// @cap-todo(ac:F-038/AC-3) Decay rate controls how aggressively drift reduces edge weights.
|
|
20
|
+
/** Default decay damping rate — new weight = current * (1 - maxDrift * DECAY_RATE). */
|
|
21
|
+
const DEFAULT_DECAY_RATE = 0.3;
|
|
22
|
+
|
|
23
|
+
// @cap-todo(ac:F-038/AC-5) Dormant nodes reactivate when new affinity score >= 0.40
|
|
24
|
+
/** Threshold for dormant node reactivation. */
|
|
25
|
+
const DORMANT_REACTIVATION_THRESHOLD = 0.40;
|
|
26
|
+
|
|
27
|
+
// --- Types ---
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} Cluster
|
|
31
|
+
* @property {string} id - Stable cluster ID (hash of sorted member thread IDs)
|
|
32
|
+
* @property {string[]} members - Array of thread IDs in this cluster
|
|
33
|
+
* @property {string} label - Auto-generated label from top 2-3 concepts
|
|
34
|
+
* @property {string} createdAt - ISO timestamp
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Object} ClusterResult
|
|
39
|
+
* @property {Cluster[]} clusters - Detected clusters
|
|
40
|
+
* @property {Object} graph - Mutated graph with cluster membership assigned
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} DriftMetrics
|
|
45
|
+
* @property {number} fileDrift - File intersection drift (0.0-1.0)
|
|
46
|
+
* @property {number} keywordDrift - Keyword Jaccard divergence (0.0-1.0)
|
|
47
|
+
* @property {number} clusterDrift - Cluster affinity drift (0.0-1.0)
|
|
48
|
+
* @property {number} maxDrift - Maximum of the three drift metrics
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} DecayResult
|
|
53
|
+
* @property {Array<{source: string, target: string, oldWeight: number, newWeight: number}>} decayedEdges - Edges that had their weight reduced
|
|
54
|
+
* @property {string[]} dormantNodes - Node IDs newly marked dormant
|
|
55
|
+
* @property {string[]} reactivatedNodes - Node IDs reactivated from dormancy
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} AffinityResult
|
|
60
|
+
* @property {string} sourceThreadId - First thread ID
|
|
61
|
+
* @property {string} targetThreadId - Second thread ID
|
|
62
|
+
* @property {number} compositeScore - Weighted composite score (0.0-1.0)
|
|
63
|
+
* @property {string} band - Classification band
|
|
64
|
+
* @property {Object[]} signals - Individual signal results
|
|
65
|
+
* @property {string} computedAt - ISO timestamp
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {Object} ClusterDetectionOptions
|
|
70
|
+
* @property {number} [linkageThreshold] - Minimum affinity for cluster merging
|
|
71
|
+
* @property {Object<string, string[]>} [taxonomy] - Concept taxonomy for labeling
|
|
72
|
+
* @property {number} [decayRate] - Decay damping rate
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
// --- Utility Functions ---
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clamp a number to [0.0, 1.0].
|
|
79
|
+
* @param {number} n
|
|
80
|
+
* @returns {number}
|
|
81
|
+
*/
|
|
82
|
+
function _clamp01(n) {
|
|
83
|
+
return Math.max(0, Math.min(1, n));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compute Jaccard similarity between two sets.
|
|
88
|
+
* @param {Set<string>} setA
|
|
89
|
+
* @param {Set<string>} setB
|
|
90
|
+
* @returns {number} Similarity score (0.0-1.0)
|
|
91
|
+
*/
|
|
92
|
+
function _jaccard(setA, setB) {
|
|
93
|
+
if (setA.size === 0 && setB.size === 0) return 0;
|
|
94
|
+
|
|
95
|
+
let intersectionCount = 0;
|
|
96
|
+
for (const item of setA) {
|
|
97
|
+
if (setB.has(item)) intersectionCount++;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const unionSize = setA.size + setB.size - intersectionCount;
|
|
101
|
+
return unionSize > 0 ? intersectionCount / unionSize : 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find the graph node ID for a thread by its thread ID.
|
|
106
|
+
* Thread nodes have metadata.threadId matching the thr-XXXX id.
|
|
107
|
+
* @param {Object} graph - MemoryGraph
|
|
108
|
+
* @param {string} threadId - Thread ID (thr-XXXX)
|
|
109
|
+
* @returns {string|null} Graph node ID or null
|
|
110
|
+
*/
|
|
111
|
+
function _findThreadNodeId(graph, threadId) {
|
|
112
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
113
|
+
if (node.type === 'thread' && node.metadata && node.metadata.threadId === threadId) {
|
|
114
|
+
return nodeId;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get all active affinity edges from the graph.
|
|
122
|
+
* @param {Object} graph - MemoryGraph
|
|
123
|
+
* @returns {Object[]} Active affinity edges
|
|
124
|
+
*/
|
|
125
|
+
function _getAffinityEdges(graph) {
|
|
126
|
+
return (graph.edges || []).filter(e => e.active && e.type === 'affinity');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collect file paths from feature nodes connected to a thread node.
|
|
131
|
+
* @param {Object} graph - MemoryGraph
|
|
132
|
+
* @param {string} threadNodeId - Graph node ID of the thread
|
|
133
|
+
* @returns {Set<string>} Set of file paths
|
|
134
|
+
*/
|
|
135
|
+
function _collectFilesForThread(graph, threadNodeId) {
|
|
136
|
+
const files = new Set();
|
|
137
|
+
for (const edge of (graph.edges || [])) {
|
|
138
|
+
if (!edge.active) continue;
|
|
139
|
+
let neighborId = null;
|
|
140
|
+
if (edge.source === threadNodeId) neighborId = edge.target;
|
|
141
|
+
else if (edge.target === threadNodeId) neighborId = edge.source;
|
|
142
|
+
if (neighborId && graph.nodes[neighborId] && graph.nodes[neighborId].type === 'feature') {
|
|
143
|
+
const node = graph.nodes[neighborId];
|
|
144
|
+
if (node.metadata && Array.isArray(node.metadata.files)) {
|
|
145
|
+
for (const f of node.metadata.files) {
|
|
146
|
+
files.add(f);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return files;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Clustering ---
|
|
155
|
+
|
|
156
|
+
// @cap-todo(ac:F-038/AC-1) Single-linkage clustering over thread nodes using affinity scores as distance metric
|
|
157
|
+
/**
|
|
158
|
+
* Detect clusters from pairwise affinity results using single-linkage clustering.
|
|
159
|
+
*
|
|
160
|
+
* Algorithm: Start with each thread as its own cluster. Repeatedly merge the two
|
|
161
|
+
* clusters with highest inter-cluster affinity (single-linkage = MAX affinity between
|
|
162
|
+
* any member of cluster A and any member of cluster B), as long as that affinity >= threshold.
|
|
163
|
+
*
|
|
164
|
+
* @param {AffinityResult[]} affinityResults - Pairwise affinity results
|
|
165
|
+
* @param {Object} [options]
|
|
166
|
+
* @param {number} [options.linkageThreshold] - Minimum affinity for merging (default 0.40)
|
|
167
|
+
* @returns {Array<{id: string, members: string[]}>} Clusters (without labels yet)
|
|
168
|
+
*/
|
|
169
|
+
function detectClusters(affinityResults, options) {
|
|
170
|
+
const threshold = (options && options.linkageThreshold != null)
|
|
171
|
+
? options.linkageThreshold
|
|
172
|
+
: DEFAULT_LINKAGE_THRESHOLD;
|
|
173
|
+
|
|
174
|
+
// Collect all unique thread IDs
|
|
175
|
+
const threadIdSet = new Set();
|
|
176
|
+
for (const r of affinityResults) {
|
|
177
|
+
threadIdSet.add(r.sourceThreadId);
|
|
178
|
+
threadIdSet.add(r.targetThreadId);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Initialize: each thread is its own cluster
|
|
182
|
+
// Map from thread ID -> cluster index
|
|
183
|
+
const threadIds = [...threadIdSet];
|
|
184
|
+
const clusterMap = new Map();
|
|
185
|
+
let nextClusterIdx = 0;
|
|
186
|
+
|
|
187
|
+
for (const tid of threadIds) {
|
|
188
|
+
clusterMap.set(tid, nextClusterIdx++);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build a fast lookup of affinity scores: "tidA|tidB" -> score (canonical key order)
|
|
192
|
+
const pairScores = new Map();
|
|
193
|
+
for (const r of affinityResults) {
|
|
194
|
+
const key = _pairKey(r.sourceThreadId, r.targetThreadId);
|
|
195
|
+
// Keep the maximum score if duplicates exist
|
|
196
|
+
const existing = pairScores.get(key) || 0;
|
|
197
|
+
if (r.compositeScore > existing) {
|
|
198
|
+
pairScores.set(key, r.compositeScore);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Single-linkage: repeatedly merge the two clusters with the highest inter-cluster affinity
|
|
203
|
+
let mergedSomething = true;
|
|
204
|
+
while (mergedSomething) {
|
|
205
|
+
mergedSomething = false;
|
|
206
|
+
|
|
207
|
+
// Find distinct cluster indices
|
|
208
|
+
const clusterIndices = [...new Set(clusterMap.values())];
|
|
209
|
+
if (clusterIndices.length <= 1) break;
|
|
210
|
+
|
|
211
|
+
// Build cluster -> members mapping
|
|
212
|
+
const clusterMembers = new Map();
|
|
213
|
+
for (const [tid, cidx] of clusterMap) {
|
|
214
|
+
if (!clusterMembers.has(cidx)) clusterMembers.set(cidx, []);
|
|
215
|
+
clusterMembers.get(cidx).push(tid);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Find the pair of clusters with the highest single-linkage affinity
|
|
219
|
+
let bestAffinity = -1;
|
|
220
|
+
let bestPair = null;
|
|
221
|
+
const clusterIdxList = [...clusterMembers.keys()];
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < clusterIdxList.length; i++) {
|
|
224
|
+
for (let j = i + 1; j < clusterIdxList.length; j++) {
|
|
225
|
+
const membersI = clusterMembers.get(clusterIdxList[i]);
|
|
226
|
+
const membersJ = clusterMembers.get(clusterIdxList[j]);
|
|
227
|
+
|
|
228
|
+
// Single-linkage: MAX affinity between any member pair
|
|
229
|
+
let maxAffinity = 0;
|
|
230
|
+
for (const mA of membersI) {
|
|
231
|
+
for (const mB of membersJ) {
|
|
232
|
+
const key = _pairKey(mA, mB);
|
|
233
|
+
const score = pairScores.get(key) || 0;
|
|
234
|
+
if (score > maxAffinity) maxAffinity = score;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (maxAffinity > bestAffinity) {
|
|
239
|
+
bestAffinity = maxAffinity;
|
|
240
|
+
bestPair = [clusterIdxList[i], clusterIdxList[j]];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Merge if above threshold
|
|
246
|
+
if (bestPair && bestAffinity >= threshold) {
|
|
247
|
+
const [keepIdx, mergeIdx] = bestPair;
|
|
248
|
+
for (const [tid, cidx] of clusterMap) {
|
|
249
|
+
if (cidx === mergeIdx) {
|
|
250
|
+
clusterMap.set(tid, keepIdx);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
mergedSomething = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Build final cluster objects
|
|
258
|
+
const clusterGroups = new Map();
|
|
259
|
+
for (const [tid, cidx] of clusterMap) {
|
|
260
|
+
if (!clusterGroups.has(cidx)) clusterGroups.set(cidx, []);
|
|
261
|
+
clusterGroups.get(cidx).push(tid);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const clusters = [];
|
|
265
|
+
for (const members of clusterGroups.values()) {
|
|
266
|
+
members.sort();
|
|
267
|
+
clusters.push({
|
|
268
|
+
id: generateClusterId(members),
|
|
269
|
+
members,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Sort clusters by size descending, then by ID for stability
|
|
274
|
+
clusters.sort((a, b) => b.members.length - a.members.length || a.id.localeCompare(b.id));
|
|
275
|
+
|
|
276
|
+
return clusters;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Create a canonical pair key from two thread IDs (alphabetically sorted).
|
|
281
|
+
* @param {string} tidA
|
|
282
|
+
* @param {string} tidB
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
function _pairKey(tidA, tidB) {
|
|
286
|
+
return tidA < tidB ? `${tidA}|${tidB}` : `${tidB}|${tidA}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// @cap-todo(ac:F-038/AC-7) Cluster ID derived from sorted member thread IDs hash (stable as long as members don't change)
|
|
290
|
+
/**
|
|
291
|
+
* Generate a stable cluster ID from sorted member thread IDs.
|
|
292
|
+
* @param {string[]} memberThreadIds - Thread IDs (will be sorted internally)
|
|
293
|
+
* @returns {string} Cluster ID in format "cluster-{8 hex chars}"
|
|
294
|
+
*/
|
|
295
|
+
function generateClusterId(memberThreadIds) {
|
|
296
|
+
const sorted = [...memberThreadIds].sort();
|
|
297
|
+
const hash = crypto.createHash('sha256')
|
|
298
|
+
.update(sorted.join('|'))
|
|
299
|
+
.digest('hex')
|
|
300
|
+
.substring(0, 8);
|
|
301
|
+
return `cluster-${hash}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// @cap-todo(ac:F-038/AC-2) Auto-generated dynamic labels from top 2-3 weighted concepts — ephemeral, recalculated each run
|
|
305
|
+
/**
|
|
306
|
+
* Generate a cluster label from the combined text of member threads projected into concept space.
|
|
307
|
+
* Uses the SEED_TAXONOMY from cap-semantic-pipeline for concept projection.
|
|
308
|
+
* Labels are ephemeral — recalculated each time, never stored as permanent names.
|
|
309
|
+
*
|
|
310
|
+
* @param {Object[]} memberThreads - Thread objects that are members of the cluster
|
|
311
|
+
* @param {Object<string, string[]>} taxonomy - Concept taxonomy { concept: [keywords] }
|
|
312
|
+
* @returns {string} Label in format "concept1 \u00b7 concept2 \u00b7 concept3"
|
|
313
|
+
*/
|
|
314
|
+
function generateClusterLabel(memberThreads, taxonomy) {
|
|
315
|
+
if (!memberThreads || memberThreads.length === 0) return 'unnamed';
|
|
316
|
+
if (!taxonomy || Object.keys(taxonomy).length === 0) return 'unnamed';
|
|
317
|
+
|
|
318
|
+
// Combine all thread text
|
|
319
|
+
const combinedText = memberThreads.map(t => _getThreadText(t)).join(' ');
|
|
320
|
+
|
|
321
|
+
// Project into concept space
|
|
322
|
+
const conceptScores = _projectToConcepts(combinedText, taxonomy);
|
|
323
|
+
|
|
324
|
+
// Take top 2-3 concepts by weight
|
|
325
|
+
const sorted = [...conceptScores.entries()]
|
|
326
|
+
.sort((a, b) => b[1] - a[1])
|
|
327
|
+
.slice(0, 3)
|
|
328
|
+
.filter(([, score]) => score > 0);
|
|
329
|
+
|
|
330
|
+
if (sorted.length === 0) return 'unnamed';
|
|
331
|
+
|
|
332
|
+
return sorted.map(([concept]) => concept).join(' \u00b7 ');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Extract combined text from a thread for concept projection.
|
|
337
|
+
* @param {Object} thread - Thread object
|
|
338
|
+
* @returns {string}
|
|
339
|
+
*/
|
|
340
|
+
function _getThreadText(thread) {
|
|
341
|
+
const parts = [];
|
|
342
|
+
if (thread.problemStatement) parts.push(thread.problemStatement);
|
|
343
|
+
if (thread.solutionShape) parts.push(thread.solutionShape);
|
|
344
|
+
if (Array.isArray(thread.boundaryDecisions)) {
|
|
345
|
+
parts.push(...thread.boundaryDecisions);
|
|
346
|
+
}
|
|
347
|
+
if (Array.isArray(thread.keywords)) {
|
|
348
|
+
parts.push(thread.keywords.join(' '));
|
|
349
|
+
}
|
|
350
|
+
if (thread.name) parts.push(thread.name);
|
|
351
|
+
return parts.join(' ');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Project text into concept space using a taxonomy.
|
|
356
|
+
* Mirrors the logic of cap-semantic-pipeline.cjs projectToConcepts but inlined
|
|
357
|
+
* to avoid cross-module dependency.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} text - Text to project
|
|
360
|
+
* @param {Object<string, string[]>} taxonomy - Concept taxonomy
|
|
361
|
+
* @returns {Map<string, number>} Concept -> weight mapping
|
|
362
|
+
*/
|
|
363
|
+
function _projectToConcepts(text, taxonomy) {
|
|
364
|
+
/** @type {Map<string, number>} */
|
|
365
|
+
const vector = new Map();
|
|
366
|
+
|
|
367
|
+
if (!text || typeof text !== 'string') return vector;
|
|
368
|
+
|
|
369
|
+
const lowerText = text.toLowerCase();
|
|
370
|
+
|
|
371
|
+
for (const [concept, keywords] of Object.entries(taxonomy)) {
|
|
372
|
+
let matchCount = 0;
|
|
373
|
+
for (const kw of keywords) {
|
|
374
|
+
if (lowerText.indexOf(kw) !== -1) {
|
|
375
|
+
matchCount++;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const score = keywords.length > 0 ? matchCount / keywords.length : 0;
|
|
379
|
+
if (score > 0) {
|
|
380
|
+
vector.set(concept, score);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return vector;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --- Divergence Decay ---
|
|
388
|
+
|
|
389
|
+
// @cap-todo(ac:F-038/AC-3) Divergence-based decay using 3 drift metrics: file-drift, keyword-drift, cluster-drift
|
|
390
|
+
// @cap-todo(ac:F-038/AC-6) No time-based decay — only measured divergence reduces scores
|
|
391
|
+
/**
|
|
392
|
+
* Compute drift metrics between two threads based on their current state vs. previous affinity.
|
|
393
|
+
*
|
|
394
|
+
* Three drift metrics:
|
|
395
|
+
* 1. file-drift: Shrinking file intersection relative to original shared files
|
|
396
|
+
* 2. keyword-drift: Jaccard divergence of current keyword sets
|
|
397
|
+
* 3. cluster-drift: Drop in average affinity to cluster members vs. original
|
|
398
|
+
*
|
|
399
|
+
* @param {Object} threadNodeA - Graph node for thread A
|
|
400
|
+
* @param {Object} threadNodeB - Graph node for thread B
|
|
401
|
+
* @param {Object} graph - MemoryGraph
|
|
402
|
+
* @param {Object} previousAffinity - Previous affinity edge metadata { compositeScore, originalSharedFiles }
|
|
403
|
+
* @param {Object} [currentAffinityMap] - Map of pair keys to current affinity scores
|
|
404
|
+
* @returns {DriftMetrics}
|
|
405
|
+
*/
|
|
406
|
+
function computeDrift(threadNodeA, threadNodeB, graph, previousAffinity, currentAffinityMap) {
|
|
407
|
+
// --- File drift ---
|
|
408
|
+
const filesA = _collectFilesForThread(graph, threadNodeA.id);
|
|
409
|
+
const filesB = _collectFilesForThread(graph, threadNodeB.id);
|
|
410
|
+
|
|
411
|
+
let currentSharedFiles = 0;
|
|
412
|
+
for (const f of filesA) {
|
|
413
|
+
if (filesB.has(f)) currentSharedFiles++;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const originalSharedFiles = (previousAffinity && previousAffinity.originalSharedFiles != null)
|
|
417
|
+
? previousAffinity.originalSharedFiles
|
|
418
|
+
: currentSharedFiles;
|
|
419
|
+
|
|
420
|
+
const fileDrift = originalSharedFiles > 0
|
|
421
|
+
? _clamp01(1 - (currentSharedFiles / originalSharedFiles))
|
|
422
|
+
: 0;
|
|
423
|
+
|
|
424
|
+
// --- Keyword drift ---
|
|
425
|
+
const keywordsA = new Set(
|
|
426
|
+
(threadNodeA.metadata && Array.isArray(threadNodeA.metadata.keywords))
|
|
427
|
+
? threadNodeA.metadata.keywords
|
|
428
|
+
: []
|
|
429
|
+
);
|
|
430
|
+
const keywordsB = new Set(
|
|
431
|
+
(threadNodeB.metadata && Array.isArray(threadNodeB.metadata.keywords))
|
|
432
|
+
? threadNodeB.metadata.keywords
|
|
433
|
+
: []
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const keywordSimilarity = _jaccard(keywordsA, keywordsB);
|
|
437
|
+
const keywordDrift = _clamp01(1 - keywordSimilarity);
|
|
438
|
+
|
|
439
|
+
// --- Cluster drift ---
|
|
440
|
+
// If either node has a cluster, compute average affinity to cluster members
|
|
441
|
+
// vs. the original average affinity stored in the edge
|
|
442
|
+
let clusterDrift = 0;
|
|
443
|
+
|
|
444
|
+
if (currentAffinityMap && previousAffinity && previousAffinity.compositeScore > 0) {
|
|
445
|
+
const tidA = threadNodeA.metadata && threadNodeA.metadata.threadId;
|
|
446
|
+
const tidB = threadNodeB.metadata && threadNodeB.metadata.threadId;
|
|
447
|
+
|
|
448
|
+
if (tidA && tidB) {
|
|
449
|
+
const key = _pairKey(tidA, tidB);
|
|
450
|
+
const currentScore = currentAffinityMap.get(key) || 0;
|
|
451
|
+
const originalScore = previousAffinity.compositeScore;
|
|
452
|
+
|
|
453
|
+
if (originalScore > 0) {
|
|
454
|
+
clusterDrift = _clamp01(1 - (currentScore / originalScore));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
fileDrift,
|
|
461
|
+
keywordDrift,
|
|
462
|
+
clusterDrift,
|
|
463
|
+
maxDrift: Math.max(fileDrift, keywordDrift, clusterDrift),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// @cap-todo(ac:F-038/AC-4) Decay reduces affinity edge weights but never deletes nodes; dormant nodes get dormant:true flag
|
|
468
|
+
/**
|
|
469
|
+
* Apply decay to affinity edges in the graph based on drift results.
|
|
470
|
+
* Reduces edge weights: newWeight = currentWeight * (1 - maxDrift * decayRate).
|
|
471
|
+
* Never deletes nodes or edges.
|
|
472
|
+
*
|
|
473
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
474
|
+
* @param {Map<string, DriftMetrics>} driftResults - Map of pair key -> DriftMetrics
|
|
475
|
+
* @param {Object} [options]
|
|
476
|
+
* @param {number} [options.decayRate] - Damping rate (default 0.3)
|
|
477
|
+
* @returns {{ decayedEdges: Array<{source: string, target: string, oldWeight: number, newWeight: number}> }}
|
|
478
|
+
*/
|
|
479
|
+
function applyDecay(graph, driftResults, options) {
|
|
480
|
+
const decayRate = (options && options.decayRate != null) ? options.decayRate : DEFAULT_DECAY_RATE;
|
|
481
|
+
const decayedEdges = [];
|
|
482
|
+
|
|
483
|
+
// Build thread node ID -> thread ID mapping for quick lookup
|
|
484
|
+
const nodeToThread = new Map();
|
|
485
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
486
|
+
if (node.type === 'thread' && node.metadata && node.metadata.threadId) {
|
|
487
|
+
nodeToThread.set(nodeId, node.metadata.threadId);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
for (const edge of (graph.edges || [])) {
|
|
492
|
+
if (!edge.active || edge.type !== 'affinity') continue;
|
|
493
|
+
|
|
494
|
+
const tidSource = nodeToThread.get(edge.source);
|
|
495
|
+
const tidTarget = nodeToThread.get(edge.target);
|
|
496
|
+
if (!tidSource || !tidTarget) continue;
|
|
497
|
+
|
|
498
|
+
const key = _pairKey(tidSource, tidTarget);
|
|
499
|
+
const drift = driftResults.get(key);
|
|
500
|
+
if (!drift || drift.maxDrift <= 0) continue;
|
|
501
|
+
|
|
502
|
+
const oldWeight = (edge.metadata && edge.metadata.compositeScore != null)
|
|
503
|
+
? edge.metadata.compositeScore
|
|
504
|
+
: 0;
|
|
505
|
+
|
|
506
|
+
const newWeight = oldWeight * (1 - drift.maxDrift * decayRate);
|
|
507
|
+
|
|
508
|
+
if (!edge.metadata) edge.metadata = {};
|
|
509
|
+
edge.metadata.compositeScore = newWeight;
|
|
510
|
+
|
|
511
|
+
// Store original shared files count for future drift calculations
|
|
512
|
+
if (edge.metadata.originalSharedFiles == null) {
|
|
513
|
+
// First decay pass — snapshot the current state as baseline
|
|
514
|
+
// This is set externally or defaults to 0 if not present
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
decayedEdges.push({
|
|
518
|
+
source: edge.source,
|
|
519
|
+
target: edge.target,
|
|
520
|
+
oldWeight,
|
|
521
|
+
newWeight,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
graph.lastUpdated = new Date().toISOString();
|
|
526
|
+
|
|
527
|
+
return { decayedEdges };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --- Dormant Node Management ---
|
|
531
|
+
|
|
532
|
+
// @cap-todo(ac:F-038/AC-4) dormant nodes get dormant:true flag
|
|
533
|
+
/**
|
|
534
|
+
* Mark a node as dormant. Sets metadata.dormant = true.
|
|
535
|
+
* Does NOT delete the node or its edges.
|
|
536
|
+
*
|
|
537
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
538
|
+
* @param {string} nodeId - Graph node ID to mark dormant
|
|
539
|
+
* @returns {Object} The mutated graph
|
|
540
|
+
*/
|
|
541
|
+
function markDormant(graph, nodeId) {
|
|
542
|
+
const node = graph.nodes[nodeId];
|
|
543
|
+
if (!node) return graph;
|
|
544
|
+
|
|
545
|
+
if (!node.metadata) node.metadata = {};
|
|
546
|
+
node.metadata.dormant = true;
|
|
547
|
+
node.updatedAt = new Date().toISOString();
|
|
548
|
+
graph.lastUpdated = new Date().toISOString();
|
|
549
|
+
|
|
550
|
+
return graph;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// @cap-todo(ac:F-038/AC-5) Dormant nodes reactivate when new affinity score >= 0.40
|
|
554
|
+
/**
|
|
555
|
+
* Reactivate a dormant node. Sets metadata.dormant = false.
|
|
556
|
+
*
|
|
557
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
558
|
+
* @param {string} nodeId - Graph node ID to reactivate
|
|
559
|
+
* @returns {Object} The mutated graph
|
|
560
|
+
*/
|
|
561
|
+
function reactivateNode(graph, nodeId) {
|
|
562
|
+
const node = graph.nodes[nodeId];
|
|
563
|
+
if (!node) return graph;
|
|
564
|
+
|
|
565
|
+
if (!node.metadata) node.metadata = {};
|
|
566
|
+
node.metadata.dormant = false;
|
|
567
|
+
node.updatedAt = new Date().toISOString();
|
|
568
|
+
graph.lastUpdated = new Date().toISOString();
|
|
569
|
+
|
|
570
|
+
return graph;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Check which dormant nodes should be reactivated based on new affinity results.
|
|
575
|
+
* A dormant node reactivates when any new affinity score touching it is >= reactivation threshold.
|
|
576
|
+
*
|
|
577
|
+
* @param {Object} graph - MemoryGraph
|
|
578
|
+
* @param {AffinityResult[]} newAffinityResults - New affinity results to check
|
|
579
|
+
* @param {Object} [options]
|
|
580
|
+
* @param {number} [options.reactivationThreshold] - Score threshold (default 0.40)
|
|
581
|
+
* @returns {string[]} List of reactivated graph node IDs
|
|
582
|
+
*/
|
|
583
|
+
function checkReactivation(graph, newAffinityResults, options) {
|
|
584
|
+
const threshold = (options && options.reactivationThreshold != null)
|
|
585
|
+
? options.reactivationThreshold
|
|
586
|
+
: DORMANT_REACTIVATION_THRESHOLD;
|
|
587
|
+
|
|
588
|
+
const reactivated = [];
|
|
589
|
+
|
|
590
|
+
// Find all dormant thread node IDs and their thread IDs
|
|
591
|
+
const dormantNodes = new Map(); // threadId -> nodeId
|
|
592
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
593
|
+
if (node.type === 'thread' && node.metadata && node.metadata.dormant === true) {
|
|
594
|
+
dormantNodes.set(node.metadata.threadId, nodeId);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (dormantNodes.size === 0) return reactivated;
|
|
599
|
+
|
|
600
|
+
for (const result of newAffinityResults) {
|
|
601
|
+
if (result.compositeScore < threshold) continue;
|
|
602
|
+
|
|
603
|
+
// Check if either thread in this result is dormant
|
|
604
|
+
for (const tid of [result.sourceThreadId, result.targetThreadId]) {
|
|
605
|
+
const nodeId = dormantNodes.get(tid);
|
|
606
|
+
if (nodeId) {
|
|
607
|
+
reactivateNode(graph, nodeId);
|
|
608
|
+
reactivated.push(nodeId);
|
|
609
|
+
dormantNodes.delete(tid); // Don't reactivate twice
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return reactivated;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Identify thread nodes whose ALL affinity edges are below the silent threshold,
|
|
619
|
+
* and mark them as dormant.
|
|
620
|
+
*
|
|
621
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
622
|
+
* @param {Object} [options]
|
|
623
|
+
* @param {number} [options.dormantThreshold] - Below this, edges count as weak (default 0.40)
|
|
624
|
+
* @returns {string[]} List of newly dormant node IDs
|
|
625
|
+
*/
|
|
626
|
+
function identifyAndMarkDormant(graph, options) {
|
|
627
|
+
const threshold = (options && options.dormantThreshold != null)
|
|
628
|
+
? options.dormantThreshold
|
|
629
|
+
: DEFAULT_LINKAGE_THRESHOLD;
|
|
630
|
+
|
|
631
|
+
const newlyDormant = [];
|
|
632
|
+
|
|
633
|
+
// Build thread node ID -> thread ID mapping
|
|
634
|
+
const threadNodeIds = [];
|
|
635
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
636
|
+
if (node.type === 'thread' && node.active) {
|
|
637
|
+
threadNodeIds.push(nodeId);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for (const nodeId of threadNodeIds) {
|
|
642
|
+
const node = graph.nodes[nodeId];
|
|
643
|
+
// Skip already dormant nodes
|
|
644
|
+
if (node.metadata && node.metadata.dormant === true) continue;
|
|
645
|
+
|
|
646
|
+
// Find all active affinity edges touching this node
|
|
647
|
+
const affinityEdges = (graph.edges || []).filter(e =>
|
|
648
|
+
e.active && e.type === 'affinity' &&
|
|
649
|
+
(e.source === nodeId || e.target === nodeId)
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// If no affinity edges, skip (don't mark dormant for nodes with no edges at all)
|
|
653
|
+
if (affinityEdges.length === 0) continue;
|
|
654
|
+
|
|
655
|
+
// Check if ALL edges are below threshold
|
|
656
|
+
const allBelowThreshold = affinityEdges.every(e =>
|
|
657
|
+
(e.metadata && e.metadata.compositeScore != null)
|
|
658
|
+
? e.metadata.compositeScore < threshold
|
|
659
|
+
: true
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
if (allBelowThreshold) {
|
|
663
|
+
markDormant(graph, nodeId);
|
|
664
|
+
newlyDormant.push(nodeId);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return newlyDormant;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// --- Cluster Membership ---
|
|
672
|
+
|
|
673
|
+
// @cap-todo(ac:F-038/AC-7) Cluster membership stored as computed property on thread nodes
|
|
674
|
+
/**
|
|
675
|
+
* Assign cluster membership to thread nodes in the graph.
|
|
676
|
+
* Updates each thread node's metadata with cluster info:
|
|
677
|
+
* metadata.cluster = { id, label, joinedAt }
|
|
678
|
+
*
|
|
679
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
680
|
+
* @param {Cluster[]} clusters - Clusters with id, members, and label
|
|
681
|
+
* @returns {Object} The mutated graph
|
|
682
|
+
*/
|
|
683
|
+
function assignClusterMembership(graph, clusters) {
|
|
684
|
+
const now = new Date().toISOString();
|
|
685
|
+
|
|
686
|
+
// Build thread ID -> cluster mapping
|
|
687
|
+
const threadToCluster = new Map();
|
|
688
|
+
for (const cluster of clusters) {
|
|
689
|
+
for (const tid of cluster.members) {
|
|
690
|
+
threadToCluster.set(tid, cluster);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Update graph nodes
|
|
695
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
696
|
+
if (node.type !== 'thread') continue;
|
|
697
|
+
|
|
698
|
+
const threadId = node.metadata && node.metadata.threadId;
|
|
699
|
+
if (!threadId) continue;
|
|
700
|
+
|
|
701
|
+
const cluster = threadToCluster.get(threadId);
|
|
702
|
+
if (cluster) {
|
|
703
|
+
if (!node.metadata) node.metadata = {};
|
|
704
|
+
// Preserve existing joinedAt if cluster hasn't changed
|
|
705
|
+
const existingCluster = node.metadata.cluster;
|
|
706
|
+
const joinedAt = (existingCluster && existingCluster.id === cluster.id)
|
|
707
|
+
? existingCluster.joinedAt
|
|
708
|
+
: now;
|
|
709
|
+
|
|
710
|
+
node.metadata.cluster = {
|
|
711
|
+
id: cluster.id,
|
|
712
|
+
label: cluster.label,
|
|
713
|
+
joinedAt,
|
|
714
|
+
};
|
|
715
|
+
node.updatedAt = now;
|
|
716
|
+
} else {
|
|
717
|
+
// Thread not in any cluster — clear cluster membership
|
|
718
|
+
if (node.metadata && node.metadata.cluster) {
|
|
719
|
+
delete node.metadata.cluster;
|
|
720
|
+
node.updatedAt = now;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
graph.lastUpdated = now;
|
|
726
|
+
return graph;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- Full Pipeline ---
|
|
730
|
+
|
|
731
|
+
// @cap-todo(ac:F-038/AC-8) Clustering completes within 500ms for 200 nodes and 1000 edges
|
|
732
|
+
/**
|
|
733
|
+
* Run full cluster detection pipeline:
|
|
734
|
+
* 1. Detect clusters from affinity scores (single-linkage)
|
|
735
|
+
* 2. Generate labels for each cluster
|
|
736
|
+
* 3. Assign cluster membership to graph nodes
|
|
737
|
+
*
|
|
738
|
+
* @param {AffinityResult[]} affinityResults - Pairwise affinity results
|
|
739
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
740
|
+
* @param {Object[]} threads - Thread objects for label generation
|
|
741
|
+
* @param {Object} [options]
|
|
742
|
+
* @param {number} [options.linkageThreshold] - Minimum affinity for merging
|
|
743
|
+
* @param {Object<string, string[]>} [options.taxonomy] - Concept taxonomy for labeling
|
|
744
|
+
* @returns {ClusterResult}
|
|
745
|
+
*/
|
|
746
|
+
function runClusterDetection(affinityResults, graph, threads, options) {
|
|
747
|
+
const taxonomy = (options && options.taxonomy) || null;
|
|
748
|
+
const linkageThreshold = (options && options.linkageThreshold != null)
|
|
749
|
+
? options.linkageThreshold
|
|
750
|
+
: DEFAULT_LINKAGE_THRESHOLD;
|
|
751
|
+
|
|
752
|
+
// 1. Detect clusters
|
|
753
|
+
const rawClusters = detectClusters(affinityResults, { linkageThreshold });
|
|
754
|
+
|
|
755
|
+
// Build thread ID -> thread object map for label generation
|
|
756
|
+
const threadMap = new Map();
|
|
757
|
+
for (const t of threads) {
|
|
758
|
+
threadMap.set(t.id, t);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 2. Generate labels and finalize clusters
|
|
762
|
+
const now = new Date().toISOString();
|
|
763
|
+
const clusters = rawClusters.map(rc => {
|
|
764
|
+
const memberThreads = rc.members
|
|
765
|
+
.map(tid => threadMap.get(tid))
|
|
766
|
+
.filter(Boolean);
|
|
767
|
+
|
|
768
|
+
const label = taxonomy
|
|
769
|
+
? generateClusterLabel(memberThreads, taxonomy)
|
|
770
|
+
: _generateFallbackLabel(memberThreads);
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
id: rc.id,
|
|
774
|
+
members: rc.members,
|
|
775
|
+
label,
|
|
776
|
+
createdAt: now,
|
|
777
|
+
};
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// 3. Assign membership to graph nodes
|
|
781
|
+
assignClusterMembership(graph, clusters);
|
|
782
|
+
|
|
783
|
+
return { clusters, graph };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Generate a fallback label when no taxonomy is provided.
|
|
788
|
+
* Uses top keywords from member threads.
|
|
789
|
+
*
|
|
790
|
+
* @param {Object[]} memberThreads - Thread objects
|
|
791
|
+
* @returns {string}
|
|
792
|
+
*/
|
|
793
|
+
function _generateFallbackLabel(memberThreads) {
|
|
794
|
+
if (!memberThreads || memberThreads.length === 0) return 'unnamed';
|
|
795
|
+
|
|
796
|
+
// Collect keyword frequency
|
|
797
|
+
const kwFreq = new Map();
|
|
798
|
+
for (const t of memberThreads) {
|
|
799
|
+
for (const kw of (t.keywords || [])) {
|
|
800
|
+
kwFreq.set(kw, (kwFreq.get(kw) || 0) + 1);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const sorted = [...kwFreq.entries()]
|
|
805
|
+
.sort((a, b) => b[1] - a[1])
|
|
806
|
+
.slice(0, 3)
|
|
807
|
+
.filter(([, count]) => count > 0);
|
|
808
|
+
|
|
809
|
+
if (sorted.length === 0) return 'unnamed';
|
|
810
|
+
|
|
811
|
+
return sorted.map(([kw]) => kw).join(' \u00b7 ');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Run full decay pass:
|
|
816
|
+
* 1. Compute drift for each affinity edge
|
|
817
|
+
* 2. Apply decay to edge weights
|
|
818
|
+
* 3. Identify and mark dormant nodes
|
|
819
|
+
* 4. Check for reactivations from new affinity results
|
|
820
|
+
*
|
|
821
|
+
* @param {Object} graph - MemoryGraph (mutated)
|
|
822
|
+
* @param {AffinityResult[]} currentAffinities - Current affinity results
|
|
823
|
+
* @param {AffinityResult[]} [previousAffinities] - Previous affinity results (for cluster-drift baseline)
|
|
824
|
+
* @param {Object} [options]
|
|
825
|
+
* @param {number} [options.decayRate] - Decay damping rate (default 0.3)
|
|
826
|
+
* @param {number} [options.dormantThreshold] - Threshold for dormancy (default 0.40)
|
|
827
|
+
* @returns {DecayResult}
|
|
828
|
+
*/
|
|
829
|
+
function runDecayPass(graph, currentAffinities, previousAffinities, options) {
|
|
830
|
+
const decayRate = (options && options.decayRate != null) ? options.decayRate : DEFAULT_DECAY_RATE;
|
|
831
|
+
const dormantThreshold = (options && options.dormantThreshold != null)
|
|
832
|
+
? options.dormantThreshold
|
|
833
|
+
: DEFAULT_LINKAGE_THRESHOLD;
|
|
834
|
+
|
|
835
|
+
// Build current affinity lookup
|
|
836
|
+
const currentAffinityMap = new Map();
|
|
837
|
+
for (const r of currentAffinities) {
|
|
838
|
+
const key = _pairKey(r.sourceThreadId, r.targetThreadId);
|
|
839
|
+
currentAffinityMap.set(key, r.compositeScore);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Build previous affinity lookup for baseline
|
|
843
|
+
const previousAffinityMap = new Map();
|
|
844
|
+
if (previousAffinities) {
|
|
845
|
+
for (const r of previousAffinities) {
|
|
846
|
+
const key = _pairKey(r.sourceThreadId, r.targetThreadId);
|
|
847
|
+
previousAffinityMap.set(key, {
|
|
848
|
+
compositeScore: r.compositeScore,
|
|
849
|
+
originalSharedFiles: null, // Will be computed from graph state
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Also build from existing graph edges as baseline
|
|
855
|
+
const nodeToThread = new Map();
|
|
856
|
+
for (const [nodeId, node] of Object.entries(graph.nodes || {})) {
|
|
857
|
+
if (node.type === 'thread' && node.metadata && node.metadata.threadId) {
|
|
858
|
+
nodeToThread.set(nodeId, node.metadata.threadId);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// 1. Compute drift for each active affinity edge
|
|
863
|
+
const driftResults = new Map();
|
|
864
|
+
|
|
865
|
+
for (const edge of _getAffinityEdges(graph)) {
|
|
866
|
+
const tidSource = nodeToThread.get(edge.source);
|
|
867
|
+
const tidTarget = nodeToThread.get(edge.target);
|
|
868
|
+
if (!tidSource || !tidTarget) continue;
|
|
869
|
+
|
|
870
|
+
const key = _pairKey(tidSource, tidTarget);
|
|
871
|
+
const nodeA = graph.nodes[edge.source];
|
|
872
|
+
const nodeB = graph.nodes[edge.target];
|
|
873
|
+
if (!nodeA || !nodeB) continue;
|
|
874
|
+
|
|
875
|
+
// Get previous affinity baseline from the edge itself or from previous results
|
|
876
|
+
const prevFromMap = previousAffinityMap.get(key);
|
|
877
|
+
const previousAffinity = prevFromMap || {
|
|
878
|
+
compositeScore: (edge.metadata && edge.metadata.compositeScore) || 0,
|
|
879
|
+
originalSharedFiles: (edge.metadata && edge.metadata.originalSharedFiles) || null,
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const drift = computeDrift(nodeA, nodeB, graph, previousAffinity, currentAffinityMap);
|
|
883
|
+
driftResults.set(key, drift);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// 2. Apply decay
|
|
887
|
+
const { decayedEdges } = applyDecay(graph, driftResults, { decayRate });
|
|
888
|
+
|
|
889
|
+
// 3. Mark dormant nodes
|
|
890
|
+
const dormantNodes = identifyAndMarkDormant(graph, { dormantThreshold });
|
|
891
|
+
|
|
892
|
+
// 4. Check reactivations
|
|
893
|
+
const reactivatedNodes = checkReactivation(graph, currentAffinities, {
|
|
894
|
+
reactivationThreshold: dormantThreshold,
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
decayedEdges,
|
|
899
|
+
dormantNodes,
|
|
900
|
+
reactivatedNodes,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// --- Module Exports ---
|
|
905
|
+
|
|
906
|
+
// @cap-decision Exporting internal helpers prefixed with _ for testing, following project convention.
|
|
907
|
+
module.exports = {
|
|
908
|
+
// Clustering
|
|
909
|
+
detectClusters,
|
|
910
|
+
generateClusterId,
|
|
911
|
+
generateClusterLabel,
|
|
912
|
+
|
|
913
|
+
// Divergence decay
|
|
914
|
+
computeDrift,
|
|
915
|
+
applyDecay,
|
|
916
|
+
|
|
917
|
+
// Dormant node management
|
|
918
|
+
markDormant,
|
|
919
|
+
reactivateNode,
|
|
920
|
+
checkReactivation,
|
|
921
|
+
identifyAndMarkDormant,
|
|
922
|
+
|
|
923
|
+
// Cluster membership
|
|
924
|
+
assignClusterMembership,
|
|
925
|
+
|
|
926
|
+
// Full pipelines
|
|
927
|
+
runClusterDetection,
|
|
928
|
+
runDecayPass,
|
|
929
|
+
|
|
930
|
+
// Constants
|
|
931
|
+
DEFAULT_LINKAGE_THRESHOLD,
|
|
932
|
+
DEFAULT_DECAY_RATE,
|
|
933
|
+
DORMANT_REACTIVATION_THRESHOLD,
|
|
934
|
+
|
|
935
|
+
// Internal (for testing)
|
|
936
|
+
_clamp01,
|
|
937
|
+
_jaccard,
|
|
938
|
+
_findThreadNodeId,
|
|
939
|
+
_getAffinityEdges,
|
|
940
|
+
_collectFilesForThread,
|
|
941
|
+
_pairKey,
|
|
942
|
+
_getThreadText,
|
|
943
|
+
_projectToConcepts,
|
|
944
|
+
_generateFallbackLabel,
|
|
945
|
+
};
|