@cocorograph/hub-agent 0.7.102 → 0.7.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/claude-history.mjs +44 -1
- package/src/claude-stream-bridge.mjs +89 -49
- package/src/inject-source-store.mjs +206 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -23,6 +23,46 @@ import {
|
|
|
23
23
|
maskMessageObject,
|
|
24
24
|
redactSecrets,
|
|
25
25
|
} from "./tool-result-mask.mjs"
|
|
26
|
+
import { lookupInjectSource } from "./inject-source-store.mjs"
|
|
27
|
+
|
|
28
|
+
/** user 履歴イベントの本文を取り出す (string content / text ブロック配列の両対応)。
|
|
29
|
+
* 連携カード source の永続タグ付けで、注入本文との一致判定に使う。 */
|
|
30
|
+
function historyUserText(event) {
|
|
31
|
+
const content = event?.message?.content
|
|
32
|
+
if (typeof content === "string") return content
|
|
33
|
+
if (Array.isArray(content)) {
|
|
34
|
+
return content
|
|
35
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
36
|
+
.map((b) => b.text)
|
|
37
|
+
.join("\n")
|
|
38
|
+
}
|
|
39
|
+
return ""
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 連携カード種別 (Phase 2 永続化): hydrate した履歴イベントのうち、この cwd で注入と本文一致する
|
|
44
|
+
* user メッセージへ source を付け直す (reattach / cold resume / 別端末でもカードを維持する)。
|
|
45
|
+
* hydrateCache の共有オブジェクトを壊さないよう、タグを付けるイベントだけ **shallow-clone** して
|
|
46
|
+
* 差し替える (source が無いイベントは元のまま = 参照維持でコスト無し)。
|
|
47
|
+
* @param {object[]} events
|
|
48
|
+
* @param {string} cwd
|
|
49
|
+
* @returns {Promise<object[]>}
|
|
50
|
+
*/
|
|
51
|
+
export async function tagHistoryInjectSources(events, cwd) {
|
|
52
|
+
if (!cwd || !Array.isArray(events) || events.length === 0) return events
|
|
53
|
+
let out = null // 変更が発生したときだけ配列を複製する (通常セッションはコスト 0)
|
|
54
|
+
for (let i = 0; i < events.length; i++) {
|
|
55
|
+
const ev = events[i]
|
|
56
|
+
if (!ev || ev.type !== "user" || ev.source) continue
|
|
57
|
+
const text = historyUserText(ev)
|
|
58
|
+
if (!text) continue
|
|
59
|
+
const source = await lookupInjectSource(cwd, text)
|
|
60
|
+
if (!source) continue
|
|
61
|
+
if (!out) out = events.slice()
|
|
62
|
+
out[i] = { ...ev, source }
|
|
63
|
+
}
|
|
64
|
+
return out || events
|
|
65
|
+
}
|
|
26
66
|
|
|
27
67
|
export const MAX_HISTORY_LINES = 500
|
|
28
68
|
|
|
@@ -282,7 +322,10 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
|
|
|
282
322
|
const filePath = jsonlPath({ cwd, session_id, projectsRoot })
|
|
283
323
|
try {
|
|
284
324
|
const result = await readSessionHistory(filePath, { maxLines, logger })
|
|
285
|
-
|
|
325
|
+
// 連携カード種別 (Phase 2 永続化): 注入 user メッセージへ source を付け直す (hydrateCache は
|
|
326
|
+
// filePath キーで cwd を持たないため、cwd を持つここでタグ付けする。クローンで cache 非破壊)。
|
|
327
|
+
const events = await tagHistoryInjectSources(result.events, cwd)
|
|
328
|
+
return { ...result, events, file_path: filePath }
|
|
286
329
|
} catch (err) {
|
|
287
330
|
return {
|
|
288
331
|
events: [],
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
maskJsonlFileAtRest,
|
|
33
33
|
} from "./tool-result-mask.mjs"
|
|
34
34
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
35
|
+
import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
|
|
@@ -530,15 +531,16 @@ export class ClaudeStreamSession {
|
|
|
530
531
|
/** マスク済みイベントを onEvent へ流す共通経路。秘匿情報 / 非公開スキルプロンプト本文を
|
|
531
532
|
* 表示・WS 配信前に伏せる。マスク失敗時は素のイベントを流す (表示が止まるより素通し優先)。 */
|
|
532
533
|
/** 連携カード種別 (Phase 2): 注入本文と source を FIFO に控える。sendToCwd/sendToStream が
|
|
533
|
-
* session.sendMessage()
|
|
534
|
+
* session.sendMessage() を呼ぶ直前に記録し、**drain 昇格時に _emitQueueState が started 本文
|
|
535
|
+
* 一致で消費**して startedSources として frontend へ載せる。
|
|
534
536
|
*
|
|
535
|
-
* ⚠️
|
|
536
|
-
*
|
|
537
|
-
* (
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
* @param {string} text 注入本文 (
|
|
537
|
+
* ⚠️ claude は SDK が user メッセージを live echo しない (reducer set_queue コメント参照)。
|
|
538
|
+
* user バブルは queue_state の started 昇格で作られるため、当初の _emit(user echo) タグ方式は
|
|
539
|
+
* claude で発火せず注入が青バブルのままだった (2026-07-23 実機で判明)。消費点を _emitQueueState
|
|
540
|
+
* に移した。相関は「注入本文の完全一致」(footer 込みで一意性が高い)。ID メタ伝播が堅牢だが
|
|
541
|
+
* started は本文しか持たないため本文相関を採る。衝突リスクは①送信失敗時の stale → _removeInjectSource
|
|
542
|
+
* で handle 同定除去、②未 drain の無限成長 → 32 件上限、で最小化。衝突時も誤タグ止まりで安全。
|
|
543
|
+
* @param {string} text 注入本文 (drain 時の started 本文と一致する)
|
|
542
544
|
* @param {'peer'|'orchestrator'|undefined} source
|
|
543
545
|
* @returns {object|null} この entry の固有 handle (identity)。配送失敗時に _removeInjectSource へ
|
|
544
546
|
* 渡して**その entry だけ**を除去する。無効な source/text では null。 */
|
|
@@ -563,30 +565,6 @@ export class ClaudeStreamSession {
|
|
|
563
565
|
if (idx !== -1) this._pendingInjectSources.splice(idx, 1)
|
|
564
566
|
}
|
|
565
567
|
|
|
566
|
-
/** user echo イベントの本文が控えた注入本文と一致すれば source タグを付けて返す (それ以外は
|
|
567
|
-
* 無変更)。frontend はこの source があれば専用の連携依頼カードへ分岐する。共有/at-rest の
|
|
568
|
-
* イベントを壊さないよう shallow-clone に付ける (top-level に source を足すだけ)。 */
|
|
569
|
-
_tagInjectSource(event) {
|
|
570
|
-
if (!this._pendingInjectSources.length) return event
|
|
571
|
-
if (!event || event.type !== "user" || !event.message) return event
|
|
572
|
-
const content = event.message.content
|
|
573
|
-
const text =
|
|
574
|
-
typeof content === "string"
|
|
575
|
-
? content
|
|
576
|
-
: Array.isArray(content)
|
|
577
|
-
? content
|
|
578
|
-
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
579
|
-
.map((b) => b.text)
|
|
580
|
-
.join("\n")
|
|
581
|
-
: ""
|
|
582
|
-
if (!text) return event
|
|
583
|
-
const idx = this._pendingInjectSources.findIndex((p) => p.text === text)
|
|
584
|
-
if (idx === -1) return event
|
|
585
|
-
const { source } = this._pendingInjectSources[idx]
|
|
586
|
-
this._pendingInjectSources.splice(idx, 1)
|
|
587
|
-
return { ...event, source }
|
|
588
|
-
}
|
|
589
|
-
|
|
590
568
|
_emit(event) {
|
|
591
569
|
if (!this.onEvent) return
|
|
592
570
|
let out = event
|
|
@@ -601,7 +579,6 @@ export class ClaudeStreamSession {
|
|
|
601
579
|
out = event
|
|
602
580
|
}
|
|
603
581
|
}
|
|
604
|
-
out = this._tagInjectSource(out)
|
|
605
582
|
this.onEvent(out)
|
|
606
583
|
}
|
|
607
584
|
|
|
@@ -1026,6 +1003,9 @@ export class ClaudeStreamSession {
|
|
|
1026
1003
|
// 初回ターンの ultracode は起動時 options.settings に焼き込むため、起動前 reconcile
|
|
1027
1004
|
// (applyFlagSettings) のための空窓も不要。
|
|
1028
1005
|
if (!this._residentQuery || this._residentQueryReturned) {
|
|
1006
|
+
// Phase 2 (major#1): resident fresh start は started を出さないため、注入なら source 付きで
|
|
1007
|
+
// 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
|
|
1008
|
+
this._maybeEmitInjectStarted(prompt)
|
|
1029
1009
|
this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
|
|
1030
1010
|
return { accepted: true }
|
|
1031
1011
|
}
|
|
@@ -1038,6 +1018,11 @@ export class ClaudeStreamSession {
|
|
|
1038
1018
|
ultracode,
|
|
1039
1019
|
)
|
|
1040
1020
|
if (outcome === "closed") return { accepted: false, reason: "queue_dead" }
|
|
1021
|
+
// Phase 2 (round2 major): 起動済み・idle な resident query への delivery は
|
|
1022
|
+
// _reconcileThenDeliver 内で pushed (inputQueue.push) / restarted
|
|
1023
|
+
// (_startResidentTurnWithInput) のどちらも started を出さない。注入なら source 付き
|
|
1024
|
+
// started をここで先出しして昇格バブルをカード化する (通常メッセージは no-op)。
|
|
1025
|
+
this._maybeEmitInjectStarted(prompt)
|
|
1041
1026
|
return { accepted: true }
|
|
1042
1027
|
}
|
|
1043
1028
|
|
|
@@ -1058,6 +1043,9 @@ export class ClaudeStreamSession {
|
|
|
1058
1043
|
this._emitQueueState()
|
|
1059
1044
|
return { accepted: true }
|
|
1060
1045
|
}
|
|
1046
|
+
// Phase 2 (major#1): per-message idle 直接実行も started を出さないため、注入なら source 付きで
|
|
1047
|
+
// 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
|
|
1048
|
+
this._maybeEmitInjectStarted(prompt)
|
|
1061
1049
|
// ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
|
|
1062
1050
|
// 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
|
|
1063
1051
|
// 握っているため通常は reject しないが、念のため warn だけ残す。
|
|
@@ -1304,6 +1292,20 @@ export class ClaudeStreamSession {
|
|
|
1304
1292
|
// force のときは再アタッチ snapshot なので重複チェックを完全にバイパスする。
|
|
1305
1293
|
if (!force && started.length === 0 && sig === this._lastEmittedQueueSig) return
|
|
1306
1294
|
this._lastEmittedQueueSig = sig
|
|
1295
|
+
// 連携カード種別 (Phase 2, 2026-07-23 修正): **claude は SDK が user メッセージを live echo
|
|
1296
|
+
// しない** (reducer set_queue のコメント参照)。ゆえに user バブルは queue_state の started
|
|
1297
|
+
// (drain 昇格) から作られる。当初 _emit の user echo にタグする実装は claude では発火せず
|
|
1298
|
+
// (dead code)、注入が青バブルのままだった (実機確認)。正しくは started 各テキストの source を
|
|
1299
|
+
// ここで _pendingInjectSources から引いて startedSources として載せ、frontend が昇格バブルへ
|
|
1300
|
+
// source を付ける。started は全 drain 経路 (_drainPending / resident / per-message finally) が
|
|
1301
|
+
// 通る funnel なのでここ 1 箇所で全経路をカバーする。注入本文 (footer 込み) の完全一致で消費。
|
|
1302
|
+
const startedSources = started.map((text) => {
|
|
1303
|
+
const idx = this._pendingInjectSources.findIndex((p) => p.text === text)
|
|
1304
|
+
if (idx === -1) return null
|
|
1305
|
+
const { source } = this._pendingInjectSources[idx]
|
|
1306
|
+
this._pendingInjectSources.splice(idx, 1)
|
|
1307
|
+
return source
|
|
1308
|
+
})
|
|
1307
1309
|
try {
|
|
1308
1310
|
// messages は全文を載せる。frontend は実行開始 (drain) 時にこれを user バブルへ
|
|
1309
1311
|
// 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
|
|
@@ -1314,12 +1316,25 @@ export class ClaudeStreamSession {
|
|
|
1314
1316
|
messages: this._pendingMessages.map((m) => m.text),
|
|
1315
1317
|
items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
|
|
1316
1318
|
started,
|
|
1319
|
+
// started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
|
|
1320
|
+
startedSources,
|
|
1317
1321
|
})
|
|
1318
1322
|
} catch {
|
|
1319
1323
|
/* ignore */
|
|
1320
1324
|
}
|
|
1321
1325
|
}
|
|
1322
1326
|
|
|
1327
|
+
/** 連携カード種別 (Phase 2, cross-review major#1): **idle 直接送信経路**用。busy→queue→drain は
|
|
1328
|
+
* _emitQueueState([text]) を通るが、idle 直接実行 (resident fresh start / per-message 即時) は
|
|
1329
|
+
* started を出さないため、注入の source が startedSources に載らずカード化されない。ここで
|
|
1330
|
+
* 「その本文が注入 (source 記録済み) のときだけ」started を 1 件 emit して昇格バブルを source 付き
|
|
1331
|
+
* で作る。通常メッセージ (source 無し) は emit せず従来経路 (optimistic 等) に任せる = 挙動不変。 */
|
|
1332
|
+
_maybeEmitInjectStarted(prompt) {
|
|
1333
|
+
if (this._pendingInjectSources.some((p) => p.text === prompt)) {
|
|
1334
|
+
this._emitQueueState([prompt])
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1323
1338
|
/** 空 InputQueue 窓封鎖 (T06118): fresh start する resume 常駐 query を「入力を先に積んで
|
|
1324
1339
|
* から起動」する (push 先行)。fresh な InputQueue を作り、実ユーザーメッセージを push して
|
|
1325
1340
|
* から query を起動するため、「resume + 空入力」の窓で SDK が自動継続ターン (Continue from
|
|
@@ -2697,14 +2712,28 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2697
2712
|
// 連携カード種別 (Phase 2): 配送先が確定した後に記録する (ambiguous/not_found では記録しない)。
|
|
2698
2713
|
// handle を控え、失敗時にその entry だけを除去する (round2: text 除去は並行同一本文で誤消費)。
|
|
2699
2714
|
const injectHandle = session._recordInjectSource?.(text, source)
|
|
2700
|
-
session
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2715
|
+
session
|
|
2716
|
+
.sendMessage({ role: "user", content: text })
|
|
2717
|
+
.then((r) => {
|
|
2718
|
+
// Phase 2 (major#3): sendMessage は throw せず {accepted:false} で失敗を返す経路がある
|
|
2719
|
+
// (queue_dead / empty_message / pre-canceled)。この場合は echo/started は来ないので
|
|
2720
|
+
// stale な live source entry を掃除し、**永続化もしない** (persist round1 major#1: 失敗を
|
|
2721
|
+
// 永続化すると 30 日 stale が残り後続の同文 human を誤カード化する)。
|
|
2722
|
+
if (r && r.accepted === false) {
|
|
2723
|
+
session._removeInjectSource?.(injectHandle)
|
|
2724
|
+
return
|
|
2725
|
+
}
|
|
2726
|
+
// 受理された (queued/started) ときだけ hydrate 用に永続化する (best-effort、live と独立)。
|
|
2727
|
+
recordInjectSourcePersistent(cwd, text, source).catch(() => {})
|
|
2728
|
+
})
|
|
2729
|
+
.catch((err) => {
|
|
2730
|
+
// 配送失敗 (throw) でも echo は来ないので stale な live entry を掃除する (永続化はしない)。
|
|
2731
|
+
session._removeInjectSource?.(injectHandle)
|
|
2732
|
+
this.logger?.error(
|
|
2733
|
+
{ cwd, err: err?.message },
|
|
2734
|
+
"sendToCwd: sendMessage threw unexpectedly",
|
|
2735
|
+
)
|
|
2736
|
+
})
|
|
2708
2737
|
return { status: "delivered", session: this._describeCandidate(session, att) }
|
|
2709
2738
|
}
|
|
2710
2739
|
|
|
@@ -2750,14 +2779,25 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2750
2779
|
const { session, attached } = matches[0]
|
|
2751
2780
|
// 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found/ambiguous では記録しない)。
|
|
2752
2781
|
const injectHandle = session._recordInjectSource?.(text, source)
|
|
2753
|
-
session
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2782
|
+
session
|
|
2783
|
+
.sendMessage({ role: "user", content: text })
|
|
2784
|
+
.then((r) => {
|
|
2785
|
+
// Phase 2 (major#3 / persist major#1): {accepted:false} は echo/started 来ないので live を
|
|
2786
|
+
// 掃除し永続化もしない (失敗を永続化すると 30 日 stale で後続同文 human を誤カード化)。
|
|
2787
|
+
if (r && r.accepted === false) {
|
|
2788
|
+
session._removeInjectSource?.(injectHandle)
|
|
2789
|
+
return
|
|
2790
|
+
}
|
|
2791
|
+
// 受理時のみ hydrate 用に永続化 (best-effort)。
|
|
2792
|
+
recordInjectSourcePersistent(cwd, text, source).catch(() => {})
|
|
2793
|
+
})
|
|
2794
|
+
.catch((err) => {
|
|
2795
|
+
session._removeInjectSource?.(injectHandle)
|
|
2796
|
+
this.logger?.error(
|
|
2797
|
+
{ session_id: sessionId, err: err?.message },
|
|
2798
|
+
"sendToStream: sendMessage threw unexpectedly",
|
|
2799
|
+
)
|
|
2800
|
+
})
|
|
2761
2801
|
return { status: "delivered", session: this._describeCandidate(session, attached) }
|
|
2762
2802
|
}
|
|
2763
2803
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 連携カード種別 (Phase 2) の **永続ストア** (2026-07-24)。
|
|
3
|
+
*
|
|
4
|
+
* 背景 (WHY): claude の連携カードは注入時の live イベント (queue_state.startedSources) にだけ
|
|
5
|
+
* source が乗り、会話履歴 (jsonl) には書かれない。そのため reattach / cold resume / 別端末
|
|
6
|
+
* (スマホ) で開くと履歴 hydrate 由来の user バブルには source が無く、カードが青バブルへ戻る。
|
|
7
|
+
* 「履歴でも連携カードのまま残したい」(ユーザー要望 2026-07-24) を満たすため、注入本文と source を
|
|
8
|
+
* cwd 単位でサイドカー (`~/.hub/inject-sources.json`) に永続化し、hydrate 時に本文一致で付け直す。
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 設計 (cross-review 指摘への対応):
|
|
11
|
+
* - **隔離**: cwd 単位でリストを分ける (別 workspace の注入と混ざらない)。
|
|
12
|
+
* - **相関**: 注入本文の完全一致 (footer 込みで一意性が高い)。ID メタ経路が無い claude の制約下で
|
|
13
|
+
* live 経路 (queue_state) と同じ相関方式に揃える。衝突時も誤タグ止まりでクラッシュしない。
|
|
14
|
+
* - **失敗は永続化しない** (persist round1 major#1): 呼び出し側 (claude-stream-bridge) が
|
|
15
|
+
* sendMessage 受理時 (accepted!==false) のみ record を呼ぶ。reject/accepted:false では呼ばない
|
|
16
|
+
* ため、30 日 stale が残って後続同文 human を誤カード化する事故が起きない。
|
|
17
|
+
* - **TTL / 上限** (persist round1 major#2): MAX_AGE_MS 経過 entry は **record 時だけでなく load 時と
|
|
18
|
+
* lookup 時にも** prune する (cold resume / 非活性 cwd の期限切れを永久 lookup しない)。
|
|
19
|
+
* per-cwd 上限 (MAX_PER_CWD) + cwd 総数上限 (MAX_CWDS、最終活性が古い cwd を退避) で無限成長を防ぐ。
|
|
20
|
+
* - **並行/権限** (persist round1 minor): tmp 名は pid + 連番でユニーク化 (複数 process 競合回避)、
|
|
21
|
+
* ファイル 0600 / ディレクトリ 0700 で本文 (平文) の権限を絞る (jsonl と同等の秘匿レベル)。
|
|
22
|
+
* - **書き込み**: 1s デバウンス + atomic write (tmp+rename)。単一 hub-agent プロセス内は event loop で
|
|
23
|
+
* 直列化。デバウンス中のプロセス死で最後の数件がロストしても live 表示は無傷 (hydrate だけ劣化)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFile, writeFile, rename, mkdir } from "node:fs/promises"
|
|
27
|
+
import path from "node:path"
|
|
28
|
+
import os from "node:os"
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PATH = path.join(os.homedir() || "/tmp", ".hub", "inject-sources.json")
|
|
31
|
+
export const STORE_PATH = process.env.HUB_INJECT_SOURCE_STORE || DEFAULT_PATH
|
|
32
|
+
const MAX_PER_CWD = 500
|
|
33
|
+
const MAX_CWDS = 200
|
|
34
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 日
|
|
35
|
+
const WRITE_DEBOUNCE_MS = 1000
|
|
36
|
+
|
|
37
|
+
/** @type {Map<string, Array<{text: string, source: 'peer'|'orchestrator', at: number}>> | null} */
|
|
38
|
+
let _cache = null
|
|
39
|
+
let _writeScheduled = false
|
|
40
|
+
let _dirty = false
|
|
41
|
+
let _writeChain = Promise.resolve()
|
|
42
|
+
let _loadPromise = null
|
|
43
|
+
let _tmpCounter = 0
|
|
44
|
+
|
|
45
|
+
// round2 minor: at が数値でない entry は「期限切れ」扱いにして prune する (欠落を永久 fresh 扱い
|
|
46
|
+
// にするとリークする)。正常な record は必ず at を付けるので、これは破損/外部書き込み対策。
|
|
47
|
+
function isFresh(entry, nowMs) {
|
|
48
|
+
return typeof entry?.at === "number" && nowMs - entry.at < MAX_AGE_MS
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** cwd リストを TTL + 件数上限で prune する (新しい順を保つため末尾側を残す)。 */
|
|
52
|
+
function pruneList(list, nowMs) {
|
|
53
|
+
let out = list.filter((e) => isFresh(e, nowMs))
|
|
54
|
+
if (out.length > MAX_PER_CWD) out = out.slice(out.length - MAX_PER_CWD)
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** cwd 総数上限: 空リスト cwd を落とし、超過分は「最終 entry が最も古い cwd」から退避する。 */
|
|
59
|
+
function enforceCwdCap(cache) {
|
|
60
|
+
for (const [cwd, list] of cache) if (!list.length) cache.delete(cwd)
|
|
61
|
+
if (cache.size <= MAX_CWDS) return
|
|
62
|
+
const byRecency = [...cache.entries()].sort((a, b) => {
|
|
63
|
+
const at = (l) => (l.length ? l[l.length - 1].at || 0 : 0)
|
|
64
|
+
return at(a[1]) - at(b[1])
|
|
65
|
+
})
|
|
66
|
+
const drop = cache.size - MAX_CWDS
|
|
67
|
+
for (let i = 0; i < drop; i++) cache.delete(byRecency[i][0])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 全 cwd を prune する (load / 定期整理用)。変化があれば true。 */
|
|
71
|
+
function pruneAll(cache, nowMs) {
|
|
72
|
+
let changed = false
|
|
73
|
+
for (const [cwd, list] of cache) {
|
|
74
|
+
const pruned = pruneList(list, nowMs)
|
|
75
|
+
if (pruned.length !== list.length) changed = true
|
|
76
|
+
cache.set(cwd, pruned)
|
|
77
|
+
}
|
|
78
|
+
const before = cache.size
|
|
79
|
+
enforceCwdCap(cache)
|
|
80
|
+
if (cache.size !== before) changed = true
|
|
81
|
+
return changed
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** サイドカーを 1 度だけ読み込み in-memory 化する (load 時に prune する)。 */
|
|
85
|
+
async function ensureLoaded() {
|
|
86
|
+
if (_cache) return
|
|
87
|
+
if (_loadPromise) return _loadPromise
|
|
88
|
+
_loadPromise = (async () => {
|
|
89
|
+
const cache = new Map()
|
|
90
|
+
try {
|
|
91
|
+
const raw = await readFile(STORE_PATH, "utf-8")
|
|
92
|
+
const obj = JSON.parse(raw)
|
|
93
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
94
|
+
for (const [cwd, list] of Object.entries(obj)) {
|
|
95
|
+
if (!Array.isArray(list)) continue
|
|
96
|
+
const clean = list.filter(
|
|
97
|
+
(e) =>
|
|
98
|
+
e &&
|
|
99
|
+
typeof e.text === "string" &&
|
|
100
|
+
(e.source === "peer" || e.source === "orchestrator"),
|
|
101
|
+
)
|
|
102
|
+
if (clean.length) cache.set(cwd, clean)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
/* 初回はファイル無し = 空 */
|
|
107
|
+
}
|
|
108
|
+
// major#2: load 時に期限切れを prune する (cold resume / 非活性 cwd を永久 lookup しない)。
|
|
109
|
+
if (pruneAll(cache, Date.now())) {
|
|
110
|
+
_cache = cache
|
|
111
|
+
scheduleWrite()
|
|
112
|
+
} else {
|
|
113
|
+
_cache = cache
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
await _loadPromise
|
|
117
|
+
_loadPromise = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scheduleWrite() {
|
|
121
|
+
_dirty = true
|
|
122
|
+
if (_writeScheduled) return
|
|
123
|
+
_writeScheduled = true
|
|
124
|
+
const t = setTimeout(() => {
|
|
125
|
+
_writeScheduled = false
|
|
126
|
+
// round2 minor (write race): 書き込みを **promise チェーンで直列化**する。デバウンス満了時に
|
|
127
|
+
// _dirty を見て 1 回だけ実行し、実行時点の _cache を snapshot する。これで「遅い I/O の最中に
|
|
128
|
+
// 後続 timer が発火し、古い snapshot の rename が新しい snapshot を後着上書きする」逆転を防ぐ
|
|
129
|
+
// (rename が重ならない = 直列)。実行中に更に record されたら _dirty が立ち、チェーン末尾で再実行。
|
|
130
|
+
_writeChain = _writeChain.then(async () => {
|
|
131
|
+
if (!_dirty) return
|
|
132
|
+
_dirty = false
|
|
133
|
+
try {
|
|
134
|
+
const obj = {}
|
|
135
|
+
if (_cache) for (const [cwd, list] of _cache) if (list.length) obj[cwd] = list
|
|
136
|
+
await mkdir(path.dirname(STORE_PATH), { recursive: true, mode: 0o700 })
|
|
137
|
+
// minor: tmp 名を pid + 連番でユニーク化 (複数 process 同時書き込みの取り違え防止)。
|
|
138
|
+
const tmp = `${STORE_PATH}.${process.pid}.${_tmpCounter++}.tmp`
|
|
139
|
+
// minor: 本文 (平文) を含むため 0600 で書く (jsonl と同等の秘匿レベル)。
|
|
140
|
+
await writeFile(tmp, JSON.stringify(obj), { mode: 0o600 })
|
|
141
|
+
await rename(tmp, STORE_PATH)
|
|
142
|
+
} catch {
|
|
143
|
+
/* best effort: 永続化失敗は live カードには影響しない */
|
|
144
|
+
}
|
|
145
|
+
// 実行中に record されて再度 dirty になっていたら、次の debounce を張って追従する。
|
|
146
|
+
if (_dirty && !_writeScheduled) scheduleWrite()
|
|
147
|
+
})
|
|
148
|
+
}, WRITE_DEBOUNCE_MS)
|
|
149
|
+
t.unref?.()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 注入が **受理された後** に呼ぶ: この cwd の注入本文と source を永続化する (hydrate で付け直す)。
|
|
153
|
+
* ⚠️ 呼び出し側は sendMessage 受理時のみ呼ぶこと (失敗を永続化しないため。round1 major#1)。
|
|
154
|
+
* @param {string} cwd
|
|
155
|
+
* @param {string} text 注入本文 (jsonl の user メッセージ本文と一致する)
|
|
156
|
+
* @param {'peer'|'orchestrator'|undefined} source
|
|
157
|
+
* @param {number} [nowMs] */
|
|
158
|
+
export async function recordInjectSourcePersistent(cwd, text, source, nowMs = Date.now()) {
|
|
159
|
+
if (!cwd || !text || (source !== "peer" && source !== "orchestrator")) return
|
|
160
|
+
await ensureLoaded()
|
|
161
|
+
const list = _cache.get(cwd) || []
|
|
162
|
+
list.push({ text, source, at: nowMs })
|
|
163
|
+
_cache.set(cwd, pruneList(list, nowMs))
|
|
164
|
+
enforceCwdCap(_cache)
|
|
165
|
+
scheduleWrite()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** hydrate 時に呼ぶ: この cwd で本文が (期限内の) 注入と一致すれば source を返す (最新一致優先)。
|
|
169
|
+
* 期限切れ entry はここでも lazy prune する (major#2)。
|
|
170
|
+
* @param {string} cwd
|
|
171
|
+
* @param {string} text
|
|
172
|
+
* @returns {Promise<'peer'|'orchestrator'|null>} */
|
|
173
|
+
export async function lookupInjectSource(cwd, text) {
|
|
174
|
+
if (!cwd || !text) return null
|
|
175
|
+
await ensureLoaded()
|
|
176
|
+
const list = _cache.get(cwd)
|
|
177
|
+
if (!list || !list.length) return null
|
|
178
|
+
const nowMs = Date.now()
|
|
179
|
+
let hit = null
|
|
180
|
+
let hasStale = false
|
|
181
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
182
|
+
const e = list[i]
|
|
183
|
+
if (!isFresh(e, nowMs)) {
|
|
184
|
+
hasStale = true
|
|
185
|
+
continue // 期限切れは一致とみなさない
|
|
186
|
+
}
|
|
187
|
+
if (hit === null && e.text === text) hit = e.source
|
|
188
|
+
}
|
|
189
|
+
if (hasStale) {
|
|
190
|
+
// 期限切れを取り除いて次回以降の lookup/成長を抑える。
|
|
191
|
+
const pruned = list.filter((e) => isFresh(e, nowMs))
|
|
192
|
+
if (pruned.length) _cache.set(cwd, pruned)
|
|
193
|
+
else _cache.delete(cwd)
|
|
194
|
+
scheduleWrite()
|
|
195
|
+
}
|
|
196
|
+
return hit
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** テスト用: in-memory キャッシュを破棄して次回 ensureLoaded を強制する。 */
|
|
200
|
+
export function _resetForTest() {
|
|
201
|
+
_cache = null
|
|
202
|
+
_loadPromise = null
|
|
203
|
+
_writeScheduled = false
|
|
204
|
+
_dirty = false
|
|
205
|
+
_writeChain = Promise.resolve()
|
|
206
|
+
}
|