본문으로 건너뛰기
0

dsh-project-memory

00080000/dsh-project-memory

기본 task-system 통합이 있는 dsh용 프로젝트 메모리: 세션 todo와 파일 읽기가 자동으로 세션 간 작업 기록(task↔file 링크)으로 압축되어, 재맥락화 없이 워크플로를 전환하고 재개할 수 있습니다. 읽기 시점 메모리(사전 스캔 없음): 코드 심볼을 추출하고, 문서를 요약하며, docs↔symbols를 교차 연결합니다. 선택적 TS semantic enhancement(TS 설치 시 자동 켜짐)를 제공합니다. 경험 노트는 자동 중복 제거됩니다. 모든 회상에는 추적 가능한 인용이 포함됩니다. 순수 JS, 단일 의존성, 바로 사용 가능, 조용히 동작하며 오버헤드가 없습니다.

설치

dsh plugin --profile web add github:00080000/dsh-project-memory

README

dsh-project-memory

如果这个插件帮你省下 1 小时 Debug 时间,请点个 Star。

English | 简体中文

ci License: MIT npm npm downloads Listed on dsh-plugin.org Awesome

A persistent project development memory for DeepSeek Harness (dsh) agents. Built specifically for project development, natively integrated with dsh's task system: task lists and files read during a session are automatically persisted as cross-session task records, with tasks ↔ files linked — workflows can be switched and resumed, no need to re-scope the whole project, solving context loss. Documents (PDF/Markdown/txt) and code symbols are stored separately per workspace; documents are automatically cross-linked to the code symbols they mention. Experience notes (problem → solution) are automatically deduplicated, preventing repeated mistakes. All data is stored per project on disk, survives session compaction and handover; recalls include path:line citations for source verification. Only one dependency, no vector DB, no native builds.

The plugin keeps a compact project memory on disk, with every entry pointing to a concrete file and line — the agent can reorient quickly instead of re-reading the whole project.

Task panel: task list, step progress, and involved files

Features

  • TaskBridge: cross-session development tasks — the session's todo list and the files it touches are persisted as per-project task entities, so a workflow can be switched and resumed without re-scoping the project. Associated files stay in recency-weighted order (a read never outranks a written file), so a resumed session sees where to look first. New sessions continue with list_tasks → select_task. Work delegated to subagents creates no task. On hosts without session events the task tools still work as a plain record list.
  • Task panel and editing — draggable cards show a task's steps and files, and can collapse to a mini-bar or be hidden. Title, steps and status can be edited inline, writing back through the same path as model updates; four visual themes change material, geometry, typeface and density only, and colours follow the host. The panel stays hidden until summoned, syncs in the background on session switch, never reopens itself after a refresh, and a render failure cannot take down the host.
  • Document memory — PDF, Markdown and plain text are chunked and summarized without a model call. Each entry keeps a short summary for injection, a bounded term set for recall, and a citation back to the source.
  • Code symbol memory — a dependency-free scanner extracts functions, classes, methods, interfaces and type aliases with full signatures across 8 languages. Where typescript is installed, an optional second layer adds inferred return types, generics and interface shapes; it runs in the background and never blocks indexing.
  • Automatic, read-time memorization — a file is memorized the moment the model reads it, so memory is a byproduct of normal work rather than an upfront scan, and files nobody reads are never indexed. A background poll re-memorizes changed files only.
  • Doc ↔ code cross-linking — a document that mentions a symbol is surfaced when that symbol is queried. Links are resolved against the current symbol table, so they cannot go stale.
  • BM25 recall — ranked search over documents, symbols, experience notes and insights, with optional LLM query expansion. Tuned for CJK: phrase boost, a synonym table, and CJK-aware boundaries for doc↔symbol linking.
  • Experience notes — problems → solutions, deduplicated by overlap rather than repeated, bounded by project size, and returned only when a search matches.
  • Tiered insight memory (lessons / decisions / procedures) — one entity across task, project and global scope. Near-duplicates merge or reinforce, and promotion moves an entry between scopes instead of copying it. Use is recorded, so decay and capacity rank by activity rather than age alone. The panel exposes a per-scope view for reviewing and editing entries.
  • Triggered injection — an insight may carry an authored trigger: only when triggers, guard narrows it, and prevents records what breaks without the entry. Corpus text can never trigger an injection; the statistical channel is gated separately and silent by default.

