Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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");
});
});