freshcontext-mcp 0.3.17 → 0.3.19

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +17 -0
  3. package/README.md +459 -296
  4. package/SECURITY.md +34 -0
  5. package/TRADEMARKS.md +9 -0
  6. package/dist/adapters/arxiv.js +92 -48
  7. package/dist/adapters/hackernews.js +16 -16
  8. package/dist/adapters/registry.js +232 -0
  9. package/dist/core/decay.js +61 -0
  10. package/dist/core/decision.js +176 -0
  11. package/dist/core/envelope.js +59 -0
  12. package/dist/core/explain.js +28 -0
  13. package/dist/core/guards.js +17 -0
  14. package/dist/core/index.js +11 -0
  15. package/dist/core/pipeline.js +101 -0
  16. package/dist/core/provenance.js +73 -0
  17. package/dist/core/rank.js +84 -0
  18. package/dist/core/signal.js +101 -0
  19. package/dist/core/sourceProfiles.js +126 -0
  20. package/dist/core/types.js +1 -0
  21. package/dist/core/utility.js +90 -0
  22. package/dist/rest/handler.js +126 -0
  23. package/dist/server.js +40 -2
  24. package/dist/tools/evaluateContext.js +127 -0
  25. package/dist/tools/freshnessStamp.js +1 -137
  26. package/dist/types.js +0 -1
  27. package/docs/API_DESIGN.md +434 -0
  28. package/docs/CODEX_MCP_USAGE.md +116 -0
  29. package/docs/CORE_API.md +226 -0
  30. package/docs/CORE_MCP_BOUNDARY.md +106 -0
  31. package/docs/DEPENDENCY_DILIGENCE.md +63 -0
  32. package/docs/FUTURE_LANES.md +173 -0
  33. package/docs/HA_PRI_V2_DESIGN.md +279 -0
  34. package/docs/RELEASE_INTEGRITY.md +55 -0
  35. package/docs/RELEASE_NOTES.md +55 -0
  36. package/docs/SIGNAL_CONTRACT.md +89 -0
  37. package/docs/SOURCE_PROFILES.md +427 -0
  38. package/freshcontext.schema.json +103 -103
  39. package/package-script-guard.mjs +141 -0
  40. package/package.json +94 -59
  41. package/server.json +27 -28
  42. package/dist/apify.js +0 -133
