freshcontext-mcp 0.3.23 → 0.4.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.
package/README.md CHANGED
@@ -170,7 +170,7 @@ FreshContext does not certify truth. It records why context was used, supported,
170
170
 
171
171
  `evaluate_context` does not fetch URLs, crawl, scrape, browse, read folders, or call adapters. It only evaluates candidate context the caller provides.
172
172
 
173
- Current boundary: `evaluate_context` is part of the npm/local stdio MCP server prepared for `0.3.22`. The hosted Cloudflare Worker MCP endpoint was last verified separately at `0.3.22 / 22 tools`. The Worker remains a separate deployment surface, so future package interfaces should be re-verified remotely before being claimed live.
173
+ Current boundary: `evaluate_context` is part of the npm/local stdio MCP server prepared for `0.4.0`. The hosted Cloudflare Worker MCP endpoint was last verified separately at `0.4.0 / 22 tools`. The Worker remains a separate deployment surface, so future package interfaces should be re-verified remotely before being claimed live.
174
174
 
175
175
  ### Network Boundary
176
176
 
@@ -1,5 +1,5 @@
1
1
  import { validateUrl } from "../security.js";
2
- const USER_AGENT = "freshcontext-mcp/0.3.21 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)";
2
+ const USER_AGENT = "freshcontext-mcp/0.4.0 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)";
3
3
  const DEFAULT_ARXIV_SIGNAL_SCORE = 0.8;
