Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
An on-device AI plugin for Joplin that semantically clusters notes, suggests tags and notebook structures, and detects stale/archivable notes.

> [!NOTE]
> This plugin is under active development as part of GSoC 2026. The embedding pipeline, clustering (K-Means, K-Medoids, HDBSCAN), and an interactive UI panel are implemented. Settings customization and staleness analysis are upcoming features.
> This plugin is under active development as part of GSoC 2026. The embedding pipeline, clustering (K-Means, HDBSCAN), and an interactive UI panel are implemented. Settings customization and staleness analysis are upcoming features.

---

Expand All @@ -29,7 +29,7 @@ The plugin implements a background-threaded embedding pipeline:

- **Embedding Pipeline**: On-device embedding generation with WebGPU acceleration and WASM fallback
- **Native AI Integration**: Automatically uses Joplin's built-in AI Search embeddings when available
- **Multi-Strategy Clustering**: Compare K-Means, K-Medoids, and HDBSCAN results side-by-side
- **Multi-Strategy Clustering**: Compare K-Means and HDBSCAN results side-by-side
- **Interactive Panel**: Drag-and-drop notes between clusters, rename clusters, add custom categories
- **Apply Categorization**: Organize notes into notebooks and/or tags based on clustering results
- **Undo Support**: Revert any applied categorization with full change tracking
Expand Down
3 changes: 2 additions & 1 deletion src/pipeline/clustering/autoK.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DistanceFn, silhouetteScore } from './metrics';
import { kmeans } from './kmeans';
// NOTE: kmedoids is not used in the default pipeline (too slow), but kept here for manual benchmarking
import { kmedoids } from './kmedoids';
import { log } from '../../utils/logger';

