Skip to content

Architecture Decision Records

Significant design choices — why each was made, what alternatives were rejected, and the consequences.


0001 — Tree View for Browsing Memories

Status: Accepted

Context

Memories need to be browsable. An early prototype used a QuickPick list. QuickPick is a transient input widget — it dismisses on focus loss and cannot persist state.

Decision

Use a TreeDataProvider registered to a named view (hacklm-memory.memoriesView) for browsing memories. Keep QuickPick only for the control panel (a transient action menu, not a browsing surface).

Consequences

  • The tree view lives in the Explorer sidebar, grouped by category.
  • Users can see all memories at a glance without opening a transient widget.
  • MemoryTreeProvider in memoryTreeView.ts owns this view.
  • QuickPick (memoryPanel.ts) is limited to control panel actions (cleanup, delete, settings).

0002 — Output Channel Singleton

Status: Accepted

Context

Multiple commands (cleanup, session review) need to write output to the user. A naive implementation would call vscode.window.createOutputChannel() in each operation, creating duplicate channels in the Output panel.

Decision

Use a lazy singleton OutputChannel defined in outputChannel.ts. One shared instance per extension activation. Never call createOutputChannel() per operation.

Consequences

  • getOutputChannel() returns the singleton, creating it on first call via ??=.
  • disposeOutputChannel() is called in deactivate() to clean up.
  • All cleanup reports and LM error logs go to the same "HackLM Memory" output channel.
  • Contributors must import from outputChannel.ts — never create a new channel inline.

0003 — Hide Internal Commands from Command Palette

Status: Accepted

Context

Some commands are registered in extension.ts for internal use (e.g. hacklm-memory.revealEntry is called programmatically from the tree view, not by users). Showing these in the Command Palette creates noise.

Decision

Use "commandPalette": [{"command": "...", "when": "false"}] in extension/package.json under menus to hide internal commands from the palette.

Consequences

  • Internal commands are registered and functional but invisible in the Command Palette.
  • User-facing commands remain visible and searchable.
  • All commands still appear in keyboard shortcut settings where users can bind them if desired.

0004 — Tree View Collapsed by Default

Status: Accepted

Context

VS Code tree views registered in contributes.views can have an initial visibility. Without configuration, a new view may automatically expand on first install, disrupting the Explorer sidebar.

Decision

All registered tree views default to "visibility": "collapsed" in package.json.

Consequences

  • New installs do not hijack the Explorer sidebar.
  • Users who want the view open can expand it once; VS Code remembers the state.

0005 — viewsWelcome Required for Every Tree View

Status: Accepted

Context

When a tree view has no data, VS Code shows a blank panel by default. This is confusing for new users who have not stored any memories yet.

Decision

Always add a viewsWelcome contribution for every registered tree view. Also set treeView.message in code for dynamic empty states after initial load.

Consequences

  • New users see a helpful message instead of a blank panel.
  • The viewsWelcome entry handles the initial empty state. The treeView.message property handles cases where memories are deleted after initial load.

0006 — Centralised LM Calls

Status: Accepted

Context

Multiple modules need LM requests (storeMemory, cleanupMemory, sessionReview). Without centralisation, each would independently call vscode.lm.selectChatModels and handle timeouts, cancellation tokens, and error logging differently.

Decision

All LM calls go through lm.ts (resolveModel() and sendLmRequest()). No module calls vscode.lm.selectChatModels or sendRequest directly.

Consequences

  • Fallback logic, timeout handling, cancellation token forwarding, and error logging are in one place.
  • Model family selection is controlled by a single setting (hacklm-memory.lmFamily).
  • If the LM API changes, only lm.ts needs updating.

0007 — LM Justification Required

Status: Accepted

Context

VS Code's LM sendRequest accepts a justification string that appears in the permission prompt shown to the user. An empty string results in a blank reason.

Decision

Every sendLmRequest call must pass a non-empty justification. The SendLmRequestOptions interface makes justification a required field. Passing an empty string is forbidden.

Consequences

  • Users always see a meaningful reason when an LM permission prompt appears.
  • TypeScript enforces that callers provide a justification.

0008 — LM Cancellation Token Forwarding

Status: Accepted

Context

LM tool invoke() methods receive a vscode.CancellationToken. Discarding it means users cannot cancel long-running LM calls.

