@martinloop/mcp 0.3.9 → 0.5.0

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 (96) hide show
  1. package/README.md +82 -141
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +1 -1
  4. package/dist/resources.d.ts +1 -2
  5. package/dist/resources.js +47 -95
  6. package/dist/server-validation.d.ts +1 -1
  7. package/dist/server-validation.js +16 -11
  8. package/dist/server.d.ts +23 -0
  9. package/dist/server.js +152 -7
  10. package/dist/tools/preflight.d.ts +2 -0
  11. package/dist/tools/preflight.js +42 -2
  12. package/dist/tools/run-dossier.d.ts +3 -1
  13. package/dist/tools/run-dossier.js +40 -2
  14. package/dist/tools/run-loop.d.ts +1 -4
  15. package/dist/tools/run-loop.js +6 -15
  16. package/dist/tools/tool-support.d.ts +2 -1
  17. package/dist/tools/tool-support.js +1 -12
  18. package/dist/vendor/adapters/claude-cli.js +38 -35
  19. package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
  20. package/dist/vendor/adapters/cli-bridge.js +54 -13
  21. package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
  22. package/dist/vendor/adapters/codex-launcher.js +118 -19
  23. package/dist/vendor/adapters/index.d.ts +1 -3
  24. package/dist/vendor/adapters/index.js +1 -3
  25. package/dist/vendor/adapters/openai-compatible.js +51 -12
  26. package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
  27. package/dist/vendor/contracts/context-handoff.d.ts +100 -0
  28. package/dist/vendor/contracts/context-handoff.js +11 -0
  29. package/dist/vendor/contracts/context-manifest.d.ts +195 -0
  30. package/dist/vendor/contracts/context-manifest.js +17 -0
  31. package/dist/vendor/contracts/context-shadow.d.ts +75 -0
  32. package/dist/vendor/contracts/context-shadow.js +8 -0
  33. package/dist/vendor/contracts/delivery.d.ts +47 -0
  34. package/dist/vendor/contracts/delivery.js +18 -0
  35. package/dist/vendor/contracts/exits.d.ts +126 -0
  36. package/dist/vendor/contracts/exits.js +32 -0
  37. package/dist/vendor/contracts/index.d.ts +41 -2
  38. package/dist/vendor/contracts/index.js +24 -0
  39. package/dist/vendor/contracts/mission.d.ts +102 -0
  40. package/dist/vendor/contracts/mission.js +64 -0
  41. package/dist/vendor/contracts/operator.d.ts +3 -1
  42. package/dist/vendor/contracts/operator.js +2 -1
  43. package/dist/vendor/contracts/verified-handoff.d.ts +100 -0
  44. package/dist/vendor/contracts/verified-handoff.js +35 -0
  45. package/dist/vendor/core/context-chain-gate.d.ts +89 -0
  46. package/dist/vendor/core/context-chain-gate.js +173 -0
  47. package/dist/vendor/core/context-compiler.d.ts +53 -0
  48. package/dist/vendor/core/context-compiler.js +261 -0
  49. package/dist/vendor/core/context-handoff.d.ts +42 -0
  50. package/dist/vendor/core/context-handoff.js +167 -0
  51. package/dist/vendor/core/context-shadow.d.ts +39 -0
  52. package/dist/vendor/core/context-shadow.js +200 -0
  53. package/dist/vendor/core/delivery/index.d.ts +6 -0
  54. package/dist/vendor/core/delivery/index.js +4 -0
  55. package/dist/vendor/core/delivery/message-client.d.ts +18 -0
  56. package/dist/vendor/core/delivery/message-client.js +38 -0
  57. package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
  58. package/dist/vendor/core/delivery/message-ledger.js +97 -0
  59. package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
  60. package/dist/vendor/core/delivery/message-schema.js +111 -0
  61. package/dist/vendor/core/delivery/update-check.d.ts +19 -0
  62. package/dist/vendor/core/delivery/update-check.js +107 -0
  63. package/dist/vendor/core/exit-signal.d.ts +88 -0
  64. package/dist/vendor/core/exit-signal.js +339 -0
  65. package/dist/vendor/core/exits.d.ts +41 -0
  66. package/dist/vendor/core/exits.js +258 -0
  67. package/dist/vendor/core/index.d.ts +44 -1
  68. package/dist/vendor/core/index.js +1088 -662
  69. package/dist/vendor/core/mission/aggregate.d.ts +27 -0
  70. package/dist/vendor/core/mission/aggregate.js +42 -0
  71. package/dist/vendor/core/mission/index.d.ts +2 -0
  72. package/dist/vendor/core/mission/index.js +1 -0
  73. package/dist/vendor/core/persistence/compiler.d.ts +8 -0
  74. package/dist/vendor/core/persistence/compiler.js +156 -0
  75. package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
  76. package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
  77. package/dist/vendor/core/persistence/index.d.ts +4 -0
  78. package/dist/vendor/core/persistence/index.js +2 -0
  79. package/dist/vendor/core/persistence/ledger.d.ts +1 -1
  80. package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
  81. package/dist/vendor/core/persistence/mission-store.js +281 -0
  82. package/dist/vendor/core/policy.d.ts +2 -0
  83. package/dist/vendor/core/policy.js +5 -2
  84. package/dist/vendor/core/rollback.js +15 -1
  85. package/dist/vendor/core/savings.d.ts +13 -0
  86. package/dist/vendor/core/savings.js +22 -0
  87. package/dist/vendor/core/termination-store.d.ts +15 -0
  88. package/dist/vendor/core/termination-store.js +44 -0
  89. package/dist/vendor/core/verified-handoff.d.ts +67 -0
  90. package/dist/vendor/core/verified-handoff.js +210 -0
  91. package/package.json +3 -13
  92. package/server.json +3 -3
  93. package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
  94. package/dist/vendor/adapters/stub-agent-cli.js +0 -40
  95. package/dist/vendor/adapters/verifier-only.d.ts +0 -9
  96. package/dist/vendor/adapters/verifier-only.js +0 -63
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Context Handoff Verifier — A-CTX-2
3
+ *
4
+ * Pure functions for verifying upstream-to-downstream context handoffs and
5
+ * deciding whether to circuit-break before the downstream agent executes.
6
+ *
7
+ * Standing rules:
8
+ * - These functions are pure and side-effect-free.
9
+ * - verifyContextHandoff fails closed — any ambiguity → not ok.
10
+ * - decideContextCircuitBreak is the sole gate before adapter execution.
11
+ * - The downstream adapter MUST NOT be called when shouldStop is true.
12
+ */
13
+ import { HANDOFF_SCHEMA_VERSION } from "../contracts/index.js";
14
+ // ─── Supported schema versions ────────────────────────────────────────────────
15
+ const SUPPORTED_SCHEMAS = new Set([HANDOFF_SCHEMA_VERSION]);
16
+ /**
17
+ * Deterministically verifies a context handoff.
18
+ *
19
+ * Returns ok=false when ANY of the following is true:
20
+ * - schemaVersion is not in SUPPORTED_SCHEMAS
21
+ * - producerReceiptVerified is false
22
+ * - upstreamIntegrity is not "verified"
23
+ * - a required artifact is absent from availableArtifacts
24
+ * - an artifact sha256 does not appear in availableArtifacts (hash changed)
25
+ * - any required claim has state "unverified" or "rejected"
26
+ * - any claim has state "unknown"
27
+ * - unresolvedAssumptions is non-empty
28
+ * - handoffId, chainId, producerRunId, or producerReceiptHash is blank
29
+ */
30
+ export function verifyContextHandoff(input) {
31
+ const { handoff, producerReceiptVerified, availableArtifacts } = input;
32
+ const reasons = [];
33
+ // ── Schema ──────────────────────────────────────────────────────────────────
34
+ if (!SUPPORTED_SCHEMAS.has(handoff.schemaVersion)) {
35
+ return {
36
+ ok: false,
37
+ integrity: "unsupported_schema",
38
+ reasons: [
39
+ {
40
+ code: "unsupported_schema",
41
+ message: `Handoff schemaVersion "${handoff.schemaVersion}" is not supported. Supported: ${[...SUPPORTED_SCHEMAS].join(", ")}.`
42
+ }
43
+ ]
44
+ };
45
+ }
46
+ // ── Required identity fields ─────────────────────────────────────────────────
47
+ if (!isPresent(handoff.handoffId)) {
48
+ reasons.push({ code: "missing_handoff_id", message: "handoffId is required." });
49
+ }
50
+ if (!isPresent(handoff.chainId)) {
51
+ reasons.push({ code: "missing_chain_id", message: "chainId is required." });
52
+ }
53
+ if (!isPresent(handoff.producerRunId)) {
54
+ reasons.push({ code: "missing_producer_run_id", message: "producerRunId is required." });
55
+ }
56
+ if (!isPresent(handoff.producerReceiptHash)) {
57
+ reasons.push({ code: "missing_producer_receipt_hash", message: "producerReceiptHash is required." });
58
+ }
59
+ // ── Producer receipt verification ────────────────────────────────────────────
60
+ if (!producerReceiptVerified) {
61
+ reasons.push({
62
+ code: "producer_receipt_unverified",
63
+ message: "The producer receipt hash could not be independently verified."
64
+ });
65
+ }
66
+ // ── Upstream integrity propagation ───────────────────────────────────────────
67
+ if (handoff.upstreamIntegrity !== "verified") {
68
+ reasons.push({
69
+ code: `upstream_integrity_${handoff.upstreamIntegrity}`,
70
+ message: `Upstream integrity is "${handoff.upstreamIntegrity}" — cannot proceed.`
71
+ });
72
+ }
73
+ // ── Artifact verification ────────────────────────────────────────────────────
74
+ for (const artifact of handoff.artifacts) {
75
+ const present = availableArtifacts.has(artifact.sha256);
76
+ if (artifact.required && !present) {
77
+ reasons.push({
78
+ code: "missing_required_artifact",
79
+ message: `Required artifact is absent (sha256: ${artifact.sha256}${artifact.label ? `, label: ${artifact.label}` : ""}).`,
80
+ artifactSha256: artifact.sha256
81
+ });
82
+ }
83
+ else if (!present) {
84
+ reasons.push({
85
+ code: "artifact_hash_mismatch",
86
+ message: `Artifact hash not found in available set (sha256: ${artifact.sha256}${artifact.label ? `, label: ${artifact.label}` : ""}).`,
87
+ artifactSha256: artifact.sha256
88
+ });
89
+ }
90
+ }
91
+ // ── Claim verification ────────────────────────────────────────────────────────
92
+ for (const claim of handoff.claims) {
93
+ if (claim.verificationState === "rejected") {
94
+ reasons.push({
95
+ code: "claim_rejected",
96
+ message: `Claim "${claim.claimId}" was rejected: ${claim.statement}`,
97
+ claimId: claim.claimId
98
+ });
99
+ }
100
+ else if (claim.verificationState === "unverified") {
101
+ reasons.push({
102
+ code: "claim_unverified",
103
+ message: `Claim "${claim.claimId}" is unverified: ${claim.statement}`,
104
+ claimId: claim.claimId
105
+ });
106
+ }
107
+ else if (claim.verificationState === "unknown") {
108
+ reasons.push({
109
+ code: "claim_unknown",
110
+ message: `Claim "${claim.claimId}" has unknown verification state: ${claim.statement}`,
111
+ claimId: claim.claimId
112
+ });
113
+ }
114
+ }
115
+ // ── Unresolved assumptions ────────────────────────────────────────────────────
116
+ if (handoff.unresolvedAssumptions.length > 0) {
117
+ reasons.push({
118
+ code: "unresolved_assumptions",
119
+ message: `${handoff.unresolvedAssumptions.length} assumption(s) remain unresolved: ${handoff.unresolvedAssumptions.slice(0, 3).join("; ")}${handoff.unresolvedAssumptions.length > 3 ? " …" : ""}`
120
+ });
121
+ }
122
+ const ok = reasons.length === 0;
123
+ const integrity = ok ? "verified" : deriveIntegrity(handoff, reasons);
124
+ return { ok, integrity, reasons };
125
+ }
126
+ // ─── Circuit breaker ──────────────────────────────────────────────────────────
127
+ /**
128
+ * Converts a ContextHandoffVerification into a gate decision.
129
+ *
130
+ * Healthy: { shouldStop: false, silent: true }
131
+ * Broken: { shouldStop: true, silent: false, reasonCode, message, nextAction }
132
+ */
133
+ export function decideContextCircuitBreak(verification) {
134
+ if (verification.ok) {
135
+ return {
136
+ shouldStop: false,
137
+ silent: true,
138
+ verification
139
+ };
140
+ }
141
+ const first = verification.reasons[0];
142
+ return {
143
+ shouldStop: true,
144
+ silent: false,
145
+ verification,
146
+ reasonCode: first?.code ?? "handoff_verification_failed",
147
+ message: first?.message ?? "Context handoff verification failed.",
148
+ nextAction: "Repair or replace the upstream handoff receipt, then retry."
149
+ };
150
+ }
151
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
152
+ function isPresent(value) {
153
+ return typeof value === "string" && value.trim().length > 0;
154
+ }
155
+ function deriveIntegrity(handoff, reasons) {
156
+ const codes = reasons.map((r) => r.code);
157
+ if (codes.includes("unsupported_schema"))
158
+ return "unsupported_schema";
159
+ if (codes.some((c) => c === "upstream_integrity_tamper_detected" ||
160
+ c === "artifact_hash_mismatch")) {
161
+ return "tamper_detected";
162
+ }
163
+ if (codes.includes("producer_receipt_unverified") || codes.includes("upstream_integrity_evidence_boundary")) {
164
+ return "evidence_boundary";
165
+ }
166
+ return "incomplete";
167
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Context Shadow Compiler — A-CTX-0
3
+ *
4
+ * Deterministic shadow manifest emitter. Observes the compiled prompt and
5
+ * records what a context-aware selector would have chosen, without altering
6
+ * the actual provider input in any way.
7
+ *
8
+ * Invariants:
9
+ * - nowMs is always a captured input; Date.now() is never called internally
10
+ * - No source text appears in any output type
11
+ * - Required segments are always preferred first
12
+ * - required-over-budget is recorded, never thrown
13
+ * - Manifest hash covers metadata only (no content)
14
+ */
15
+ import type { ContextC5EnvelopeV1, ContextEvidence, ContextShadowManifestV1 } from "../contracts/index.js";
16
+ export interface ContextShadowSegment {
17
+ segmentId: string;
18
+ kind: string;
19
+ required: boolean;
20
+ text: string;
21
+ }
22
+ export interface CompileContextShadowInput {
23
+ runId: string;
24
+ taskId?: string;
25
+ adapter: string;
26
+ model?: string;
27
+ /** Milliseconds since epoch — must be explicit. Never call Date.now() here. */
28
+ nowMs: number;
29
+ shadowBudgetTokens: number;
30
+ modelWindowTokens?: number;
31
+ modelWindowEvidence?: ContextEvidence;
32
+ segments: readonly ContextShadowSegment[];
33
+ }
34
+ export interface CompileContextShadowResult {
35
+ manifest: ContextShadowManifestV1;
36
+ receipt: ContextC5EnvelopeV1;
37
+ }
38
+ export declare function estimateContextTokens(text: string): number;
39
+ export declare function compileContextShadow(input: CompileContextShadowInput): CompileContextShadowResult;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Context Shadow Compiler — A-CTX-0
3
+ *
4
+ * Deterministic shadow manifest emitter. Observes the compiled prompt and
5
+ * records what a context-aware selector would have chosen, without altering
6
+ * the actual provider input in any way.
7
+ *
8
+ * Invariants:
9
+ * - nowMs is always a captured input; Date.now() is never called internally
10
+ * - No source text appears in any output type
11
+ * - Required segments are always preferred first
12
+ * - required-over-budget is recorded, never thrown
13
+ * - Manifest hash covers metadata only (no content)
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { CONTEXT_SHADOW_MANIFEST_VERSION, CONTEXT_C5_VERSION } from "../contracts/index.js";
17
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
18
+ function sha256(value) {
19
+ return createHash("sha256").update(value, "utf8").digest("hex");
20
+ }
21
+ function canonicalize(value) {
22
+ if (Array.isArray(value)) {
23
+ return value.map(canonicalize);
24
+ }
25
+ if (value !== null && typeof value === "object") {
26
+ return Object.fromEntries(Object.entries(value)
27
+ .sort(([a], [b]) => a.localeCompare(b))
28
+ .map(([k, v]) => [k, canonicalize(v)]));
29
+ }
30
+ return value;
31
+ }
32
+ function canonicalJson(value) {
33
+ return JSON.stringify(canonicalize(value));
34
+ }
35
+ export function estimateContextTokens(text) {
36
+ if (text.length === 0)
37
+ return 0;
38
+ return Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4));
39
+ }
40
+ // ─── Compiler ────────────────────────────────────────────────────────────────
41
+ export function compileContextShadow(input) {
42
+ const { runId, adapter, nowMs, shadowBudgetTokens, segments } = input;
43
+ // Validate: all segmentIds must be non-empty and unique
44
+ const seen = new Set();
45
+ for (const seg of segments) {
46
+ if (!seg.segmentId || seg.segmentId.trim() === "") {
47
+ throw new Error("compileContextShadow: segmentId must be non-empty");
48
+ }
49
+ if (seen.has(seg.segmentId)) {
50
+ throw new Error(`compileContextShadow: duplicate segmentId "${seg.segmentId}"`);
51
+ }
52
+ seen.add(seg.segmentId);
53
+ }
54
+ // Hash each segment's content — never expose the text itself
55
+ const hashed = segments.map((seg) => ({
56
+ segmentId: seg.segmentId,
57
+ kind: seg.kind,
58
+ required: seg.required,
59
+ contentHash: sha256(seg.text),
60
+ estimatedTokens: estimateContextTokens(seg.text)
61
+ }));
62
+ // Compute actualPromptHash from all segments in original order
63
+ const actualPromptHash = sha256(canonicalJson(hashed.map((h) => ({ segmentId: h.segmentId, contentHash: h.contentHash }))));
64
+ const actualEstimatedTokens = hashed.reduce((sum, h) => sum + h.estimatedTokens, 0);
65
+ // Compute compilerInputHash — includes nowMs for cross-run determinism
66
+ const compilerInputHash = sha256(canonicalJson({
67
+ adapter,
68
+ nowMs,
69
+ segments: hashed.map((h) => ({
70
+ segmentId: h.segmentId,
71
+ kind: h.kind,
72
+ required: h.required,
73
+ contentHash: h.contentHash,
74
+ estimatedTokens: h.estimatedTokens
75
+ })),
76
+ shadowBudgetTokens,
77
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
78
+ ...(input.model === undefined ? {} : { model: input.model })
79
+ }));
80
+ // Shadow selection: required first (in original order), then optional
81
+ const required = hashed.filter((h) => h.required);
82
+ const optional = hashed.filter((h) => !h.required);
83
+ let remaining = shadowBudgetTokens;
84
+ const decisions = [];
85
+ // Required segments — always included even if over budget
86
+ let requiredOverBudget = false;
87
+ const requiredTokens = required.reduce((s, h) => s + h.estimatedTokens, 0);
88
+ if (requiredTokens > shadowBudgetTokens) {
89
+ requiredOverBudget = true;
90
+ }
91
+ for (const h of required) {
92
+ const included = !requiredOverBudget;
93
+ decisions.push({
94
+ segmentId: h.segmentId,
95
+ kind: h.kind,
96
+ contentHash: h.contentHash,
97
+ estimatedTokens: h.estimatedTokens,
98
+ actuallyIncluded: true,
99
+ proposedDecision: included ? "included" : "excluded",
100
+ reason: requiredOverBudget ? "required_over_budget" : "required"
101
+ });
102
+ if (!requiredOverBudget) {
103
+ remaining -= h.estimatedTokens;
104
+ }
105
+ }
106
+ // Optional segments — include while budget remains
107
+ for (const h of optional) {
108
+ if (h.estimatedTokens <= remaining) {
109
+ decisions.push({
110
+ segmentId: h.segmentId,
111
+ kind: h.kind,
112
+ contentHash: h.contentHash,
113
+ estimatedTokens: h.estimatedTokens,
114
+ actuallyIncluded: true,
115
+ proposedDecision: "included",
116
+ reason: "within_shadow_budget"
117
+ });
118
+ remaining -= h.estimatedTokens;
119
+ }
120
+ else {
121
+ decisions.push({
122
+ segmentId: h.segmentId,
123
+ kind: h.kind,
124
+ contentHash: h.contentHash,
125
+ estimatedTokens: h.estimatedTokens,
126
+ actuallyIncluded: true,
127
+ proposedDecision: "excluded",
128
+ reason: "shadow_budget_exhausted"
129
+ });
130
+ }
131
+ }
132
+ // Restore original segment order in decisions list
133
+ const orderMap = new Map(segments.map((s, i) => [s.segmentId, i]));
134
+ decisions.sort((a, b) => (orderMap.get(a.segmentId) ?? 0) - (orderMap.get(b.segmentId) ?? 0));
135
+ const proposedEstimatedTokens = decisions
136
+ .filter((d) => d.proposedDecision === "included")
137
+ .reduce((s, d) => s + d.estimatedTokens, 0);
138
+ const capturedAt = new Date(nowMs).toISOString();
139
+ const manifestId = sha256(`${runId}:${compilerInputHash}:${capturedAt}`);
140
+ // Manifest hash covers metadata only — no content
141
+ const manifestHashInput = canonicalJson({
142
+ adapter,
143
+ capturedAt,
144
+ compilerInputHash,
145
+ manifestId,
146
+ mode: "shadow",
147
+ modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
148
+ modelWindowTokens: input.modelWindowTokens ?? null,
149
+ requiredOverBudget,
150
+ runId,
151
+ shadowBudgetTokens,
152
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
153
+ ...(input.model === undefined ? {} : { model: input.model })
154
+ });
155
+ const manifestHash = sha256(manifestHashInput);
156
+ const includedCount = decisions.filter((d) => d.proposedDecision === "included").length;
157
+ const excludedCount = decisions.filter((d) => d.proposedDecision === "excluded").length;
158
+ const manifest = {
159
+ schemaVersion: CONTEXT_SHADOW_MANIFEST_VERSION,
160
+ manifestId,
161
+ manifestHash,
162
+ compilerInputHash,
163
+ runId,
164
+ adapter,
165
+ capturedAt,
166
+ mode: "shadow",
167
+ modelWindowTokens: input.modelWindowTokens ?? null,
168
+ modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
169
+ shadowBudgetTokens,
170
+ actualPromptHash,
171
+ actualEstimatedTokens,
172
+ proposedEstimatedTokens,
173
+ requiredOverBudget,
174
+ decisions,
175
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
176
+ ...(input.model === undefined ? {} : { model: input.model })
177
+ };
178
+ const receipt = {
179
+ schemaVersion: CONTEXT_C5_VERSION,
180
+ resource: "context",
181
+ event: "shadow_compiled",
182
+ runId,
183
+ manifestId,
184
+ manifestHash,
185
+ mode: "shadow",
186
+ adapter,
187
+ actualEstimatedTokens,
188
+ proposedEstimatedTokens,
189
+ candidateCount: decisions.length,
190
+ includedCount,
191
+ excludedCount,
192
+ requiredOverBudget,
193
+ modelWindowTokens: input.modelWindowTokens ?? null,
194
+ modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
195
+ createdAt: capturedAt,
196
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
197
+ ...(input.model === undefined ? {} : { model: input.model })
198
+ };
199
+ return { manifest, receipt };
200
+ }
@@ -0,0 +1,6 @@
1
+ export { fetchSelectedMessage } from "./message-client.js";
2
+ export type { MessageClientOptions, MessageSelectRequest } from "./message-client.js";
3
+ export { cacheMessage, isCooldownExpired, isDismissed, loadDeliveryRecord, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./message-ledger.js";
4
+ export { parseMessageSelectionResponse } from "./message-schema.js";
5
+ export type { ParseFailure, ParseResult, ValidationError } from "./message-schema.js";
6
+ export { getCliInstalledVersion, getMcpInstalledVersion, isNewerVersion } from "./update-check.js";
@@ -0,0 +1,4 @@
1
+ export { fetchSelectedMessage } from "./message-client.js";
2
+ export { cacheMessage, isCooldownExpired, isDismissed, loadDeliveryRecord, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./message-ledger.js";
3
+ export { parseMessageSelectionResponse } from "./message-schema.js";
4
+ export { getCliInstalledVersion, getMcpInstalledVersion, isNewerVersion } from "./update-check.js";
@@ -0,0 +1,18 @@
1
+ import { type DeliveryMessage } from "../../contracts/index.js";
2
+ export interface MessageSelectRequest {
3
+ installId?: string;
4
+ clientVersion: string;
5
+ clientKind: "cli" | "mcp";
6
+ /** Enum signal — never a transcript or raw content */
7
+ trigger?: "first_verified_run" | "milestone_reached" | "version_check";
8
+ }
9
+ export interface MessageClientOptions {
10
+ endpoint?: string;
11
+ timeoutMs?: number;
12
+ }
13
+ /**
14
+ * Fetch a server-selected message from the Control Plane.
15
+ * Returns null on any network failure, timeout, or validation error.
16
+ * Never throws — the primary command must never be affected.
17
+ */
18
+ export declare function fetchSelectedMessage(request: MessageSelectRequest, options?: MessageClientOptions): Promise<DeliveryMessage | null>;
@@ -0,0 +1,38 @@
1
+ import { parseMessageSelectionResponse } from "./message-schema.js";
2
+ const DEFAULT_ENDPOINT = "https://api.martinloop.com/v1/messages/select";
3
+ const REQUEST_TIMEOUT_MS = 4_000;
4
+ /**
5
+ * Fetch a server-selected message from the Control Plane.
6
+ * Returns null on any network failure, timeout, or validation error.
7
+ * Never throws — the primary command must never be affected.
8
+ */
9
+ export async function fetchSelectedMessage(request, options = {}) {
10
+ const endpoint = options.endpoint ?? process.env["MARTIN_MESSAGE_ENDPOINT"] ?? DEFAULT_ENDPOINT;
11
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
12
+ let controller;
13
+ let timeoutId;
14
+ try {
15
+ controller = new AbortController();
16
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
17
+ const response = await fetch(endpoint, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json", "Accept": "application/json" },
20
+ body: JSON.stringify(request),
21
+ signal: controller.signal,
22
+ });
23
+ if (!response.ok)
24
+ return null;
25
+ const raw = await response.text();
26
+ const result = parseMessageSelectionResponse(raw);
27
+ if (!result.ok)
28
+ return null;
29
+ return result.message ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ finally {
35
+ if (timeoutId !== undefined)
36
+ clearTimeout(timeoutId);
37
+ }
38
+ }
@@ -0,0 +1,35 @@
1
+ import { type DeliveryMessage, type DeliveryRecord } from "../../contracts/index.js";
2
+ /**
3
+ * Load DeliveryRecord from disk. Returns empty record on any read/parse failure — never throws.
4
+ */
5
+ export declare function loadDeliveryRecord(ledgerPath: string): DeliveryRecord;
6
+ /**
7
+ * Persist DeliveryRecord atomically via temp-file + rename.
8
+ */
9
+ export declare function saveDeliveryRecord(ledgerPath: string, record: DeliveryRecord): void;
10
+ /**
11
+ * Returns true when the cooldown period has elapsed and the message should be shown.
12
+ * Clock-skew safe: if now < lastShownAtEpochMs (clock jumped back), treat cooldown as expired.
13
+ */
14
+ export declare function isCooldownExpired(record: DeliveryRecord, nowMs: number): boolean;
15
+ /**
16
+ * Returns true if this message id was permanently dismissed.
17
+ */
18
+ export declare function isDismissed(record: DeliveryRecord, messageId: string): boolean;
19
+ /**
20
+ * Record that a message was shown. Updates cooldown window.
21
+ */
22
+ export declare function recordShown(record: DeliveryRecord, message: DeliveryMessage, nowMs: number): DeliveryRecord;
23
+ /**
24
+ * Record that a message was permanently dismissed.
25
+ */
26
+ export declare function recordDismissed(record: DeliveryRecord, messageId: string): DeliveryRecord;
27
+ /**
28
+ * Cache a fetched message for offline use.
29
+ */
30
+ export declare function cacheMessage(record: DeliveryRecord, message: DeliveryMessage, nowMs: number): DeliveryRecord;
31
+ /**
32
+ * Resolve the default ledger path: $MARTIN_STATE_DIR/delivery-record.json
33
+ * or ~/.martin/delivery-record.json.
34
+ */
35
+ export declare function resolveDefaultLedgerPath(): string;
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DELIVERY_RECORD_SCHEMA_VERSION } from "../../contracts/index.js";
5
+ const EMPTY_RECORD = {
6
+ schemaVersion: DELIVERY_RECORD_SCHEMA_VERSION,
7
+ dismissedIds: [],
8
+ };
9
+ /**
10
+ * Load DeliveryRecord from disk. Returns empty record on any read/parse failure — never throws.
11
+ */
12
+ export function loadDeliveryRecord(ledgerPath) {
13
+ try {
14
+ const raw = fs.readFileSync(ledgerPath, "utf8");
15
+ const parsed = JSON.parse(raw);
16
+ if (typeof parsed === "object" &&
17
+ parsed !== null &&
18
+ !Array.isArray(parsed) &&
19
+ parsed["schemaVersion"] === DELIVERY_RECORD_SCHEMA_VERSION) {
20
+ return parsed;
21
+ }
22
+ }
23
+ catch {
24
+ // file missing or corrupt — start fresh
25
+ }
26
+ return { ...EMPTY_RECORD, dismissedIds: [] };
27
+ }
28
+ /**
29
+ * Persist DeliveryRecord atomically via temp-file + rename.
30
+ */
31
+ export function saveDeliveryRecord(ledgerPath, record) {
32
+ const dir = path.dirname(ledgerPath);
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ const tmp = path.join(os.tmpdir(), `martin-dlv-${process.pid}-${Date.now()}.json`);
35
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
36
+ fs.renameSync(tmp, ledgerPath);
37
+ }
38
+ /**
39
+ * Returns true when the cooldown period has elapsed and the message should be shown.
40
+ * Clock-skew safe: if now < lastShownAtEpochMs (clock jumped back), treat cooldown as expired.
41
+ */
42
+ export function isCooldownExpired(record, nowMs) {
43
+ const until = record.cooldownUntilEpochMs;
44
+ if (until === undefined)
45
+ return true;
46
+ const last = record.lastShownAtEpochMs ?? 0;
47
+ // If clock appears to have gone backwards past last-shown, treat as expired
48
+ if (nowMs < last)
49
+ return true;
50
+ return nowMs >= until;
51
+ }
52
+ /**
53
+ * Returns true if this message id was permanently dismissed.
54
+ */
55
+ export function isDismissed(record, messageId) {
56
+ return record.dismissedIds.includes(messageId);
57
+ }
58
+ /**
59
+ * Record that a message was shown. Updates cooldown window.
60
+ */
61
+ export function recordShown(record, message, nowMs) {
62
+ return {
63
+ ...record,
64
+ lastMessageId: message.id,
65
+ lastShownAtEpochMs: nowMs,
66
+ cooldownUntilEpochMs: nowMs + message.cooldownHours * 60 * 60 * 1000,
67
+ };
68
+ }
69
+ /**
70
+ * Record that a message was permanently dismissed.
71
+ */
72
+ export function recordDismissed(record, messageId) {
73
+ if (record.dismissedIds.includes(messageId))
74
+ return record;
75
+ return {
76
+ ...record,
77
+ dismissedIds: [...record.dismissedIds, messageId],
78
+ };
79
+ }
80
+ /**
81
+ * Cache a fetched message for offline use.
82
+ */
83
+ export function cacheMessage(record, message, nowMs) {
84
+ return {
85
+ ...record,
86
+ cachedMessage: message,
87
+ cachedAtEpochMs: nowMs,
88
+ };
89
+ }
90
+ /**
91
+ * Resolve the default ledger path: $MARTIN_STATE_DIR/delivery-record.json
92
+ * or ~/.martin/delivery-record.json.
93
+ */
94
+ export function resolveDefaultLedgerPath() {
95
+ const stateDir = process.env["MARTIN_STATE_DIR"] ?? path.join(os.homedir(), ".martin");
96
+ return path.join(stateDir, "delivery-record.json");
97
+ }
@@ -0,0 +1,16 @@
1
+ import { type DeliveryMessage } from "../../contracts/index.js";
2
+ export type ValidationError = "schema_version_unknown" | "schema_unknown_kind" | "schema_unknown_action_type" | "schema_invalid_id" | "schema_invalid_date" | "schema_invalid_cooldown" | "schema_url_not_https" | "schema_text_too_long" | "schema_response_too_large" | "schema_malformed";
3
+ export interface ParseResult {
4
+ ok: true;
5
+ message: DeliveryMessage | undefined;
6
+ }
7
+ export interface ParseFailure {
8
+ ok: false;
9
+ error: ValidationError;
10
+ detail?: string;
11
+ }
12
+ /**
13
+ * Parse and validate a raw response body from the message selection endpoint.
14
+ * Returns ok:false on any violation — caller must render nothing and log the error.
15
+ */
16
+ export declare function parseMessageSelectionResponse(raw: string): ParseResult | ParseFailure;