Expand Down Expand Up @@ -87,7 +88,7 @@ export function computeKRange(n: number): [number, number] {
* produces more useful note categories.
*
* @param vectors Input data points (N x D), already UMAP-reduced if applicable
* @param algorithm Which algorithm to use: 'kmeans' or 'kmedoids'
* @param algorithm Which algorithm to use: 'kmeans' or 'kmedoids' (note: kmedoids is not used in the default pipeline)
* @param distFn Distance function (cosine or euclidean)
* @param seed Seed for reproducible initialization
* @returns The optimal K, its assignments, and its silhouette score
Expand Down
3 changes: 2 additions & 1 deletion src/pipeline/clustering/benchmark.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CategorizationConfig, BenchmarkResult, ClusteringStrategy } from '../../types/cluster';
import { DistanceFn, getDistanceFn, silhouetteScore, euclideanDistance } from './metrics';
import { kmeans } from './kmeans';
// NOTE: kmedoids is not used in the default pipeline (too slow), but kept here for manual benchmarking
import { kmedoids } from './kmedoids';
import { hdbscan } from './hdbscan';
import { findOptimalK } from './autoK';
Expand All @@ -25,7 +26,7 @@ export function runStrategy(
switch (strategy.algorithm) {
case 'kmeans':
return kmeans(vectors, strategy.K ?? DEFAULT_K, distFn, seed);
case 'kmedoids':
case 'kmedoids': // NOTE: not used in default pipeline strategies (too slow)
return kmedoids(vectors, strategy.K ?? DEFAULT_K, distFn, seed);
case 'hdbscan':
return hdbscan(vectors, strategy.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE, strategy.minSamples, distFn);
Expand Down
7 changes: 7 additions & 0 deletions src/pipeline/clustering/kmedoids.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* NOTE: kmedoids is NOT currently used in the default pipeline strategies.
* It was removed due to prohibitively high runtime (~38s for ~500 notes)
* compared to kmeans and hdbscan. The implementation is retained here for
* potential future use or manual benchmarking.
*/

import { DistanceFn } from './metrics';
import { mulberry32 } from '../../utils/prng';

Expand Down
2 changes: 0 additions & 2 deletions src/pipeline/pipelineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export function createAdaptiveConfig(
intermediateNeighbors: adaptiveNeighbors(noteCount),
strategies: [
{ name: 'kmeans-auto', algorithm: 'kmeans', K: 'auto' },
{ name: 'kmedoids-auto', algorithm: 'kmedoids', K: 'auto' },
{ name: 'hdbscan', algorithm: 'hdbscan', minClusterSize: 3, minSamples: 2 },
],
};
Expand All @@ -56,7 +55,6 @@ export function createPipelineConfig(metric: MetricType = 'cosine', seed = 42):
intermediateNeighbors: 5,
strategies: [
{ name: 'kmeans-auto', algorithm: 'kmeans', K: 'auto' },
{ name: 'kmedoids-auto', algorithm: 'kmedoids', K: 'auto' },
{ name: 'hdbscan', algorithm: 'hdbscan', minClusterSize: 3, minSamples: 2 },
],
};
Expand Down
3 changes: 2 additions & 1 deletion src/types/cluster.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// NOTE: 'kmedoids' is kept in the type for compatibility but is not used in the default pipeline strategies (too slow)
export type ClusteringAlgorithm = 'kmeans' | 'kmedoids' | 'hdbscan';

export interface ClusteringStrategy {
/** Human-readable label for this run, e.g. 'kmeans-5' */
name: string;
algorithm: ClusteringAlgorithm;
/** Number of clusters (kmeans / kmedoids). Use 'auto' for automatic selection via silhouette sweep. */
/** Number of clusters (kmeans / kmedoids). Use 'auto' for automatic selection via silhouette sweep. Note: kmedoids is not active in the default pipeline. */
K?: number | 'auto';
/** Minimum points to form a cluster (hdbscan, default: 3) */
minClusterSize?: number;
Expand Down
4 changes: 1 addition & 3 deletions src/webview/components/StrategySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ function getStrategyDisplayName(name: string, isHighest: boolean): string {
baseName = 'HDBSCAN';
} else if (name.startsWith('kmeans')) {
baseName = 'K-Means (Testing)';
} else if (name.startsWith('kmedoids')) {
baseName = 'K-Medoids (Testing)';
}

if (isHighest) {
Expand Down Expand Up @@ -63,7 +61,7 @@ export const StrategySection: React.FC<StrategySectionProps> = ({
<div className="strategy-pills">
{strategies
.map((s, idx) => ({ s, idx }))
.filter(({ s }) => !s.strategyName.startsWith('kmeans') && !s.strategyName.startsWith('kmedoids'))
.filter(({ s }) => !s.strategyName.startsWith('kmeans'))
.map(({ s, idx }) => (
<span key={idx} className={`strategy-pill${idx === selectedStrategyIndex ? ' active' : ''}`}>
{getStrategyDisplayName(s.strategyName, false)}: {s.silhouetteScore.toFixed(2)}
Expand Down
3 changes: 1 addition & 2 deletions src/webview/context/AppStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,7 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setStrategies(msg.strategies || []);
setNotes(msg.notes || []);
const nonTestingIdx = (msg.strategies || []).findIndex(
(s: BenchmarkResult) =>
!s.strategyName.startsWith('kmeans') && !s.strategyName.startsWith('kmedoids'),
(s: BenchmarkResult) => !s.strategyName.startsWith('kmeans'),
);
const fallbackIdx = nonTestingIdx !== -1 ? nonTestingIdx : 0;
setSelectedStrategyIndex(msg.selectedStrategyIndex ?? fallbackIdx);
Expand Down
5 changes: 2 additions & 3 deletions test/pipeline/pipelineConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('adaptive scaling functions', () => {
expect(config.metric).toBe('cosine');
expect(config.intermediateDim).toBe(19);
expect(config.intermediateNeighbors).toBe(10);
expect(config.strategies.length).toBe(3);
expect(config.strategies.length).toBe(2);
});
});

Expand All @@ -84,10 +84,9 @@ describe('DEFAULT_CONFIG', () => {
expect(DEFAULT_CONFIG.seed).toBe(42);
});

it('includes kmeans, kmedoids, and hdbscan strategies', () => {
it('includes kmeans and hdbscan strategies', () => {
const names = DEFAULT_CONFIG.strategies.map((s) => s.algorithm);
expect(names).toContain('kmeans');
expect(names).toContain('kmedoids');
expect(names).toContain('hdbscan');
});
});
Loading