@@ -0,0 +1,176 @@
1
+ import { getSourceProfile } from "./sourceProfiles.js";
2
+ const CITATION_INTENTS = new Set(["citation_check", "student_research"]);
3
+ const STRICT_REFRESH_PROFILES = new Set(["market_finance", "jobs_opportunities"]);
4
+ function resolveSourceProfile(profile) {
5
+ if (!profile)
6
+ return undefined;
7
+ return typeof profile === "string" ? getSourceProfile(profile) : profile;
8
+ }
9
+ function profileId(profile) {
10
+ return profile?.profile_id;
11
+ }
12
+ function unique(values) {
13
+ return [...new Set(values.filter(Boolean))];
14
+ }
15
+ function hasFailureReason(evaluation) {
16
+ return evaluation.reasons.some((reason) => /\b(?:failed|failure|timeout|error|blocked|upstream)\b/i.test(reason));
17
+ }
18
+ function isCitationIntent(intent) {
19
+ return intent !== undefined && CITATION_INTENTS.has(intent);
20
+ }
21
+ function nonAdviceWarnings(intent) {
22
+ switch (intent) {
23
+ case "citation_check":
24
+ case "student_research":
25
+ return ["FreshContext judges citation readiness and context usefulness; it does not certify truth."];
26
+ case "medical_literature_triage":
27
+ return ["FreshContext provides literature triage only; it is not medical advice."];
28
+ case "market_watch":
29
+ return ["FreshContext provides market signal triage only; it is not investment advice."];
30
+ case "business_due_diligence":
31
+ return ["FreshContext supports context triage only; it is not legal, tax, or investment advice."];
32
+ case "job_search":
33
+ return ["FreshContext provides opportunity triage only; it is not employment or legal advice."];
34
+ default:
35
+ return [];
36
+ }
37
+ }
38
+ function decisionResult(decision, reasons, warnings) {
39
+ const copyReasons = unique(reasons);
40
+ const copyWarnings = unique(warnings);
41
+ switch (decision) {
42
+ case "use_first":
43
+ return {
44
+ decision,
45
+ label: "Use first",
46
+ meaning: "This is strong, current context for the task.",
47
+ action: "Use this near the top of the context bundle.",
48
+ reasons: copyReasons,
49
+ warnings: copyWarnings,
50
+ };
51
+ case "cite_as_primary":
52
+ return {
53
+ decision,
54
+ label: "Cite as primary",
55
+ meaning: "This source is relevant, current, and traceable enough to use as main evidence.",
56
+ action: "Use it as primary citation evidence, while keeping normal source-review standards.",
57
+ reasons: copyReasons,
58
+ warnings: copyWarnings,
59
+ };
60
+ case "cite_as_supporting":
61
+ return {
62
+ decision,
63
+ label: "Cite as supporting",
64
+ meaning: "This source is useful evidence, but should not be the only or latest support.",
65
+ action: "Use it as supporting evidence and pair it with stronger or newer sources.",
66
+ reasons: copyReasons,
67
+ warnings: copyWarnings,
68
+ };
69
+ case "use_as_background":
70
+ return {
71
+ decision,
72
+ label: "Use as background",
73
+ meaning: "This source is relevant context, but not strong enough for latest-evidence claims.",
74
+ action: "Use it for framing, history, or background rather than as the main current source.",
75
+ reasons: copyReasons,
76
+ warnings: copyWarnings,
77
+ };
78
+ case "needs_verification":
79
+ return {
80
+ decision,
81
+ label: "Needs verification",
82
+ meaning: "This source may be useful, but its date, confidence, or traceability is uncertain.",
83
+ action: "Verify the source details before citing it, acting on it, or sending it to a model as trusted context.",
84
+ reasons: copyReasons,
85
+ warnings: copyWarnings,
86
+ };
87
+ case "needs_refresh":
88
+ return {
89
+ decision,
90
+ label: "Needs refresh",
91
+ meaning: "This source may be useful, but it is too stale or date-uncertain for this source type.",
92
+ action: "Refresh or re-query this source before relying on it as current context.",
93
+ reasons: copyReasons,
94
+ warnings: copyWarnings,
95
+ };
96
+ case "watch_only":
97
+ return {
98
+ decision,
99
+ label: "Watch only",
100
+ meaning: "This is an interesting signal, but not strong enough to prioritize.",
101
+ action: "Monitor it or keep it as a weak signal; do not use it as main evidence.",
102
+ reasons: copyReasons,
103
+ warnings: copyWarnings,
104
+ };
105
+ case "exclude":
106
+ return {
107
+ decision,
108
+ label: "Exclude",
109
+ meaning: "This source is failed, too weak, or unsafe to include as useful context.",
110
+ action: "Keep it out of the final context bundle unless a human explicitly reviews it.",
111
+ reasons: copyReasons,
112
+ warnings: copyWarnings,
113
+ };
114
+ }
115
+ }
116
+ export function interpretEvaluation(evaluation, options = {}) {
117
+ const sourceProfile = resolveSourceProfile(options.sourceProfile);
118
+ const sourceProfileId = profileId(sourceProfile);
119
+ const intentProfile = options.intentProfile;
120
+ const reasons = unique([
121
+ evaluation.explanation,
122
+ ...evaluation.signal.reasons,
123
+ ...evaluation.utility.reasons,
124
+ ...evaluation.reasons,
125
+ ]);
126
+ const warnings = [...nonAdviceWarnings(intentProfile)];
127
+ const finalScore = evaluation.ranked.final_score;
128
+ const utilityScore = evaluation.utility.score;
129
+ const freshnessScore = evaluation.freshness_score;
130
+ const confidence = evaluation.ranked.confidence;
131
+ const isFailed = evaluation.signal.status === "failed"
132
+ || (confidence === "low" && hasFailureReason(evaluation));
133
+ if (sourceProfile) {
134
+ reasons.push(`source profile ${sourceProfile.profile_id} uses ${sourceProfile.date_policy} date policy`);
135
+ }
136
+ if (intentProfile) {
137
+ reasons.push(`intent profile ${intentProfile} selected`);
138
+ }
139
+ if (isFailed) {
140
+ return decisionResult("exclude", reasons, warnings);
141
+ }
142
+ if (sourceProfileId
143
+ && STRICT_REFRESH_PROFILES.has(sourceProfileId)
144
+ && (freshnessScore === null || freshnessScore < 50)) {
145
+ return decisionResult("needs_refresh", reasons, warnings);
146
+ }
147
+ if (evaluation.signal.date_confidence === "unknown") {
148
+ if (sourceProfileId === "academic_research" && finalScore >= 0.75) {
149
+ return decisionResult(isCitationIntent(intentProfile) ? "cite_as_supporting" : "use_as_background", reasons, warnings);
150
+ }
151
+ return decisionResult("needs_verification", reasons, warnings);
152
+ }
153
+ if (finalScore >= 0.85
154
+ && freshnessScore !== null
155
+ && freshnessScore >= 70
156
+ && utilityScore >= 60
157
+ && confidence === "high") {
158
+ if (sourceProfile?.authority_hint === "high" && isCitationIntent(intentProfile)) {
159
+ return decisionResult("cite_as_primary", reasons, warnings);
160
+ }
161
+ return decisionResult("use_first", reasons, warnings);
162
+ }
163
+ if (finalScore >= 0.55 && freshnessScore !== null && freshnessScore < 50) {
164
+ return decisionResult(isCitationIntent(intentProfile) ? "cite_as_supporting" : "use_as_background", reasons, warnings);
165
+ }
166
+ if (finalScore < 0.35 && utilityScore < 30) {
167
+ return decisionResult(confidence === "low" ? "exclude" : "watch_only", reasons, warnings);
168
+ }
169
+ if (finalScore >= 0.55) {
170
+ return decisionResult("use_as_background", reasons, warnings);
171
+ }
172
+ return decisionResult("watch_only", reasons, warnings);
173
+ }
174
+ export function interpretEvaluations(evaluations, options = {}) {
175
+ return evaluations.map((evaluation) => interpretEvaluation(evaluation, options));
176
+ }
@@ -0,0 +1,59 @@
1
+ import { calculateFreshnessScore, isMeaningfullyFutureDate, scoreLabel } from "./decay.js";
2
+ import { looksLikeFailedAdapterContent } from "./guards.js";
3
+ export function stampFreshness(result, options, adapter) {
4
+ const retrieved_at = new Date().toISOString();
5
+ const failedContent = looksLikeFailedAdapterContent(result.raw);
6
+ const content_date = failedContent ? null : result.content_date;
7
+ const futureDated = !failedContent && isMeaningfullyFutureDate(content_date, retrieved_at);
8
+ const freshness_confidence = failedContent || futureDated ? "low" : result.freshness_confidence;
9
+ const freshness_score = calculateFreshnessScore(content_date, retrieved_at, adapter);
10
+ return {
11
+ content: result.raw.slice(0, options.maxLength ?? 8000),
12
+ source_url: options.url,
13
+ content_date,
14
+ retrieved_at,
15
+ freshness_confidence,
16
+ freshness_score,
17
+ adapter,
18
+ };
19
+ }
20
+ export function toStructuredJSON(ctx) {
21
+ return {
22
+ freshcontext: {
23
+ source_url: ctx.source_url,
24
+ content_date: ctx.content_date,
25
+ retrieved_at: ctx.retrieved_at,
26
+ freshness_confidence: ctx.freshness_confidence,
27
+ freshness_score: ctx.freshness_score,
28
+ adapter: ctx.adapter,
29
+ },
30
+ content: ctx.content,
31
+ };
32
+ }
33
+ export function formatForLLM(ctx, options = {}) {
34
+ const publishedLabel = options.publishedLabel ?? "Published";
35
+ const unknownDateText = options.unknownDateText ?? "Publish date: unknown";
36
+ const dateInfo = ctx.content_date
37
+ ? `${publishedLabel}: ${ctx.content_date}`
38
+ : unknownDateText;
39
+ const scoreLine = ctx.freshness_score !== null
40
+ ? `Score: ${ctx.freshness_score}/100 (${scoreLabel(ctx.freshness_score)})`
41
+ : `Score: unknown`;
42
+ const textEnvelope = [
43
+ `[FRESHCONTEXT]`,
44
+ `Source: ${ctx.source_url}`,
45
+ `${dateInfo}`,
46
+ `Retrieved: ${ctx.retrieved_at}`,
47
+ `Confidence: ${ctx.freshness_confidence}`,
48
+ `${scoreLine}`,
49
+ `---`,
50
+ ctx.content,
51
+ `[/FRESHCONTEXT]`,
52
+ ].join("\n");
53
+ const jsonBlock = [
54
+ `[FRESHCONTEXT_JSON]`,
55
+ JSON.stringify(toStructuredJSON(ctx), null, 2),
56
+ `[/FRESHCONTEXT_JSON]`,
57
+ ].join("\n");
58
+ return `${textEnvelope}\n\n${jsonBlock}`;
59
+ }
@@ -0,0 +1,28 @@
1
+ function sourceLabel(input) {
2
+ return input.source_type || input.source || "source";
3
+ }
4
+ export function explainSignal(input) {
5
+ const source = sourceLabel(input);
6
+ if (input.freshness_score === null) {
7
+ if (input.semantic_score < 0.5) {
8
+ return `Low confidence: weak semantic match and missing freshness data for ${source}.`;
9
+ }
10
+ return `Missing freshness data for ${source}; ranked mostly by semantic relevance.`;
11
+ }
12
+ if (input.semantic_score < 0.5) {
13
+ if (input.freshness_score >= 70) {
14
+ return `Fresh signal from ${source}, but semantic relevance is weak.`;
15
+ }
16
+ return `Weak semantic match with limited freshness for ${source}.`;
17
+ }
18
+ if (input.freshness_score >= 90) {
19
+ return `Strong semantic match and current freshness for ${source}.`;
20
+ }
21
+ if (input.freshness_score >= 70) {
22
+ return `Relevant signal with reliable freshness for ${source}.`;
23
+ }
24
+ if (input.freshness_score >= 50) {
25
+ return `Relevant signal, but freshness should be verified for ${source}.`;
26
+ }
27
+ return `Relevant signal, but stale for ${source}.`;
28
+ }
@@ -0,0 +1,17 @@
1
+ export function looksLikeFailedAdapterContent(raw) {
2
+ const trimmed = raw.trim();
3
+ if (!trimmed)
4
+ return true;
5
+ if (/^\[(?:error|security)\]/i.test(trimmed))
6
+ return true;
7
+ if (/^(?:error|failed|upstream|timeout)\b/i.test(trimmed))
8
+ return true;
9
+ const meaningful = trimmed
10
+ .split(/\r?\n/)
11
+ .map((line) => line.trim())
12
+ .filter(Boolean);
13
+ if (!meaningful.length)
14
+ return true;
15
+ const failureLines = meaningful.filter((line) => /\b(?:error|failed|failure|timeout|401|403|404|429|5\d\d)\b/i.test(line));
16
+ return failureLines.length === meaningful.length;
17
+ }
@@ -0,0 +1,11 @@
1
+ export { LAMBDA, calculateFreshnessScore, scoreLabel } from "./decay.js";
2
+ export { looksLikeFailedAdapterContent } from "./guards.js";
3
+ export { stampFreshness, toStructuredJSON, formatForLLM } from "./envelope.js";
4
+ export { explainSignal } from "./explain.js";
5
+ export { rankSignals, rankSignal, clampScore } from "./rank.js";
6
+ export { calculateContextUtility } from "./utility.js";
7
+ export { SIGNAL_CONTRACT_VERSION, normalizeSignal } from "./signal.js";
8
+ export { evaluateSignal, evaluateSignals } from "./pipeline.js";
9
+ export { interpretEvaluation, interpretEvaluations } from "./decision.js";
10
+ export { BUILT_IN_SOURCE_PROFILES, getSourceProfile, listSourceProfiles } from "./sourceProfiles.js";
11
+ export { canonicalizeHaPriContent, sha256Hex, calculateHaPriV2, verifyHaPriV2, } from "./provenance.js";
@@ -0,0 +1,101 @@
1
+ import { LAMBDA, calculateFreshnessScore } from "./decay.js";
2
+ import { formatForLLM, toStructuredJSON } from "./envelope.js";
3
+ import { calculateHaPriV2 } from "./provenance.js";
4
+ import { rankSignal } from "./rank.js";
5
+ import { normalizeSignal } from "./signal.js";
6
+ import { calculateContextUtility } from "./utility.js";
7
+ function ageHours(signal) {
8
+ if (!signal.published_at)
9
+ return 0;
10
+ const published = new Date(signal.published_at).getTime();
11
+ const retrieved = new Date(signal.retrieved_at).getTime();
12
+ if (isNaN(published) || isNaN(retrieved))
13
+ return 0;
14
+ return Math.max(0, (retrieved - published) / (1000 * 60 * 60));
15
+ }
16
+ function envelopeConfidence(signal) {
17
+ if (signal.status === "failed" || signal.date_confidence === "unknown")
18
+ return "low";
19
+ return signal.date_confidence;
20
+ }
21
+ function createEnvelope(signal, freshnessScore, options) {
22
+ if (!options.includeEnvelope || signal.content === undefined)
23
+ return undefined;
24
+ const ctx = {
25
+ content: signal.content.slice(0, options.envelopeMaxLength ?? 8000),
26
+ source_url: signal.source,
27
+ content_date: signal.published_at,
28
+ retrieved_at: signal.retrieved_at,
29
+ freshness_confidence: envelopeConfidence(signal),
30
+ freshness_score: freshnessScore,
31
+ adapter: signal.source_type,
32
+ };
33
+ return {
34
+ context: ctx,
35
+ text: formatForLLM(ctx, options.envelopeFormat),
36
+ structured: toStructuredJSON(ctx),
37
+ };
38
+ }
39
+ function createProvenance(signal, options, reasons) {
40
+ if (!options.includeProvenance)
41
+ return undefined;
42
+ const resultId = options.provenance?.resultId ?? signal.id;
43
+ const engineVersion = options.provenance?.engineVersion;
44
+ if (!signal.content) {
45
+ reasons.push("provenance was requested but content was missing");
46
+ return undefined;
47
+ }
48
+ if (!resultId) {
49
+ reasons.push("provenance was requested but resultId was missing");
50
+ return undefined;
51
+ }
52
+ if (!engineVersion) {
53
+ reasons.push("provenance was requested but engineVersion was missing");
54
+ return undefined;
55
+ }
56
+ return calculateHaPriV2({
57
+ resultId,
58
+ rawContent: signal.content,
59
+ semanticFingerprint: options.provenance?.semanticFingerprint ?? null,
60
+ adapter: signal.source_type,
61
+ publishedAt: signal.published_at,
62
+ retrievedAt: signal.retrieved_at,
63
+ engineVersion,
64
+ });
65
+ }
66
+ export function evaluateSignal(input, options = {}) {
67
+ const signal = normalizeSignal(input, options);
68
+ const freshness_score = signal.status === "failed" || signal.date_confidence === "unknown"
69
+ ? null
70
+ : calculateFreshnessScore(signal.published_at, signal.retrieved_at, signal.source_type);
71
+ const utility = calculateContextUtility({
72
+ contextualRelevance: signal.semantic_score * 100,
73
+ lambda: LAMBDA[signal.source_type] ?? LAMBDA.default,
74
+ ageHours: ageHours(signal),
75
+ dateConfidence: signal.date_confidence,
76
+ status: signal.status,
77
+ });
78
+ const ranked = rankSignal(signal, options);
79
+ const reasons = [...signal.reasons, ...utility.reasons];
80
+ const envelope = createEnvelope(signal, freshness_score, options);
81
+ const provenance = createProvenance(signal, options, reasons);
82
+ return {
83
+ signal,
84
+ freshness_score,
85
+ utility,
86
+ ranked,
87
+ explanation: ranked.reason,
88
+ envelope,
89
+ provenance,
90
+ reasons,
91
+ };
92
+ }
93
+ export function evaluateSignals(inputs, options = {}) {
94
+ return inputs
95
+ .map((input, index) => ({ evaluation: evaluateSignal(input, options), index }))
96
+ .sort((a, b) => {
97
+ const scoreDiff = b.evaluation.ranked.final_score - a.evaluation.ranked.final_score;
98
+ return scoreDiff !== 0 ? scoreDiff : a.index - b.index;
99
+ })
100
+ .map(({ evaluation }) => evaluation);
101
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+ const HA_PRI_V2_VERSION = "FRESHCONTEXT_HA_PRI_V2";
3
+ const NULL_SENTINEL = "null";
4
+ function fieldValue(value) {
5
+ return value ?? NULL_SENTINEL;
6
+ }
7
+ export function canonicalizeHaPriContent(input) {
8
+ return input
9
+ .replace(/\r\n/g, "\n")
10
+ .replace(/\r/g, "\n")
11
+ .split("\n")
12
+ .map((line) => line.replace(/[ \t]+$/g, ""))
13
+ .join("\n");
14
+ }
15
+ export function sha256Hex(input) {
16
+ return createHash("sha256").update(input, "utf8").digest("hex");
17
+ }
18
+ export function calculateHaPriV2(input) {
19
+ const canonicalContentSha256 = sha256Hex(canonicalizeHaPriContent(input.rawContent));
20
+ const semanticFingerprintSha256 = sha256Hex(fieldValue(input.semanticFingerprint));
21
+ const resultId = fieldValue(input.resultId);
22
+ const adapter = fieldValue(input.adapter);
23
+ const publishedAt = fieldValue(input.publishedAt);
24
+ const retrievedAt = fieldValue(input.retrievedAt);
25
+ const engineVersion = fieldValue(input.engineVersion);
26
+ const signingPayload = [
27
+ HA_PRI_V2_VERSION,
28
+ `result_id=${resultId}`,
29
+ `canonical_content_sha256=${canonicalContentSha256}`,
30
+ `semantic_fingerprint_sha256=${semanticFingerprintSha256}`,
31
+ `adapter=${adapter}`,
32
+ `published_at=${publishedAt}`,
33
+ `retrieved_at=${retrievedAt}`,
34
+ `engine_version=${engineVersion}`,
35
+ ].join("\n");
36
+ return {
37
+ version: HA_PRI_V2_VERSION,
38
+ resultId,
39
+ canonicalContentSha256,
40
+ semanticFingerprintSha256,
41
+ adapter,
42
+ publishedAt,
43
+ retrievedAt,
44
+ engineVersion,
45
+ signingPayload,
46
+ haPriSigV2: sha256Hex(signingPayload),
47
+ };
48
+ }
49
+ export function verifyHaPriV2(input, actualSig) {
50
+ if (actualSig === null || actualSig === undefined || actualSig.trim() === "") {
51
+ return {
52
+ status: "unknown",
53
+ expected: null,
54
+ actual: actualSig ?? null,
55
+ reasons: ["missing ha_pri_sig_v2; verification status unknown"],
56
+ };
57
+ }
58
+ const expected = calculateHaPriV2(input).haPriSigV2;
59
+ if (actualSig === expected) {
60
+ return {
61
+ status: "valid",
62
+ expected,
63
+ actual: actualSig,
64
+ reasons: [],
65
+ };
66
+ }
67
+ return {
68
+ status: "invalid",
69
+ expected,
70
+ actual: actualSig,
71
+ reasons: ["stored ha_pri_sig_v2 did not match recomputed signature"],
72
+ };
73
+ }
@@ -0,0 +1,84 @@
1
+ import { calculateFreshnessScore } from "./decay.js";
2
+ import { explainSignal } from "./explain.js";
3
+ import { looksLikeFailedAdapterContent } from "./guards.js";
4
+ const DEFAULT_SEMANTIC_WEIGHT = 0.7;
5
+ const DEFAULT_FRESHNESS_WEIGHT = 0.3;
6
+ export function clampScore(value) {
7
+ if (!Number.isFinite(value))
8
+ return 0;
9
+ return Math.min(1, Math.max(0, value));
10
+ }
11
+ function positiveNumber(value) {
12
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
13
+ }
14
+ function resolveWeights(options) {
15
+ let semantic = positiveNumber(options.semanticWeight);
16
+ let freshness = positiveNumber(options.freshnessWeight);
17
+ if (semantic === 0 && freshness === 0) {
18
+ semantic = DEFAULT_SEMANTIC_WEIGHT;
19
+ freshness = DEFAULT_FRESHNESS_WEIGHT;
20
+ }
21
+ const total = semantic + freshness;
22
+ return {
23
+ semantic: semantic / total,
24
+ freshness: freshness / total,
25
+ };
26
+ }
27
+ function resolveRetrievedAt(signal, options) {
28
+ if (signal.retrieved_at)
29
+ return signal.retrieved_at;
30
+ if (options.now instanceof Date)
31
+ return options.now.toISOString();
32
+ if (typeof options.now === "string")
33
+ return options.now;
34
+ return new Date().toISOString();
35
+ }
36
+ function resolveSourceType(signal, options) {
37
+ return signal.source_type ?? options.defaultSourceType ?? signal.source ?? "default";
38
+ }
39
+ function isFailedSignal(signal) {
40
+ return signal.status === "failed"
41
+ || (signal.content !== undefined && looksLikeFailedAdapterContent(signal.content));
42
+ }
43
+ function confidenceFor(signal, semanticScore, freshnessScore) {
44
+ if (isFailedSignal(signal)) {
45
+ return "low";
46
+ }
47
+ if (freshnessScore !== null && semanticScore >= 0.7) {
48
+ return "high";
49
+ }
50
+ if (freshnessScore !== null || semanticScore >= 0.5) {
51
+ return "medium";
52
+ }
53
+ return "low";
54
+ }
55
+ export function rankSignal(signal, options = {}) {
56
+ const weights = resolveWeights(options);
57
+ const semantic_score = clampScore(signal.semantic_score);
58
+ const freshness_score = isFailedSignal(signal) || signal.date_confidence === "unknown"
59
+ ? null
60
+ : calculateFreshnessScore(signal.published_at ?? signal.content_date ?? null, resolveRetrievedAt(signal, options), resolveSourceType(signal, options));
61
+ const freshnessComponent = freshness_score === null ? 0 : clampScore(freshness_score / 100);
62
+ const final_score = clampScore(semantic_score * weights.semantic + freshnessComponent * weights.freshness);
63
+ const confidence = confidenceFor(signal, semantic_score, freshness_score);
64
+ const ranked = {
65
+ ...signal,
66
+ semantic_score,
67
+ freshness_score,
68
+ final_score,
69
+ confidence,
70
+ };
71
+ return {
72
+ ...ranked,
73
+ reason: explainSignal(ranked),
74
+ };
75
+ }
76
+ export function rankSignals(signals, options = {}) {
77
+ return signals
78
+ .map((signal, index) => ({ ranked: rankSignal(signal, options), index }))
79
+ .sort((a, b) => {
80
+ const scoreDiff = b.ranked.final_score - a.ranked.final_score;
81
+ return scoreDiff !== 0 ? scoreDiff : a.index - b.index;
82
+ })
83
+ .map(({ ranked }) => ranked);
84
+ }
@@ -0,0 +1,101 @@
1
+ import { isMeaningfullyFutureDate } from "./decay.js";
2
+ import { looksLikeFailedAdapterContent } from "./guards.js";
3
+ export const SIGNAL_CONTRACT_VERSION = "freshcontext.signal.v1";
4
+ const DATE_CONFIDENCE_VALUES = new Set(["high", "medium", "low", "unknown"]);
5
+ const STATUS_VALUES = new Set(["success", "partial", "stale", "failed", "unknown"]);
6
+ function isSignalDateConfidence(value) {
7
+ return typeof value === "string" && DATE_CONFIDENCE_VALUES.has(value);
8
+ }
9
+ function isContextUtilityStatus(value) {
10
+ return typeof value === "string" && STATUS_VALUES.has(value);
11
+ }
12
+ function normalizeDate(value) {
13
+ if (!value)
14
+ return null;
15
+ const timestamp = new Date(value).getTime();
16
+ if (isNaN(timestamp))
17
+ return null;
18
+ return new Date(timestamp).toISOString();
19
+ }
20
+ function resolveRetrievedAt(input, options, reasons) {
21
+ const retrievedAt = normalizeDate(input.retrieved_at);
22
+ if (retrievedAt)
23
+ return retrievedAt;
24
+ if (input.retrieved_at) {
25
+ reasons.push("retrieved_at was invalid; used normalization time");
26
+ }
27
+ const optionNow = options.now instanceof Date
28
+ ? (isNaN(options.now.getTime()) ? null : options.now.toISOString())
29
+ : normalizeDate(options.now);
30
+ return optionNow ?? new Date().toISOString();
31
+ }
32
+ function normalizeSemanticScore(value, reasons) {
33
+ if (typeof value !== "number" || !Number.isFinite(value)) {
34
+ reasons.push("semantic_score was missing or invalid; clamped to 0");
35
+ return 0;
36
+ }
37
+ if (value < 0) {
38
+ reasons.push("semantic_score was below 0; clamped to 0");
39
+ return 0;
40
+ }
41
+ if (value > 1) {
42
+ reasons.push("semantic_score exceeded 1; clamped to 1");
43
+ return 1;
44
+ }
45
+ return value;
46
+ }
47
+ function resolveDateConfidence(input, hasTrustedDate) {
48
+ if (!hasTrustedDate)
49
+ return "unknown";
50
+ if (isSignalDateConfidence(input.date_confidence))
51
+ return input.date_confidence;
52
+ if (input.freshness_confidence)
53
+ return input.freshness_confidence;
54
+ return "medium";
55
+ }
56
+ function resolveStatus(input, failedContent) {
57
+ if (failedContent)
58
+ return "failed";
59
+ if (isContextUtilityStatus(input.status))
60
+ return input.status;
61
+ return "success";
62
+ }
63
+ export function normalizeSignal(input, options = {}) {
64
+ const reasons = [];
65
+ const retrieved_at = resolveRetrievedAt(input, options, reasons);
66
+ const rawPublishedAt = input.published_at ?? input.content_date ?? null;
67
+ let published_at = normalizeDate(rawPublishedAt);
68
+ if (!input.published_at && input.content_date) {
69
+ reasons.push("content_date alias was normalized to published_at");
70
+ }
71
+ if (rawPublishedAt && !published_at) {
72
+ reasons.push("published_at/content_date was invalid; cleared");
73
+ }
74
+ if (published_at && isMeaningfullyFutureDate(published_at, retrieved_at)) {
75
+ published_at = null;
76
+ reasons.push("published_at/content_date was meaningfully future-dated; cleared");
77
+ }
78
+ const failedContent = input.content !== undefined && looksLikeFailedAdapterContent(input.content);
79
+ if (failedContent) {
80
+ reasons.push("content looked like failed adapter output; status set to failed");
81
+ }
82
+ const source_type = input.source_type ?? options.defaultSourceType ?? "default";
83
+ if (!input.source_type && !options.defaultSourceType) {
84
+ reasons.push("source_type was missing; defaulted to default");
85
+ }
86
+ return {
87
+ contract_version: SIGNAL_CONTRACT_VERSION,
88
+ id: input.id,
89
+ source: input.source,
90
+ source_type,
91
+ title: input.title,
92
+ content: input.content,
93
+ published_at,
94
+ retrieved_at,
95
+ semantic_score: normalizeSemanticScore(input.semantic_score, reasons),
96
+ date_confidence: resolveDateConfidence(input, published_at !== null),
97
+ status: resolveStatus(input, failedContent),
98
+ metadata: input.metadata ? { ...input.metadata } : {},
99
+ reasons,
100
+ };
101
+ }