instar 1.2.67 → 1.2.69

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.
Files changed (68) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +50 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +9 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/PostUpdateMigrator.d.ts +12 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +138 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/SessionManager.d.ts.map +1 -1
  12. package/dist/core/SessionManager.js +3 -0
  13. package/dist/core/SessionManager.js.map +1 -1
  14. package/dist/core/StandardsRegistryParser.d.ts +50 -0
  15. package/dist/core/StandardsRegistryParser.d.ts.map +1 -0
  16. package/dist/core/StandardsRegistryParser.js +120 -0
  17. package/dist/core/StandardsRegistryParser.js.map +1 -0
  18. package/dist/core/reviewers/standards-conformance.d.ts +49 -0
  19. package/dist/core/reviewers/standards-conformance.d.ts.map +1 -0
  20. package/dist/core/reviewers/standards-conformance.js +120 -0
  21. package/dist/core/reviewers/standards-conformance.js.map +1 -0
  22. package/dist/core/types.d.ts +10 -0
  23. package/dist/core/types.d.ts.map +1 -1
  24. package/dist/core/types.js.map +1 -1
  25. package/dist/providers/adapters/anthropic-headless/transport/agenticSessionHeadless.d.ts.map +1 -1
  26. package/dist/providers/adapters/anthropic-headless/transport/agenticSessionHeadless.js +1 -0
  27. package/dist/providers/adapters/anthropic-headless/transport/agenticSessionHeadless.js.map +1 -1
  28. package/dist/providers/adapters/openai-codex/transport/agenticSessionHeadless.d.ts.map +1 -1
  29. package/dist/providers/adapters/openai-codex/transport/agenticSessionHeadless.js +1 -0
  30. package/dist/providers/adapters/openai-codex/transport/agenticSessionHeadless.js.map +1 -1
  31. package/dist/providers/adapters/openai-codex/transport/codexSpawn.d.ts +2 -0
  32. package/dist/providers/adapters/openai-codex/transport/codexSpawn.d.ts.map +1 -1
  33. package/dist/providers/adapters/openai-codex/transport/codexSpawn.js +4 -0
  34. package/dist/providers/adapters/openai-codex/transport/codexSpawn.js.map +1 -1
  35. package/dist/server/AgentServer.d.ts +6 -0
  36. package/dist/server/AgentServer.d.ts.map +1 -1
  37. package/dist/server/AgentServer.js +21 -0
  38. package/dist/server/AgentServer.js.map +1 -1
  39. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  40. package/dist/server/CapabilityIndex.js +1 -0
  41. package/dist/server/CapabilityIndex.js.map +1 -1
  42. package/dist/server/routes.d.ts +6 -0
  43. package/dist/server/routes.d.ts.map +1 -1
  44. package/dist/server/routes.js +60 -1
  45. package/dist/server/routes.js.map +1 -1
  46. package/dist/server/specReviewRoutes.d.ts +32 -0
  47. package/dist/server/specReviewRoutes.d.ts.map +1 -0
  48. package/dist/server/specReviewRoutes.js +120 -0
  49. package/dist/server/specReviewRoutes.js.map +1 -0
  50. package/dist/threadline/ConversationStore.d.ts +158 -0
  51. package/dist/threadline/ConversationStore.d.ts.map +1 -0
  52. package/dist/threadline/ConversationStore.js +341 -0
  53. package/dist/threadline/ConversationStore.js.map +1 -0
  54. package/dist/threadline/ThreadlineRouter.d.ts.map +1 -1
  55. package/dist/threadline/ThreadlineRouter.js +24 -0
  56. package/dist/threadline/ThreadlineRouter.js.map +1 -1
  57. package/dist/threadline/WarrantsReplyGate.d.ts +110 -0
  58. package/dist/threadline/WarrantsReplyGate.d.ts.map +1 -0
  59. package/dist/threadline/WarrantsReplyGate.js +263 -0
  60. package/dist/threadline/WarrantsReplyGate.js.map +1 -0
  61. package/dist/threadline/mcp-http-client.d.ts.map +1 -1
  62. package/dist/threadline/mcp-http-client.js +6 -0
  63. package/dist/threadline/mcp-http-client.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/data/builtin-manifest.json +63 -63
  66. package/upgrades/1.2.68.md +97 -0
  67. package/upgrades/1.2.69.md +73 -0
  68. package/upgrades/side-effects/standards-conformance-gate.md +87 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Spec-review HTTP routes — the standards-conformance gate surface.