Decision

The CancellationToken from invoke() is always forwarded to sendLmRequest() via the token option. Parameters with unused tokens use the _token underscore prefix convention; any token that reaches an LM call must be passed through.

Consequences

  • Users can cancel LM operations via the VS Code progress notification.
  • Long gap-analysis or redundancy-check calls do not block indefinitely.

0009 — LM Timeout Cleanup

Status: Accepted

Context

sendLmRequest supports an optional timeoutMs parameter via Promise.race. A naive implementation never calls clearTimeout, leaving a dangling timer after the race resolves.

Decision

clearTimeout(timeoutHandle) is called immediately after Promise.race resolves, regardless of which promise won.

Consequences

  • No dangling setTimeout handles after any sendLmRequest call.
  • The timedOut flag ensures that if the response stream is still being consumed when the timeout fires, iteration stops cleanly.

0010 — Session Review Architecture

Status: Accepted

Context

One design option was to instruct the Copilot model to self-trigger session review at the end of turns. The model cannot reliably self-trigger tool calls — turns can end abruptly.

Decision

sessionReview.ts drives programmatic gap analysis. The extension triggers gap analysis automatically every 3rd successful storeMemory call (triggerGapAnalysis()). Users can also invoke it manually via the HackLM Memory: Review Session command.

Consequences

  • Gap analysis runs reliably at a known frequency without depending on model self-discipline.
  • Both auto-trigger and manual review paths share the same analysis logic.
  • The frequency (every 3 stores) is a candidate for a future user setting.

0011 — Cleanup Merge Threshold

Status: Accepted

Context

Write-time dedup uses SKIP_THRESHOLD=0.8 and UPDATE_THRESHOLD=0.6. Entries that passed those thresholds can still be semantically related enough to merge over time.

Decision

Cleanup uses a separate merge threshold of 0.3 Jaccard similarity via findSimilarClusters(). The cleanup threshold is an independent constant — never unified with the write-time thresholds.

Consequences

  • Cleanup is more aggressive than write-time dedup. This is intentional — write-time dedup must be fast and conservative; cleanup runs less frequently and can afford broader merges.
  • Two separate constant sets: SKIP_THRESHOLD/UPDATE_THRESHOLD (in dedup.ts) and the threshold parameter to findSimilarClusters (default 0.5, called with 0.3 in cleanup).

0012 — Gap Analysis — No Changelog

Status: Accepted

Context

Without explicit constraints, the gap analysis LLM might suggest entries phrased as past events ("last cleanup removed 3 entries"). These are changelog entries, not memory.

Decision

The gap analysis prompt explicitly forbids changelog or past-event suggestions. The LLM must only suggest forward-looking, present-tense guidelines — things that will still be relevant in a future session.

Consequences

  • Memory stays actionable and durable.
  • Maintainers editing the gap analysis prompt must preserve this constraint.

0013 — Category Limits Rationale

Status: Accepted

Context

Each memory category has a maximum entry count enforced during cleanup. Limits are set per-category based on observed growth rates and value.

Decision

Category Limit Rationale
Instruction 30 Low count, high value. Instructions should be crisp.
Decision 40 Grows fast on active projects.
Quirk 40 Accumulates over time. Cleanup keeps it relevant.
Preference 40 Style choices accumulate.
Security 30 Should be small and authoritative.

All limits are user-configurable via hacklm-memory.categoryLimit.<Category> settings.

Consequences

  • Cleanup prunes the lowest-scoring entries when a category exceeds its limit.
  • Users on large projects may need to increase limits via settings.

0014 — Model Resolution

Status: Accepted

Context

Multiple modules need a LanguageModelChat instance. Without centralisation, each would call vscode.lm.selectChatModels independently with potentially different family strings.

Decision

Model resolution always happens in lm.ts. resolveFamily() reads hacklm-memory.lmFamily (default gpt-5-mini). resolveModel() calls selectChatModels with that family and returns the first result or null. Callers never call selectChatModels directly.

Consequences

  • One place to update if the LM selection API changes.
  • Callers treat null as "LM unavailable — proceed without" (fail-open).

0015 — GPL v3.0 License

Status: Accepted

Context

The project was originally licensed under MIT. The maintainer switched to a copyleft license to ensure derivative works remain open source.

