fix(web): keep consultation conclusions across turns and surface cache hits (BUG-555, BUG-556)

Session history was silently clipped to the first 4000 characters of the last 12 messages, so follow-ups could not see timing or audit tables. Keep an append-only tail plus a checkpoint summary, retry overflow in the same request, and expose cache hit rate in admin usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 15:16:18 +08:00
co-authored by Cursor
parent 9ce7a374ed
commit bf8ad0d1ff
29 changed files with 1295 additions and 100 deletions
@@ -65,6 +65,34 @@ function metric(
};
}
type CacheRow = {
days: string;
actual_model_id: string | null;
runs_with_cache: string;
hits: string;
read_tokens: string;
write_tokens: string;
no_cache_tokens: string;
};
function cacheEntry(row: CacheRow) {
const runsWithCache = Number(row.runs_with_cache);
const hits = Number(row.hits);
const readTokens = Number(row.read_tokens);
const writeTokens = Number(row.write_tokens);
const noCacheTokens = Number(row.no_cache_tokens);
const billed = readTokens + writeTokens + noCacheTokens;
return {
actualModelId: row.actual_model_id,
runsWithCache,
hitRate: runsWithCache > 0 ? hits / runsWithCache : null,
readTokens,
writeTokens,
noCacheTokens,
cacheShare: billed > 0 ? readTokens / billed : null,
};
}
export async function GET() {
try {
await requirePermission("billing.orders.read");
@@ -99,6 +127,26 @@ export async function GET() {
group by f.feature_key
order by f.feature_key
`, [FEATURE_KEYS]);
const cacheRows = await queryAdminRows<CacheRow>(`
with windows(days) as (
select 7
union all
select 30
)
select w.days::text as days,
l.actual_model_id,
count(*)::text as runs_with_cache,
count(*) filter (where coalesce((l.metadata->'cache'->>'readTokens')::numeric, 0) > 0)::text as hits,
coalesce(sum(coalesce((l.metadata->'cache'->>'readTokens')::numeric, 0)), 0)::text as read_tokens,
coalesce(sum(coalesce((l.metadata->'cache'->>'writeTokens')::numeric, 0)), 0)::text as write_tokens,
coalesce(sum(coalesce((l.metadata->'cache'->>'noCacheTokens')::numeric, 0)), 0)::text as no_cache_tokens
from windows w
join public.usage_ledger l
on l.created_at >= now() - make_interval(days => w.days)
and l.metadata ? 'cache'
group by w.days, l.actual_model_id
order by w.days, l.actual_model_id
`);
return NextResponse.json({
window: { days: 30, since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
@@ -112,6 +160,10 @@ export async function GET() {
durationMs: metric(row, "avg_duration_ms", "p50_duration_ms", "p95_duration_ms", "max_duration_ms"),
},
})),
cache: {
days7: cacheRows.filter((row) => row.days === "7").map(cacheEntry),
days30: cacheRows.filter((row) => row.days === "30").map(cacheEntry),
},
});
} catch (error) {
return adminErrorResponse(error);
+2 -2
View File
@@ -3,5 +3,5 @@ import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
export const runtime="nodejs";
type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;total_count:string};
export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;cache_read_tokens:string|null;total_count:string};
export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,cacheReadTokens:r.cache_read_tokens==null?null:Number(r.cache_read_tokens),createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
+168 -57
View File
@@ -29,10 +29,10 @@ import {
shouldLoadGeneralDailyPanchanga,
} from "@/lib/consultation-entrypoint";
import { CreditRpcError } from "@/lib/consultation-billing";
import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings";
import { cachedHistoryMessage, cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { resolveSessionLanguageModel, loadLanguageModelCatalog } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -72,7 +72,17 @@ import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga";
import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history";
import {
consultationHistoryWindow,
consultationUserTurnContent,
isContextOverflowError,
lastConsultationPair,
parseSessionContextSummary,
} from "@/lib/consultation-session-history";
import {
checkpointSessionContextSummary,
generateSessionContextSummaryText,
} from "@/lib/session-context-summary";
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
import { z } from "zod";
@@ -279,7 +289,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
@@ -336,11 +346,16 @@ export async function POST(request: Request) {
const visibleQuestion = parsed.data.question;
// Client `history` stays in the request schema for old bundles and is not read.
const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages);
const contextSummary = parseSessionContextSummary(chatSession.context_summary);
const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {
contextWindow: sessionModel.contextWindow,
});
const storedHistory = historyWindow.tail;
const userControlledPrompt = [
parsed.data.question,
historyWindow.summaryText,
...storedHistory.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
].filter(Boolean).join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
{
@@ -551,6 +566,40 @@ export async function POST(request: Request) {
}
const usageStartedAt = Date.now();
async function checkpointConsultationContext() {
try {
const { data: sessionRow, error } = await supabase
.from("chat_sessions")
.select("messages, context_summary")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
if (error || !sessionRow) return;
const catalog = await loadLanguageModelCatalog();
const summaryModel = catalog.defaultModelId
? catalog.models.find((model) => model.id === catalog.defaultModelId) ?? selectedModel
: selectedModel;
await checkpointSessionContextSummary({
messages: sessionRow.messages,
summary: sessionRow.context_summary,
generateText: (prompt, signal) => generateSessionContextSummaryText(summaryModel, prompt, signal),
update: async (summary, seenUpdatedAt) => {
let query = supabase.from("chat_sessions")
.update({ context_summary: summary })
.eq("id", sessionId)
.eq("user_id", userId);
query = seenUpdatedAt
? query.eq("context_summary->>updatedAt", seenUpdatedAt)
: query.is("context_summary", null);
const { data, error: writeError } = await query.select("id");
if (writeError) throw writeError;
return Boolean(data?.length);
},
});
} catch (error) {
console.warn("session context summary failed", error);
}
}
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
const usageRecord = resolved as Record<string, unknown>;
@@ -615,6 +664,9 @@ export async function POST(request: Request) {
if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "completion_rejected");
}
if (completion.success) {
void checkpointConsultationContext();
}
return "completed";
} catch (error) {
await cancel();
@@ -775,26 +827,36 @@ export async function POST(request: Request) {
}
};
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const baseMessages = [
...(cacheBoundary ? [cacheBoundary] : []),
...history.map((message) => message.role === "user"
const modeInstruction = consultationMode === "general_no_birth_time" || (consultationMode === "declared_birth_window" && generalDailyContext)
? generalNoMinuteInstruction(Boolean(generalDailyContext))
: consultationMode === "declared_birth_window"
? declaredWindowInstruction()
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。";
const consultationBaseMessages = (overflow: boolean) => {
const tail = overflow ? lastConsultationPair(history) : history;
const mapped = tail.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user" as const,
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
consultationMode === "general_no_birth_time" || (consultationMode === "declared_birth_window" && generalDailyContext)
? generalNoMinuteInstruction(Boolean(generalDailyContext))
: consultationMode === "declared_birth_window"
? declaredWindowInstruction()
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。",
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
];
: { role: "assistant" as const, content: message.text });
const cachedTail = mapped.length > 0
? [...mapped.slice(0, -1), cachedHistoryMessage(mapped[mapped.length - 1]!, selectedModel.model)]
: mapped;
return [
...(cacheBoundary ? [cacheBoundary] : []),
...cachedTail,
{
role: "user" as const,
content: consultationUserTurnContent({
currentTime: currentTimeContext(requestTime),
name,
instruction: modeInstruction,
extra: generalDailyContextPrompt(generalDailyContext),
summaryText: historyWindow.summaryText,
question: resolvedQuestion.modelQuestion,
}),
},
];
};
let baseMessages = consultationBaseMessages(false);
const agentAbortSignal = AbortSignal.timeout(AGENT_TIMEOUT_MS);
const streamOptions = {
runId: requestId,
@@ -803,6 +865,27 @@ export async function POST(request: Request) {
hooks,
...consultationGenerationSettings(selectedModel.model),
};
async function streamWithOverflowRetry(agent: {
stream: (
messages: typeof baseMessages,
options: typeof streamOptions,
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
}) {
try {
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
return result;
} catch (error) {
if (!isContextOverflowError(error)) throw error;
// Same request, same wait: shrink to summary + last pair. Consultation
// clients cannot parse rectification `attempt.reset`, so the retry stays
// server-side and never opens a second user-visible wait.
baseMessages = consultationBaseMessages(true);
const overflow = await agent.stream(baseMessages, streamOptions);
usages.push(overflow.totalUsage);
return overflow;
}
}
const workflowReceipt: WorkflowReceipt = usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)
? {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
@@ -822,8 +905,7 @@ export async function POST(request: Request) {
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
state.workflowReceipt = workflowReceipt;
const agent = getGeneralJyotishAgent(selectedModel);
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
const result = await streamWithOverflowRetry(agent);
// This mode has no calculation to require and no chart method to bind,
// so there is no contract for a retry to repair.
const retryForAnswer = async () => {
@@ -913,8 +995,7 @@ export async function POST(request: Request) {
state,
});
const agent = getWindowJyotishAgent(selectedModel, agentContext);
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
const result = await streamWithOverflowRetry(agent);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
@@ -1013,8 +1094,7 @@ export async function POST(request: Request) {
state,
});
const agent = getJyotishAgent(selectedModel, agentContext);
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
const result = await streamWithOverflowRetry(agent);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
@@ -1138,20 +1218,21 @@ export async function POST(request: Request) {
return await runAgenticConsultation(consultationMode, history, name, generalDailyContext);
}
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const result = await getGeneralJyotishAgent(selectedModel).stream([
...(cacheBoundary ? [cacheBoundary] : []),
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
generalNoMinuteInstruction(Boolean(generalDailyContext)),
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
]);
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const result = await getGeneralJyotishAgent(selectedModel).stream([
...(cacheBoundary ? [cacheBoundary] : []),
{
role: "user",
content: consultationUserTurnContent({
currentTime: currentTimeContext(requestTime),
name,
instruction: generalNoMinuteInstruction(Boolean(generalDailyContext)),
extra: generalDailyContextPrompt(generalDailyContext),
summaryText: historyWindow.summaryText,
question: resolvedQuestion.modelQuestion,
}),
},
]);
const workflowReceipt: WorkflowReceipt = {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready",
@@ -1216,21 +1297,51 @@ export async function POST(request: Request) {
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const result = await getLegacyJyotishAgent(selectedModel, workflowContext).stream([
const legacyHistory = history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text });
const cachedLegacyHistory = legacyHistory.length > 0
? [...legacyHistory.slice(0, -1), cachedHistoryMessage(legacyHistory[legacyHistory.length - 1]!, selectedModel.model)]
: legacyHistory;
const legacyMessages = [
...(cacheBoundary ? [cacheBoundary] : []),
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
...cachedLegacyHistory,
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
"先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
role: "user" as const,
content: consultationUserTurnContent({
currentTime: currentTimeContext(requestTime),
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
question: resolvedQuestion.modelQuestion,
}),
},
]);
];
const legacyAgent = getLegacyJyotishAgent(selectedModel, workflowContext);
let result;
try {
result = await legacyAgent.stream(legacyMessages);
} catch (error) {
if (!isContextOverflowError(error)) throw error;
const overflowHistory = lastConsultationPair(legacyHistory);
const overflowCached = overflowHistory.length > 0
? [...overflowHistory.slice(0, -1), cachedHistoryMessage(overflowHistory[overflowHistory.length - 1]!, selectedModel.model)]
: overflowHistory;
result = await legacyAgent.stream([
...(cacheBoundary ? [cacheBoundary] : []),
...overflowCached,
{
role: "user" as const,
content: consultationUserTurnContent({
currentTime: currentTimeContext(requestTime),
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
question: resolvedQuestion.modelQuestion,
}),
},
]);
}
const responseWorkflowReceipt = {
route: workflowReceipt.route,
status: workflowReceipt.status,
@@ -415,6 +415,7 @@ type Usage = {
outputTokens: number;
costMicrousd: number;
durationMs: number | null;
cacheReadTokens: number | null;
createdAt: string;
};
@@ -452,6 +453,11 @@ export function UsageResource() {
render: (_, item) =>
`${item.inputTokens.toLocaleString()} / ${item.outputTokens.toLocaleString()}`,
},
{
title: "缓存读 tokens",
dataIndex: "cacheReadTokens",
render: (value: number | null) => value == null ? "—" : value.toLocaleString(),
},
{
title: "成本",
dataIndex: "costMicrousd",
@@ -27,7 +27,19 @@ const BANDS: DistributionBand[] = [
type ModelRow = ModelPrice & { modelId: string; status: string; enabled: boolean };
type PricingRow = { featureKey: string; modelTier: string; creditCost: number; status: string; enabled: boolean };
type Product = { code: string; priceCents: number; enabled: boolean; status: string; entitlements: Array<{ featureKey: string; metadata?: { minuteLimit?: number | null; dayLimit?: number | null; billingLimit?: number | null } }> };
type UsagePayload = { data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }> };
type CacheModelRow = {
actualModelId: string | null;
runsWithCache: number;
hitRate: number | null;
readTokens: number;
writeTokens: number;
noCacheTokens: number;
cacheShare: number | null;
};
type UsagePayload = {
data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }>;
cache?: { days7: CacheModelRow[]; days30: CacheModelRow[] };
};
type LoadState = { models?: ModelRow[]; pricing?: PricingRow[]; products?: Product[]; usage?: UsagePayload; errors: string[] };
@@ -48,6 +60,10 @@ function errorText(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
function formatPercent(value: number | null) {
return value === null || !Number.isFinite(value) ? "—" : `${(value * 100).toFixed(1)}%`;
}
export function PricingSimulator() {
const [state, setState] = useState<LoadState>({ errors: [] });
const [usdToCny, setUsdToCny] = useState<number | null>(null);
@@ -158,6 +174,36 @@ export function PricingSimulator() {
<Col xs={24} md={8}><Statistic title="画像下可承载会员数" value={capacity ?? "—"} /></Col>
</Row>
</Card>
<Card title="缓存命中">
<Typography.Paragraph type="secondary">
`usage_ledger.metadata.cache` = tokens &gt; 0
</Typography.Paragraph>
{([
["近 7 天", state.usage?.cache?.days7 ?? []],
["近 30 天", state.usage?.cache?.days30 ?? []],
] as const).map(([title, rows]) => (
<div key={title} style={{ marginBottom: 16 }}>
<Typography.Text strong>{title}</Typography.Text>
<Table<CacheModelRow>
size="small"
pagination={false}
rowKey={(row, index) => `${title}-${row.actualModelId ?? "none"}-${index}`}
dataSource={rows}
locale={{ emptyText: "暂无缓存数据" }}
columns={[
{ title: "模型", dataIndex: "actualModelId", render: (value) => value ?? "—" },
{ title: "运行数", dataIndex: "runsWithCache" },
{ title: "命中率", dataIndex: "hitRate", render: formatPercent },
{ title: "缓存占比", dataIndex: "cacheShare", render: formatPercent },
{ title: "读", dataIndex: "readTokens", render: (value: number) => value.toLocaleString() },
{ title: "写", dataIndex: "writeTokens", render: (value: number) => value.toLocaleString() },
{ title: "未命中", dataIndex: "noCacheTokens", render: (value: number) => value.toLocaleString() },
]}
/>
</div>
))}
</Card>
</Space>
);
}
@@ -73,6 +73,17 @@ export function cachedSystemMessage(content: string, model?: unknown) {
};
}
export function cachedHistoryMessage<T extends { role: "user" | "assistant"; content: string }>(
message: T,
model?: unknown,
): T {
if (modelProviderId(model) !== "anthropic") return message;
return {
...message,
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" as const } } },
};
}
export function agentOutputTokenBudget(
thinking: ThinkingMode,
options: { answerTokens?: number; thinkingTokens?: number } = {},
+8 -2
View File
@@ -157,8 +157,6 @@ export function consultationMethodologyForDomains(
};
push("Shared mandatory baseline", ROUTER_FILE, markdownSection(router, SHARED_BASELINE_HEADING));
push("Full-spectrum invocation", ROUTER_FILE, markdownSection(router, "Full-Spectrum Invocation Contract"));
push("Event judgment skeleton", SKELETON_FILE, packageFile(SKELETON_FILE));
const withoutChecklist: ConsultationDomain[] = [];
for (const domain of unique) {
@@ -189,3 +187,11 @@ export function consultationMethodologyForDomains(
planCache.set(key, methodology);
return methodology;
}
/** Cross-domain method that belongs in the cached system block, not the tool result. */
export function sharedConsultationMethodMarkdown(): string {
const router = packageFile(ROUTER_FILE);
const fullSpectrum = router ? markdownSection(router, "Full-Spectrum Invocation Contract") : null;
const skeleton = packageFile(SKELETON_FILE);
return [fullSpectrum, skeleton].filter((part): part is string => Boolean(part)).join("\n\n");
}
+214 -11
View File
@@ -1,27 +1,230 @@
export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 4_000;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000;
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
export type ConsultationHistoryMessage = Readonly<{
role: "user" | "assistant";
text: string;
}>;
export function consultationHistoryFromStoredMessages(
export type SessionContextSummaryV1 = Readonly<{
version: 1;
text: string;
throughRequestId: string;
throughMessageIndex: number;
messageCount: number;
updatedAt: string;
}>;
export type ConsultationHistoryWindow = Readonly<{
tail: ConsultationHistoryMessage[];
summaryText: string | null;
droppedCount: number;
}>;
type StoredTurn = Readonly<{
index: number;
role: "user" | "assistant";
text: string;
requestId: string | null;
}>;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function historyBudgetChars(contextWindow: number | null | undefined): number {
const window = typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
? contextWindow
: DEFAULT_MODEL_CONTEXT_WINDOW;
return clamp(
(window - CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS) * CONSULTATION_HISTORY_CHAR_PER_TOKEN,
CONSULTATION_HISTORY_BUDGET_MIN_CHARS,
CONSULTATION_HISTORY_BUDGET_MAX_CHARS,
);
}
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (row.version !== 1) return null;
if (typeof row.text !== "string" || !row.text.trim()) return null;
if (typeof row.throughRequestId !== "string") return null;
if (!Number.isInteger(row.throughMessageIndex) || (row.throughMessageIndex as number) < 0) return null;
if (!Number.isInteger(row.messageCount) || (row.messageCount as number) < 0) return null;
if (typeof row.updatedAt !== "string" || !row.updatedAt) return null;
return {
version: 1,
text: row.text,
throughRequestId: row.throughRequestId,
throughMessageIndex: row.throughMessageIndex as number,
messageCount: row.messageCount as number,
updatedAt: row.updatedAt,
};
}
export function omissionMarker(omittedChars: number): string {
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
}
export function clipConsultationHistoryText(text: string): string {
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
return `${text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS)}${omissionMarker(omitted)}`;
}
export function storedConsultationTurns(
messages: unknown,
options: { excludeRequestId?: string } = {},
): ConsultationHistoryMessage[] {
): StoredTurn[] {
if (!Array.isArray(messages)) return [];
const rows: ConsultationHistoryMessage[] = [];
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const rows: StoredTurn[] = [];
messages.forEach((message, index) => {
if (!message || typeof message !== "object") return;
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) continue;
if (stored.role !== "user" && stored.role !== "assistant") continue;
if (typeof stored.text !== "string" || !stored.text) continue;
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return;
if (stored.role !== "user" && stored.role !== "assistant") return;
if (typeof stored.text !== "string" || !stored.text) return;
rows.push({
index,
role: stored.role,
text: stored.text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS),
text: stored.text,
requestId: typeof stored.requestId === "string" ? stored.requestId : null,
});
});
return rows;
}
export function lastConsultationPair<T extends { role: "user" | "assistant" }>(
rows: readonly T[],
): T[] {
if (rows.length <= 2) return [...rows];
for (let index = rows.length - 1; index >= 1; index -= 1) {
if (rows[index]?.role === "assistant" && rows[index - 1]?.role === "user") {
return rows.slice(index - 1, index + 1);
}
}
return rows.slice(-CONSULTATION_HISTORY_LIMIT);
return rows.slice(-2);
}
function turnsAfterSummary(
turns: readonly StoredTurn[],
summary: SessionContextSummaryV1 | null,
): StoredTurn[] {
if (!summary) return [...turns];
return turns.filter((turn) => turn.index > summary.throughMessageIndex);
}
export function consultationHistoryWindow(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: {
contextWindow?: number | null;
excludeRequestId?: string;
overflow?: boolean;
} = {},
): ConsultationHistoryWindow {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const afterSummary = turnsAfterSummary(turns, summary);
const selected = options.overflow ? lastConsultationPair(afterSummary) : afterSummary;
const clipped = selected.map((turn) => ({
role: turn.role,
text: clipConsultationHistoryText(turn.text),
}));
const budget = historyBudgetChars(options.contextWindow);
let droppedCount = 0;
let kept = clipped;
while (kept.length > 0 && kept.reduce((sum, message) => sum + message.text.length, 0) > budget) {
kept = kept.slice(1);
droppedCount += 1;
}
const summaryText = summary?.text.trim() || null;
return { tail: kept, summaryText, droppedCount };
}
export function consultationHistoryFromStoredMessages(
messages: unknown,
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): ConsultationHistoryMessage[] {
return consultationHistoryWindow(messages, null, {
contextWindow: options.contextWindow,
excludeRequestId: options.excludeRequestId,
}).tail;
}
export function consultationUserTurnContent(input: {
currentTime: string;
name?: string;
instruction: string;
extra?: string;
summaryText?: string | null;
question: string;
}): string {
return [
input.currentTime,
input.name ? `用户称呼:${input.name}` : "",
input.instruction,
input.extra ?? "",
input.summaryText?.trim()
? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}`
: "",
input.question,
].filter(Boolean).join("\n");
}
const CONTEXT_OVERFLOW_MARKERS = [
"context_length_exceeded",
"maximum context length",
"prompt is too long",
"input is too long",
"too many tokens",
] as const;
function errorHaystack(error: unknown): { hay: string; status: number | null } {
if (typeof error === "string") return { hay: error.toLowerCase(), status: null };
if (!error || typeof error !== "object") return { hay: String(error).toLowerCase(), status: null };
const row = error as Record<string, unknown>;
const status = typeof row.status === "number"
? row.status
: typeof row.statusCode === "number"
? row.statusCode
: null;
const parts = [
typeof row.message === "string" ? row.message : "",
typeof row.code === "string" ? row.code : "",
typeof row.type === "string" ? row.type : "",
error instanceof Error ? error.message : "",
error instanceof Error ? error.name : "",
];
const nested = row.data && typeof row.data === "object" ? row.data as Record<string, unknown> : null;
if (nested) {
if (typeof nested.message === "string") parts.push(nested.message);
if (typeof nested.code === "string") parts.push(nested.code);
}
const cause = "cause" in row ? row.cause : null;
if (cause && typeof cause === "object") {
const nestedCause = cause as Record<string, unknown>;
if (typeof nestedCause.message === "string") parts.push(nestedCause.message);
if (typeof nestedCause.code === "string") parts.push(nestedCause.code);
}
return { hay: parts.join(" ").toLowerCase(), status };
}
export function isContextOverflowError(error: unknown): boolean {
const { hay, status } = errorHaystack(error);
if (CONTEXT_OVERFLOW_MARKERS.some((marker) => hay.includes(marker))) return true;
if (hay.includes("max_tokens") && (hay.includes("context") || hay.includes("prompt") || hay.includes("input") || status === 400)) {
return true;
}
if (status === 400 && (hay.includes("context") || hay.includes("prompt") || hay.includes("token"))) {
return true;
}
return false;
}
+4 -1
View File
@@ -14,6 +14,7 @@ type PublishedModelRow = {
model_id: string; version: number; label: string; description: string; provider_model: string;
credit_cost: number; is_default: boolean; provider_code: string; provider_type: ModelProviderType;
base_url: string | null; encrypted_api_key: string | null; input_cost: string | number; output_cost: string | number;
context_window: number | null;
};
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
@@ -50,13 +51,15 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
isDefault: row.is_default, mode: row.provider_type === "openai-compatible" ? "compatible" : row.provider_type,
model, configVersion: row.version, inputCostMicrousdPerMillion: Number(row.input_cost), outputCostMicrousdPerMillion: Number(row.output_cost),
contextWindow: typeof row.context_window === "number" && Number.isFinite(row.context_window) ? row.context_window : null,
};
}
async function queryCatalog(where: string, values: readonly unknown[] = []) {
return queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
v.context_window
from public.model_config_versions v join public.model_configs c on c.id=v.config_id
join public.model_providers p on p.id=v.provider_id where ${where}
`, values);
+265
View File
@@ -0,0 +1,265 @@
import { Agent } from "@mastra/core/agent";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
lastConsultationPair,
parseSessionContextSummary,
storedConsultationTurns,
type SessionContextSummaryV1,
} from "@/lib/consultation-session-history";
import type { ResolvedLanguageModel } from "@/mastra/model";
export const SESSION_CONTEXT_SUMMARY_TIMEOUT_MS = 15_000;
export const SESSION_CONTEXT_SUMMARY_MAX_HAN = 800;
export const SESSION_CONTEXT_SUMMARY_MAX_TOKENS = 600;
export const SESSION_CONTEXT_SUMMARY_INSTRUCTIONS = `你在维护咨询会话的滚动摘要,只供下一轮模型使用。
只根据给定的问答文本写作,不要发明没出现过的事实。
输出不超过 800 个汉字,必须使用下面四个标题,每个标题下用短句:
已问过的问题
已给出的结论(含应期、置信度、blocked 项)
用户补充的事实
未决与待追问
不要写出生日期、出生时间、出生地、姓名、邮箱。`;
type DisposableAbort = Readonly<{
signal: AbortSignal;
dispose: () => void;
}>;
function composedAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): DisposableAbort {
const controller = new AbortController();
// Must stay ref'd. The platform timeout helper uses an unref timer (BUG-523).
const timeoutId = globalThis.setTimeout(() => {
if (!controller.signal.aborted) {
controller.abort(new DOMException("session context summary timed out", "TimeoutError"));
}
}, timeoutMs);
const onExternalAbort = () => {
if (!controller.signal.aborted) {
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
}
};
if (signal) {
if (signal.aborted) onExternalAbort();
else signal.addEventListener("abort", onExternalAbort);
}
return {
signal: controller.signal,
dispose: () => {
globalThis.clearTimeout(timeoutId);
signal?.removeEventListener("abort", onExternalAbort);
},
};
}
function whenAborted(signal: AbortSignal): { promise: Promise<never>; dispose: () => void } {
let onAbort: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
const fail = () => {
reject(signal.reason ?? new Error("aborted"));
};
if (signal.aborted) {
fail();
return;
}
onAbort = fail;
signal.addEventListener("abort", fail, { once: true });
});
return {
promise,
dispose: () => {
if (onAbort) signal.removeEventListener("abort", onAbort);
},
};
}
function clipHan(value: string, maxChars: number): string {
const characters = Array.from(value);
return characters.length > maxChars ? characters.slice(0, maxChars).join("") : value;
}
const ISO_DATE = /\d{4}-\d{2}-\d{2}/g;
const CLOCK_TIME = /\b\d{1,2}:\d{2}(?::\d{2})?\b/g;
const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
export function sanitizeSessionContextSummary(raw: string): string | null {
const stripped = raw
.replace(EMAIL, "")
.replace(ISO_DATE, "")
.replace(CLOCK_TIME, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!stripped) return null;
return clipHan(stripped, SESSION_CONTEXT_SUMMARY_MAX_HAN);
}
export function tailCharCount(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): number {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
? turns.filter((turn) => turn.index > summary.throughMessageIndex)
: turns;
return tail.reduce((sum, turn) => sum + turn.text.length, 0);
}
export function shouldCheckpoint(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): boolean {
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
}
export function messagesForSummaryInput(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): Array<{ role: "user" | "assistant"; text: string; index: number; requestId: string | null }> {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
? turns.filter((turn) => turn.index > summary.throughMessageIndex)
: turns;
const lastPair = lastConsultationPair(tail);
const pairStart = lastPair[0];
if (!pairStart) return [];
return tail.filter((turn) => turn.index < pairStart.index);
}
export function buildSummaryPrompt(
previous: SessionContextSummaryV1 | null,
messages: unknown,
options: { excludeRequestId?: string } = {},
): string {
const input = messagesForSummaryInput(messages, previous, options);
const lines = input.map((turn) => `${turn.role === "user" ? "用户" : "助手"}${turn.text}`);
return [
previous?.text.trim() ? `上一份摘要:\n${previous.text.trim()}` : "上一份摘要:无",
"需要并入摘要的问答(不含最后一对):",
lines.join("\n") || "(无)",
].join("\n\n");
}
export async function generateSessionContextSummaryText(
model: ResolvedLanguageModel,
prompt: string,
signal?: AbortSignal,
): Promise<string> {
const agent = new Agent({
id: `session-context-summary-${model.id}`,
name: "Session Context Summary",
model: model.model,
instructions: SESSION_CONTEXT_SUMMARY_INSTRUCTIONS,
});
const result = await agent.generate([{ role: "user", content: prompt }], {
abortSignal: signal,
modelSettings: { maxOutputTokens: SESSION_CONTEXT_SUMMARY_MAX_TOKENS },
});
return typeof result.text === "string" ? result.text : "";
}
export async function generateSessionContextSummary(input: {
model?: ResolvedLanguageModel | null;
previous: SessionContextSummaryV1 | null;
messages: unknown;
excludeRequestId?: string;
signal?: AbortSignal;
timeoutMs?: number;
generateText?: (prompt: string, signal?: AbortSignal) => Promise<string>;
}): Promise<string | null> {
const prompt = buildSummaryPrompt(input.previous, input.messages, {
excludeRequestId: input.excludeRequestId,
});
const generate = input.generateText ?? (input.model
? (nextPrompt: string, signal?: AbortSignal) => generateSessionContextSummaryText(
input.model as ResolvedLanguageModel,
nextPrompt,
signal,
)
: null);
if (!generate) return null;
const composed = composedAbortSignal(input.signal, input.timeoutMs ?? SESSION_CONTEXT_SUMMARY_TIMEOUT_MS);
const aborted = whenAborted(composed.signal);
try {
const raw = await Promise.race([generate(prompt, composed.signal), aborted.promise]);
return sanitizeSessionContextSummary(raw);
} catch {
return null;
} finally {
aborted.dispose();
composed.dispose();
}
}
export function nextSessionContextSummary(
messages: unknown,
previous: SessionContextSummaryV1 | null,
text: string,
updatedAt: string,
options: { excludeRequestId?: string } = {},
): SessionContextSummaryV1 | null {
const covered = messagesForSummaryInput(messages, previous, options);
const last = covered.at(-1);
if (!last) return null;
return {
version: 1,
text,
throughRequestId: last.requestId ?? previous?.throughRequestId ?? "",
throughMessageIndex: last.index,
messageCount: last.index + 1,
updatedAt,
};
}
export async function writeSessionContextSummary(input: {
seenUpdatedAt: string | null;
summary: SessionContextSummaryV1;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
}): Promise<"written" | "abandoned"> {
const written = await input.update(input.summary, input.seenUpdatedAt);
return written ? "written" : "abandoned";
}
export async function checkpointSessionContextSummary(input: {
messages: unknown;
summary: unknown;
excludeRequestId?: string;
now?: () => Date;
generateText: (prompt: string, signal?: AbortSignal) => Promise<string>;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
timeoutMs?: number;
}): Promise<"written" | "skipped" | "abandoned" | "failed"> {
const previous = parseSessionContextSummary(input.summary);
if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) {
return "skipped";
}
try {
const text = await generateSessionContextSummary({
previous,
messages: input.messages,
excludeRequestId: input.excludeRequestId,
generateText: input.generateText,
timeoutMs: input.timeoutMs,
});
if (!text) return "failed";
const next = nextSessionContextSummary(
input.messages,
previous,
text,
(input.now ?? (() => new Date()))().toISOString(),
{ excludeRequestId: input.excludeRequestId },
);
if (!next) return "skipped";
return writeSessionContextSummary({
seenUpdatedAt: previous?.updatedAt ?? null,
summary: next,
update: input.update,
});
} catch {
return "failed";
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ ${jyotishSkillMethodBlock}
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
The tool result's methodology field is the skill's own strict checklist for the routes that actually ran, quoted from the live skill. Treat it as the method for this answer, not as background: work through its mandatory modules against the evidence you were given, and obey its output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the delivered Full-spectrum invocation and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text.
+1
View File
@@ -16,6 +16,7 @@ export type ResolvedLanguageModel = PublicLanguageModel & {
readonly configVersion?: number;
readonly inputCostMicrousdPerMillion?: number;
readonly outputCostMicrousdPerMillion?: number;
readonly contextWindow?: number | null;
};
export type LanguageModelCatalog = {
+5 -1
View File
@@ -5,6 +5,7 @@ import {
resolveLiveJyotishSkill,
resolveLiveJyotishSkillRuntimePath,
} from "../lib/skill-package-registry.ts";
import { sharedConsultationMethodMarkdown } from "../lib/consultation-methodology.ts";
const skill = resolveLiveJyotishSkill();
@@ -113,7 +114,10 @@ const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
<jyotish-skill name="${skill.name}">
${boundMethod()}
</jyotish-skill>`;
</jyotish-skill>
<jyotish-shared-method>
${sharedConsultationMethodMarkdown()}
</jyotish-shared-method>`;
/**
* Withdraw the activation tools while keeping `skill_read`.