Skip to content
1 change: 1 addition & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
minContentCharsForCompletion: 40,
toolHeavyRatio: 0.7,
minAssistantCharsForToolHeavy: 80,
cronSentinels: [],
},
l2Induction: {
minSimilarity: 0.65,
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ const AlgorithmSchema = Type.Object({
* scored normally even if tool calls dominate. Default 80.
*/
minAssistantCharsForToolHeavy: NumberInRange(80, 0, 10_000),
/**
* User-turn prefixes that identify cron/scheduled episodes. When
* the first user turn starts with any of these, the exchange-count
* gate is bypassed. Default: Hermes cron sentinel.
*/
cronSentinels: Type.Array(Type.String(), {
default: ["[IMPORTANT: You are running as a scheduled cron job"],
}),
}, { default: {} }),
l2Induction: Type.Object({
/** Cosine ≥ this to associate a new trace with an existing L2 policy. */
Expand Down
33 changes: 29 additions & 4 deletions apps/memos-local-plugin/core/reward/reward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ function looksLikeTrivialContent(text: string): boolean {
function decideSkipReason(
snapshot: import("../session/types.js").EpisodeSnapshot,
traces: readonly TraceRow[],
cfg: Pick<RewardConfig, "minExchangesForCompletion" | "minContentCharsForCompletion" | "toolHeavyRatio" | "minAssistantCharsForToolHeavy">,
cfg: Pick<RewardConfig, "minExchangesForCompletion" | "minContentCharsForCompletion" | "toolHeavyRatio" | "minAssistantCharsForToolHeavy" | "cronSentinels">,
): string | null {
// Prefer the live snapshot's turn list; fall back to traces when the
// snapshot came from a SQLite row (no turns materialised).
Expand Down Expand Up @@ -443,9 +443,34 @@ function decideSkipReason(
// 1. Not enough real conversation turns (need at least N user-assistant exchanges)
const exchanges = Math.min(userTurns, assistantTurns);
if (exchanges < cfg.minExchangesForCompletion) {
return (
`对话轮次不足(${exchanges} 轮),需要至少 ${cfg.minExchangesForCompletion} 轮完整的问答交互才能生成摘要。`
);
// Cron episodes always have exactly 1 user turn (the task prompt) so this
// gate always fires for them. Bypass it when the first user content starts
// with a known cron sentinel — cron jobs are inherently substantive, and
// the content/triviality checks below provide the real substance filter.
//
// Fallback: if snapshot.turns is empty (recovery path), we also check
// snapshot.meta?.initialUserText. This field is set by the pipeline on
// episode creation and is generally reliable, but could be absent or
// stale in unusual recovery scenarios — if so, the episode falls through
// to the old "skip" behavior (no false positives, just a missed score).
const sentinels = cfg.cronSentinels ?? [];
if (sentinels.length > 0) {
const firstUserContent =
userContents[0] ??
(snapshot.meta?.initialUserText as string | undefined) ??
"";
if (sentinels.some((s) => firstUserContent.startsWith(s))) {
// Cron episode — skip the exchange-count gate, fall through to content checks.
} else {
return (
`对话轮次不足(${exchanges} 轮),需要至少 ${cfg.minExchangesForCompletion} 轮完整的问答交互才能生成摘要。`
);
}
} else {
return (
`对话轮次不足(${exchanges} 轮),需要至少 ${cfg.minExchangesForCompletion} 轮完整的问答交互才能生成摘要。`
);
}
}

// 2. No user messages at all
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/reward/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export interface RewardConfig {
* that the tool-heavy heuristic would otherwise skip. Default 80.
*/
minAssistantCharsForToolHeavy: number;
/**
* User-turn prefixes that identify cron/scheduled episodes. When the
* first user turn starts with any of these prefixes, the exchange-count
* gate (minExchangesForCompletion) is bypassed — cron jobs are
* inherently substantive regardless of turn count. Content and
* triviality checks still apply. Default: Hermes cron sentinel.
*/
cronSentinels?: string[];
}

// ─── User feedback inputs ──────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,4 +376,59 @@ describe("reward/integration", () => {
expect(res.feedbackCount).toBe(2);
expect(res.rHuman).toBeGreaterThan(0);
});

it("cron episode with 1 exchange is not skipped when sentinel matches", async () => {
const sid = "s_cron_1";
const eid = "ep_cron_1";
seedEpisode(handle, eid, sid, ["tr_cron_1"]);
seedTrace(handle, "tr_cron_1", eid, sid, {
userText:
"[IMPORTANT: You are running as a scheduled cron job. Please review recent activity and write a reflection card.",
agentText:
"Reviewed the last 48 hours of conversation traces and wrote a reflection card to ~/Faye/memory/reflections/2026-05-31.md. Commit succeeded.",
});

const events: RewardEvent[] = [];
const bus = createRewardEventBus();
bus.onAny((e) => events.push(e));

const runner = createRewardRunner({
tracesRepo: handle.repos.traces,
episodesRepo: handle.repos.episodes,
feedbackRepo: handle.repos.feedback,
llm: fakeLlm({
completeJson: {
"reward.reward.r_human.v3": {
goal_achievement: 0.8,
process_quality: 0.8,
user_satisfaction: 0.8,
label: "success",
reason: "cron reflection card written and committed",
},
},
}),
bus,
cfg: {
...cfg(),
minExchangesForCompletion: 2,
minContentCharsForCompletion: 40,
cronSentinels: ["[IMPORTANT: You are running as a scheduled cron job"],
},
now: () => NOW,
});

const res = await runner.run({
episodeId: eid as unknown as Parameters<typeof runner.run>[0]["episodeId"],
feedback: [],
trigger: "implicit_fallback",
});

// Must reach the LLM scorer, not be abandoned as trivial.
expect(res.humanScore.source).toBe("llm");
expect(res.rHuman).toBeGreaterThan(0);
expect(events.some((e) => e.kind === "reward.updated")).toBe(true);
// Must NOT be heuristic-skipped (reward.scored with source=heuristic means skipped).
const scoredEvent = events.find((e) => e.kind === "reward.scored");
expect((scoredEvent as { source?: string } | undefined)?.source).not.toBe("heuristic");
});
});