Decision

The project uses GNU General Public License v3.0 (GPL-3.0-only). Applies to all source in the repository. "license": "GPL-3.0-only" in both package.json files.

Consequences

  • Anyone distributing a modified version must release modifications under GPL v3.0.
  • End users can use the extension freely without restriction.
  • Contributors agree their contributions are licensed under GPL v3.0 (stated in CONTRIBUTING.md).

0016 — VS Code-First, JetBrains Deferred

Status: Accepted

Context

HackLM Memory could theoretically support multiple editors. JetBrains requires a fundamentally different technology stack (Kotlin, IntelliJ Platform).

Decision

The initial release targets VS Code. JetBrains support is deferred until a maintainer with JetBrains plugin experience volunteers. Support for additional editors will be delivered via MCP (see ADR 0017).

Consequences

  • The extension is written in TypeScript against the VS Code extension API exclusively.
  • The storage layer (dedup.ts, scoring.ts, search.ts, markdownStore.ts) has no vscode dependency and could be extracted into a packages/storage package for future ports.
  • The .memory/*.md file format (documented in api-reference.md) is the stable, editor-agnostic contract any future implementation must honour.

0017 — Additional Editor Support via MCP

Status: Accepted (not yet implemented)

Context

Some editors (e.g. Google Antigravity) support MCP (Model Context Protocol), which allows agents to call external tools. These editors do not share the VS Code extension API surface.

Decision

Support for MCP-capable editors will be delivered as a separate MCP server (mcp/) rather than by modifying the existing extension. The current extension remains VS Code-focused with no changes.

When the MCP server is built: - The storage layer is extracted to packages/storage (shared by both extension/ and mcp/). - The MCP server accepts workspaceRoot as a per-call parameter (no VS Code workspace context). - LM-based redundancy checks are omitted — Jaccard fuzzy matching is sufficient without a second LM pass. - Gap analysis and session review are omitted — no user-prompt UI exists in the MCP context.

Consequences

  • Zero changes to the existing VS Code extension.
  • No code duplication — storage logic lives once in packages/storage.
  • The MCP server is a clean, dependency-light Node.js process.
  • Feature parity is intentionally incomplete: gap analysis and LM dedup are VS Code-only features.

0018 — Two-tier Write Locking for Memory Files

Status: Accepted

Context

The original in-process withFileLock (a chained-promise mutex keyed by file path) only protects concurrent calls within a single extension host process. When multiple agents run in the same worktree — each in its own process — simultaneous read-modify-write operations on the same .memory/*.md file produce last-writer-wins corruption. Reddit usage data shows parallel agents on the same worktree is a real pattern (e.g. 8 parallel code-review sub-agents, parallel feature agents).

Decision

Add a second, outer lock layer: crossProcessLock.ts implements a cross-process advisory lockfile at .memory/.lock. All writes first acquire this lock, then fall through to the existing per-file in-process lock.

Key choices:

  • One directory-level lock, not per-file lockfiles. Write frequency is low. A single .lock file avoids proliferating 5+ lockfiles and is simpler to reason about.
  • Pure Node.js — no runtime dependencies. fs.open(path, 'wx') is the atomic exclusive-create primitive; it is one line. The extension has zero runtime npm dependencies and must stay that way.
  • Stale detection: PID liveness + age fallback. process.kill(pid, 0) checks PID existence without sending a signal. If the PID is gone the lock is stale. Age > 10 s is a fallback for cross-machine or PID-reuse edge cases.
  • clearStaleLockOnStartup() called from ensureMemoryDir. Cleans up any lock file left behind by a crashed process on the first write of a new session.

Alternatives Rejected

  • proper-lockfile npm package: correct, but adds a runtime dependency. Not worth it given the core primitive is trivial to implement.
  • Per-file lockfiles: more granular but multiplies file clutter and adds complexity for no practical benefit at current write rates.

Consequences

  • Parallel agents writing memories to the same worktree are serialised. No data loss.
  • Zero new npm dependencies.
  • The .lock file is visible in the .memory/ folder during writes. It is ephemeral (removed on release) and should be added to .gitignore by projects that track .memory/.
  • withMemoryWriteLock(filePath, fn) in markdownStore.ts is the only call site — neither lock is accessible outside this module.