Installation

The plugin relies only on stable public APIs declared through peerDependencies.

cd dsh-project-memory && dsh plugin --profile web add . -w

The -w (workspace-root) flag is required: the profile directory is a pnpm workspace root, and pnpm rejects add there without it. From elsewhere, use the same command with an absolute path.

The plugin is also published on npm as a scoped package:

dsh plugin --profile web add @yolk_vat-y/dsh-project-memory -w

Each indexed project has its own store at <root>/.dsh-project-memory/. It keeps itself out of git by default — there is nothing to add to your .gitignore.

Usage

The tools below are invoked by the agent, not typed by the user. In the chat, just ask naturally — e.g. "index this project" or "what does the auth module do?" — or simply keep working, and the agent calls the matching tool automatically. By default files are indexed the moment the model reads them, so memory fills in while you work. watch_repo keeps explicitly-watched roots fresh in the background; index_repo forces a full backfill (unchanged files are skipped).

ToolPurpose
index_doc file_pathIndex one document (PDF/MD/txt): chunk, summarize deterministically, and store with a path:line citation. Unchanged files are skipped.
index_repo rootIndex a whole project: documents get deterministic summaries, code files get a token-free symbol table. Incremental, cleans up deleted files, cross-links documents to symbols. A non-existent or excluded root is rejected before anything is written.
watch_repo rootEnable automatic refresh: a background poll detects new and changed files and re-indexes only those. Watched roots persist across restarts; a non-existent or excluded root is refused, roots that disappear are dropped rather than re-created.
memory_stats rootShow what the store contains: totals, last index time, and the per-file list sorted by recency.
query_memory queryRanked search over documents, symbols, experience and insights, optionally query-expanded by the LLM. type selects a layer (all / doc / symbol / experience / insight / task). Returns hits with relative scores, sources or insight ids, and doc→symbol references.
list_tasksList task records for the project (archived marked). Call first in a new session before continuing work.
select_taskBind the session to a task so its todo list and file reads sync into it. Exact taskId, or exact title (multiple matches return candidates; no match creates a new task). Pass title with taskId to rename. Auto-unarchives.
archive_taskArchive a task (hide from default views, exclude from capacity, stop syncing). select_task restores it.
show_task_panelShow the task panel in the UI. Call when the user asks to see the task list or when you want to display the panel.
/tasks (typed by the user, not the model)The only user command: shows the task stack and drives the workflow card. Every other action is a sub-verb invoked by card buttons: switch / archive / unbind / rename / todos … for tasks, insight list / confirm / promote / demote / archive / restore / delete / save / edit for memory.
remember problem solutionSave an experience note. Similar problems supersede instead of duplicating.
forget id_or_queryDelete stale experience notes.
save_lessonSave a lesson / decision / procedure at task, project or global scope. Near-duplicates merge (≥ 0.7 overlap) or reinforce (0.65–0.7); two tasks hitting the same insight promote it task → project, three or more → global. Key params: title, kind, scope, the content (pattern/fix, choice/reason or steps), and trigger (when is the only trigger surface, guard narrows it, prevents records what breaks without it).

/tasks appears in the web / menu inside an icon-bearing Workflow group, with three view entries — Tasks / Project Memory / Global Memory (labelled in the UI language). It is the plugin's only registered command; every other action rides it as a sub-verb driven by card buttons.

How it works

The design follows four principles:

  • Volatility — context is ephemeral; it is lost when a session is compacted.
  • Persistence — the memory is stored on disk and survives compaction and new sessions.
  • Compactness — one bounded declaration line per symbol, and a ≤300-char summary plus a bounded term set per document chunk. Derived data is never stored: doc→symbol links are computed at read time. Index size depends on symbol density and chunk length, so treat these as measurements of specific corpora (2026-09-25), not guarantees: a code-only Vue app (289 files) lands at 325 bytes/entry ≈ 21% of source, a symbol-dense TypeScript monorepo (12,408 files / 106 MB of code + 14 MB of docs) at 22% of source for the code layer (553 bytes/entry overall) and 130% for the document layer, and a 274-document workspace (PDFs and Markdown) at 1,506 bytes/entry.
  • Verifiability — recalls carry a path:line citation where applicable, so the agent can confirm details against the source.