3
+ *
4
+ * POST /spec/conformance-check — check a spec against the constitution → report
5
+ * GET /spec/conformance-metrics — observability funnel (runs, per-standard flags)
6
+ *
7
+ * Signal-only (spec §4): returns a report; never blocks. Operator/skill-callable;
8
+ * classified INTERNAL (build-time tool, not an agent-discoverable runtime capability).
9
+ *
10
+ * Spec: docs/specs/standards-conformance-gate.md §3, §5.
11
+ */
12
+ import { Router } from 'express';
13
+ import type { IntelligenceProvider } from '../core/types.js';
14
+ export declare function createSpecReviewRoutes(deps: {
15
+ intelligence: IntelligenceProvider | null;
16
+ /** Path to docs/STANDARDS-REGISTRY.md (the constitution). */
17
+ registryPath: string;
18
+ /** Directory specs are read from; specPath inputs are resolved within it (no traversal). */
19
+ specsDir: string;
20
+ /** Where to persist conformance metrics. */
21
+ stateDir: string;
22
+ /** Master switch; default true. When false, routes 503-stub. */
23
+ enabled?: boolean;
24
+ /** Model tier override (default 'capable'). */
25
+ model?: 'fast' | 'balanced' | 'capable';
26
+ }): Router;
27
+ /** Exposed for the CLI (no HTTP): run a conformance check on raw markdown. */
28
+ export declare function runConformanceCheck(markdown: string, registryMarkdown: string, intelligence: IntelligenceProvider | null, model?: 'fast' | 'balanced' | 'capable'): Promise<{
29
+ report: import("../core/reviewers/standards-conformance.js").ConformanceReport;
30
+ registryCanary: import("../core/StandardsRegistryParser.js").RegistryCanaryResult;
31
+ }>;
32
+ //# sourceMappingURL=specReviewRoutes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specReviewRoutes.d.ts","sourceRoot":"","sources":["../../src/server/specReviewRoutes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAIjC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAsC7D,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,YAAY,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAC1C,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,4FAA4F;IAC5F,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,+CAA+C;IAC/C,KAAK,CAAC,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;CACzC,GAAG,MAAM,CAiET;AAED,8EAA8E;AAC9E,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,MAAM,EACxB,YAAY,EAAE,oBAAoB,GAAG,IAAI,EACzC,KAAK,CAAC,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;;;GAOxC"}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Spec-review HTTP routes — the standards-conformance gate surface.
3
+ *
4
+ * POST /spec/conformance-check — check a spec against the constitution → report
5
+ * GET /spec/conformance-metrics — observability funnel (runs, per-standard flags)
6
+ *
7
+ * Signal-only (spec §4): returns a report; never blocks. Operator/skill-callable;
8
+ * classified INTERNAL (build-time tool, not an agent-discoverable runtime capability).
9
+ *
10
+ * Spec: docs/specs/standards-conformance-gate.md §3, §5.
11
+ */
12
+ import { Router } from 'express';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { loadStandardsRegistry, parseStandardsRegistry, runRegistryCanary } from '../core/StandardsRegistryParser.js';
16
+ import { StandardsConformanceReviewer } from '../core/reviewers/standards-conformance.js';
17
+ function emptyMetrics() {
18
+ return { runs: 0, degraded: 0, findings_total: 0, by_standard: {}, last_run_at: null };
19
+ }
20
+ function loadMetrics(file) {
21
+ try {
22
+ if (fs.existsSync(file)) {
23
+ const m = JSON.parse(fs.readFileSync(file, 'utf-8'));
24
+ return { ...emptyMetrics(), ...m, by_standard: m.by_standard ?? {} };
25
+ }
26
+ }
27
+ catch { /* corrupt → fresh */ }
28
+ return emptyMetrics();
29
+ }
30
+ function saveMetrics(file, m) {
31
+ try {
32
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
33
+ fs.writeFileSync(tmp, JSON.stringify(m, null, 2));
34
+ fs.renameSync(tmp, file);
35
+ }
36
+ catch (err) {
37
+ console.error(`[specReviewRoutes] metrics save failed: ${err}`);
38
+ }
39
+ }
40
+ export function createSpecReviewRoutes(deps) {
41
+ const router = Router();
42
+ const enabled = deps.enabled !== false;
43
+ const metricsFile = path.join(deps.stateDir, 'spec-conformance-metrics.json');
44
+ const reviewer = new StandardsConformanceReviewer(deps.intelligence, { model: deps.model });
45
+ if (!enabled) {
46
+ router.use('/spec', (_req, res) => res.status(503).json({ error: 'spec conformance gate disabled' }));
47
+ return router;
48
+ }
49
+ /** Resolve a caller-supplied specPath safely within specsDir (block traversal). */
50
+ function resolveSpecPath(specPath) {
51
+ const resolved = path.resolve(deps.specsDir, specPath);
52
+ const base = path.resolve(deps.specsDir);
53
+ if (resolved !== base && !resolved.startsWith(base + path.sep))
54
+ return null;
55
+ return resolved;
56
+ }
57
+ router.post('/spec/conformance-check', async (req, res) => {
58
+ let markdown;
59
+ if (typeof req.body?.markdown === 'string' && req.body.markdown.trim()) {
60
+ markdown = req.body.markdown;
61
+ }
62
+ else if (typeof req.body?.specPath === 'string' && req.body.specPath.trim()) {
63
+ const p = resolveSpecPath(req.body.specPath);
64
+ if (!p)
65
+ return res.status(400).json({ error: 'specPath escapes specsDir' });
66
+ if (!fs.existsSync(p))
67
+ return res.status(404).json({ error: 'spec not found' });
68
+ try {
69
+ markdown = fs.readFileSync(p, 'utf-8');
70
+ }
71
+ catch (err) {
72
+ return res.status(500).json({ error: `read failed: ${err.message}` });
73
+ }
74
+ }
75
+ else {
76
+ return res.status(400).json({ error: 'provide markdown or specPath' });
77
+ }
78
+ if (typeof markdown !== 'string' || !markdown.trim()) {
79
+ return res.status(400).json({ error: 'empty spec content' });
80
+ }
81
+ // Load + canary the constitution; a drifted/partial registry must not silently
82
+ // produce a misleadingly-clean report.
83
+ let articles;
84
+ try {
85
+ articles = loadStandardsRegistry(deps.registryPath);
86
+ }
87
+ catch (err) {
88
+ return res.status(503).json({ error: `constitution unreadable: ${err.message}` });
89
+ }
90
+ const canary = runRegistryCanary(articles);
91
+ const report = await reviewer.review(markdown, articles);
92
+ // Record metrics (best-effort).
93
+ try {
94
+ const m = loadMetrics(metricsFile);
95
+ m.runs += 1;
96
+ if (report.degraded)
97
+ m.degraded += 1;
98
+ m.findings_total += report.findings.length;
99
+ for (const f of report.findings)
100
+ m.by_standard[f.standard] = (m.by_standard[f.standard] ?? 0) + 1;
101
+ m.last_run_at = report.checkedAt;
102
+ saveMetrics(metricsFile, m);
103
+ }
104
+ catch { /* metering best-effort */ }
105
+ res.json({ report, registryCanary: canary });
106
+ });
107
+ router.get('/spec/conformance-metrics', (_req, res) => {
108
+ res.json({ metrics: loadMetrics(metricsFile) });
109
+ });
110
+ return router;
111
+ }
112
+ /** Exposed for the CLI (no HTTP): run a conformance check on raw markdown. */
113
+ export async function runConformanceCheck(markdown, registryMarkdown, intelligence, model) {
114
+ const articles = parseStandardsRegistry(registryMarkdown);
115
+ const canary = runRegistryCanary(articles);
116
+ const reviewer = new StandardsConformanceReviewer(intelligence, { model });
117
+ const report = await reviewer.review(markdown, articles);
118
+ return { report, registryCanary: canary };
119
+ }
120
+ //# sourceMappingURL=specReviewRoutes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specReviewRoutes.js","sourceRoot":"","sources":["../../src/server/specReviewRoutes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ConversationStore — the single source of truth for a Threadline conversation.
3
+ *
4
+ * Phase 1 of the Threadline re-assessment (THREADLINE-CONVERSATION-KEYSTONE-SPEC.md).
5
+ * Collapses the state previously smeared across `ThreadResumeMap`,
6
+ * `ContextThreadMap`, the in-memory peer-affinity map and `inbox.jsonl` into one
7
+ * durable record keyed by `threadId`. It is the ONLY place conversation turn
8
+ * state lives — the one-shot reply worker provably cannot self-police a loop, so
9
+ * the turn count + novelty hashes must live here, on the conversation.
10
+ *
11
+ * Concurrency: every write goes through `mutate(threadId, fn)`, a single-writer
12
+ * surface modeled on `CommitmentTracker.mutate()` — a per-threadId FIFO queue
13
+ * plus optimistic CAS on the record's `version`. This is the fix for the
14
+ * convergence-flagged race where two near-simultaneous inbound messages (or a
15
+ * live-inject racing a resume) would clobber `turnCount`/`lastOutboundHash` under
16
+ * the legacy last-writer-wins `load→mutate→persist`.
17
+ *
18
+ * Storage: {stateDir}/threadline/conversations.json (server-write-only, atomic
19
+ * tmp+rename). NOT relay-hosted — authoritative state stays local. The
20
+ * append-only SharedStateLedger remains the audit trail; this is the live
21
+ * mutable state it logs transitions from.
22
+ */
23
+ /**
24
+ * Conversation lifecycle state. Superset of the legacy `ThreadState`
25
+ * (`active|idle|resolved|failed|archived`) plus `open` (created, not yet
26
+ * worked) and `awaiting-reply` (we replied, waiting on the peer). `idle` is
27
+ * also the warrants-a-reply gate's "suppressed, do not spawn" terminal-ish
28
+ * state.
29
+ */
30
+ export type ConversationState = 'open' | 'active' | 'idle' | 'awaiting-reply' | 'resolved' | 'failed' | 'archived';
31
+ /**
32
+ * The durable conversation record. EXHAUSTIVE against the legacy stores — an
33
+ * incomplete field list silently drops live data on migration (convergence
34
+ * finding). Every field the four legacy stores held is preserved here.
35
+ */
36
+ export interface Conversation {
37
+ /** Primary key — the Threadline thread id. */
38
+ threadId: string;
39
+ /** Monotonic version for optimistic CAS in mutate(). */
40
+ version: number;
41
+ /** Conversation participants — self + peer fingerprint(s). */
42
+ participants: {
43
+ /** This agent's name/fingerprint (best-effort). */
44
+ self?: string;
45
+ /** Remote peer fingerprint(s). */
46
+ peers: string[];
47
+ };
48
+ /** Convenience display handle for the primary remote peer. */
49
+ remoteAgent?: string;
50
+ /** Lifecycle state. */
51
+ state: ConversationState;
52
+ /** When state became 'resolved' (grace-period clock). */
53
+ resolvedAt?: string;
54
+ /** The Claude/Codex session UUID the resume primitive depends on. */
55
+ sessionUuid?: string;
56
+ /** The live (or last-known) tmux session bound to this conversation. */
57
+ boundSessionName?: string;
58
+ /** Owning Telegram topic (formerly originTopicId). */
59
+ boundTopicId?: number;
60
+ /** Originating topic-session name at send time (fast-path resume cache). */
61
+ originSessionName?: string;
62
+ /** Spawn mode of the worker handling this conversation. */
63
+ spawnMode?: 'interactive' | 'pipe';
64
+ /** Thread subject. */
65
+ subject?: string;
66
+ /** A2A contextId mapped to this thread, if any. */
67
+ contextId?: string;
68
+ /**
69
+ * The authenticated agent identity that owns the contextId binding. Dropping
70
+ * this reopens the session-smuggling vector ContextThreadMap defends.
71
+ */
72
+ agentIdentity?: string;
73
+ pinned: boolean;
74
+ messageCount: number;
75
+ machineOrigin?: string;
76
+ migratedTo?: string;
77
+ migrateFrom?: string;
78
+ turnCount: number;
79
+ lastInboundHash?: string;
80
+ lastOutboundHash?: string;
81
+ createdAt: string;
82
+ savedAt: string;
83
+ lastActivityAt: string;
84
+ /** Snapshot of the unified-trust level at last contact. */
85
+ trustLevel?: string;
86
+ /** Snapshot of the MoltBridge IQS band at last contact. */
87
+ iqsBand?: string;
88
+ }
89
+ /** Mutation function: receives a draft clone, returns the next record. */
90
+ export type ConversationMutateFn = (draft: Conversation) => Conversation | Promise<Conversation>;
91
+ export declare class ConversationStore {
92
+ private filePath;
93
+ private store;
94
+ /** Per-threadId FIFO mutate queues — serialize concurrent writers. */
95
+ private mutateQueues;
96
+ private mutateRunning;
97
+ /** Ephemeral verified-only peer-affinity hints (peerFingerprint → hint). */
98
+ private affinity;
99
+ constructor(stateDir: string);
100
+ /** Get a conversation by threadId. Returns null if missing or TTL-expired. */
101
+ get(threadId: string): Conversation | null;
102
+ /** True if a (non-expired) conversation exists for the threadId. */
103
+ has(threadId: string): boolean;
104
+ /** Find conversations whose peer set includes the given fingerprint/name. */
105
+ getByParticipant(participant: string): Conversation[];
106
+ /** Find the conversation bound to a Telegram topic, if any. */
107
+ getByTopicId(topicId: number): Conversation | null;
108
+ /** Reverse lookup: conversation owning an A2A contextId (identity-bound). */
109
+ getByContextId(contextId: string, agentIdentity: string): Conversation | null;
110
+ /** List active/idle conversations (not resolved/failed/archived). */
111
+ listActive(): Conversation[];
112
+ /** Total stored conversations (for monitoring). */
113
+ size(): number;
114
+ /**
115
+ * Single-writer mutate surface. Every write path routes through here so
116
+ * concurrent writers (the inbound funnel gate, the router resume/spawn, the
117
+ * relay-send binding stamp) can't clobber each other.
118
+ *
119
+ * Contract (mirrors CommitmentTracker.mutate):
120
+ * - FIFO queue per threadId, max depth 256.
121
+ * - Optimistic CAS on `version`: read → fn(clone) → write iff version
122
+ * unchanged, else retry (max 5). On success version is incremented and
123
+ * the store is persisted atomically.
124
+ * - If the conversation does not exist yet, the fn receives a fresh skeleton
125
+ * (state 'open', version 0) so callers can upsert in one call.
126
+ */
127
+ mutate(threadId: string, fn: ConversationMutateFn): Promise<Conversation>;
128
+ private drainMutateQueue;
129
+ private applyMutationWithCAS;
130
+ /**
131
+ * Direct upsert of a full record WITHOUT going through the CAS queue. ONLY
132
+ * for migration / bulk import where there are no concurrent writers. Runtime
133
+ * writers MUST use mutate().
134
+ */
135
+ importDirect(conversation: Conversation): void;
136
+ /** Persist after a batch of importDirect calls. */
137
+ flush(): void;
138
+ /** Remove a conversation. */
139
+ remove(threadId: string): void;
140
+ /**
141
+ * Record a short-lived, verified-only affinity hint (peer → most-recent
142
+ * thread). Deliberately NOT persisted (accepted loss on restart). Callers
143
+ * MUST only call this for VERIFIED peers — an unverified affinity would be a
144
+ * hijack vector.
145
+ */
146
+ recordAffinity(peerFingerprint: string, threadId: string): void;
147
+ /** Look up a fresh affinity hint, honoring the sliding + absolute windows. */
148
+ getAffinity(peerFingerprint: string): string | null;
149
+ /** Prune expired + resolved-past-grace + LRU-overflow (non-pinned). */
150
+ prune(): void;
151
+ private skeleton;
152
+ private isExpired;
153
+ private pruneIfNeeded;
154
+ private pruneMap;
155
+ private loadStore;
156
+ private saveStore;
157
+ }
158
+ //# sourceMappingURL=ConversationStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ConversationStore.d.ts","sourceRoot":"","sources":["../../src/threadline/ConversationStore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,341 @@
1
+ /**
2
+ * ConversationStore — the single source of truth for a Threadline conversation.
3
+ *
4
+ * Phase 1 of the Threadline re-assessment (THREADLINE-CONVERSATION-KEYSTONE-SPEC.md).
5
+ * Collapses the state previously smeared across `ThreadResumeMap`,
6
+ * `ContextThreadMap`, the in-memory peer-affinity map and `inbox.jsonl` into one
7
+ * durable record keyed by `threadId`. It is the ONLY place conversation turn
8
+ * state lives — the one-shot reply worker provably cannot self-police a loop, so
9
+ * the turn count + novelty hashes must live here, on the conversation.
10
+ *
11
+ * Concurrency: every write goes through `mutate(threadId, fn)`, a single-writer
12
+ * surface modeled on `CommitmentTracker.mutate()` — a per-threadId FIFO queue
13
+ * plus optimistic CAS on the record's `version`. This is the fix for the
14
+ * convergence-flagged race where two near-simultaneous inbound messages (or a
15
+ * live-inject racing a resume) would clobber `turnCount`/`lastOutboundHash` under
16
+ * the legacy last-writer-wins `load→mutate→persist`.
17
+ *
18
+ * Storage: {stateDir}/threadline/conversations.json (server-write-only, atomic
19
+ * tmp+rename). NOT relay-hosted — authoritative state stays local. The
20
+ * append-only SharedStateLedger remains the audit trail; this is the live
21
+ * mutable state it logs transitions from.
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ // ── Constants ───────────────────────────────────────────────────
26
+ /** Entries older than 7 days are pruned (non-pinned only) — matches legacy TTL. */
27
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
28
+ /** Resolved conversations get a 7-day grace before removal — matches legacy. */
29
+ const RESOLVED_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
30
+ /** Cap before LRU eviction of non-pinned entries — matches ThreadResumeMap. */
31
+ const MAX_ENTRIES = 1000;
32
+ /** Max depth of the per-id mutate queue. Enqueue beyond this rejects. */
33
+ const MUTATE_QUEUE_MAX_DEPTH = 256;
34
+ /** Max CAS retries when the version drifts under an apply. */
35
+ const MUTATE_CAS_MAX_RETRIES = 5;
36
+ // ── Ephemeral verified-only peer-affinity (NOT persisted) ────────
37
+ //
38
+ // The legacy peer-affinity map is a SHORT (10-min sliding / 2-hr absolute),
39
+ // verified-only, deliberately ephemeral hint — promoting it to a durable,
40
+ // unverified binding would reopen a hijack vector (spec §1). We keep it in
41
+ // memory on the store so it is lost on restart by design (accepted loss).
42
+ const AFFINITY_SLIDING_MS = 10 * 60 * 1000;
43
+ const AFFINITY_ABSOLUTE_MS = 2 * 60 * 60 * 1000;
44
+ // ── Implementation ──────────────────────────────────────────────
45
+ export class ConversationStore {
46
+ filePath;
47
+ store;
48
+ /** Per-threadId FIFO mutate queues — serialize concurrent writers. */
49
+ mutateQueues = new Map();
50
+ mutateRunning = new Set();
51
+ /** Ephemeral verified-only peer-affinity hints (peerFingerprint → hint). */
52
+ affinity = new Map();
53
+ constructor(stateDir) {
54
+ const threadlineDir = path.join(stateDir, 'threadline');
55
+ fs.mkdirSync(threadlineDir, { recursive: true });
56
+ this.filePath = path.join(threadlineDir, 'conversations.json');
57
+ this.store = this.loadStore();
58
+ }
59
+ // ── Reads (synchronous, from in-memory store) ──────────────────
60
+ /** Get a conversation by threadId. Returns null if missing or TTL-expired. */
61
+ get(threadId) {
62
+ const c = this.store.conversations[threadId];
63
+ if (!c)
64
+ return null;
65
+ if (!c.pinned && this.isExpired(c))
66
+ return null;
67
+ return c;
68
+ }
69
+ /** True if a (non-expired) conversation exists for the threadId. */
70
+ has(threadId) {
71
+ return this.get(threadId) !== null;
72
+ }
73
+ /** Find conversations whose peer set includes the given fingerprint/name. */
74
+ getByParticipant(participant) {
75
+ const out = [];
76
+ for (const c of Object.values(this.store.conversations)) {
77
+ if (c.pinned || !this.isExpired(c)) {
78
+ if (c.participants.peers.includes(participant) || c.remoteAgent === participant) {
79
+ out.push(c);
80
+ }
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ /** Find the conversation bound to a Telegram topic, if any. */
86
+ getByTopicId(topicId) {
87
+ for (const c of Object.values(this.store.conversations)) {
88
+ if (c.boundTopicId === topicId && (c.pinned || !this.isExpired(c)))
89
+ return c;
90
+ }
91
+ return null;
92
+ }
93
+ /** Reverse lookup: conversation owning an A2A contextId (identity-bound). */
94
+ getByContextId(contextId, agentIdentity) {
95
+ for (const c of Object.values(this.store.conversations)) {
96
+ if (c.contextId === contextId && (c.pinned || !this.isExpired(c))) {
97
+ // Identity binding — prevents session smuggling (ContextThreadMap parity).
98
+ if (c.agentIdentity && c.agentIdentity !== agentIdentity)
99
+ return null;
100
+ return c;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+ /** List active/idle conversations (not resolved/failed/archived). */
106
+ listActive() {
107
+ const out = [];
108
+ for (const c of Object.values(this.store.conversations)) {
109
+ if ((c.state === 'active' || c.state === 'idle' || c.state === 'open' || c.state === 'awaiting-reply') &&
110
+ (c.pinned || !this.isExpired(c))) {
111
+ out.push(c);
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ /** Total stored conversations (for monitoring). */
117
+ size() {
118
+ return Object.keys(this.store.conversations).length;
119
+ }
120
+ // ── Writes (single-writer CAS) ─────────────────────────────────
121
+ /**
122
+ * Single-writer mutate surface. Every write path routes through here so
123
+ * concurrent writers (the inbound funnel gate, the router resume/spawn, the
124
+ * relay-send binding stamp) can't clobber each other.
125
+ *
126
+ * Contract (mirrors CommitmentTracker.mutate):
127
+ * - FIFO queue per threadId, max depth 256.
128
+ * - Optimistic CAS on `version`: read → fn(clone) → write iff version
129
+ * unchanged, else retry (max 5). On success version is incremented and
130
+ * the store is persisted atomically.
131
+ * - If the conversation does not exist yet, the fn receives a fresh skeleton
132
+ * (state 'open', version 0) so callers can upsert in one call.
133
+ */
134
+ async mutate(threadId, fn) {
135
+ return new Promise((resolve, reject) => {
136
+ let queue = this.mutateQueues.get(threadId);
137
+ if (!queue) {
138
+ queue = [];
139
+ this.mutateQueues.set(threadId, queue);
140
+ }
141
+ if (queue.length >= MUTATE_QUEUE_MAX_DEPTH) {
142
+ reject(new Error(`ConversationStore.mutate: queue full for ${threadId} (depth ${queue.length} >= ${MUTATE_QUEUE_MAX_DEPTH})`));
143
+ return;
144
+ }
145
+ queue.push({ fn, resolve, reject });
146
+ void this.drainMutateQueue(threadId);
147
+ });
148
+ }
149
+ async drainMutateQueue(threadId) {
150
+ if (this.mutateRunning.has(threadId))
151
+ return;
152
+ this.mutateRunning.add(threadId);
153
+ try {
154
+ const queue = this.mutateQueues.get(threadId);
155
+ while (queue && queue.length > 0) {
156
+ const entry = queue.shift();
157
+ try {
158
+ const result = await this.applyMutationWithCAS(threadId, entry.fn);
159
+ entry.resolve(result);
160
+ }
161
+ catch (err) {
162
+ entry.reject(err instanceof Error ? err : new Error(String(err)));
163
+ }
164
+ }
165
+ if (queue && queue.length === 0)
166
+ this.mutateQueues.delete(threadId);
167
+ }
168
+ finally {
169
+ this.mutateRunning.delete(threadId);
170
+ }
171
+ }
172
+ async applyMutationWithCAS(threadId, fn) {
173
+ let attempt = 0;
174
+ while (attempt <= MUTATE_CAS_MAX_RETRIES) {
175
+ const current = this.store.conversations[threadId] ?? this.skeleton(threadId);
176
+ const observedVersion = current.version ?? 0;
177
+ const draft = { ...current, participants: { ...current.participants, peers: [...current.participants.peers] } };
178
+ const next = await fn(draft);
179
+ // CAS: the record's version must not have drifted underneath us.
180
+ const latest = this.store.conversations[threadId];
181
+ const latestVersion = latest?.version ?? (latest ? 0 : observedVersion);
182
+ if (latest && latestVersion !== observedVersion) {
183
+ attempt++;
184
+ continue;
185
+ }
186
+ const committed = {
187
+ ...next,
188
+ threadId,
189
+ version: observedVersion + 1,
190
+ savedAt: new Date().toISOString(),
191
+ };
192
+ this.store.conversations[threadId] = committed;
193
+ this.pruneIfNeeded();
194
+ this.saveStore();
195
+ return committed;
196
+ }
197
+ throw new Error(`ConversationStore.mutate: CAS retry budget exhausted for ${threadId} after ${MUTATE_CAS_MAX_RETRIES} retries`);
198
+ }
199
+ /**
200
+ * Direct upsert of a full record WITHOUT going through the CAS queue. ONLY
201
+ * for migration / bulk import where there are no concurrent writers. Runtime
202
+ * writers MUST use mutate().
203
+ */
204
+ importDirect(conversation) {
205
+ const existing = this.store.conversations[conversation.threadId];
206
+ this.store.conversations[conversation.threadId] = {
207
+ ...conversation,
208
+ version: existing ? Math.max(existing.version ?? 0, conversation.version ?? 0) : (conversation.version ?? 0),
209
+ savedAt: new Date().toISOString(),
210
+ };
211
+ }
212
+ /** Persist after a batch of importDirect calls. */
213
+ flush() {
214
+ this.saveStore();
215
+ }
216
+ /** Remove a conversation. */
217
+ remove(threadId) {
218
+ if (this.store.conversations[threadId]) {
219
+ delete this.store.conversations[threadId];
220
+ this.saveStore();
221
+ }
222
+ }
223
+ // ── Ephemeral verified-only peer affinity (in-memory, non-durable) ──
224
+ /**
225
+ * Record a short-lived, verified-only affinity hint (peer → most-recent
226
+ * thread). Deliberately NOT persisted (accepted loss on restart). Callers
227
+ * MUST only call this for VERIFIED peers — an unverified affinity would be a
228
+ * hijack vector.
229
+ */
230
+ recordAffinity(peerFingerprint, threadId) {
231
+ const now = Date.now();
232
+ const existing = this.affinity.get(peerFingerprint);
233
+ if (existing && existing.threadId === threadId) {
234
+ existing.lastSeen = now;
235
+ }
236
+ else {
237
+ this.affinity.set(peerFingerprint, { threadId, firstSeen: now, lastSeen: now });
238
+ }
239
+ }
240
+ /** Look up a fresh affinity hint, honoring the sliding + absolute windows. */
241
+ getAffinity(peerFingerprint) {
242
+ const hint = this.affinity.get(peerFingerprint);
243
+ if (!hint)
244
+ return null;
245
+ const now = Date.now();
246
+ if (now - hint.lastSeen > AFFINITY_SLIDING_MS || now - hint.firstSeen > AFFINITY_ABSOLUTE_MS) {
247
+ this.affinity.delete(peerFingerprint);
248
+ return null;
249
+ }
250
+ return hint.threadId;
251
+ }
252
+ // ── Maintenance ────────────────────────────────────────────────
253
+ /** Prune expired + resolved-past-grace + LRU-overflow (non-pinned). */
254
+ prune() {
255
+ this.pruneMap();
256
+ this.saveStore();
257
+ }
258
+ // ── Private helpers ────────────────────────────────────────────
259
+ skeleton(threadId) {
260
+ const now = new Date().toISOString();
261
+ return {
262
+ threadId,
263
+ version: 0,
264
+ participants: { peers: [] },
265
+ state: 'open',
266
+ pinned: false,
267
+ messageCount: 0,
268
+ turnCount: 0,
269
+ createdAt: now,
270
+ savedAt: now,
271
+ lastActivityAt: now,
272
+ };
273
+ }
274
+ isExpired(c) {
275
+ const now = Date.now();
276
+ if (c.state === 'resolved' && c.resolvedAt) {
277
+ return now - new Date(c.resolvedAt).getTime() > RESOLVED_GRACE_MS;
278
+ }
279
+ const ref = c.lastActivityAt || c.savedAt;
280
+ return now - new Date(ref).getTime() > MAX_AGE_MS;
281
+ }
282
+ pruneIfNeeded() {
283
+ if (Object.keys(this.store.conversations).length > MAX_ENTRIES)
284
+ this.pruneMap();
285
+ }
286
+ pruneMap() {
287
+ const map = this.store.conversations;
288
+ // Phase 1: drop expired / resolved-past-grace (non-pinned).
289
+ for (const key of Object.keys(map)) {
290
+ const c = map[key];
291
+ if (c.pinned)
292
+ continue;
293
+ if (this.isExpired(c))
294
+ delete map[key];
295
+ }
296
+ // Phase 2: LRU eviction if still over cap.
297
+ const keys = Object.keys(map);
298
+ if (keys.length <= MAX_ENTRIES)
299
+ return;
300
+ const unpinned = [];
301
+ for (const key of keys) {
302
+ const c = map[key];
303
+ if (c.pinned)
304
+ continue;
305
+ unpinned.push({ key, t: new Date(c.lastActivityAt || c.savedAt).getTime() });
306
+ }
307
+ unpinned.sort((a, b) => a.t - b.t);
308
+ const toEvict = keys.length - MAX_ENTRIES;
309
+ for (let i = 0; i < toEvict && i < unpinned.length; i++) {
310
+ delete map[unpinned[i].key];
311
+ }
312
+ }
313
+ loadStore() {
314
+ try {
315
+ if (fs.existsSync(this.filePath)) {
316
+ const data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
317
+ if (data && data.version === 1 && data.conversations && typeof data.conversations === 'object') {
318
+ return data;
319
+ }
320
+ }
321
+ }
322
+ catch {
323
+ // Corrupted — start fresh.
324
+ }
325
+ return { version: 1, conversations: {}, lastModified: new Date().toISOString() };
326
+ }
327
+ saveStore() {
328
+ this.store.lastModified = new Date().toISOString();
329
+ try {
330
+ const dir = path.dirname(this.filePath);
331
+ fs.mkdirSync(dir, { recursive: true });
332
+ const tmpPath = `${this.filePath}.${process.pid}.tmp`;
333
+ fs.writeFileSync(tmpPath, JSON.stringify(this.store, null, 2) + '\n');
334
+ fs.renameSync(tmpPath, this.filePath);
335
+ }
336
+ catch {
337
+ // @silent-fallback-ok — state persistence failure, retried on next write.
338
+ }
339
+ }
340
+ }
341
+ //# sourceMappingURL=ConversationStore.js.map