プラグインシステムは、外部バイナリをサブプロセスとして起動し、stdio 改行区切り JSON で通信する拡張機構です。ChatGPT の会話履歴や Claude.ai の会話履歴など、gkill 本体が対応していない外部データを Kyou として統合表示できます。
| 特徴 | 内容 |
|---|---|
| 実行モデル | 外部バイナリをサブプロセスとして起動し stdin/stdout で通信 |
| 通信プロトコル | 改行区切り JSON(newline-delimited JSON) |
| データ粒度 | プラグインが決める。ChatGPT / Claude.ai は 1 メッセージ = 1 Kyou、Claude Code も 1 発言(人間の発言1つ、またはそれに対する一連の応答)= 1 Kyou で、1 ターンから人間側と応答側の 2 件が出る(data_type は歴史的に claude_code_turn のまま) |
| コンテンツ表示 | GetContentHTML が返す HTML を iframe (srcdoc) で描画 |
| ダークテーマ | postMessage 経由で親ページからテーマを通知、CSS変数で切り替え |
$GKILL_HOME/plugins/{userID}/{pluginName}/
manifest.json — プラグインメタデータ
config.json — プラグイン設定(SDK が起動時に自動生成)
{executable} — プラグインバイナリ(Linux/macOS)
{executable}.exe — プラグインバイナリ(Windows)
例:
$GKILL_HOME/plugins/admin/gkill_plugin_claudeai/
manifest.json
config.json
gkill_plugin_claudeai (Linux)
gkill_plugin_claudeai.exe (Windows)
プラグインが作るキャッシュは、gkill の他の派生キャッシュと同じくキャッシュディレクトリ配下に置く (「6. SQLite3 キャッシュ」参照)。
プラグインディレクトリ直下に配置するメタデータファイル。
実例(src/plugins/gkill_plugin_claudeai/manifest.json):
{
"protocol_version": "1",
"name": "gkill_plugin_claudeai",
"version": "1.2.0",
"description": "Claude.ai のチャット履歴をgkillタイムラインに表示する。エクスポートした ZIP を解凍せずにデータソースのフォルダへ置いて使用。",
"data_type": "claude_conversation",
"rep_name": "Claude.ai",
"executable": "gkill_plugin_claudeai",
"min_gkill_version": "1.1.10"
}定義は src/server/gkill/api/gkill_plugin/plugin_manifest.go の PluginManifest 構造体(9フィールド)。
| フィールド | 説明 |
|---|---|
protocol_version |
プロトコルバージョン。現在は "1"(--gkill-protocol-version で渡される) |
name |
プラグインの識別名。ディレクトリ名と一致させること(rep_name とは別物) |
version |
プラグインのバージョン(例: "1.0.0") |
description |
プラグインの説明文 |
data_type |
このプラグインが生成する Kyou の data_type 値。provides を使わないなら既存の data_type(kmemo, kc 等)と衝突しない一意な名前にする。provides に型別データを書く場合は逆に、その種別と同じ値(kc 等)にしなければならない(クライアントは data_type の接頭辞で型別ビューを出し分けるため) |
rep_name |
タイムライン上でのリポジトリ表示名。GetRepName() が返す値。get_rep_name で rep_names を申告するプラグインでは、記録が名乗るのは申告した名前で、こちらは設定画面の引き当てに使う識別子になる |
executable |
実行ファイル名(拡張子なし、OS に応じて .exe 等を自動付与) |
min_gkill_version |
このプラグインが動作する最低 gkill バージョン |
provides |
このプラグインが Kyou のメタ情報以外に提供するデータ種別(省略可)。詳細は「14. 型別データ・付随データの提供」参照 |
同梱プラグイン7本(archived_git_commit_log / chatgpt / claudeai / claudecode / codex / fitbit / google_locationhistory)は manifest.json を //go:embed でバイナリに埋め込んでおり、
次のフラグで標準出力に書き出せる。サンプルの gkill_example は埋め込みもフラグも持たない。
| フラグ | 出力 |
|---|---|
--gkill-print-manifest |
埋め込み済みの manifest.json |
--gkill-print-config |
既定の config.json |
--gkill-build-cache |
(SDK 共通)stdio ループに入らず Handler.BuildCache を同期で1回実行し、stdout に結果行 built / no_cache を1行だけ書いて終了する。gkill_server generate_plugin_cache が使う(下記「単独モード」) |
また初回起動時に sdk.EnsureConfig(plugin/sdk/config.go:37-54)が
Handler.DefaultConfig の内容で manifest.json の隣に config.json を作成する。
既存ファイルは決して上書きしない(既に存在する場合・既定値が nil の場合・プラグインディレクトリが空文字の場合は何もしない)。
pluginRepositoryImpl.ensureStarted() がプラグインバイナリをサブプロセスとして起動する。
cmd := exec.CommandContext(context.Background(),
execPath,
"--gkill-plugin-dir", p.pluginDir,
"--gkill-user-id", p.userID,
"--gkill-protocol-version", p.manifest.ProtocolVersion,
)context.Background() を使用するため、HTTP リクエストがキャンセルされてもプロセスは終了しない。
個々の呼び出しの打ち切りについても同様で、詳細は下記「タイムアウトと打ち切り」を参照。
gkill_server generate_plugin_cache <plugin_name|all> <user_id...>(main/common/generate_plugin_cache.go)は
稼働中サーバに頼らず、上と同じ引数に --gkill-build-cache を足してプラグインを直接起動し、終わるまで待つ。
sdk.Run はこのフラグを見ると stdio ループに入らず Handler.BuildCache(ctx, cfg) を同期で1回呼び、
stdout に結果行(built、BuildCache が nil なら no_cache)を1行だけ書いて exit 0、エラーなら stderr(と logs/gkill_plugin_<name>_error.log)に出して exit 1 で終わる。
同梱7本は既存の同期構築関数(build / refresh)を BuildCache から呼ぶだけで、常駐ビルダは起こさない
(plugin/sdk/build_cache_test.go の TestBundledPluginsWireBuildCache が配線をソース走査で固定する)。
gkill 側は子プロセスの stdin を繋がず(フラグを知らない旧バイナリが stdio ループへ入っても EOF で終わる)、
stdout 全体が結果行と完全一致するときだけ成功にする。exit 0 で stdout が空(旧 SDK・SDK を使わない独自実装)や
exit 2(Go の flag が未知フラグで落ちた旧 SDK)は失敗として報告する。プラグインは逐次に起動し、タイムアウトは無い。
実行ファイルの解決は常駐起動と同じ reps.PluginExecutablePath。経緯と却下案は
ADR-1003。
- リクエスト: 親プロセスが
stdinに JSON を 1 行(\n終端)書き込む - レスポンス: プラグインプロセスが
stdoutに JSON を 1 行(\n終端)書き込む
親 → stdin: {"id":"uuid","command":"find_kyous","query":{...}}\n
親 ← stdout: {"id":"uuid","kyous":[...]}\n
bufio.Scanner のバッファサイズは親子で異なる。
| 側 | 上限 | 実装 |
|---|---|---|
| 親(gkill 本体) | 32MB | dao/reps/plugin_repository_impl.go:174 |
| 子(プラグイン SDK) | 1MB | plugin/sdk/sdk.go:83-84 |
親側が 32MB なのは大きな HTML レスポンスで bufio.Scanner: token too long を防ぐため。
リクエスト JSON は 1MB を超えないため SDK 側は既定のままになっている。
打ち切りの契機は2つあり、プロセスに手を出すかどうかが異なる。
| 契機 | 意味 | 挙動 |
|---|---|---|
| 呼び出し元のキャンセル | HTTPクライアントの切断・サーバ終了。誰も結果を必要としていない | 待つのをやめて即 return。プロセスには触らない |
| 順番待ちの打ち切り | 実行スロットが空かない(既定10秒 maxPluginQueueWait)。混んでいるだけ |
ErrPluginBusy を返す。プロセスには触らない |
| gkill自身のデッドライン | 既定30秒(IsAlive は5秒)。応答が返らない=詰まっている |
プロセスを回収(Process.Kill() して started=false) |
順序が要点で、まず順番待ちをして、スロットを取ってから期限を張る。
期限をスロット取得より前に張ると、行列に並んでいるだけで期限を食い潰し、
正常に応答しているプラグインを期限切れとして殺してしまう
(2026-08-06以前はこうなっていて、一覧の行数ぶんの本文取得が同時に来ると
プロセスの回収と再起動が延々と繰り返され、待ち行列がまったく消化されなくなった)。
呼び出し元が Deadline を持つ場合は、その絶対時刻ではなく残り時間の長さを
実行予算として引き継ぐ。こうすると IsAlive の「5秒だけ待つ」という意図を保ったまま、
行列で予算を食い潰すことがなくなる。
callCommand は呼び出し元の context からキャンセルを切り離し(context.WithoutCancel)、
実行予算ぶんの timeoutCtx をスロット取得後に作る。Deadline が無い場合は既定30秒。
判定はタイミングではなくエラーの種類で行う(context.DeadlineExceeded なら回収、
context.Canceled なら放置)。呼び出し元が Deadline 付きだと両者がほぼ同時に Done になり、
select がどちらを選ぶか決まらないため。
この切り分けが無いと、フロントが AbortController で前のリクエストを打ち切るたび
(ダッシュボードの再取得など)にユーザーのプラグインプロセスが落ちる。
打ち切った呼び出しの応答は遅れて届くので、sendRequest はレスポンスIDを
req.ID と突き合わせ、一致しないものを読み捨てる。ID が空のものは SDK の
パースエラー応答なので自分宛てとして扱う。
コマンド(PluginRequest.Command) |
説明 |
|---|---|
find_kyous |
検索クエリに合致する Kyou 一覧を返す。ワード条件(words / not_words / words_and)の判定はプラグインの責任 — gkill 側は本文を持たないので再判定しない(plugin_repository_impl.go の pluginKyouMatchesQuery は Calendar と IDs だけ)。判定は SDK の sdk.Query.MatchText(本体と同じ規則: 大小無視の部分一致、ID は前方一致、しかも 7 文字以上の語だけ、除外語は ID を見ない)を使う。型別リポジトリ経由(rep_types 指定・Mi 画面)では索引の型別データを本体と同じ列で判定するので、プラグインが独自に照合対象へ足した語はそこでは当たらない |
get_kyou |
指定 ID の Kyou 1 件を返す |
get_rep_name |
gkill 上のリポジトリ表示名を返す。応答の rep_name は manifest と同じ代表名。rep_names[] を載せると、1本のプラグインが複数の rep 名(zip に固めた Git リポジトリごとの名前など)を名乗れる(SDK は Handler.RepNames)。gkill はその名前を get_all_rep_names・query.reps の絞り込み・本文取得の引き当て・MCP の get_rep_infos.plugins[] / get_plugin_list.rep_names に使い、TTL 60 秒でキャッシュする(rep 名の列挙のたびに送る)。null(欄なし)は「未対応 → manifest の1つ」、[] は「いまは0個」で意味が違う。失敗しても前回値か manifest 名にフォールバックしてエラーにしない(ADR-0308) |
get_content_html |
指定 ID の Kyou のコンテンツ HTML を返す |
get_config_html |
プラグイン設定画面の HTML を返す |
post_config |
設定フォームの送信データを受け取る |
get_gps_logs |
期間内の GPS ログを返す(provides に gpslog があるプラグインのみ)。1レスポンス32MBの上限があるためページングする |
ping |
疎通確認 |
close |
プロセス終了要求 |
| パス | 内容 |
|---|---|
src/server/gkill/api/gkill_plugin/ |
PluginRequest, PluginResponse, PluginKyou, PluginQuery, PluginManifest |
src/server/gkill/plugin/sdk/ |
プラグイン側 SDK(sdk.Run(), sdk.Handler, sdk.Kyou, sdk.Query) |
プラグインの設定は manifest.json と同じフォルダ(--gkill-plugin-dir で渡される
$GKILL_HOME/plugins/{userID}/{プラグイン名}/)の config.json に置く。
sdk.Run() は起動時に sdk.EnsureConfig() を呼び、Handler.DefaultConfig が設定されていて
config.json が無ければ既定値で生成する。既存ファイルは決して上書きしない。
DefaultConfig が nil(gkill_example など)なら何も生成しない。
設定はプラグイン Kyou のコンテキストメニュー「プラグイン設定」から編集できる。
plugin-html-view.vue が plugin-config-dialog.vue を持ち、メニューから rep_name を受けて開く。
ダイアログは get_plugin_config_html で設定 HTML を取得して iframe に表示する。
iframe は sandbox="allow-scripts allow-forms"(allow-same-origin なし)なので、
iframe 自身は gkill の API を叩けない。保存は親(ダイアログ)が postMessage で肩代わりする。
| 向き | メッセージ |
|---|---|
| iframe → 親 | { gkill_plugin_config: { <キー>: <値>, ... } } |
| 親 → iframe | { gkill_plugin_config_result: { ok: boolean, error?: string } } |
| 親 → iframe | `{ gkill_theme: "dark" |
親は受け取ったフォームを post_plugin_config に送り、成功したら設定 HTML を取り直して
読み込み件数などの表示を更新する。生成された config.json を手で編集する経路も従来どおり使える。
gkill-api.ts の get_plugin_list() は今も MCP 専用で、フロントエンドに呼び出し元が無い。
/api/get_plugin_list の唯一の実利用者は MCP サーバの gkill_get_plugin_list ツール
(src/server/gkill/mcp/plugin_tools.go)。/api/get_plugin_content_html は画面(plugin-html-view.vue)と
MCP の本文インライン埋め込み(inlinePluginContents)の両方が使う。
複数の HTTP リクエストが同時に stdin/stdout を操作すると、送受信データが混在し「unexpected end of JSON input」などのエラーが発生する。
pluginRepositoryImpl が sync.Mutex を保持し、callCommand() 全体をロックする。
type pluginRepositoryImpl struct {
// 容量1のチャネル。ミューテックスと違い「待つのをやめられる」
callSlot chan struct{}
userID string
pluginDir string
manifest gkill_plugin.PluginManifest
proc *pluginProcess
}
func (p *pluginRepositoryImpl) callCommand(ctx context.Context, req gkill_plugin.PluginRequest) (*gkill_plugin.PluginResponse, error) {
// 1. 行列に並ぶ(待ちきれなければ ErrPluginBusy。プロセスには手を出さない)
release, err := p.acquireCallSlot(ctx, queueWait)
defer release()
// 2. スロットを取ってから期限を張る
// 3. ensureStarted → sendRequest(クラッシュ時のみ1回リトライ)
}pluginProcess 構造体(プロセスハンドル側)には mutex を置かない。
stdout の読み取りはプロセスごとに常駐する1本の goroutine(リーダー)が担当し、
bufio.Scanner に触れるのはリーダーだけにしている(bufio.Scanner は並行安全ではない)。
リクエスト側はバッファ1の respCh 経由で応答を受け取る。誰も読んでいなくても
リーダーが残らないよう、送信は retired チャネルとの select で行う。
| 設計判断 | 内容 | 理由 |
|---|---|---|
| Mutex の位置 | pluginRepositoryImpl struct |
プロセス再起動後も同じ mutex を使い続けられる |
| プロセス起動 | context.Background() を使用 |
HTTP リクエストキャンセルでプロセスが終了するのを防ぐ |
| 呼び出し元のキャンセル | 待つのをやめるだけで、プロセスには触らない | フロントは全リクエストに AbortController を張っている。切断でプロセスを落とすと、画面を操作しただけでプラグインが死ぬ |
| 呼び出しタイムアウト | 期限が無ければ既定 30 秒を注入し、超過時は Process.Kill() |
応答しないプラグインが gkill 全体を止めるのを防ぐ。回収しないと以降の呼び出しも詰まったままになる |
| レスポンスの突き合わせ | resp.ID と req.ID を照合し、不一致なら読み捨てる |
打ち切った呼び出しの応答が遅れて届いても、別の記録の中身を返さないようにする |
| stdout の読み取り | プロセスごとに常駐リーダー1本 | bufio.Scanner を複数 goroutine で共有しない。打ち切りのたびに goroutine が増えない |
| クラッシュ復旧 | 失敗時に回収 → ensureStarted() → 再送信を1回リトライ。ただし打ち切り時はリトライしない |
プロセスが予期せず終了した場合の自動復旧。打ち切りの再試行で待ち時間が倍増するのを避ける |
| Scanner バッファ | 親 32MB / SDK 1MB | 大きなHTMLレスポンスで bufio.Scanner: token too long を防ぐ(親側のみ拡張) |
実装: src/server/gkill/dao/reps/plugin_repository_impl.go
ChatGPT・Claude.ai・Claude Code プラグインはデータファイルをパースしてメッセージを SQLite3 にキャッシュする。
$GKILL_HOME/caches/plugin_cache/{userID}/{pluginName}/cache.db
thumb_cache / video_cache / git_commit_log_cache と同じく gkill_options.CacheDir 配下に置く。
gkill 本体は起動時に環境変数 GKILL_HOME を設定し(common.InitGkillOptions)、プラグインの
サブプロセスはその環境を引き継ぐため、プラグインは環境変数からキャッシュルートを解決できる。
環境変数が無い場合は --gkill-plugin-dir($GKILL_HOME/plugins/{userID}/{pluginName})から遡って
推定し、それも解けなければプラグインフォルダ直下にフォールバックする
(実装: src/server/gkill/plugin/sdk/cache_path.go の sdk.CacheDBPath。
6プラグインが1文字違わず同じものを持っていたので SDK へ集約した。
パス要素として使える値かの検証は sdk.IsSafePathElement)。
キャッシュは clear_cache plugin <all|user_id...> で削除できる(clear_cache all にも含まれる)。
generate_plugin_cache <plugin_name|all> <user_id...> で稼働中サーバ無しに同期構築できる(4章「単独モード」)。
スキーマは ChatGPT / Claude.ai と Claude Code で別物なので分けて示す。
cache_meta(キャッシュ管理テーブル)
| カラム | 型 | 説明 |
|---|---|---|
key |
TEXT PRIMARY KEY | メタ情報のキー(schema_version・source_signature(ZIP エントリの Path:CRC32:Size を並べたもの)・source_problems(走査の問題。JSON)・file_count・構築状態) |
value |
TEXT NOT NULL | 値 |
conv_cache(会話テーブル)
| カラム | 型 | 説明 |
|---|---|---|
conv_id |
TEXT PRIMARY KEY | 会話 ID |
title |
TEXT NOT NULL | 会話タイトル |
create_time_unix |
INTEGER NOT NULL | 作成時刻(Unix秒) |
msg_cache(メッセージテーブル)
| カラム | 型 | 説明 | ChatGPT | Claude.ai |
|---|---|---|---|---|
msg_id |
TEXT PRIMARY KEY | メッセージ ID | ○ | ○ |
conv_id |
TEXT NOT NULL | 所属会話 ID | ○ | ○ |
sender |
TEXT NOT NULL | 送信者(user/assistant or human/assistant) |
○ | ○ |
text |
TEXT NOT NULL | メッセージテキスト | ○ | ○ |
related_time_unix |
INTEGER NOT NULL | 関連時刻(Unix秒) | ○ | ○ |
create_time_unix |
INTEGER NOT NULL | 作成時刻(Unix秒) | ○ | ○ |
update_time_unix |
INTEGER | 更新時刻(Unix秒) | — | ○ |
インデックス: idx_msg_conv(conv_id), idx_msg_time(related_time_unix)
cache_meta に加えて以下の2テーブルを持つ。schema_version によるマイグレーション付き。
file_cache(取り込み済みファイル)
| カラム | 型 | 説明 |
|---|---|---|
path |
TEXT PRIMARY KEY | ソースファイルパス |
mtime_unix |
INTEGER NOT NULL | 最終更新時刻(Unix秒) |
size |
INTEGER NOT NULL | ファイルサイズ |
kind |
TEXT NOT NULL | ファイル種別 |
session_id |
TEXT NOT NULL | 所属セッション ID |
message_cache(メッセージ)
| カラム | 型 | 説明 |
|---|---|---|
message_id |
TEXT PRIMARY KEY | メッセージ ID |
role |
TEXT NOT NULL | human / assistant 等 |
source_path |
TEXT NOT NULL | 取り込み元ファイル |
session_id |
TEXT NOT NULL | セッション ID |
session_title |
TEXT NOT NULL | セッションタイトル |
project |
TEXT NOT NULL | プロジェクト名 |
branch |
TEXT NOT NULL | git ブランチ名 |
message_text |
TEXT NOT NULL | 表示用テキスト |
search_text |
TEXT NOT NULL | 検索用テキスト |
body_json |
TEXT NOT NULL | 元データの JSON |
related_time_unix |
INTEGER NOT NULL | 関連時刻(Unix秒) |
update_time_unix |
INTEGER NOT NULL | 更新時刻(Unix秒) |
インデックス: idx_msg_time(related_time_unix), idx_msg_session(session_id), idx_msg_src(source_path)
Claude Code と似ているが2段構成になっている点が違う。
| テーブル | 役割 |
|---|---|
file_cache |
走査結果。差分判定は (mtime_unix, size)。素性(スレッドID・親・cwd・ブランチ)もここ |
thread_item |
第1段。1ファイルを正規化した要素列。(thread_id, seq) が主キーで、ファイル単位で丸ごと差し替える |
dirty_thread |
畳み直しが要るスレッド。ルートの解決は畳み直しのときに行う |
thread_title |
session_index.jsonl 由来のスレッド名。ロールアウトとは独立した無効化単位 |
kyou_cache |
第2段。Kyou 1件。body_json に詳細ビュー用の本体が入る |
スレッドIDは session_meta.session_id ではなくファイル名の uuid。 実データ数十ファイルのうち
半数近くに session_id が無く、あってもサブエージェントでは親のIDが入っているため
(ファイル数より少ない種類しかない)、これをキーにすると親子の Kyou ID が衝突する。
プラグインによって3方式ある。
- ChatGPT / Claude.ai(全体再構築) —
build(pluginDir, patterns)がsdk.OpenSourcesでエクスポート ZIP を開き、会話エントリのPath:CRC32:Sizeを並べた署名をcache_metaの前回値と 突き合わせ、変化していれば全会話を読み直して gen 付きでバッチ upsert し、finalizeBuildが古い gen を掃除する(キャッシュ全体を作り直す)。同じ会話 ID が複数の ZIP にあれば update_time が新しい版だけを採る(ADR-0310)。 - Claude Code(セッション単位の差分更新) —
refresh(cache.go:209)がfile_cacheのMtimeUnix/Sizeを突き合わせて(:236-243)変化のあったファイルだけをdirtySessionsとして拾い、 そのセッションだけ作り直す。変化が無ければ早期リターンする(:255-257)。 - Codex(ファイル単位の取り込み + スレッド木単位の畳み直し) — 変わったファイルだけ
thread_itemを差し替え、そのスレッドが属する木だけkyou_cacheを作り直す。 セッション単位にしないのは、サブエージェントを含むグループでは1行の追記のために 十数ファイル・百MB超を読み直すことになるため。子が新しく現れたときも親が消えたときも 両方のルートを畳み直し対象にする。スレッド名の更新はロールアウトを1バイトも読み直さない。
| プラグイン | メソッド | 説明 |
|---|---|---|
| ChatGPT / Claude.ai | GetMessages(pluginDir) |
全メッセージ一覧取得(FindKyous で使用) |
| ChatGPT / Claude.ai | GetMsgByID(pluginDir, msgID) |
msg_cache LEFT JOIN conv_cache WHERE msg_id = ? で1件取得 |
| ChatGPT / Claude.ai | GetConvForMsg(...) |
メッセージが属する会話を取得 |
| Claude Code | GetMessages(pluginDir, src) / GetMessage(pluginDir, src, messageID) / GetStats(...) |
Claude Code は GetMsgByID ではなく GetMessage |
Claude Code のメソッドは第2引数に展開済みソース(expandedSource)を取る。ChatGPT / Claude.ai の読み取りはキャッシュだけを見る(ZIP を開くのはビルダの build(pluginDir, patterns) だけ)。
3プラグインとも config.json の source_dirs キーで取り込み元を指定する(ChatGPT / Claude.ai はエクスポート ZIP を置いたフォルダか ZIP そのもの。展開済み JSON は読まない)
(Claude Code の既定は ["~/.claude/projects"])。
***?[]のグロブ、~、環境変数を展開する- 検索のたびに読み直される(起動時に一度読むだけではないので、gkill の再起動なしに反映される)
- 既定の
config.jsonには_commentと_example_source_dirsの説明キーも書き出される
1 Kyou = 1 メッセージの粒度で、クリックした件のみを HTML として返す。
ChatGPT / Claude.ai:
GetContentHTML(kyouID)
→ globalCache.GetMsgByID(pluginDir, src, kyouID) // 1件のみ取得
→ renderSingleMsgHTML(convTitle, msg) // 単一メッセージHTML生成
Claude Code:
GetContentHTML(kyouID)
→ globalCache.GetMessage(pluginDir, src, messageID)
→ renderMessageHTML(t) // 見つかった場合
→ renderNotFoundHTML() // 見つからない場合
- 会話タイトル(
convTitle)をヘッダに表示 - 送信者ラベル:
- ChatGPT:
user→ 「あなた」 / その他 → 「ChatGPT」 - Claude.ai:
human→ 「あなた」 / その他 → 「Claude」
- ChatGPT:
- CSS カスタムプロパティでテーマを切り替え
| 変数 | ライト値 | ダーク値 | 用途 |
|---|---|---|---|
--bg |
#ffffff |
#212121 |
背景色 |
--text |
#333333 |
#e0e0e0 |
テキスト色 |
--msg-user-bg |
#dbeafe |
#1a3557 |
ユーザメッセージ背景(ChatGPT。Claude.ai は --msg-human-bg) |
--msg-assistant-bg |
#f3f4f6 |
#2d2d2d |
アシスタントメッセージ背景 |
--scrollbar-thumb |
#2672ed |
#2672ed |
スクロールバー(gkill primary色) |
--scrollbar-track |
— | — | スクロールバーの溝 |
--sender-color |
— | — | 送信者ラベルの文字色 |
--ts-color |
— | — | タイムスタンプの文字色 |
--title-color |
— | — | 会話タイトルの文字色 |
ユーザ側メッセージの背景変数名はプラグインで異なる(ChatGPT は
--msg-user-bg、 Claude.ai は--msg-human-bg)。送信者ラベルの語彙(user/human)に合わせてある。
plugin-html-view.vue は表示先によって渡し方を変える。判定は
is_list_view = typeof props.height === 'number'。
| 経路 | 使う画面 | 渡し方 |
|---|---|---|
| srcdoc 直書き | 一覧(kyou-list-view は数値の height を渡す) |
:srcdoc="html" |
| ローダー + postMessage 注入 | Ryuu('fit-content')/rykv 詳細ペイン('auto')/KyouDialog('unset') |
srcdoc は定数ローダー。本文は { gkill_plugin_html } で送り、ローダーが document.open()+write()+close() で差し替える |
ローダー方式はダイアログのブラウザバック対策。srcdoc を直接書き換えると
iframe のナビゲーションがダイアログの pushState より後になり、閉じるのに戻る操作が2回要る。
定数ローダーなら iframe のナビゲーションは最初の1回で終わり、以後の差し替えは
replacement ナビゲーション(joint history エントリを増やさない)になる。
注入は必ずローダーの合図を待つ。
iframe(ローダー)→ 親: { gkill_plugin_loader_ready: true } // リスナー登録の直後
親 → iframe: { gkill_plugin_html: "<html>…" }
iframe.contentWindow はローダーが読み込まれる前(about:blank の時点)から真なので、
それを見て先に送るとリスナー未登録の iframe にメッセージが届いて黙って消える。
親は同じ HTML を送り直さない(sent_html で重複注入と注入ループを防いでいる)ため、
一度取りこぼすと本文が二度と入らず、80px の空箱になる。
どちらが先に着くかは /api/get_plugin_content_html の応答速度次第で、
ServiceWorker がこのエンドポイントをキャッシュ優先で返すぶん
2回目以降の表示ほど取りこぼしやすい(=「ときどき本文が出ない」)。
ready を受けたら sent_html を落として送り直す。document.open() はローダーが
window に張ったリスナーごと捨てるので、ローダーが読み込み直されたときは
前に送ったぶんが失われているため。
表示する Kyou が変わったときは iframe 自体を作り直す(:key)—— 同じ iframe を
使い回すと、本文で上書き済みのローダーには2件目が永久に入らない。
@load では sent_html を落としてはいけない。 注入後の document.close() でも
iframe の load は発火しうるので、注入 → load → 注入…の無限ループになる。
プラグイン本文は iframe の中なので、中で起きたダブルクリックは親のDOMへ伝播しない。
gkill の Kyou はどこでもダブルクリックで KyouDialog が開くのに、
プラグイン本文だけ開かない状態になっていた(Ryuu・rykv 詳細ペイン・KyouDialog のすべて。
一覧だけは pointer-events: none で iframe が受け取らないため素通しされて動いていた)。
対策はクライアント側で本文 HTML の末尾に転送スクリプトを足すこと。 プラグインは各自で HTML を組み立てる(サードパーティ製もありうる)ので、 プラグイン側には手を入れない。
iframe → 親: { gkill_iframe_dblclick: true }
親: iframe 要素の上で MouseEvent('dblclick', { bubbles: true }) を撃ち直す
新しい emit 経路は作らない。本物のDOMイベントとして撃ち直すことで、
kyou-view.vue の @dblclick(詳細ペイン・KyouDialog)と
ryuu-item-view.vue の @dblclick(Ryuu)にそのまま届き、
ネイティブの記録種別と完全に同じ経路で KyouDialog が開く。
右クリックは転送しない。iframe 内リンクのブラウザ標準メニュー (「新しいタブで開く」等)を潰してしまうため。
iframe は sandbox="allow-scripts allow-forms" で動作するため、allow-same-origin がなくセッション Cookie にアクセスできない。テーマ情報は postMessage で通知する。
親(plugin-html-view.vue)→ iframe:
{ gkill_theme: 'dark' | 'light' }
タイミング:
1. iframe の onload イベント発火時
2. application_config.use_dark_theme の watch 変更時
3. iframe から最初の gkill_iframe_size を受け取ったとき(=本文が描かれた合図)
3 が要るのはローダー注入経路のため。ローダーは gkill_theme を無視する(本文を差し替えるだけ)ので、
1 の onload だけに頼ると document.close() が2度目の load を焚かないブラウザで
本文がライトテーマのまま残る。
3 は「最初の1回」に限る。 本文側はテーマを受け取ると
setTimeout(notifySize, 10) でサイズを測り直して送ってくるので、
サイズを受けるたびにテーマを送り返すと10ms周期のピンポンが止まらなくなる。
window.addEventListener('message', function(e) {
if (e.data && e.data.gkill_theme) {
document.documentElement.setAttribute('data-theme', e.data.gkill_theme);
setTimeout(notifySize, 10); // レイアウト安定後にサイズ再計測
}
});[data-theme="dark"] セレクタで CSS 変数を上書きし、Light / Dark を切り替える。
iframe はコンテンツの高さを自分では制御できないため、postMessage で親に高さを伝える。
function notifySize() {
window.parent.postMessage({
gkill_iframe_size: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight
}
}, '*');
}
// トリガー: DOMContentLoaded / ResizeObserver / テーマ変更後function onWindowMessage(e: MessageEvent): void {
// 自分の iframe 以外からのメッセージは無視
if (!iframe_ref.value || e.source !== iframe_ref.value.contentWindow) return
if (!e.data) return
// 他に gkill_plugin_loader_ready(本文注入の合図)と
// gkill_iframe_dblclick(ダブルクリック転送)を同じ入口で捌く
if (e.data.gkill_iframe_size) {
const h = e.data.gkill_iframe_size.height
if (typeof h === 'number' && h > 0) iframe_content_height.value = h
send_theme_to_iframe()
}
}リスナーは onMounted ではなくセットアップ時に張る。
ローダーが名乗る gkill_plugin_loader_ready を取りこぼすと本文を一度も送れなくなるため。
iframe_content_height が 0 の間は 80px をフォールバック高さとして使用する。scrolling="no" で iframe 自身のスクロールバーを非表示にし、スクロールは親コンポーネントに委譲する。
実装: src/client/pages/views/plugin-html-view.vue
GitCommitLogContextMenu と同じ項目に加えて、プラグイン固有の「プラグイン設定」を持つ。
| メニュー項目 | 表示条件 |
|---|---|
| タグ履歴からタグを追加 | 常時 |
| タグを追加 | 常時 |
| テキストを追加 | 常時 |
| リポスト | 常時 |
| タスク化 | 常時 |
| 通知を追加 | 常時 |
| 内容コピー | 常時 |
| IDをコピー | 常時 |
| プラグイン設定 | 常時(plugin-config-dialog.vue を開く) |
| フォルダを開く | session_is_local 時のみ |
| ファイルを開く | session_is_local 時のみ |
| ファイル | 役割 |
|---|---|
src/client/pages/views/plugin-html-view.vue |
メインViewコンポーネント(iframeでHTML表示) |
src/client/pages/views/plugin-html-view-props.ts |
PluginHtmlViewProps(KyouViewPropsBase を拡張) |
src/client/classes/use-plugin-html-view.ts |
Viewレベル Composable(コンテキストメニュー表示・crudRelayHandlers) |
src/client/pages/views/plugin-html-context-menu.vue |
コンテキストメニューコンポーネント |
src/client/pages/views/plugin-html-context-menu-props.ts |
PluginHtmlContextMenuProps(= KyouViewPropsBase) |
src/client/classes/use-plugin-html-context-menu.ts |
コンテキストメニュー Composable |
| 項目 | 内容 |
|---|---|
| エンドポイント | POST /api/get_plugin_content_html |
| キャッシュストア | gkill-post-kyou-cache(他 Kyou データと共通) |
| キャッシュキー | /cache/api/plugin_content_html/{kyou_id} |
| 識別子フィールド | body.kyou_id(他エンドポイントの body.id / body.target_id とは命名が異なる) |
delete_gkill_kyou_cache(id) の data_types 配列に 'plugin_content_html' が含まれており、タグ追加・削除などの操作時に自動でキャッシュがクリアされる。
実装:
src/client/serviceWorker.ts— キャッシュ保存ロジックsrc/client/classes/delete-gkill-cache.ts— キャッシュ削除ロジック
| プラグイン名 | Rep名 | DataType | ソースディレクトリ |
|---|---|---|---|
| gkill_plugin_chatgpt | ChatGPT | chatgpt_conversation |
src/plugins/gkill_plugin_chatgpt/ |
| gkill_plugin_claudeai | Claude.ai | claude_conversation |
src/plugins/gkill_plugin_claudeai/ |
| gkill_plugin_claudecode | ClaudeCode | claude_code_turn |
src/plugins/gkill_plugin_claudecode/ |
| gkill_plugin_codex | Codex | codex_turn |
src/plugins/gkill_plugin_codex/ |
| gkill_plugin_fitbit | Fitbit | kc |
src/plugins/gkill_plugin_fitbit/ |
| gkill_plugin_google_locationhistory | GoogleLocation | google_location_visit |
src/plugins/gkill_plugin_google_locationhistory/ |
| gkill_plugin_archived_git_commit_log | ArchivedGit(manifest)。記録はリポジトリ名を名乗る(rep_names) |
git_commit_log(provides: ["git_commit_log"]) |
src/plugins/gkill_plugin_archived_git_commit_log/ |
| gkill_example | (サンプル) | example_kyou |
src/plugins/examples/gkill_example/ |
gkill_example は固定の Kyou を返すだけのサンプル実装で、DefaultConfig を持たない
(= config.json を生成しない)。それ以外の7つは source_dirs 設定と SQLite3 キャッシュを持つ。
cd src/plugins/gkill_plugin_chatgpt
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_chatgpt.exe .
cd src/plugins/gkill_plugin_claudeai
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_claudeai.exe .
cd src/plugins/gkill_plugin_claudecode
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_claudecode.exe .
cd src/plugins/gkill_plugin_codex
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_codex.exe .
cd src/plugins/gkill_plugin_fitbit
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_fitbit.exe .
cd src/plugins/gkill_plugin_google_locationhistory
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_google_locationhistory.exe .
cd src/plugins/gkill_plugin_archived_git_commit_log
GOOS=windows GOARCH=amd64 go build -o gkill_plugin_archived_git_commit_log.exe .デプロイ先: $GKILL_HOME/plugins/{userID}/{pluginName}/
AIクライアント(MCP)からもプラグインの記録を読める。プラグインKyouの本文はgkill本体に保存されていない(convertPluginKyouToKyou は Kyou の形に収まらない Texts / Tags / Typed / Notifications を落とし、メタデータだけをKyouにする。落とされたぶんは provides を宣言していれば PluginTypedIndex が別途組み立てて型別リポジトリのアダプタから配る)ので、AIに本文を届けるには画面と同じく GetContentHTML を経由するしかない。
| ツール名 | gkill API | 説明 |
|---|---|---|
gkill_get_plugin_list |
/api/get_plugin_list |
プラグイン一覧(name / version / description / data_type / rep_name / rep_names(複数の rep 名を申告するプラグインだけ)/ emits_kyou / provides / is_alive / process_running / has_last_error / typed_index / gps_index。診断用は has_last_error 以降 — 実装は req_res/get_plugin_list_response.go) |
APIの last_error と typed_index.last_build_error は、MCP では中身を返さない。どちらもプラグインが動いている端末のディレクトリ構成を含み、AIの文脈へ入れば資料やコミットメッセージへ引き写される経路ができるため、plugin_tools.go の handlePluginToolCall が落として has_last_error / has_last_build_error だけを立てる(落としたときだけ warnings に1行)。Go 側も出口で端末固有の情報を伏せる(ADR-0707)。
読み取り専用。設定書き換え(/api/post_plugin_config)はMCPに公開していない。
本文取得は独立したツールではなく、gkill_get_kyous の引数 include_plugin_content で行う。かつては1件ずつ取る gkill_get_plugin_content ツールがあったが、N件読むのにツール呼び出しがN+1回必要で、そのたびにLLMのターンを消費していたため廃止した。
gkill_get_plugin_list … どのプラグインが入っているか(data_type / rep_name)と、
その役割(emits_kyou / provides)を知る
↓ emits_kyou=false ならここで分岐する。そのプラグインはKyouを1件も出さないので
data_type / rep_name は検索値ではなく、provides に対応する経路
(gpslog なら gkill_get_gps_log)から読む
↓
gkill_get_kyous … include_plugin_content:true を付けて検索する。
(include_plugin_content) payload.kind = "plugin" のKyouに本文が入って返る
(data_type / rep_name / kyou_id / plugin_name / description
+ content_status / content_text)
インライン化は MCP 層(src/server/gkill/mcp/plugin_tools.go の inlinePluginContents)が担当する。/api/get_kyous_mcp のレスポンスから kind:"plugin" のペイロードを集め、rep_name ごとにグループ化して /api/get_plugin_content_html を叩き、HTML→テキスト変換した結果をペイロードに書き戻す。gkill側には一括取得エンドポイントもプラグインプロトコルの一括コマンドも無く、追加していない。
同一プラグインへ並列に投げてはいけない。 プラグインの stdio は1本しかなく呼び出しは直列化されるので、同時発行しても速くならず順番待ちが伸びるだけで、待ちきれなかったぶんは ErrPluginBusy になる。したがってインライン取得は rep内は必ず直列・rep間は並列(既定4) にしてある。(2026-08-06以前は期限を排他ロックの前に張っていたため、ロック待ちで期限を食い潰して Process.Kill() が走り、MCPの読み取りが原因でユーザーのプラグインが落ちていた。)
実行中のリクエストを abort しても、いまはプロセスには影響しない(呼び出し元のキャンセルではプロセスを回収しない)。それでも MCP 側は abort せず「新しいリクエストを始めない」だけでデッドラインを実現している。MCPサーバは古い gkill にも接続しうるためである。
そのほかの上限: 1回あたり20件・1件4000文字(plugin_content_max_text_length で最大200000)・合計200000文字。あるrepで1件失敗したらそのrepの残りは投げず content_skipped_reason: "rep_error" にする(タイムアウトでプロセスが死んでいる可能性が高く、投げ続けてもコールドスタートで待たされるだけのため)。個別の失敗は content_status に落ち、gkill_get_kyous 全体は失敗しない。
handle_get_kyous_mcp.go のペイロード構築は、data_type そのものではなく payloadKindOfDataType で寄せたペイロード種別の switch で分岐する。Mi / MiReKyou / TimeIs の data_type はリポジトリのSQLが射影名を焼き込むため(mi_create / mi_check / mi_limit / mi_start / mi_end、mirekyou_*、timeis_start / timeis_end)、素の型名との完全一致では拾えないからである。接頭辞判定は mirekyou_ を mi_ より先に見ること。そこにも当たらないKyouは repositories.PluginReps から rep_name で manifest を引き当てて PluginPayloadMCPDTO(kind: "plugin")にする。kyou_id をペイロードに載せているのは、include_id 指定なしでもコンテンツ取得ができるようにするため(idfペイロードが rep_name / file_name を常に載せているのと同じ考え方)。
完全一致の switch で書いていた期間は Mi / TimeIs / MiReKyou / ReKyou の4型が丸ごと
defaultへ落ち、Payloadがomitemptyなのでキーごと消えていた(Miの板名もタイトルもMCPに届かなかった)。加えて、ペイロードの元データを引くFindMi/FindMiReKyouは5つのIncludeXxxMiがSQL射影を選ぶスイッチになっており、全falseだと必ず空を返すので、ID指定の一括取得でも作成射影を明示的に立てる必要がある。回帰はhandle_get_kyous_mcp_test.goが押さえる。
plugin_content_format は既定 text。プラグインのコンテンツHTMLは <style> と <script> を含む完結したHTML文書で、バイト数の大半が表示用のボイラープレートになるため、そのまま返すとAIのトークンを浪費するだけになる。MCPサーバ側(src/server/gkill/mcp/html_text.go)で正規表現ベースの軽量変換をかける:
<script>/<style>/ コメントは中身ごと破棄<br>とブロック要素の境界を改行に変換(タグ隣接だけで空行ができないよう、内部マーカー経由で連続をまとめる)<details>/<summary>(ツール実行・thinkingの折りたたみ)の中身は残す- HTMLエンティティをデコード(
&は最後に処理し、エスケープ済みマークアップが復活しないようにする) plugin_content_max_text_length(既定4000文字)を超えたら切り詰め、content_status: "truncated"にする
plugin_content_format: "html" で生HTMLを content_html に、"both" で両方返す。長い記録1件の全文が欲しいときは query.ids(非null)でその1件に絞り、plugin_content_max_text_length を上げる。
プラグインは Kyou のメタ情報だけでなく、その Kyou の型別データ(数値記録・メモ・ブックマーク等)と 付随データ(タグ・テキスト・通知)、および GPS ログも提供できる。
これがあると、プラグインの記録がネイティブの記録と同じように扱われる。 数値記録として返せば Dnote の推移グラフでそのまま集計でき、タグを返せばタグ一覧に載って絞り込みに使える。
{
"data_type": "kc",
"provides": ["kc", "tag"]
}| 値 | 提供するもの | 登録されるリポジトリ |
|---|---|---|
kmemo / kc / urlog / nlog / lantana / timeis / mi / git_commit_log |
Kyou の型別データ | KmemoReps / KCReps / … / GitCommitLogReps |
tag / text / notification |
Kyou の付随データ | TagReps / TextReps / NotificationReps |
gpslog |
GPS ログ(Kyou ではない) | GPSLogReps |
未指定(省略・空配列)が既定で、このとき gkill はアダプタを一切登録しない。 既存プラグイン(chatgpt / claudeai / claudecode / example)は manifest.json を書き換えなくても従来どおり動く。
未知の値は警告して読み飛ばす(dao/plugin_manager.go の loadManifest)。
タイプミスでプラグインごと無効化すると、それまで出ていた記録まで丸ごと消えてしまうため、エラーにはしない。
{
"id": "...", "data_type": "kc", "related_time": "...", "update_time": "...",
"tags": ["fitbit", "歩数(日計)"],
"texts": ["メモ"],
"typed": { "kc": { "title": "歩数(日計)", "num_value": "12345" } },
"notifications": [{ "content": "通知", "notification_time": "..." }]
}typed に非nilにしてよいのは高々1つ。2つ以上あるときは
Kmemo→KC→URLog→Nlog→Lantana→TimeIs→Mi→GitCommitLog の順で最初の1つだけを採用し、残りは警告ログに落とす。
git_commit_log は typed.git_commit_log{commit_message, addition, deletion} で、ID をコミットハッシュ・時刻をコミッタ日時にすると native の git rep と同じ形になり、
稼働中リポジトリと同じハッシュのコミットは検索結果の重複除去((ID, data_type, related_time))で1件に畳まれる(ADR-0309)。
型別データは ID も時刻も持たない。親の PluginKyou からコピーされる。
クライアントは「Kyou の update_time と型別データの update_time が秒精度で一致する版」を選んで表示するので
(client/classes/datas/kyou.ts)、別々の更新時刻を持たせられるようにすると
1秒ずれただけで型別ビューが空になる罠をプラグイン作者に押し付けることになる。
タグ・テキスト・通知の ID はサーバ側で値から決定的に導出する(pluginDerivedID、UUIDv5)。
添字ではなく値を種にしているので、プラグインが返す並び順が変わっても ID が変わらず、
一度消したタグを付け直すと同じ ID に戻る(gkill 側の削除版が効き続ける)。
実装: src/server/gkill/dao/reps/plugin_typed_index.go
アダプタの読み取りメソッドは決してプラグインへ往復しない。1件ずつ聞きに行くと破綻するため。
find_filterのgetAllTagsは検索1回につき全TagRepsのFindTagsを呼ぶ- MCP の
handle_get_kyous_mcpは Kyou 1件ごとにGetTagsByTargetID等を呼ぶ - ブラウザは画面上の Kyou ごとに
get_kc/get_tags_by_target_idを8並列で投げる
一方プラグインへの呼び出しは容量1のスロットで完全に直列化される(「5. 並行制御」参照)。 万件規模の一覧を Dnote で舐めると件数ぶんの直列 stdio 呼び出しになり、プロセスが殺され続ける。
| 項目 | 動き |
|---|---|
| 索引を埋めるのは | pluginRepositoryImpl.FindKyous の副作用と、UpdateCache からの明示的な再構築 |
| 読み取りは | 不変スナップショット(atomic.Pointer)から即答。冷たければ空を返し、バックグラウンドで温め直しを予約する |
| 再構築の最短間隔 | 30秒。UpdateCache は Reps / TagReps / TextReps / NotificationReps から立て続けに呼ばれるため |
| 失敗時 | 既存のスナップショットを空で潰さない。AppendPluginFindWarning に記録してメッセージに出す |
索引の状態は get_plugin_list の typed_index から観測できる。 state は
never_built(一度も構築していない)/ failed(直近の構築が失敗)/ ok の3値 ——
ok フラグの false だけでは「未構築」と「構築失敗」が潰れており、呼び出し側は
直しようが無かった(2巡目の指摘で分離)。last_build_error は直近の構築失敗の
理由(タイムアウト・ErrPluginBusy・JSON不正など gkill 側で起きるもの)で、
プラグインプロセスの stderr 末尾である last_error とは別物 —— 索引構築の失敗要因は
stderr には出ない(プラグイン側の全文は $GKILL_HOME/logs/gkill_plugin_<name>*.log。ADR-0313)。last_attempt_at は直近に構築を試みた時刻で、再構築はバックオフ中だと
エラーすら発生しないため、これが無いと「なぜ何も起きていないのか」が分からない
(PluginTypedIndex.Stats())。
typed_index は Kyou の索引であり、record_count はプラグインが返した Kyou の
ユニークID数。provides が gpslog だけのプラグインにはそもそも作らない
(PluginManifest.NeedsTypedIndex())—— GPSログはKyouではないので索引の材料が1件も無く、
作ると never_built / 0 のまま永久に固定されて「索引が壊れている=位置情報が使えない」と
誤読される(実利用報告。→ ADR-0608)。
GPSの取り込み状況は別枠の gps_index(point_count / oldest / newest / fetched_at)で、
統計は gpsLogRepositoryPluginImpl が取得のたびにプラグインリポジトリへ預ける
(一覧APIは wrapAuth でリポジトリ解決を通らないため、アダプタ側から預ける形にしてある)。
GPSが一度も要求されていなければキーごと省略される —— 「壊れている」ではなく「まだ読んでいない」。
FindKyous で索引を埋めるのは、Reps 経由の検索が goForRep(dao/reps/repositories.go)で
スレッドプールを迂回するので、そこでブロックしても安全なため。
しかも「一覧を出した直後に、その一覧の各件の型別データが引かれる」という実際の順序に一致する。
実装: plugin_typed_adapters.go(型別7種 + 共通ベース)、plugin_attached_adapters.go(付随3種)、
gps_log_repository_plugin_impl.go(GPS ログ)。
| メソッド群 | 挙動 |
|---|---|
Find* / Get* / Get*ByTargetID / FindKyous |
索引から即答。プラグインへは委譲しない |
Add*Info(書き込み) |
必ずエラー。プラグインの記録は読み取り専用 |
Close |
no-op。プロセスを閉じるのは pluginRepositoryImpl.Close と PluginManager.CloseAll だけ |
GetLatestDataRepositoryAddress |
型別は空・付随は実データ(下記) |
型別アダプタがアドレス表を返さないのは意図的。KCReps 等は UpdateCache の getAddrTargets に
含まれないのでそもそも呼ばれないが、仮に返すと replaceLatestKyouInfos の対象になり、
プラグインが UpdateTime をわずかに揺らしただけでレコードごと検索結果から消える。
逆に付随データ(Tag/Text/Notification)は getAddrTargets に含まれ、
find_filter が isLatestData でふるうので、返さないと --cache_in_memory=false で全部落ちる。
FindKyous も索引から答える。KCRepositories.FindKyous は threads.Go でファンアウトするので
(goForRep の迂回は Repositories にしかない)、ここで委譲すると rep 種別指定の検索が
プールのスロットを握ったままプラグインのロックを待つことになる。
プラグインが rep_name を返さないときは manifest の rep_name で埋める
(convertPluginKyouToKyou)。プロトコル上は manifest.json の rep_name と一致させる約束だが、
申告をそのまま写しているだけなので空で返ってくることがある。rep 名での絞り込みは
検索結果の Kyou.RepName で行う(GUI は常に reps を送る)ので、
ここが空だとそのプラグインの記録が通常検索から丸ごと消える。エラーも警告も出ない。
空でない不一致は上書きしない(Kyou.rep_name は API 応答にも出ていて、
クライアントのコンテキストメニューや get_kyou_histories_by_rep_name が乗っているため)。
代わりに「manifest名 → 申告名」の組み合わせごとに1回だけ警告する
(記録1件ごとに出すと実データで数十万行になる)。
また findCtx.MatchReps は rep 名がキーなので、1つのプラグインが2種類以上を提供すると
アダプタが片方しか入らない。そのため rep 種別が指定されているときは
「自分の種別」ではなく「provides ∩ 指定された種別」を返す。
dao/gkill_dao_manager.go のプラグイン発見のループで、provides に応じて各リストへ append する。
KCReps → Reps のコピーループより後にあることが要点で、
先に足すとアダプタまで Reps に入り、プラグイン本体と二重に検索されて同じ記録が2回出る。
WriteXxxRep には決して入れない(読み取り専用のため)。
XxxReps の要素数でキャッシュ rep を判定してはいけない。
アダプタの append は「キャッシュ rep で XxxReps を1個に差し替える」処理より後なので、
provides を持つプラグインが1つ入るだけで len(XxxReps) は2になる。
かつて書き込み後のキャッシュ反映が len(repositories.TagReps) == 1 && *gkill_options.CacheTagReps
という個数判定で守られており、Fitbit プラグイン(provides: ["kc","tag"])を入れた環境で
タグと数値記録の書き込みがキャッシュ rep へ反映されなくなっていた。
読み取りはキャッシュ rep しか見ない(下層 rep へフォールバックしない)ので、
付けたタグが次の UpdateCache まで最大1分間見えず、その間に PWA が古い応答を
キャッシュし直すと更新 ID の再通知が来ないぶん恒久的に古いまま焼き付く。
キャッシュ rep の実体は構築時に GkillRepositories.CachedReps へ控えてあり、
書き込み後の反映は repositories.WriteThroughXxxCache(ctx, ...) を使う。
個数判定の再発は usecase/write_through_cache_test.go の TestNoRepsCountCacheGuard
(ソース走査)が落とす。
manifest.json の emits_kyou を false にすると、そのプラグインは Reps に登録されない。
省略時は true なので、既存プラグインは書き換えなくても従来どおり動く。
GPS ログだけを提供するプラグインがこれにあたる。記録を1件も返さないのに Reps に居ると
GetAllRepNames(Reps.UnWrap())に載り、rykv の「記録保管場所」の絞り込みツリーに
選んでも0件の項目が並ぶ。検索のたびに空振りの往復も1回発生する。
| 登録先 | emits_kyou: false のとき |
|---|---|
Repositories.Reps |
入れない |
Repositories.PluginReps |
入れる(設定画面・死活確認・get_plugin_list に要る) |
Repositories.GPSLogReps |
入れる |
GPS ログは rep の選択状態と無関係に常に効く。 消費側(handle_get_gps_log.go と
find_filter.go の地図フィルタ)は GPSLogReps を素通しで舐めており、FindQuery.Reps を見ない。
provides の内容から推測はしない(provides: ["kc"] と書いた作者の記録が黙って消えると
原因の分からない不具合になるため、明示的に切ってもらう)。
GPS ログは Kyou ではない(ID も更新時刻も持たない)ので、Kyou 経路ではなく専用コマンド
get_gps_logs で受け渡す。
- ページングする。1点は JSON で約95バイト、親の受信バッファは32MBなので1レスポンス約35万点が上限。
フル
Records.jsonは数百万点になる。offsetを進めてhas_more_gps_logsが false になるまで繋ぐ - アダプタは全件を1回取ってメモリに持つ(TTL 5分・singleflight)。
GPS ログは地図の描画1回ごと・地図フィルタの評価1回ごとに引かれるので、
窓を素通しすると列の数だけ行列が伸びて
ErrPluginBusyになる - エラーを飲む。
GPSLogRepositories.GetGPSLogsもfind_filter.collectFromReposも 「1つでも rep がエラーを返したら全体を失敗にする」作りなので、 ここでエラーを返すとプラグインが混んでいるだけで地図も検索も丸ごと落ちる。 失敗時は空を返してAppendPluginFindWarningに記録する - アップロード先には選べない。
ReadOnlyGPSLogRepositoryの目印を付けてあり、handle_upload_gps_log_filesが候補から外す(画面の選択肢は端末の rep 設定から作られるので 元々出てこないが、リクエストを手で組めば rep 名で当ててしまえるためサーバ側でも弾く)
型別データを提供すると、その記録はネイティブと同じビューで描かれる。
つまり data_type: "kc" の記録には KCView が出て、編集・削除メニューが付いてしまう。
クライアントは /api/get_plugin_list からプラグインの rep 名の集合を1度だけ取り
(client/classes/use-plugin-rep-names.ts)、kyou.rep_name がそこに含まれる記録では
編集・削除の項目を出さない。対象は kc / kmemo / ur-log / nlog / lantana / time-is / mi の7つのメニュー。
なお仮に編集されても破綻はしない。WriteXxxRep に同一 ID の新しい版が積まれ、
追記式の「UpdateTime が新しい版が勝つ」で上書きされ、
find_filter の最終段にある ID 単位の重複排除で表示は1件になる。
Google Takeout を取り込む2つのプラグイン(fitbit / 位置情報)、zip の Git リポジトリ(archived_git_commit_log)、ChatGPT / Claude.ai の会話履歴(ADR-0310)は、ZIP を展開せずそのまま読む。
走査は plugin/sdk/source.go に置いてあり、5本で共通。
- 展開しない。 中央ディレクトリを読んでエントリを列挙し、読むときにその場で伸長する。
本体の
handle_browse_zip_contents.goはcaches/zip_cache/へ展開するが、実データは 展開後 数GB あるうえ、あのキャッシュのキーはsha1(パス)だけで中身が変わっても無効化されない - 展開済みのフォルダは読まない。 どの書き出しのものか判別できないため。
ZIP が無いフォルダは
extracted_folderとして設定画面に出す - 差分判定は
(CRC32, Size)。 Takeout は書き出し時刻を全エントリに同じ値で入れるので、 エントリの更新時刻は中身が変わっても動かない(実データで全エントリ(1万件超)が同一値)。 CRC32 は中央ディレクトリに入っているので、読むのに伸長は要らない - 取り込み世代 = 「ZIP を含むフォルダ + ZIP名の書き出し時刻」。 分割された
takeout-...-1-001.zip/-1-002.zipは書き出し時刻が同じなので1つの世代にまとまる。 フォルダだけを単位にしてはいけない ―― 同じフォルダに翌月の書き出しを足したとき 両方が同じ世代になり、合計する指標(歩数など)が2倍になる - 並行読み取りは安全。
zip.File.Open()は共有の*os.Fileに対するSectionReaderを作るだけで、os.File.ReadAtは並行安全。fitbit の取り込みは4並列で回している - 入れ子の ZIP には潜らない。分割アーカイブ(
.z01+.zip。Go のarchive/zipでは読めない)は 黙って壊れるのではなく検出して報告する。壊れた ZIP 1本で走査全体を止めない - エントリ名のデコードは本体の
decodeZipEntryNameと同じ3段階 (汎用フラグ bit 11 →utf8.ValidString→ Shift_JIS)
世代をまたいで合算しないのは fitbit だけの問題。 ChatGPT / Claude.ai は会話 ID ごとに update_time が新しい版だけを採る(ADR-0310)。位置情報は読み出し時に
SELECT DISTINCT (時刻, 緯度, 経度) で重複を除くので、別の書き出しの同じ点は自動的に1つに畳まれる。
むしろ Google は古いデータを間引くので、古い書き出しを残しておくと消えた期間が保たれる。
fitbit 側は export 表に順位を持ち、日が重なったときは rank が最小の世代の行だけを合算する。
同じ CSV の中のデータソースもまたいで合算しない(fitbit)。 歩数の CSV にはある日から
スマホ(Phone Health Connect)の行が時計(Pixel Watch 2)の行と同じ日に並び、Fitbit アプリ自身の
日計は時計の値だけを採る。部分集計 sample_daily は (ファイル, 日, データソース) 単位で持ち、
畳み直し(chooseDataSources)で時計の行が1つでもあればそれだけ、無ければ設定
secondary_data_sources を書いた順に1つだけ採る(ADR-0312)。
- 索引は最新版だけを持つので、プラグインの型別データに履歴は無い(
Get*Historiesは0件か1件) - 削除済みデータの取り込みには対応していない(ゴミ箱にプラグインの記録は出ない)
- 型別データを提供するプラグインは、内容が変わっていない記録の
UpdateTimeを安定させること。 毎回time.Now()を入れると、gkill 側で消した記録がプラグインの再構築で復活する
- program-spec.md —
pluginRepositoryImplの実装詳細 - api-endpoints.md —
get_plugin_content_htmlエンドポイント - frontend-architecture.md —
plugin-html-view.vueコンポーネント・PWAキャッシュ - sequence-diagrams.md — プラグインコンテンツHTML取得シーケンス
src/server/gkill/mcp/README.md— MCPのプラグインツール(gkill_get_plugin_list)と本文のインライン埋め込み- glossary.md — PluginKyou, PluginRepository 用語定義