Building the memory does not require an upfront scan: files are memorized as the model reads them, so the memory grows to cover exactly what has been worked with. Re-reading an unchanged file is a no-op, so the memory stays fresh with minimal ongoing overhead.

The store is per-project and follows the codebase: changed files are re-extracted by content hash, deleted files are removed. Experience notes are retrieval-only, so accumulation does not affect context.

Design

.dsh-project-memory/
  format.json      layout marker (v2, sharded)
  shards/          one self-describing JSON per indexed source file — writes touch only dirty shards
  experience.json  problem → solution notes (retrieval-only)
  watch.json       watched roots
  tasks.json       TaskBridge task entities (cross-session)
  binding.json     current session ↔ task binding
  insights.json    project-scope insights (lessons / decisions / procedures)
  injection-audit.jsonl   one line per real injection (what / why / dropped / budget)
  admission-shadow.jsonl  one line per step, all scored candidates + features (offline replay)

Stores created before v0.2.0 migrate automatically and idempotently on first load. Within one dsh process, all tool calls share a single in-memory store per project, so hot-path indexing writes only the shard that changed.

  • Incremental — a content hash per file; only changed files are re-extracted.
  • Cross-linking — a returned document chunk resolves the symbols it mentions against the current symbol table and appends them as references, so a doc indexed before its symbols still links correctly.
  • Query expansion — when enabled, query_memory asks the LLM to rewrite the query into several variants (synonyms, EN/CN, identifier guesses) and merges the scores; when off, queries never touch the LLM. Indexing itself is model-free.
  • Consistency — the fact layer follows the codebase (hash re-extract / remove-on-delete); the experience layer is retrieval-only with supersede and forget. The store lock is in-process, so avoid running multiple dsh instances against the same project store.

Architecture (Task Panel)

TaskPanel (Container)
├── task-data-store  (server data, cross-tab sync via BroadcastChannel)
├── task-ui-store    (local UI state, localStorage)
├── task-hooks       (useTaskDrag, useTaskEdit)
└── TaskComponents   (MiniBar, TaskCard — presentational only)

The workflow panel is collapsible, adapts to dsh and theme plugin styles, and offers four card styles.

Four card styles

Configuration

