Relevant Links
-
GitHub profile: jellyfrostt · GitHub
-
Forum introduction post: Welcome to GSoC 2026 with Joplin! - #110 by jellyfrostt
-
Pull requests submitted to Joplin:
-
Other relevant experience:
-
Built RAG pipelines with Milvus, pgvector, and Chroma at GDP Labs and 에이사허브
-
Researched regarding PII detection/classification system evaluating 6 detection approaches for a compliance platform
-
Production AI chatbot with inter-session semantic memory using embeddings
1. Introduction
Hello, I'm Sasha (nickname, as my legal name is a bit difficult to pronounce), a Computer Science student based in Southeast Asia and a 4th year student equipped with some professional full-stack developer experience. My production experience includes working with React, TypeScript, Django, and AI/LLM systems across multiple companies and am very eager to dive into the world of open source!
Programming experience:
-
에이사허브: Next.js, TypeScript, Django REST Framework, WebSocket streaming, OpenAI/Claude/Gemini integration, pgvector, RAG with embeddings, Celery, AWS ECS, Terraform
-
GDP Labs: LangChain.js, LangGraph, FastAPI, React, Elasticsearch, Milvus vector database, GraphRAG, ColBERT, BM25 reranking
-
Equinox Technology: Next.js, React, Shopify/Liquid, Core Web Vitals optimization
-
PT Winniecode: MERN stack, REST APIs, authentication
-
Freelance (Alpacca Studio): Next.js, Django, PostgreSQL, multi-tenant SaaS architecture, RBAC
Why this project?:
As note collections grow, the cognitive overhead of filing notes into the right notebook and tagging them consistently becomes tedious enough that most users just stop doing it — and once organisation falls behind, it compounds. This project automates that friction away using local embeddings to classify, tag, and surface structural patterns across the collection, without requiring an API key or sending data off-device. The core intelligence runs entirely offline, which fits naturally with Joplin's privacy-first philosophy.
2. Problem Statement
2.1 Notes end up in the wrong place. Users jot down a meeting note while browsing "Recipes" — and that's where it stays. On mobile especially, switching notebooks before writing feels like too much friction, so people just don't.
2.2 Tags become inconsistent. #project, #projects, #proj — same thing, three tags. Eventually most users stop tagging altogether, which defeats the purpose.
2.3 You can't see what you actually have. Notes about the same topic end up scattered across multiple notebooks with no shared tags. There's no way to discover the latent structure in your own collection hence causing massive user frustration to manually navigate through tons of entries to find their targeted notes.
2.4 Related work. The Jarvis plugin offers embedding-based tag suggestion. This project differs in three crucial ways: (a) it suggests notebooks, not just tags — no existing Joplin plugin does this; (b) it operates in batch mode across the entire collection ("Analyse All"), not just on the currently selected note; and (c) it provides a dedicated categorisation UI with accept/reject/undo, rather than embedding AI features into a general-purpose assistant. In the broader ecosystem, Obsidian's auto-tagging plugins (AI Tagger, Auto Classifier) all require an LLM API key — none offer offline, zero-config tagging via embeddings.
3. Building Plan
A Joplin desktop plugin that uses local embeddings to automatically suggest where notes belong and how they should be tagged — without needing an API key or sending any data off-device.
3.1 Core features (works without any LLM):
-
Notebook Classification — Centroid-based classifier that compares a note's embedding against the mean vector of each notebook. When users open a note, the plugin suggests which notebook it actually belongs in with a one-click move.
-
Auto-Tagging — KNN tag propagation from semantically similar notes, weighted by cosine similarity. If your 5 closest notes are all tagged
#reactand#frontend, it suggests those tags for the current note. -
Stale Note Detection — Flags notes not edited for 6+ months using
user_updated_timeand suggests archival to a dedicated "Archive" notebook. No AI utilized just pure timestamp check, but integrated into the same suggestion panel.
3.2 Enhancement layers (optional, when LLM is configured):
-
Topic Discovery — K-Means clustering on note embeddings to reveal hidden themes. Users might discover 40 notes about "home renovation" scattered across 4 notebooks. The plugin finds these clusters, labels them via c-TF-IDF with optional LLM refinement, and suggests creating proper notebooks.
-
Agentic Organisation — LLM-powered batch action planning: "move these 12 notes to a new 'Machine Learning' notebook, tag these 8 with #research, archive these 15 stale notes." Every action is reviewed in a sidebar panel before execution. Nothing auto-executes.
3.3 Expected outcome. For daily use: open a note, see notebook + tag suggestions instantly. For periodic use: click "Analyse All Notes", review a list of concrete suggestions (move, tag, create notebook, archive), and apply approved actions with one click. Every action is undoable.
3.4 Out of scope. Full RAG / chat with notes (Idea 4), multi-modal analysis (text only), mobile-specific UI (desktop first via joplin.views.panels).
4. Technical Approach
4.1 Architecture Overview
The plugin separates into an embedding service (shared infrastructure), an intelligence engine (core classification + tagging + stale detection, and optional clustering), and a suggestion panel (UI). All run inside the Joplin plugin sandbox.
4.2 Embedding Service
4.2.1 Model selection. We opted for BAAI/bge-small-en-v1.5 via transformers.js (ONNX Runtime, WASM backend) over the commonly-cited all-MiniLM-L6-v2 because bge-small scores significantly higher on MTEB classification benchmarks specifically — 74.14 vs ~63 — and classification is exactly what this plugin does. It also supports longer token context (512 vs 256), meaning fewer notes get truncated. The size difference is negligible for desktop. Among all models under ~50MB quantised with ONNX availability, bge-small ranks #1 on MTEB Classification — the next best small model (gte-small) scores 72.31 and models that beat bge-small (gte-modernbert-base at 76.99, bge-base at 75.53) are all 100MB+, too large for a plugin. Notes exceeding 512 tokens are truncated — the model embeds the first ~350 words. For typical notes this captures the topic; for very long notes, the title and opening content still provide a usable signal. The service is model-agnostic — swapping requires only a config change and re-index.
4.2.2 Why WASM. Joplin's plugin sandbox only whitelists a few native packages (sqlite3, fs-extra). Native ONNX would need platform-specific binaries that can't be bundled. WASM runs everywhere. The build uses extraScripts in plugin.config.json to compile the Web Worker entry point separately, and a build-time copy script (following the pattern from Joplin's official worker example plugin at packages/app-cli/tests/support/plugins/worker/) to place ONNX WASM files into dist/. The plugin loads them from its installation directory at runtime.
4.2.3 Model delivery. The ONNX model weights (~34MB quantised) are auto-downloaded by transformers.js from HuggingFace on first use and cached locally in joplin.plugins.dataDir(). Subsequent loads read from the local cache — no internet required after the initial download. A progress indicator is shown during the one-time download. If the download fails, the plugin surfaces an error and retries on next trigger.
4.2.4 Lazy initialisation. The model loads on first use (when the user opens the plugin or triggers "Analyse All"), not on Joplin startup. Once loaded, the pipeline object stays in memory for the session.
4.3 Vector Storage
4.3.1 Approach. A custom binary Float32Array store: a contiguous buffer persisted as a binary file via joplin.require('fs-extra'), a Map<noteId, index> for constant-time lookup, and brute-force cosine similarity.
4.3.2 Why not the alternatives?
-
Vectra: JSON-based storage — significantly larger on disk, slower to load. Pre-1.0 library with a single maintainer. Risky dependency.
-
sqlite-vec: Good performance but requires platform-specific native extensions (
.dll/.so/.dylib) that Joplin plugins cannot bundle — onlysqlite3andfs-extraare whitelisted viajoplin.require().
The custom store is ~150 lines of TypeScript, zero dependencies, loads instantly, and handles KNN queries fast enough at personal-collection scale (5,000 notes ≈ 7.5MB, millisecond queries).
4.3.3 CRUD operations. For create, the new embedding is appended at the end of the buffer with its note ID and precomputed norm, then flushed to disk. Search is a brute-force cosine similarity scan using precomputed norms — <1ms for k=5 over 1,000 vectors, scaling linearly (10,000 vectors would still be <10ms). For update, the note's index is looked up from the ID map and the 384 floats at that offset are overwritten with the new embedding and recomputed norm. Delete uses a swap-with-last approach — the entry is swapped with the last element in the buffer and the count is shrunk by 1, giving O(1) deletion. Storage: 1,000 notes at 384 dimensions ≈ 1.5MB on disk; 10,000 notes ≈ 15MB. Disk writes are debounced during bulk indexing to avoid excessive I/O.
4.4 Incremental Indexing
4.4.1 The onNoteChange() limitation. My first instinct was to use onNoteChange() for all sync. Investigating JoplinWorkspace.ts (lines 115–128), I found it only fires for the currently selected note. That's fine for immediate feedback but misses notes synced from other devices.
4.4.2 Solution: Events API cursor polling. The reliable approach is GET /events with a persisted cursor, which returns up to 100 changes per call (as defined in packages/lib/services/rest/routes/events.ts, line 10) with has_more pagination. This catches every create/update/delete across the entire collection. Cursor bootstrap: on first run (no stored cursor), the plugin performs a full scan of all notes via paginated GET /notes, then calls GET /events without a cursor parameter — which returns an empty item list and the latest change ID as the starting cursor. From that point, incremental polling picks up only new changes.
4.4.3 Three sync triggers:
-
onNoteChange()— fast path for the currently edited note -
onSyncComplete()— batch catch-up after Joplin sync -
Periodic polling (every 5 minutes) — safety net for missed events
Each note gets a source_hash (MD5 of the note body). On re-index, only notes whose hash differs from the stored hash get re-embedded — unchanged notes are skipped entirely. Notes with encryption_applied = 1 (E2EE) are skipped and queued — their ciphertext body would produce meaningless embeddings. When decryption completes, the next poll cycle picks up the change and indexes the decrypted content. A running "Analyse All" job is guarded by a simple lock flag — if triggered again while running, the second invocation is queued rather than overlapping. If Joplin closes mid-indexing, partial progress is safe: the hash map and vector store are flushed to disk every 50 notes during bulk indexing, so the next run resumes from the last persisted point.
4.5 Centroid-Based Notebook Classification
4.5.1 How it works. For each notebook, compute its centroid — the L2-normalised mean of all its note embeddings. For a new or uncategorised note, compute cosine similarity against each centroid and suggest the highest-scoring notebook. This is O(k) per note — instant. Centroids are updated incrementally: when a single note is created, updated, or moved, only the affected notebook's centroid is recomputed (sum + divide, O(n) for that notebook). During "Analyse All", all centroids are fully recomputed from scratch. For nested notebooks (sub-notebooks via parent_id), centroids are computed per leaf notebook — a note in "Programming > Python" contributes only to the Python centroid, not the parent "Programming" centroid. Classification suggests the most specific matching notebook. If the user has only a single default notebook with all their notes, the plugin detects this (one notebook with >90% of notes) and prompts "Run 'Analyse All' to discover topic-based notebooks" rather than making meaningless single-notebook suggestions.
4.5.2 Threshold calibration for bge-small-en-v1.5. This model uses contrastive learning with temperature τ=0.01 (as documented in the official BAAI model card), which compresses its cosine similarity distribution into the interval [0.6, 1.0]. Even unrelated text pairs produce scores above 0.7. Thresholds must be calibrated for this compressed distribution:
-
Classification threshold: 0.78 — below this, suggest creating a new notebook. This sits just below BAAI's recommended filtering thresholds of 0.8+ — adjusted slightly lower because centroid classification (selecting the best match) requires a lower bar than similarity filtering (finding near-duplicates). This implements novelty/distance rejection (Dubuisson & Masson, 1993): rejecting inputs that are too far from all known classes.
-
Ambiguity margin: 0.03 — if the top-2 notebook scores are within this margin, present both options to the user. This implements ambiguity rejection (Chow, 1970; Hendrickx et al., 2021): abstaining when the input falls near a decision boundary. Within the compressed [0.6, 1.0] range, 0.03 represents 7.5% of the effective decision space. Margin-based confidence — using the gap between the top-1 and top-2 scores as an uncertainty signal — is a well-established heuristic in classification with reject option literature (Fumera & Roli, 2002), making this gap a reliable indicator of decision boundary proximity.
-
Minimum notes for stable centroid: 10 — below this, fall back to KNN classification among that notebook's individual notes.
These thresholds are starting defaults and will be initially calibrated during weeks 3–4 and refined during end-to-end testing in weeks 5–6, consistent with the literature consensus that absolute cosine similarity thresholds must be tuned per model and task (Reimers & Gurevych, EMNLP 2019). Calibration methodology: construct a labelled evaluation set from real user note collections (with mentor assistance) where each note has a known correct notebook. Sweep threshold values across [0.70, 0.90] in 0.01 increments, measure classification F1, and select the threshold maximising F1 on a held-out split. The ambiguity margin is tuned similarly by measuring the rate of correct vs incorrect suggestions in the ambiguous band.
4.5.3 When centroid vs full clustering applies:
| Scenario | Method | Why |
|---|---|---|
| Single note arrives | Centroid comparison | O(k) — instant |
| User clicks "Analyse All" | Full K-Means clustering | Discovers new structure |
| After sync with many changes | Centroid + periodic re-cluster | Balance speed and quality |
| First-time setup (no notebooks) | Full clustering + labelling | Build structure from scratch |
4.6 KNN Auto-Tagging
4.6.1 Algorithm. Find the k-nearest neighbours, collect their tags, weight by cosine similarity, and suggest tags that meet the threshold.
4.6.2 Parameters (calibrated for bge-small's compressed [0.6, 1.0] range per BAAI model card):
-
k = 5 (adaptive: 3–10 based on corpus size)
-
Weighting: cosine similarity directly — weighted voting typically produces ~1–5% better results than unweighted in classification tasks
-
Tag threshold: score ≥ 0.78 AND tag appears in ≥ 2 of k neighbours. The 0.78 threshold is reused from notebook classification because both tasks operate on the same embedding space with the same compressed similarity distribution — a neighbour scoring below 0.78 is too semantically distant to propagate tags from reliably. The dual condition (score AND frequency) provides additional filtering that classification does not need.
-
Maximum 5 suggestions per note — avoids choice overload
4.6.3 Cold-start handling. When fewer than 10 notes have tags, KNN lacks sufficient examples. The fallback chain:
-
If LLM is configured → ask the LLM to suggest tags from the note content + user's existing tag vocabulary
-
If no LLM → display "Tag 10+ notes to enable auto-suggestions." This is honest and avoids generating random tags from cluster keywords (users create organisational tags like
#todo, not topical keywords likemachine, learning, neural)
4.7 Stale Note Detection
During "Analyse All", query all notes and flag those where user_updated_time exceeds the configurable threshold (default: 6 months). Using user_updated_time rather than updated_time avoids false negatives from sync-triggered updates. Additionally, the plugin tracks last_viewed_time per note via onNoteSelectionChange — stored in dataDir(), not in note userData (which would trigger unnecessary sync events). A note that is frequently viewed but never edited (e.g. a reference document) is not stale. The effective staleness check uses max(user_updated_time, last_viewed_time). Stale notes surface as "Archive?" suggestions in the same panel, using move_note to relocate to a user-configured "Archive" notebook (auto-created if absent). No AI required.
4.8 Topic Discovery via K-Means Clustering
This is a periodic "Analyse All" feature — it answers "what hidden topics exist in my notes?"
4.8.1 Why K-Means over HDBSCAN.
-
Every note must be assigned. HDBSCAN marks outliers as "noise" — users expect every note in a notebook.
-
Notebook count is predictable (5–30). K-Means with auto-k maps naturally.
-
JavaScript ecosystem:
ml-kmeans(v7.0.0, maintained TypeScript). No mature JS HDBSCAN exists. -
On L2-normalised embeddings, minimising Euclidean distance is equivalent to maximising cosine similarity —
||u-v||² = 2(1 - cos(u,v))— so K-Means directly optimises the right metric.
4.8.2 Automatic k selection. Centroid-based simplified silhouette replaces O(n²) full pairwise with O(n·k) centroid distances: a(i) = distance to own centroid, b(i) = distance to nearest other centroid, s(i) = (b-a)/max(a,b). Search range: k = 2 to min(√n, 30). Sampling for large collections.
4.8.3 Cluster labelling. c-TF-IDF treats each cluster as a single document and computes class-based term frequency weighted by inverse document frequency across clusters. Top terms become the cluster's keyword label. When an LLM is available, keywords + sample note titles get refined into human-readable labels.
4.8.4 Cluster-to-suggestion flow. After clustering, the plugin compares discovered clusters against existing notebooks. For each cluster that does not align with any existing notebook (low centroid overlap), it generates a suggestion: "Create notebook '[cluster label]' and move [N] notes into it." For clusters that partially overlap an existing notebook, it suggests moving the outlier notes. All suggestions appear in the same sidebar panel as notebook classification and tagging suggestions, with the same Accept/Reject/Undo workflow. The user can preview which notes belong to each cluster before accepting.
4.9 Agentic LLM Layer
When an LLM provider is configured, the plugin generates a batch action plan that the user reviews before execution.
4.9.1 LLM-to-API mapping. Four tools following the OpenAI function-calling JSON Schema format (which Ollama also supports via its compatible endpoint):
| Tool | Description | Joplin API Call |
|---|---|---|
add_tag | Add tag to note (creates if needed) | POST /tags → POST /tags/:id/notes |
remove_tag | Remove tag from note | DELETE /tags/:id/notes/:noteId |
move_note | Move note to notebook | PUT /notes/:id with { parent_id } |
create_notebook | Create new notebook | POST /folders with { title, parent_id } |
All four were manually verified to exist in the Joplin codebase:
-
Tag.addNote() via POST /tags/:id/notes in packages/lib/services/rest/routes/tags.ts
-
Tag.removeNote() via DELETE /tags/:id/notes/:noteId in the same file
-
Note.save({ parent_id }) via PUT /notes/:id in routes/notes.ts
-
Folder.save() via POST /folders via defaultAction in routes/folders.ts
4.9.2 Execution flow:
4.9.3 Safety guarantees.
-
Nothing auto-executes. The sidebar presents each suggestion with type (TAG/MOVE), confidence %, reason, and Accept/Reject buttons
-
Accept All / Reject All available at the top
-
"Never suggest again" — when rejecting a suggestion, the user can mark it as permanently ignored. The plugin stores an ignore map (
noteId → Set<suggestionHash>) indataDir()and filters these from all future runs -
Every executed action records its inverse for the undo stack (session-scoped; cleared on Joplin restart)
-
All note/folder IDs validated via
joplin.data.get()before execution -
JSON Schema validation on every tool call; retry with error feedback (max 2)
4.9.4 LLM context design. The prompt sent to the LLM contains: (a) the note's title and first ~500 tokens of body text (not the full note, to limit token usage), (b) the list of existing notebook names and tag vocabulary, and (c) the top-3 candidate notebooks/tags from the embedding-based classifier as pre-computed hints. The LLM's role is to refine and plan batch actions across multiple notes — not to replace the embedding classifier. For remote LLM providers, note content is sent over HTTPS; this is explicitly disclosed in the settings panel (see 4.11).
4.9.5 Multi-provider support. Since Ollama exposes an OpenAI-compatible endpoint (/v1/chat/completions), a single client class handles both — only the base URL changes. No LLM is the default; all embedding-based features work without one.
4.10 Plugin Registration
The plugin registers via joplin.plugins.register() in onStart:
-
Settings section (
joplin.settings.registerSection): "AI Categoriser" with LLM provider dropdown (none/Ollama/OpenAI), API key (SettingItem.secure— OS keychain), Ollama URL, and auto-tag-on-save toggle. Similarity thresholds are internally configurable and calibrated during development — not exposed as user-facing settings. -
Sidebar panel (
joplin.views.panels.create): suggestion review UI loaded viasetHtmlandaddScript -
Command (
joplin.commands.register): "AI: Analyse All Notes" via Tools menu (joplin.views.menuItems.create) -
Event hooks:
onNoteChangefor immediate suggestions,onSyncCompletefor batch re-indexing,onNoteSelectionChangefor cached suggestions on note switch
4.11 Privacy
Everything runs locally by default. No data leaves the machine. The embedding model runs via WASM, the vector store lives in joplin.plugins.dataDir(), and all core features (notebook classification, auto-tagging, stale detection) work fully offline. When a user opts into a remote LLM provider (OpenAI), note titles and truncated body text (~500 tokens per note) are sent over HTTPS for batch action planning — the settings page shows a persistent disclosure stating exactly this. Ollama keeps everything local since it runs on the user's machine. API keys are stored via secure: true (OS keychain).
4.12 Risks and Mitigations
| Risk | Mitigation |
|---|---|
| WASM model won't load in sandbox | Build-time copy script places ONNX WASM files into dist/; follows the official Joplin worker example plugin pattern (packages/app-cli/tests/support/plugins/worker/) |
| Initial indexing freezes UI | Embedding runs in a dedicated Web Worker (new Worker()) so the main Joplin UI thread stays completely free. Within the worker, notes are processed in batches of 10 with event-loop yields between batches to keep postMessage IPC responsive (cancel signals, progress reporting). Progress bar + cancel in the UI. Partial progress persists to disk every 50 notes — resumes after restart |
| ONNX WASM memory degradation | The WASM runtime's linear memory grows but never shrinks during sustained embedding — bge-small degrades from ~47 to ~2 notes/sec after ~100 notes. Mitigated by recycling the Web Worker periodically (worker.terminate() + new Worker()). Model reload from local cache costs ~325ms per recycle. For 5,000 notes with recycling every ~100 notes: ~50 reloads × 325ms = ~16s overhead on top of ~2.4 min embedding time — acceptable. Verified in POC for both bge-small and all-MiniLM-L6-v2 |
| Encrypted notes (E2EE) | Notes with encryption_applied = 1 are skipped during indexing — ciphertext produces meaningless embeddings. They are queued and indexed after decryption completes on the next poll cycle |
| LLM tool calls return garbage | JSON Schema validation per call; retry with error context (max 2); fall back to embedding-only |
| Non-English notes | Default model is English-optimised. Multilingual model (multilingual-e5-small) is post-GSoC. Non-English still gets indexed, just lower quality |
| Cross-platform binary issues | Eliminated — custom Float32Array store is pure TypeScript, zero native deps |
4.13 Testing Strategy
-
Unit tests: Cosine similarity, centroid computation, KNN voting, c-TF-IDF, silhouette score — pure functions, Jest
-
Integration tests: Create notes via Data API → verify indexed → modify → verify re-indexed → classify → verify suggestions match expected notebooks
-
Agentic tests: Mock LLM responses with known tool calls → verify validation → verify execution → verify undo reverses action
-
Edge cases: Empty notes (skip), <20 chars (skip), image-only notes (detected by stripping markdown image/link syntax
and checking if remaining text is <20 chars — skip), encrypted notes withencryption_applied = 1(skip, queue for post-decryption), 0 tags cold start, single-note notebooks, <10 note notebooks (KNN fallback), single default notebook (prompt Analyse All)
5. Proposed Timeline
| Weeks | Phase | Deliverable |
|---|---|---|
| 1–2 | Foundation | Validate WASM model loading. Build EmbeddingService + VectorStore (Float32Array binary) + incremental indexer (Events API cursor). Unit tests for embedding + cosine similarity + store CRUD. |
| 3–4 | Core Classification | Centroid notebook classifier with threshold calibration. KNN auto-tagger with weighted voting. Stale note detector. Cold-start handling. Integration tests. |
| 5–6 | UI + End-to-End | Sidebar panel via joplin.views.panels. WebView ↔ Plugin messaging. Accept/reject with confidence indicators. Progress bar. Joplin theme styling. |
| 7–8 | Topic Discovery | K-Means with auto-k (simplified silhouette). c-TF-IDF labelling. "Analyse All" command. Target: <5s for 1,000 notes. |
| 9–10 | Agentic Layer | 4 tool definitions + JSON Schema validation. ActionExecutor with Joplin API mappings. Ollama + OpenAI provider. Undo stack. |
| 11–12 | Polish | End-to-end testing (100 / 1K / 5K notes). Performance tuning. User + developer docs. Plugin marketplace packaging. Demo video. |
Risk mitigation: Core features (embedding, classification, tagging, UI) are done by week 6. If WASM or infrastructure takes longer, enhancement layers (clustering, agentic) can be descoped without losing a working, shippable product.
6. Deliverables
Required:
-
Joplin plugin (.jpl) installable from the marketplace
-
Local embedding pipeline — bge-small-en-v1.5 via transformers.js with incremental indexing
-
Centroid-based notebook classifier
-
KNN auto-tagger with weighted voting
-
Stale note detector with configurable threshold
-
Sidebar panel UI with accept/reject/undo
-
Test suite — unit + integration
-
User guide + developer documentation
Optional (enhancement layers):
-
K-Means topic discovery with c-TF-IDF labelling and "Analyse All" command
-
Agentic LLM organiser with 4 tools, multi-provider support, and human-in-the-loop approval
7. Availability
-
Weekly availability: ~30–35 hours/week during GSoC (primary commitment)
-
Time zone: Asia/Jakarta (UTC+7)
-
Other commitments: University courses — exam periods and quizzes are to be communicated to mentors in advance.
-
Communication: Daily async on Joplin forum + GitHub. Weekly sync with mentor using their preferred platform for communication. Blockers surfaced will be communicated within 24 hours. All code submitted as early draft PRs for incremental review.