4
4
  function buildArxivApiUrl(input, maxResults = 10) {
5
5
  const trimmed = input.trim();
@@ -30,7 +30,7 @@ async function fetchStooqQuote(ticker) {
30
30
  const url = `https://stooq.com/q/l/?s=${encodeURIComponent(stooqSymbol.toLowerCase())}&f=sd2t2ohlcv&h&e=json`;
31
31
  const res = await fetch(url, {
32
32
  headers: {
33
- "User-Agent": "freshcontext-mcp/0.3.21",
33
+ "User-Agent": "freshcontext-mcp/0.4.0",
34
34
  "Accept": "application/json",
35
35
  },
36
36
  });
@@ -11,7 +11,7 @@
11
11
  */
12
12
  const HEADERS = {
13
13
  "Accept": "application/json",
14
- "User-Agent": "freshcontext-mcp/0.3.21 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
14
+ "User-Agent": "freshcontext-mcp/0.4.0 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
15
15
  };
16
16
  function parseGdeltDate(raw) {
17
17
  if (!raw)
@@ -20,7 +20,7 @@ const DATASET_ID = "d_acde1106003906a75c3fa052592f2fcb";
20
20
  const BASE_URL = "https://data.gov.sg/api/action/datastore_search";
21
21
  const HEADERS = {
22
22
  "Accept": "application/json",
23
- "User-Agent": "freshcontext-mcp/0.3.21 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
23
+ "User-Agent": "freshcontext-mcp/0.4.0 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
24
24
  };
25
25
  function formatDate(raw) {
26
26
  if (!raw)
@@ -131,7 +131,7 @@ export async function jobsAdapter(options) {
131
131
  async function fetchRemotive(query, location, maxAgeDays, keywords) {
132
132
  const url = `https://remotive.com/api/remote-jobs?search=${encodeURIComponent(query)}&limit=15`;
133
133
  const res = await fetch(url, {
134
- headers: { "User-Agent": "freshcontext-mcp/0.3.21", "Accept": "application/json" },
134
+ headers: { "User-Agent": "freshcontext-mcp/0.4.0", "Accept": "application/json" },
135
135
  });
136
136
  if (!res.ok)
137
137
  throw new Error(`Remotive ${res.status}`);
@@ -161,7 +161,7 @@ async function fetchRemoteOK(query, location, maxAgeDays, keywords) {
161
161
  const tag = query.toLowerCase().replace(/\s+/g, "-");
162
162
  const url = `https://remoteok.com/api?tag=${encodeURIComponent(tag)}`;
163
163
  const res = await fetch(url, {
164
- headers: { "User-Agent": "freshcontext-mcp/0.3.21", "Accept": "application/json" },
164
+ headers: { "User-Agent": "freshcontext-mcp/0.4.0", "Accept": "application/json" },
165
165
  });
166
166
  if (!res.ok)
167
167
  throw new Error(`RemoteOK ${res.status}`);
@@ -199,7 +199,7 @@ async function fetchArbeitnow(query, location, maxAgeDays, keywords, remoteOnly)
199
199
  params.set("remote", "true");
200
200
  const url = `https://arbeitnow.com/api/job-board-api?${params.toString()}`;
201
201
  const res = await fetch(url, {
202
- headers: { "User-Agent": "freshcontext-mcp/0.3.21", "Accept": "application/json" },
202
+ headers: { "User-Agent": "freshcontext-mcp/0.4.0", "Accept": "application/json" },
203
203
  });
204
204
  if (!res.ok)
205
205
  throw new Error(`Arbeitnow ${res.status}`);
@@ -233,7 +233,7 @@ async function fetchMuse(query, location, maxAgeDays, keywords) {
233
233
  : "&location=Flexible%20%2F%20Remote";
234
234
  const url = `https://www.themuse.com/api/public/jobs?name=${encodeURIComponent(query)}${locParam}&page=0&descending=true`;
235
235
  const res = await fetch(url, {
236
- headers: { "User-Agent": "freshcontext-mcp/0.3.21", "Accept": "application/json" },
236
+ headers: { "User-Agent": "freshcontext-mcp/0.4.0", "Accept": "application/json" },
237
237
  });
238
238
  if (!res.ok)
239
239
  throw new Error(`The Muse ${res.status}`);
@@ -263,7 +263,7 @@ async function fetchMuse(query, location, maxAgeDays, keywords) {
263
263
  // instead of all HN comments. Uses the parent_id filter to target the thread.
264
264
  async function fetchHNHiring(query, location, maxAgeDays, keywords) {
265
265
  // Step 1: Find the most recent "Ask HN: Who is hiring?" thread
266
- const threadRes = await fetch(`https://hn.algolia.com/api/v1/search?query=Ask+HN+Who+is+hiring&tags=story&hitsPerPage=5`, { headers: { "User-Agent": "freshcontext-mcp/0.3.21" } });
266
+ const threadRes = await fetch(`https://hn.algolia.com/api/v1/search?query=Ask+HN+Who+is+hiring&tags=story&hitsPerPage=5`, { headers: { "User-Agent": "freshcontext-mcp/0.4.0" } });
267
267
  if (!threadRes.ok)
268
268
  throw new Error(`HN thread search ${threadRes.status}`);
269
269
  const threadData = await threadRes.json();
@@ -273,7 +273,7 @@ async function fetchHNHiring(query, location, maxAgeDays, keywords) {
273
273
  throw new Error("HN hiring thread not found");
274
274
  // Step 2: Search comments within that thread for the query
275
275
  const searchTerms = [query, location].filter(Boolean).join(" ");
276
- const commentsRes = await fetch(`https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchTerms)}&tags=comment,story_${hiringThread.objectID}&hitsPerPage=10`, { headers: { "User-Agent": "freshcontext-mcp/0.3.21" } });
276
+ const commentsRes = await fetch(`https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchTerms)}&tags=comment,story_${hiringThread.objectID}&hitsPerPage=10`, { headers: { "User-Agent": "freshcontext-mcp/0.4.0" } });
277
277
  if (!commentsRes.ok)
278
278
  throw new Error(`HN comments ${commentsRes.status}`);
279
279
  const commentsData = await commentsRes.json();
@@ -28,7 +28,7 @@ export async function redditAdapter(options) {
28
28
  const safeUrl = validateUrl(apiUrl, "reddit");
29
29
  const res = await fetch(safeUrl, {
30
30
  headers: {
31
- "User-Agent": "freshcontext-mcp/0.3.21 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
31
+ "User-Agent": "freshcontext-mcp/0.4.0 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
32
32
  "Accept": "application/json",
33
33
  },
34
34
  });
@@ -22,7 +22,7 @@ export async function repoSearchAdapter(options) {
22
22
  const res = await fetch(apiUrl, {
23
23
  headers: {
24
24
  Accept: "application/vnd.github.v3+json",
25
- "User-Agent": "freshcontext-mcp/0.3.21 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
25
+ "User-Agent": "freshcontext-mcp/0.4.0 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
26
26
  },
27
27
  });
28
28
  if (!res.ok) {
@@ -12,7 +12,7 @@
12
12
  */
13
13
  const HEADERS = {
14
14
  "Accept": "application/json",
15
- "User-Agent": "freshcontext-mcp/0.3.21 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
15
+ "User-Agent": "freshcontext-mcp/0.4.0 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
16
16
  };
17
17
  async function fetchSecFilings(query, maxResults = 10) {
18
18
  const today = new Date().toISOString().slice(0, 10);
@@ -3,3 +3,6 @@ export declare const FUTURE_CLOCK_SKEW_TOLERANCE_MS: number;
3
3
  export declare function isMeaningfullyFutureDate(content_date: string | null, retrieved_at: string): boolean;
4
4
  export declare function calculateFreshnessScore(content_date: string | null, retrieved_at: string, adapter: string): number | null;
5
5
  export declare function scoreLabel(score: number | null): string;
6
+ export type StalenessVerdict = "fresh" | "aging" | "stale" | "unknown";
7
+ export declare function stalenessVerdict(score: number | null): StalenessVerdict;
8
+ export declare function computeRevalidateAfter(content_date: string | null, retrieved_at: string, adapter: string): string | null;
@@ -1,6 +1,13 @@
1
1
  // Spec-compliant exponential DAR model.
2
2
  // Higher lambda = data goes stale faster. Half-life formula: t1/2 = ln(2) / lambda.
3
- // Lambda is measured per hour and mirrors the Worker/D1 intelligence engine.
3
+ // Lambda is measured per hour.
4
+ //
5
+ // CANONICAL SOURCE OF DECAY POLICY. This is the single definition of LAMBDA in the
6
+ // codebase. Core (pipeline, sourceProfiles, freshness scoring) and the Worker
7
+ // intelligence module (via the core/edge boundary) both derive from it — neither
8
+ // keeps its own copy. To tune a source's decay, change it here and only here. The
9
+ // single-source guard (tests/lambdaSingleSource.test.ts) goes red if a second
10
+ // `const LAMBDA` table is ever reintroduced anywhere in runtime source.
4
11
  export const LAMBDA = {
5
12
  hackernews: 0.050,
6
13
  reddit: 0.010,
@@ -59,3 +66,35 @@ export function scoreLabel(score) {
59
66
  return "verify before acting";
60
67
  return "use with caution";
61
68
  }
69
+ // Derived from the SAME score buckets as scoreLabel, not a second decay system.
70
+ // "fresh" merges scoreLabel's "current"/"reliable" (score >= 70); "aging" matches
71
+ // "verify before acting" (>= 50); "stale" matches "use with caution" (< 50). The
72
+ // 50-point line is also where computeRevalidateAfter is anchored below — crossing
73
+ // 50 is the one staleness boundary, expressed two ways (a verdict and a timestamp).
74
+ export function stalenessVerdict(score) {
75
+ if (score === null)
76
+ return "unknown";
77
+ if (score >= 70)
78
+ return "fresh";
79
+ if (score >= 50)
80
+ return "aging";
81
+ return "stale";
82
+ }
83
+ // The timestamp at which this content's freshness_score would cross the staleness
84
+ // boundary (score = 50). Since 100 * e^(-lambda*t) = 50 solves to t = ln(2)/lambda
85
+ // — exactly one half-life — revalidate_after is always content_date + half-life.
86
+ // Uses the same null/future-date guards as calculateFreshnessScore so the two
87
+ // stay in lockstep: staleness === "unknown" if and only if revalidate_after === null.
88
+ export function computeRevalidateAfter(content_date, retrieved_at, adapter) {
89
+ if (!content_date)
90
+ return null;
91
+ const published = new Date(content_date).getTime();
92
+ const retrieved = new Date(retrieved_at).getTime();
93
+ if (isNaN(published) || isNaN(retrieved))
94
+ return null;
95
+ if (published - retrieved > FUTURE_CLOCK_SKEW_TOLERANCE_MS)
96
+ return null;
97
+ const lambda = LAMBDA[adapter] ?? LAMBDA.default;
98
+ const halfLifeHours = Math.log(2) / lambda;
99
+ return new Date(published + halfLifeHours * 60 * 60 * 1000).toISOString();
100
+ }
@@ -1,3 +1,19 @@
1
- import type { ContextDecisionOptions, ContextDecisionResult, CoreSignalEvaluationResult } from "./types.js";
1
+ import type { ContextDecision, ContextDecisionOptions, ContextDecisionResult, CoreSignalEvaluationResult, IntentProfileId, SourceProfileId } from "./types.js";
2
+ /**
3
+ * Deterministic identity for a single decision (a "verdict").
4
+ *
5
+ * Derived only from the inputs that determine the decision itself: signal
6
+ * identity, source, the decision label, and profile selection. Deliberately
7
+ * EXCLUDES utility and provenance_readiness, which must stay free to vary
8
+ * without changing what verdict this is — mirrors the existing
9
+ * "utility/provenance_readiness does not control decision labels" rule.
10
+ *
11
+ * Re-evaluating identical inputs always yields the same verdict_id. It is a
12
+ * fingerprint, not a random id, so any caller can recompute and recognize it
13
+ * later without FreshContext having to store an id-issuing table. This is
14
+ * the id a future verification event would reference when resolving a
15
+ * needs_verification (or similar) decision into a cleared state.
16
+ */
17
+ export declare function computeVerdictId(evaluation: CoreSignalEvaluationResult, decision: ContextDecision, sourceProfileId: SourceProfileId | undefined, intentProfile: IntentProfileId | undefined): string;
2
18
  export declare function interpretEvaluation(evaluation: CoreSignalEvaluationResult, options?: ContextDecisionOptions): ContextDecisionResult;
3
19
  export declare function interpretEvaluations(evaluations: CoreSignalEvaluationResult[], options?: ContextDecisionOptions): ContextDecisionResult[];
@@ -1,6 +1,8 @@
1
+ import { sha256Hex } from "./provenance.js";
1
2
  import { getSourceProfile } from "./sourceProfiles.js";
2
3
  const CITATION_INTENTS = new Set(["citation_check", "student_research"]);
3
4
  const STRICT_REFRESH_PROFILES = new Set(["market_finance", "jobs_opportunities"]);
5
+ const VERDICT_ID_VERSION = "FRESHCONTEXT_VERDICT_V1";
4
6
  function resolveSourceProfile(profile) {
5
7
  if (!profile)
6
8
  return undefined;
@@ -18,6 +20,26 @@ function hasFailureReason(evaluation) {
18
20
  function isCitationIntent(intent) {
19
21
  return intent !== undefined && CITATION_INTENTS.has(intent);
20
22
  }
23
+ // Pass 21: decision-time clock. Honors options.now for deterministic verdicts,
24
+ // falls back to wall time. Never recomputed at read.
25
+ function computeEvaluatedAt(now) {
26
+ if (now === undefined)
27
+ return new Date().toISOString();
28
+ const date = typeof now === "string" ? new Date(now) : now;
29
+ return date.toISOString();
30
+ }
31
+ // Pass 21: revalidate_after = evaluated_at + 1.0 × source profile half-life.
32
+ // The half-life is the literal inflection where the source has lost half its
33
+ // freshness signal value, the one number on the decay curve the source profile
34
+ // is calibrated to express. Explicit null when no source profile basis exists
35
+ // — never a fabricated timestamp.
36
+ function computeRevalidateAfter(evaluatedAt, sourceProfile) {
37
+ if (!sourceProfile)
38
+ return null;
39
+ const baseMs = new Date(evaluatedAt).getTime();
40
+ const halfLifeMs = sourceProfile.half_life_hours * 60 * 60 * 1000;
41
+ return new Date(baseMs + halfLifeMs).toISOString();
42
+ }
21
43
  function nonAdviceWarnings(intent) {
22
44
  switch (intent) {
23
45
  case "citation_check":
@@ -35,13 +57,46 @@ function nonAdviceWarnings(intent) {
35
57
  return [];
36
58
  }
37
59
  }
38
- function decisionResult(decision, reasons, warnings) {
60
+ /**
61
+ * Deterministic identity for a single decision (a "verdict").
62
+ *
63
+ * Derived only from the inputs that determine the decision itself: signal
64
+ * identity, source, the decision label, and profile selection. Deliberately
65
+ * EXCLUDES utility and provenance_readiness, which must stay free to vary
66
+ * without changing what verdict this is — mirrors the existing
67
+ * "utility/provenance_readiness does not control decision labels" rule.
68
+ *
69
+ * Re-evaluating identical inputs always yields the same verdict_id. It is a
70
+ * fingerprint, not a random id, so any caller can recompute and recognize it
71
+ * later without FreshContext having to store an id-issuing table. This is
72
+ * the id a future verification event would reference when resolving a
73
+ * needs_verification (or similar) decision into a cleared state.
74
+ */
75
+ export function computeVerdictId(evaluation, decision, sourceProfileId, intentProfile) {
76
+ const signal = evaluation.signal;
77
+ const basis = [
78
+ VERDICT_ID_VERSION,
79
+ `signal_id=${signal.id ?? "null"}`,
80
+ `source=${signal.source}`,
81
+ `source_type=${signal.source_type}`,
82
+ `published_at=${signal.published_at ?? "null"}`,
83
+ `decision=${decision}`,
84
+ `source_profile=${sourceProfileId ?? "null"}`,
85
+ `intent_profile=${intentProfile ?? "null"}`,
86
+ ].join("\n");
87
+ return sha256Hex(basis);
88
+ }
89
+ function decisionResult(decision, reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter) {
39
90
  const copyReasons = unique(reasons);
40
91
  const copyWarnings = unique(warnings);
92
+ const verdict_id = computeVerdictId(evaluation, decision, sourceProfileId, intentProfile);
41
93
  switch (decision) {
42
94
  case "use_first":
43
95
  return {
44
96
  decision,
97
+ verdict_id,
98
+ evaluated_at: evaluatedAt,
99
+ revalidate_after: revalidateAfter,
45
100
  label: "Use first",
46
101
  meaning: "This is strong, current context for the task.",
47
102
  action: "Use this near the top of the context bundle.",
@@ -51,6 +106,9 @@ function decisionResult(decision, reasons, warnings) {
51
106
  case "cite_as_primary":
52
107
  return {
53
108
  decision,
109
+ verdict_id,
110
+ evaluated_at: evaluatedAt,
111
+ revalidate_after: revalidateAfter,
54
112
  label: "Cite as primary",
55
113
  meaning: "This source is relevant, current, and traceable enough to use as main evidence.",
56
114
  action: "Use it as primary citation evidence, while keeping normal source-review standards.",
@@ -60,6 +118,9 @@ function decisionResult(decision, reasons, warnings) {
60
118
  case "cite_as_supporting":
61
119
  return {
62
120
  decision,
121
+ verdict_id,
122
+ evaluated_at: evaluatedAt,
123
+ revalidate_after: revalidateAfter,
63
124
  label: "Cite as supporting",
64
125
  meaning: "This source is useful evidence, but should not be the only or latest support.",
65
126
  action: "Use it as supporting evidence and pair it with stronger or newer sources.",
@@ -69,6 +130,9 @@ function decisionResult(decision, reasons, warnings) {
69
130
  case "use_as_background":
70
131
  return {
71
132
  decision,
133
+ verdict_id,
134
+ evaluated_at: evaluatedAt,
135
+ revalidate_after: revalidateAfter,
72
136
  label: "Use as background",
73
137
  meaning: "This source is relevant context, but not strong enough for latest-evidence claims.",
74
138
  action: "Use it for framing, history, or background rather than as the main current source.",
@@ -78,6 +142,9 @@ function decisionResult(decision, reasons, warnings) {
78
142
  case "needs_verification":
79
143
  return {
80
144
  decision,
145
+ verdict_id,
146
+ evaluated_at: evaluatedAt,
147
+ revalidate_after: revalidateAfter,
81
148
  label: "Needs verification",
82
149
  meaning: "This source may be useful, but its date, confidence, or traceability is uncertain.",
83
150
  action: "Verify the source details before citing it, acting on it, or sending it to a model as trusted context.",
@@ -87,6 +154,9 @@ function decisionResult(decision, reasons, warnings) {
87
154
  case "needs_refresh":
88
155
  return {
89
156
  decision,
157
+ verdict_id,
158
+ evaluated_at: evaluatedAt,
159
+ revalidate_after: revalidateAfter,
90
160
  label: "Needs refresh",
91
161
  meaning: "This source may be useful, but it is too stale or date-uncertain for this source type.",
92
162
  action: "Refresh or re-query this source before relying on it as current context.",
@@ -96,6 +166,9 @@ function decisionResult(decision, reasons, warnings) {
96
166
  case "watch_only":
97
167
  return {
98
168
  decision,
169
+ verdict_id,
170
+ evaluated_at: evaluatedAt,
171
+ revalidate_after: revalidateAfter,
99
172
  label: "Watch only",
100
173
  meaning: "This is an interesting signal, but not strong enough to prioritize.",
101
174
  action: "Monitor it or keep it as a weak signal; do not use it as main evidence.",
@@ -105,6 +178,9 @@ function decisionResult(decision, reasons, warnings) {
105
178
  case "exclude":
106
179
  return {
107
180
  decision,
181
+ verdict_id,
182
+ evaluated_at: evaluatedAt,
183
+ revalidate_after: revalidateAfter,
108
184
  label: "Exclude",
109
185
  meaning: "This source is failed, too weak, or unsafe to include as useful context.",
110
186
  action: "Keep it out of the final context bundle unless a human explicitly reviews it.",
@@ -117,6 +193,11 @@ export function interpretEvaluation(evaluation, options = {}) {
117
193
  const sourceProfile = resolveSourceProfile(options.sourceProfile);
118
194
  const sourceProfileId = profileId(sourceProfile);
119
195
  const intentProfile = options.intentProfile;
196
+ // Pass 21: decision-time clock. Computed once here and passed through to the
197
+ // factory — never recomputed at read time. revalidate_after is explicit null
198
+ // when no source profile is available (no decay basis = no honest hint).
199
+ const evaluatedAt = computeEvaluatedAt(options.now);
200
+ const revalidateAfter = computeRevalidateAfter(evaluatedAt, sourceProfile);
120
201
  const reasons = unique([
121
202
  evaluation.explanation,
122
203
  ...evaluation.signal.reasons,
@@ -136,38 +217,38 @@ export function interpretEvaluation(evaluation, options = {}) {
136
217
  reasons.push(`intent profile ${intentProfile} selected`);
137
218
  }
138
219
  if (isFailed) {
139
- return decisionResult("exclude", reasons, warnings);
220
+ return decisionResult("exclude", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
140
221
  }
141
222
  if (sourceProfileId
142
223
  && STRICT_REFRESH_PROFILES.has(sourceProfileId)
143
224
  && (freshnessScore === null || freshnessScore < 50)) {
144
- return decisionResult("needs_refresh", reasons, warnings);
225
+ return decisionResult("needs_refresh", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
145
226
  }
146
227
  if (evaluation.signal.date_confidence === "unknown") {
147
228
  if (sourceProfileId === "academic_research" && finalScore >= 0.75) {
148
- return decisionResult(isCitationIntent(intentProfile) ? "cite_as_supporting" : "use_as_background", reasons, warnings);
229
+ return decisionResult(isCitationIntent(intentProfile) ? "cite_as_supporting" : "use_as_background", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
149
230
  }
150
- return decisionResult("needs_verification", reasons, warnings);
231
+ return decisionResult("needs_verification", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
151
232
  }
152
233
  if (finalScore >= 0.85
153
234
  && freshnessScore !== null
154
235
  && freshnessScore >= 70
155
236
  && confidence === "high") {
156
237
  if (sourceProfile?.authority_hint === "high" && isCitationIntent(intentProfile)) {
157
- return decisionResult("cite_as_primary", reasons, warnings);
238
+ return decisionResult("cite_as_primary", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
158
239
  }
159
- return decisionResult("use_first", reasons, warnings);
240
+ return decisionResult("use_first", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
160
241
  }
161
242
  if (finalScore >= 0.55 && freshnessScore !== null && freshnessScore < 50) {
162
- return decisionResult(isCitationIntent(intentProfile) ? "cite_as_supporting" : "use_as_background", reasons, warnings);
243
+ return decisionResult(isCitationIntent(intentProfile) ? "cite_as_supporting" : "use_as_background", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
163
244
  }
164
245
  if (finalScore < 0.35) {
165
- return decisionResult(confidence === "low" ? "exclude" : "watch_only", reasons, warnings);
246
+ return decisionResult(confidence === "low" ? "exclude" : "watch_only", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
166
247
  }
167
248
  if (finalScore >= 0.55) {
168
- return decisionResult("use_as_background", reasons, warnings);
249
+ return decisionResult("use_as_background", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
169
250
  }
170
- return decisionResult("watch_only", reasons, warnings);
251
+ return decisionResult("watch_only", reasons, warnings, evaluation, sourceProfileId, intentProfile, evaluatedAt, revalidateAfter);
171
252
  }
172
253
  export function interpretEvaluations(evaluations, options = {}) {
173
254
  return evaluations.map((evaluation) => interpretEvaluation(evaluation, options));
@@ -0,0 +1,3 @@
1
+ export { LAMBDA, calculateFreshnessScore, scoreLabel } from "./decay.js";
2
+ export { BUILT_IN_SOURCE_PROFILES, getSourceProfile, listSourceProfiles, } from "./sourceProfiles.js";
3
+ export type { SourceProfile, SourceProfileId } from "./types.js";
@@ -0,0 +1,18 @@
1
+ // edge.ts — Edge-safe Core math boundary (Pass 20-A).
2
+ //
3
+ // Re-exports ONLY Core modules whose entire transitive import graph is free
4
+ // of node:crypto, so the Worker — or any runtime without nodejs_compat — can
5
+ // pull the math without dragging in Node-only APIs.
6
+ //
7
+ // Guard: tests/coreEdgeBoundary.test.ts walks this barrel's transitive value-
8
+ // import graph and fails if anything reachable from here imports node:crypto.
9
+ // Adding a new export here MUST keep that test green.
10
+ //
11
+ // Cleared-but-deliberately-deferred (verified crypto-free in Phase 0, kept
12
+ // out for now because no consumer needs them at the edge yet):
13
+ // guards.ts, rank.ts, utility.ts, signal.ts
14
+ // Widening the boundary before there is a real consumer just creates more
15
+ // contract to maintain. Add here only when a Worker or external consumer
16
+ // actually needs them at the edge — and re-run the guard test at that moment.
17
+ export { LAMBDA, calculateFreshnessScore, scoreLabel } from "./decay.js";
18
+ export { BUILT_IN_SOURCE_PROFILES, getSourceProfile, listSourceProfiles, } from "./sourceProfiles.js";
@@ -1,4 +1,4 @@
1
- import { calculateFreshnessScore, isMeaningfullyFutureDate, scoreLabel } from "./decay.js";
1
+ import { calculateFreshnessScore, computeRevalidateAfter, isMeaningfullyFutureDate, scoreLabel, stalenessVerdict } from "./decay.js";
2
2
  import { looksLikeFailedAdapterContent } from "./guards.js";
3
3
  export const MAX_ENVELOPE_CONTENT_LENGTH = 20000;
4
4
  function clampEnvelopeMaxLength(maxLength) {
@@ -15,6 +15,8 @@ export function stampFreshness(result, options, adapter) {
15
15
  const futureDated = !failedContent && isMeaningfullyFutureDate(content_date, retrieved_at);
16
16
  const freshness_confidence = failedContent || futureDated ? "low" : result.freshness_confidence;
17
17
  const freshness_score = calculateFreshnessScore(content_date, retrieved_at, adapter);
18
+ const staleness = stalenessVerdict(freshness_score);
19
+ const revalidate_after = computeRevalidateAfter(content_date, retrieved_at, adapter);
18
20
  return {
19
21
  content: result.raw.slice(0, clampEnvelopeMaxLength(options.maxLength)),
20
22
  source_url: options.url,
@@ -23,6 +25,8 @@ export function stampFreshness(result, options, adapter) {
23
25
  freshness_confidence,
24
26
  freshness_score,
25
27
  adapter,
28
+ staleness,
29
+ revalidate_after,
26
30
  };
27
31
  }
28
32
  export function toStructuredJSON(ctx) {
@@ -34,6 +38,8 @@ export function toStructuredJSON(ctx) {
34
38
  freshness_confidence: ctx.freshness_confidence,
35
39
  freshness_score: ctx.freshness_score,
36
40
  adapter: ctx.adapter,
41
+ staleness: ctx.staleness,
42
+ revalidate_after: ctx.revalidate_after,
37
43
  },
38
44
  content: ctx.content,
39
45
  };
@@ -47,6 +53,9 @@ export function formatForLLM(ctx, options = {}) {
47
53
  const scoreLine = ctx.freshness_score !== null
48
54
  ? `Score: ${ctx.freshness_score}/100 (${scoreLabel(ctx.freshness_score)})`
49
55
  : `Score: unknown`;
56
+ const stalenessLine = ctx.revalidate_after !== null
57
+ ? `Staleness: ${ctx.staleness} (revalidate by ${ctx.revalidate_after})`
58
+ : `Staleness: ${ctx.staleness}`;
50
59
  const textEnvelope = [
51
60
  `[FRESHCONTEXT]`,
52
61
  `Source: ${ctx.source_url}`,
@@ -54,6 +63,7 @@ export function formatForLLM(ctx, options = {}) {
54
63
  `Retrieved: ${ctx.retrieved_at}`,
55
64
  `Confidence: ${ctx.freshness_confidence}`,
56
65
  `${scoreLine}`,
66
+ `${stalenessLine}`,
57
67
  `---`,
58
68
  ctx.content,
59
69
  `[/FRESHCONTEXT]`,
@@ -1,4 +1,5 @@
1
- export { LAMBDA, calculateFreshnessScore, scoreLabel } from "./decay.js";
1
+ export { LAMBDA, calculateFreshnessScore, scoreLabel, stalenessVerdict, computeRevalidateAfter } from "./decay.js";
2
+ export type { StalenessVerdict } from "./decay.js";
2
3
  export { looksLikeFailedAdapterContent } from "./guards.js";
3
4
  export { stampFreshness, toStructuredJSON, formatForLLM } from "./envelope.js";
4
5
  export { explainSignal } from "./explain.js";
@@ -6,9 +7,9 @@ export { rankSignals, rankSignal, clampScore } from "./rank.js";
6
7
  export { calculateContextUtility } from "./utility.js";
7
8
  export { SIGNAL_CONTRACT_VERSION, normalizeSignal } from "./signal.js";
8
9
  export { evaluateSignal, evaluateSignals } from "./pipeline.js";
9
- export { interpretEvaluation, interpretEvaluations } from "./decision.js";
10
+ export { interpretEvaluation, interpretEvaluations, computeVerdictId } from "./decision.js";
10
11
  export { toReadableContextResult } from "./readable.js";
11
12
  export { prepareProvenanceReadiness } from "./provenanceReadiness.js";
12
13
  export { BUILT_IN_SOURCE_PROFILES, getSourceProfile, listSourceProfiles } from "./sourceProfiles.js";
13
- export { canonicalizeHaPriContent, sha256Hex, calculateHaPriV2, verifyHaPriV2, } from "./provenance.js";
14
- export type { FreshContext, ExtractOptions, AdapterResult, EnvelopeFormatOptions, SignalConfidence, SignalDateConfidence, SignalContractVersion, SourceAuthorityHint, SourceDatePolicy, SourceFailurePolicy, SourceProfile, SourceProfileId, SourceSurface, ContextDecision, IntentProfileId, ContextDecisionOptions, ContextDecisionResult, HumanReadableHandoffResult, HumanReadableContextResult, SignalNormalizeOptions, FreshContextSignalInput, FreshContextSignal, FreshSignal, RankedSignal, RankOptions, ContextUtilityStatus, ContextUtilityInput, ContextUtilityResult, HaPriV2Input, HaPriV2Material, HaPriV2Result, HaPriVerificationStatus, HaPriV2VerificationResult, ProvenanceReadinessState, ProvenanceSourceIdentityCompleteness, ProvenanceTimingCompleteness, ProvenanceReadinessInput, ProvenanceReadinessOptions, ProvenanceSourceIdentityResult, ProvenanceReadinessResult, CoreSignalProvenanceOptions, CoreSignalEnvelopeResult, CoreSignalEvaluationOptions, CoreSignalEvaluationResult, } from "./types.js";
14
+ export { canonicalizeHaPriContent, sha256Hex, calculateHaPriV2, buildHaPriPayload, buildHaPriPayloadV3, verifyHaPriV2, } from "./provenance.js";
15
+ export type { FreshContext, ExtractOptions, AdapterResult, EnvelopeFormatOptions, SignalConfidence, SignalDateConfidence, SignalContractVersion, SourceAuthorityHint, SourceDatePolicy, SourceFailurePolicy, SourceProfile, SourceProfileId, SourceSurface, ContextDecision, IntentProfileId, ContextDecisionOptions, ContextDecisionResult, HumanReadableHandoffResult, HumanReadableContextResult, SignalNormalizeOptions, FreshContextSignalInput, FreshContextSignal, FreshSignal, RankedSignal, RankOptions, ContextUtilityStatus, ContextUtilityInput, ContextUtilityResult, HaPriV2Input, HaPriV3Input, HaPriV2Material, HaPriV2Result, HaPriVerificationStatus, HaPriV2VerificationResult, ProvenanceReadinessState, ProvenanceSourceIdentityCompleteness, ProvenanceTimingCompleteness, ProvenanceReadinessInput, ProvenanceReadinessOptions, ProvenanceSourceIdentityResult, ProvenanceReadinessResult, CoreSignalProvenanceOptions, CoreSignalEnvelopeResult, CoreSignalEvaluationOptions, CoreSignalEvaluationResult, } from "./types.js";
@@ -1,4 +1,4 @@
1
- export { LAMBDA, calculateFreshnessScore, scoreLabel } from "./decay.js";
1
+ export { LAMBDA, calculateFreshnessScore, scoreLabel, stalenessVerdict, computeRevalidateAfter } from "./decay.js";
2
2
  export { looksLikeFailedAdapterContent } from "./guards.js";
3
3
  export { stampFreshness, toStructuredJSON, formatForLLM } from "./envelope.js";
4
4
  export { explainSignal } from "./explain.js";
@@ -6,8 +6,8 @@ export { rankSignals, rankSignal, clampScore } from "./rank.js";
6
6
  export { calculateContextUtility } from "./utility.js";
7
7
  export { SIGNAL_CONTRACT_VERSION, normalizeSignal } from "./signal.js";
8
8
  export { evaluateSignal, evaluateSignals } from "./pipeline.js";
9
- export { interpretEvaluation, interpretEvaluations } from "./decision.js";
9
+ export { interpretEvaluation, interpretEvaluations, computeVerdictId } from "./decision.js";
10
10
  export { toReadableContextResult } from "./readable.js";
11
11
  export { prepareProvenanceReadiness } from "./provenanceReadiness.js";
12
12
  export { BUILT_IN_SOURCE_PROFILES, getSourceProfile, listSourceProfiles } from "./sourceProfiles.js";
13
- export { canonicalizeHaPriContent, sha256Hex, calculateHaPriV2, verifyHaPriV2, } from "./provenance.js";
13
+ export { canonicalizeHaPriContent, sha256Hex, calculateHaPriV2, buildHaPriPayload, buildHaPriPayloadV3, verifyHaPriV2, } from "./provenance.js";
@@ -1,4 +1,4 @@
1
- import { LAMBDA, calculateFreshnessScore } from "./decay.js";
1
+ import { LAMBDA, calculateFreshnessScore, computeRevalidateAfter, stalenessVerdict } from "./decay.js";
2
2
  import { formatForLLM, toStructuredJSON } from "./envelope.js";
3
3
  import { calculateHaPriV2 } from "./provenance.js";
4
4
  import { prepareProvenanceReadiness } from "./provenanceReadiness.js";
@@ -22,6 +22,13 @@ function envelopeConfidence(signal) {
22
22
  function createEnvelope(signal, freshnessScore, options) {
23
23
  if (!options.includeEnvelope || signal.content === undefined)
24
24
  return undefined;
25
+ // Mirrors the same failed/unknown override applied to freshnessScore at the
26
+ // call site, so staleness === "unknown" iff revalidate_after === null holds
27
+ // here too (computeRevalidateAfter's own date guard alone wouldn't catch a
28
+ // failed-status signal that still carries a parseable published_at).
29
+ const revalidate_after = signal.status === "failed" || signal.date_confidence === "unknown"
30
+ ? null
31
+ : computeRevalidateAfter(signal.published_at, signal.retrieved_at, signal.source_type);
25
32
  const ctx = {
26
33
  content: signal.content.slice(0, options.envelopeMaxLength ?? 8000),
27
34
  source_url: signal.source,
@@ -30,6 +37,8 @@ function createEnvelope(signal, freshnessScore, options) {
30
37
  freshness_confidence: envelopeConfidence(signal),
31
38
  freshness_score: freshnessScore,
32
39
  adapter: signal.source_type,
40
+ staleness: stalenessVerdict(freshnessScore),
41
+ revalidate_after,
33
42
  };
34
43
  return {
35
44
  context: ctx,
@@ -1,5 +1,7 @@
1
- import type { HaPriV2Input, HaPriV2Result, HaPriV2VerificationResult } from "./types.js";
1
+ import type { HaPriV2Input, HaPriV3Input, HaPriV2Result, HaPriV2VerificationResult } from "./types.js";
2
2
  export declare function canonicalizeHaPriContent(input: string): string;
3
3
  export declare function sha256Hex(input: string): string;
4
4
  export declare function calculateHaPriV2(input: HaPriV2Input): HaPriV2Result;
5
+ export declare function buildHaPriPayload(input: HaPriV2Input): string;
6
+ export declare function buildHaPriPayloadV3(input: HaPriV3Input): string;
5
7
  export declare function verifyHaPriV2(input: HaPriV2Input, actualSig: string | null | undefined): HaPriV2VerificationResult;