KeyDefaultMeaning
memoryDir.dsh-project-memorystore directory inside each indexed root
chunkChars / maxChunksPerFile3000 / 40max chars per document chunk, max chunks per document
maxFileSizeMb50skip documents (incl. PDF) and code files larger than this (MB)
maxPdfPages1000PDF page cap
maxOutputChars8000cap for query_memory result text (chars)
lazyIndexingtrueindex files the moment the model reads them
autoIndexOnFirstUsefalsefull scan of the current working directory on plugin load (opt-in)
watch / watchIntervaltrue / 30background refresh; base poll interval in seconds (idle polls back off up to 2 minutes and reset on any change)
maxScanFiles / maxScanDepth20000 / 12per-scan caps on files and directory depth; a truncated pass is reported and never removes what it did not reach. Set 0 to disable
allowUnsafeRootsfalseallow explicit tool calls to target a directory on the excluded list. Automatic paths (lazy indexing, session audit, TaskBridge, autoIndexOnFirstUse) stay inert there regardless
llmQueryExpansion / expansionCountfalse / 6expand queries via the LLM before search (off by default to save tokens); max variants
tsPath / enableTypeScript(auto) / trueoptional absolute path to a specific typescript install; set enableTypeScript: false to disable the type-aware layer entirely
tasklist.enabled / tasklist.syncHostOnAdopttrue / trueTaskBridge auto-sync (task entities from the session todo list and file reads); push a bound task's steps to the host task list so dsh mirrors it
insight.*dedupOverlap 0.7 · reinforceBand 0.65 · maxProject 100 · maxGlobalProcedures 200 · promoteConfidence 0.7 · globalPromoteTasks 3 · decayDays 90 · globalFile (auto)insight dedupe / reinforce / promotion / capacity / archive settings
reflection.enabledfalseLLM reflection, draft-only at task level (fires on task switch-away / archive). cooldownMs 1800000, maxLessonsPerReflect 3, maxDecisionsPerReflect 2
autoContext.enabledtruesilent injection wrapper (resident task card + gated items); inert until the host exposes a resolvable session cwd. maxTokens 400 (per step), editedMax 3 (recently written files shown on the task card), signalMinRatio 0.5 (a hint must reach half of its layer's top score), skipEchoSelfTodo true, budgetLog off, reinjectItemsAfter 0 (steps before the same insight may be injected again), rootNotice true
autoContext.gateCooldownSteps2minimum number of pre-steps between two item injections (the resident task card is exempt — it is a state snapshot). This is the main "don't inject often" dial
autoContext.maxItemsPerSession / autoContext.maxItemCharsPerSession60 / 24000runaway fuse, not a throttle — a hard per-session ceiling by count and characters; once reached, the item channel stays silent for the rest of the session
autoContext.hintMinCoverage0.45absolute floor for the statistical channel: the IDF-weighted share of the query's information mass the entry covers. A ratio-only threshold cannot tell signal from "best of a bad lot"
autoContext.hintMinMatched / autoContext.hintMinSupport2 / 0.15a hint must share at least this many terms with the query, and at least this share of the query's terms must exist in the corpus at all — otherwise the channel stays silent this round
autoContext.legacyScopefilterhow to treat a legacy trigger.scope: filter keeps the old semantics, ignore drops it. npm run selfcheck:triggers reports entries whose scope cannot intersect the project tag space
autoContext.auditLogtrueappend one JSONL line per actual injection to injection-audit.jsonl (what / why / dropped / budget); rotates to .1 past auditMaxBytes (262144). Silent on I/O errors
autoContext.shadowLog / autoContext.shadowMaxBytestrue / 524288append one JSONL line per step (including steps that injected nothing) to admission-shadow.jsonl: scored candidates with their features and the gate that rejected each one — what makes a threshold change answerable offline. Disk only, never in the prompt; rotates keeping one .1 generation

Injection admission (why it stays quiet)

Automatic injection used to be a retrieval problem ("which entry is most related to this text?"), which is total — a ranking always returns something, so noise was structural. It is now an admission problem ("is this step about to cross a boundary I have been burned by?"), with silence as the default. Only when triggers, from a low-dimensional typed signal: normalized operations, the files this step is about to write, and intent words from the human message after stripping quoted/path references — tool arguments, file contents and filenames can never trigger anything. guard only narrows (extension/name globs such as *.pptx or README* are ignored outright: they can only lie). The statistical channel needs the relative score, an absolute coverage floor and at least two shared terms, and item injections are limited to one every gateCooldownSteps and capped per session — the resident task card is exempt, and the budget is a ceiling, not a target. Injections are appended at the tail of the history, so the cached prefix is never rewritten: they cost cache-read tokens, not cache misses. Every real injection is logged with its reason and dropped candidates, and a shadow log adds one line per step — including the steps that correctly injected nothing. npm run eval:injection scores 8 labelled scenarios on a synthetic pool (currently precision 1.00 / recall 1.00 with a clean control group); that pool is the CI baseline, not evidence about your data — point the harness at your own store (--store) and the control group becomes a hard gate.

Toggling features

The two most relevant switches are lazyIndexing (index a file the moment the model reads it; default on) and autoIndexOnFirstUse (full scan of the current working directory on plugin load; default off). Lazily indexed project roots are automatically registered with the watcher, so changed files stay fresh without an explicit watch_repo.

Root resolution. In order: an explicit root argument; a registered root (watch_repo); the nearest ancestor with a VCS marker (.git/.hg/.svn) or a build/manifest marker (package.json, go.mod, Cargo.toml, pyproject.toml, …); the session working directory, unless it is on the excluded list. A file that matches none of these is not indexed.

When the root comes from the working directory, the model gets one notice per session naming it and how to change it; mute with autoContext.rootNotice: false. Starting dsh in a container directory such as ~/workspace therefore makes that directory the root, and memory spans everything beneath it up to the scan limit — start dsh inside the project for one store per project.

Excluded directories. Not used as a root, matched exactly (subdirectories are unaffected): the filesystem root, the home directory, temp directories — os.tmpdir() and the shared ones (/tmp, /var/tmp, %TEMP%, %SystemRoot%\Temp) — and system / package-manager prefixes (/opt/homebrew on POSIX; %SystemRoot%, %ProgramFiles%, %ProgramData% on Windows). A session in one of these runs without memory, with one line on stderr.

Scan limits. One pass covers at most maxScanFiles files (20000) and maxScanDepth directory levels (12). A truncated pass is reported in the index_repo result and logged once per root by the watcher, and it does not remove entries it did not reach. A directory that cannot be read (e.g. EACCES) counts as a truncated pass too and is named in the result: a read failure is not a deletion.

The store lives in the tree it indexes and ignores itself: a * rule inside the store keeps it out of git status and git add -A, and means git clean -fd leaves it alone. To commit project memory deliberately, git add -f .dsh-project-memory — tracked files are not affected by ignore rules.

Settings live in the plugin's config object. To change them, add an override entry to your profile's cordis.patch.yml — for the web profile that is ~/.dsh/profiles/web/cordis.patch.yml:

- id: project-memory
  config:
    lazyIndexing: true          # on: index files as the model reads them (default)
    autoIndexOnFirstUse: false  # off: no upfront full scan (default)
    llmQueryExpansion: false    # off: do not spend tokens on LLM query expansion (default)
    watch: true                 # on: background refresh for watched roots (default)
    watchInterval: 30           # base poll interval; idle polls back off to at most 2 min
    maxScanFiles: 20000         # per-scan file cap (truncation is reported, never deletes)
    maxScanDepth: 12            # per-scan directory-depth cap
    enableTypeScript: true      # on: type-aware enhancement when TS is installed (default)
    # allowUnsafeRoots: false   # keep false unless you really want to index a home/system dir explicitly
    # budgetLog: once           # debugging: log budget drops to stderr (default off = silent)
    # reinjectItemsAfter: 20    # debugging: allow the same insight again after N steps (default 0 = once per session)
    # tsPath: /custom/path/to/typescript  # optional: force specific TS install

Only list the keys you want to change; the rest fall back to the plugin defaults. Verify the result with dsh --profile web --dump-config.

For a one-off run without editing the profile, pass the override as a CLI patch overlay:

dsh web --patch ./config.yml

where config.yml contains the same override block.

Performance

Measured on real projects (2026-09-25)

Four corpora, one machine (Node 24.19, 20 vCPU, Linux file system), each run twice with the second, warm-cache run quoted. "Cold index" is a full index pass (walk + hash + extract + commit); "query" runs the shipped scorer over 100 sampled queries; "re-index 1 file" is the watch/lazy hot path.

CorpusFiles / entriesCold indexCold loadQuery p50 / p95Re-index 1 fileStore content / on diskHeap after load
Vue 3 + Vite app (code only)289 / 2,142283 ms5.2 ms0.86 / 1.8 ms0.4 ms0.66 MB / 1.52 MB6.1 MB
Docs + PDFs workspace (274 docs)286 / 2,1206.5 s12.4 ms4.6 / 13.9 ms0.4 ms3.05 MB / 3.63 MB9.0 MB
TypeScript monorepo, 3,000-file slice3,000 / 17,7332.1 s59 ms10.2 / 21.4 ms1.8 ms11.0 MB / 19.0 MB22.6 MB
TypeScript monorepo, whole tree12,408 / 79,1688.6 s239 ms45.8 / 89.3 ms7.4 ms41.7 MB / 74.7 MB73.9 MB

How it scales. Re-indexing a changed file costs O(file), not O(corpus) — 0.4–7.4 ms across every corpus above. Cold load (≈19 µs/file), query (≈0.6 µs/entry) and resident heap (≈1.4 KB/entry) grow linearly with the index, which keeps small and mid-size projects in the single-digit-millisecond range.

Two notes on method: read+hash depends on the OS page cache (2.5 s cold vs 0.3 s warm on the 12.4k-file tree), so the warm run is quoted; and these numbers drift by up to ~20% across days on the same machine, so compare runs measured in one session.

Synthetic Benchmark (Node 24.19, 20 vCPU, Linux file system)

ScenarioScaleMeasured
Full cold index5,000 files / 20k entries373 ms avg (p50 374)
Cold load5,000 files56 ms
Hot lazy re-index (single file)5k filesp50 2.8 ms / max 3.5 ms
query_memory (cached)5k files / 20k entriesp50 3.3 ms / p95 6.9 ms
query_memory (cached)1k files / 4k entriesp50 0.7 ms / p95 1.5 ms
Full cold index10,000 files / 40k entries696 ms avg (p50 668)
Cold load10,000 files123 ms
Hot lazy re-index (single file)10k filesp50 5.9 ms / max 13.5 ms

Generated code (~4–5 symbols/file), measured 2026-09-25. Reproduce with npm run bench:synthetic -- 5000 (scripts/bench-synthetic.mjs). Pure indexing overhead, no LLM calls. The first query after a write rebuilds the IDF cache (142 ms at 40k entries, 67 ms at 20k, 14 ms at 4k); later queries hit it.

Reproduce it on your own project

The measurement ships with the repository and with the published npm package (scripts/ is part of the tarball). It needs no dsh instance, no network and no model calls, and never touches your project's own store — results go to a temp directory and are removed afterwards:

npm run bench -- /path/to/your/project
# or, with options:
node scripts/bench.mjs /path/to/your/project [--json] [--samples 100] [--no-pdf] [--keep]

It reports the cold index split into read+hash / extract / commit, cold load, IDF rebuild, cold and hot query latency (p50/p95/max over 100 sampled queries through the shipped scorer), single-file hot re-index, store size, resident heap and bytes per entry. Example — the Vue app row above:

cold index   283 ms   (read+hash 11 ms · extract 256 ms · commit 14 ms)   ← 2nd, warm-cache run
store        0.66 MB content · 1.52 MB on disk · 325 bytes/entry · cold load 5.2 ms
memory       heap 6.1 MB after load → 6.7 MB after the first query (RSS 62 MB)
hot query    p50 0.86 ms · p95 1.8 ms          (2,142 entries)
re-index 1 file  p50 0.4 ms

Pass --queries your-queries.json to run the labeled-set method (hit@5 / hit@10 / MRR) against your own project.

Design tradeoffs

  • Synchronous lock-free transactions over async locks — DSH runs single-process by architecture, so locking for a rare multi-process case would only slow the hot path (every remember / forget / index_doc).
  • Watch: compute outside, commit inside — no lock is held during parsing, and polling with mtime + content hash is used instead of fs.watch: it behaves identically on network drives, Docker volumes and WSL, without duplicate-trigger or missed-event failure modes.
  • Corrupt shards are quarantined, not repaired — a shard that fails to parse is renamed and only that file is re-indexed, so there is no WAL or embedded database adding native dependencies, lock contention and failure modes of their own.
  • No vector embeddings, no semantic search at query time — lexical retrieval already answers the queries this plugin targets. On a real Vue project with 29 labelled queries, file-level hit@5 is 96.6%, and whole-chunk terms lift document term coverage from 27.3% to 100% with MRR unchanged (0.958 vs 0.955). The method ships with the code (scripts/bench.mjs --queries your-queries.json).
  • Indexing is deterministic and model-free — a model at index time would make two indexings of one document differ, and a wrong translation at query time means zero recall; rules plus symbol links cover the common cases and work offline.
  • Model-facing memory: the agent writes, no human in the loop — the consumer is the agent, usually headless, so memory that only promotes when someone clicks a card would never promote at all. draft is a provenance marker plus an evidence threshold, not an approval queue.
  • Full entries returned directly — entries are already compact, so returning them whole is more verifiable and one round-trip cheaper.
  • forget by query is aggressive; use IDs for precision — experience notes are low-risk, high-volume and retrieval-only, so stale noise hurts more than an over-broad delete. For exact deletion use the ID shown by query_memory.
  • TypeScript enhancement is optional, lazy and cached — it runs in the background, never blocks indexing, and falls back to the regex scanner; its queue is bounded, and any dropped files are reported. The default lib is not loaded: inference that depends on global types (Promise/Array/DOM) degrades to any/unknown, while explicit annotations are unaffected.
  • Subagent sessions are out of scope for now

Development (for contributors)

These commands are for maintaining the plugin code — regular users do not need them. Installing the plugin only requires the command in Installation.

npm install
npm test                    # 592 tests
npm run eval:injection      # scenario P/R on the synthetic pool: 14/14 hits, 0 false positives, control group clean
npm run eval:injection -- --store .dsh-project-memory/insights.json   # replay on YOUR store; control group is a hard gate
npm run selfcheck:triggers  # which entries can still push, which declarations are dead (reads your local store)
npm run bench -- /path/to/project   # index/query performance on any project — no dsh needed

Release notes live in CHANGELOG.md and on GitHub Releases.

License

MIT — see LICENSE.

Copyright (c) 2026 00080000 <3388065969@qq.com>

관련 플러그인