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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Build Atlas as a production-quality, Telegram-first planning assistant. The code
## Git Workflow Rules

- **Every workflow — no exceptions — must use a git worktree and a dedicated branch.**
- Create a worktree before touching any files: `git worktree add ../atlas-<short-description> -b codex/<short-description>`
- Create a worktree before touching any files: `git worktree add .worktrees/atlas-<short-description> -b claude/<short-description>`
- All edits, experiments, and commits happen inside that worktree, never in the main checkout.
- This prevents dirty-worktree collisions between concurrent tasks (e.g. formatting runs vs. feature work).
- Follow `docs/workflows/feature-delivery.md` for product features, fixes, and behavior changes.
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/server/process-inbox-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveScheduleBlockReference,
resolveTaskReference,
type ScheduleBlock,
type ScheduleConstraint,
type Task,
} from "@atlas/core";
import {
Expand All @@ -33,6 +34,7 @@ export type ProcessInboxItemRequest = {
planningInboxTextOverride?: {
text: string;
};
scheduleConstraint?: ScheduleConstraint | null;
};

export type ProcessInboxItemDependencies = {
Expand Down Expand Up @@ -87,6 +89,10 @@ export async function processInboxItem(
throw error;
}

if (input.scheduleConstraint !== undefined) {
planning = injectScheduleConstraint(planning, input.scheduleConstraint);
}

const plannerRun = buildPlannerRun(context, planningContext, planning);

try {
Expand Down Expand Up @@ -1185,6 +1191,24 @@ function parseProcessInboxItemRequest(
return input;
}

function injectScheduleConstraint(
planning: InboxPlanningOutput,
constraint: ScheduleConstraint | null,
): InboxPlanningOutput {
return {
...planning,
actions: planning.actions.map((action) => {
if (
action.type === "create_schedule_block" ||
action.type === "move_schedule_block"
) {
return { ...action, scheduleConstraint: constraint };
}
return action;
}),
};
}

function withRenderedFollowUp(
result: ProcessedInboxResult,
timeZone: string,
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/lib/server/telegram-webhook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
buildScheduleConstraintFromSlots,
buildTelegramFollowUpIdempotencyKey,
buildTelegramWebhookIdempotencyKey,
type ConfirmedMutationRecoveryInput,
Expand Down Expand Up @@ -528,12 +529,17 @@ export async function handleTelegramWebhook(

await dependencies.primeProcessingStore?.(ingress.inboxItem);

const recoveryConstraint = buildScheduleConstraintFromSlots(
routedWithContext.policy.committedSlots,
);

const processing = await processInboxItem(
{
inboxItemId: ingress.inboxItem.id,
planningInboxTextOverride: {
text: synthesis.text,
},
scheduleConstraint: recoveryConstraint,
},
{
...(dependencies.store ? { store: dependencies.store } : {}),
Expand Down Expand Up @@ -611,9 +617,14 @@ export async function handleTelegramWebhook(
});
await dependencies.primeProcessingStore?.(ingress.inboxItem);

const mutationConstraint = buildScheduleConstraintFromSlots(
routedWithContext.policy.committedSlots,
);

const processing = await processInboxItem(
{
inboxItemId: ingress.inboxItem.id,
scheduleConstraint: mutationConstraint,
},
{
...(dependencies.store ? { store: dependencies.store } : {}),
Expand Down
9 changes: 0 additions & 9 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,9 +644,6 @@ export const createScheduleBlockPlanningActionResponseFormatSchema = z.object({
})
.nullable()
.optional(),
scheduleConstraint: scheduleConstraintResponseFormatSchema
.nullable()
.optional(),
reason: z.string().min(1).nullable().optional(),
});

Expand All @@ -665,9 +662,6 @@ export const moveScheduleBlockPlanningActionResponseFormatSchema = z.object({
})
.nullable()
.optional(),
scheduleConstraint: scheduleConstraintResponseFormatSchema
.nullable()
.optional(),
reason: z.string().min(1).nullable().optional(),
});

Expand Down Expand Up @@ -715,9 +709,6 @@ export const planningActionResponseFormatSchema = z.object({
})
.nullable()
.optional(),
scheduleConstraint: scheduleConstraintResponseFormatSchema
.nullable()
.optional(),
reason: z.string().min(1).nullable().optional(),
});

Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/time-spec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { buildScheduleConstraintFromSlots } from "./time-spec";
import { scheduleConstraintSchema } from "./index";

describe("buildScheduleConstraintFromSlots", () => {
it("returns null when no day or time provided", () => {
expect(buildScheduleConstraintFromSlots({})).toBeNull();
});

it("builds constraint from absolute time + tomorrow", () => {
const result = buildScheduleConstraintFromSlots({
day: "tomorrow",
time: { kind: "absolute", hour: 15, minute: 0 },
});

expect(result).toMatchObject({
dayReference: "tomorrow",
weekday: null,
weekOffset: null,
explicitHour: 15,
minute: 0,
preferredWindow: null,
});
expect(scheduleConstraintSchema.safeParse(result).success).toBe(true);
});

it("builds constraint from relative time only", () => {
const result = buildScheduleConstraintFromSlots({
time: { kind: "relative", minutes: 15 },
});

expect(result).toMatchObject({
dayReference: null,
weekday: null,
weekOffset: null,
relativeMinutes: 15,
explicitHour: null,
});
expect(scheduleConstraintSchema.safeParse(result).success).toBe(true);
});

it("builds constraint from window time + weekday", () => {
const result = buildScheduleConstraintFromSlots({
day: "friday",
time: { kind: "window", window: "morning" },
});

expect(result).toMatchObject({
dayReference: "weekday",
weekday: "friday",
weekOffset: 0,
explicitHour: null,
preferredWindow: "morning",
});
expect(scheduleConstraintSchema.safeParse(result).success).toBe(true);
});

it("defaults to morning window when day-only", () => {
const result = buildScheduleConstraintFromSlots({ day: "today" });

expect(result).toMatchObject({
dayReference: "today",
preferredWindow: "morning",
explicitHour: null,
});
expect(scheduleConstraintSchema.safeParse(result).success).toBe(true);
});

it("builds constraint from window time + today", () => {
const result = buildScheduleConstraintFromSlots({
day: "today",
time: { kind: "window", window: "evening" },
});

expect(result).toMatchObject({
dayReference: "today",
preferredWindow: "evening",
explicitHour: null,
});
expect(scheduleConstraintSchema.safeParse(result).success).toBe(true);
});

it("builds constraint from time-only absolute", () => {
const result = buildScheduleConstraintFromSlots({
time: { kind: "absolute", hour: 9, minute: 30 },
});

expect(result).toMatchObject({
dayReference: null,
explicitHour: 9,
minute: 30,
});
expect(scheduleConstraintSchema.safeParse(result).success).toBe(true);
});

it("synthesizes readable sourceText", () => {
const result = buildScheduleConstraintFromSlots({
day: "tomorrow",
time: { kind: "absolute", hour: 15, minute: 0 },
});
expect(result?.sourceText).toBe("tomorrow at 3pm");
});
});
75 changes: 74 additions & 1 deletion packages/core/src/time-spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,77 @@
import type { TimeSpec } from "./discourse-state";
import type { TimeSpec, ResolvedSlots } from "./discourse-state";

type Weekday =
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";

const WEEKDAYS = new Set<string>([
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
]);

export function buildScheduleConstraintFromSlots(
slots: Pick<ResolvedSlots, "day" | "time">,
): {
dayReference: "today" | "tomorrow" | "weekday" | null;
weekday: Weekday | null;
weekOffset: number | null;
relativeMinutes?: number | null;
explicitHour: number | null;
minute: number | null;
preferredWindow: "morning" | "afternoon" | "evening" | null;
sourceText: string;
} | null {
if (!slots.day && !slots.time) return null;

const timeFields = slots.time
? timeSpecToConstraintFields(slots.time)
: {
explicitHour: null,
minute: null,
relativeMinutes: null,
preferredWindow: "morning" as const,
};

let dayReference: "today" | "tomorrow" | "weekday" | null = null;
let weekday: Weekday | null = null;
let weekOffset: number | null = null;

if (slots.day) {
const lower = slots.day.toLowerCase();
if (lower === "today") {
dayReference = "today";
} else if (lower === "tomorrow") {
dayReference = "tomorrow";
} else if (WEEKDAYS.has(lower)) {
dayReference = "weekday";
weekday = lower as Weekday;
weekOffset = 0;
}
}

const parts: string[] = [];
if (slots.day) parts.push(slots.day);
if (slots.time) parts.push(formatTimeSpec(slots.time));
const sourceText = parts.join(" at ") || "scheduled";

return {
dayReference,
weekday,
weekOffset,
...timeFields,
sourceText,
};
}

export function timeSpecToHHMM(spec: TimeSpec): string | null {
if (spec.kind === "absolute") {
Expand Down
4 changes: 2 additions & 2 deletions packages/integrations/src/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,14 +487,14 @@ function normalizePlanningOutput(
return {
type: action.type,
taskRef: action.taskRef,
scheduleConstraint: action.scheduleConstraint ?? null,
scheduleConstraint: null,
reason: action.reason,
};
case "move_schedule_block":
return {
type: action.type,
blockRef: action.blockRef,
scheduleConstraint: action.scheduleConstraint ?? null,
scheduleConstraint: null,
reason: action.reason,
};
case "complete_task":
Expand Down
34 changes: 2 additions & 32 deletions packages/integrations/src/prompts/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,11 @@ export const inboxPlannerSystemPrompt = buildPromptSpec([
lines: [
"Use create_task when the inbox item introduces new work.",
"If the user asks to schedule new work, use create_task and create_schedule_block for that work unless clarification is required.",
"If the user asks to schedule new work but gives no specific timing details, default to create_task plus create_schedule_block with scheduleConstraint=null so Atlas can place it in the next reasonable opening.",
"Use create_schedule_block to schedule either a created task alias or an existing task alias.",
"If the user explicitly delegates slot choice to Atlas, such as 'schedule it for me', 'pick a time', or 'find an open spot', use a schedule action with scheduleConstraint=null so the application can choose the next reasonable opening.",
"Use create_schedule_block to schedule either a created task alias or an existing task alias. The application handles timing from context.",
"Use move_schedule_block only for one existing schedule_block alias from the provided context when that block is clearly referenced.",
"Use complete_task when the user clearly says an existing task is done or completed.",
],
},
{
title: "Temporal Encoding Rules",
lines: [
"Emit temporal intent, not computed day counts or calendar offsets.",
"Use context.referenceTime and the user's timezone only to interpret the phrase semantics safely.",
"For relative phrases like 'in 15 min', 'in 2 hours', or '15 minutes from now', set relativeMinutes and leave every absolute date/time field null.",
"For same-day phrases, use dayReference='today', weekday=null, and weekOffset=null.",
"For tomorrow phrases, use dayReference='tomorrow', weekday=null, and weekOffset=null.",
"For named weekday phrases, use dayReference='weekday' with a lowercase weekday and a weekOffset.",
"Use weekOffset=0 for Friday or this Friday, weekOffset=1 for next Friday, and weekOffset=2 for next next Friday.",
"For time-only phrases like at 3pm, leave dayReference, weekday, and weekOffset null.",
"Use explicitHour and minute for exact times.",
"When the user gives an explicit time block like '11:05 to 11:09' or 'from 2pm to 2:30pm', also set endExplicitHour and endMinute so Atlas preserves that exact duration.",
"Use preferredWindow only for broad phrases like morning, afternoon, or evening, and leave explicitHour null in those cases.",
"If the user gives a soft but usable preference like 'morning but not too early', infer a sensible time instead of asking for an exact hour. For example, 10:30am is a valid interpretation.",
"Do not ask for an exact time when the user has already given a usable broad window or delegated slot choice to Atlas.",
"If the temporal phrase cannot be represented safely by the current schema, return exactly one clarify action.",
],
},
{
title: "Clarification And Safety",
lines: [
Expand All @@ -86,17 +65,8 @@ export const inboxPlannerSystemPrompt = buildPromptSpec([
{
title: "Examples",
lines: [
"tomorrow at 3pm -> dayReference='tomorrow', weekday=null, weekOffset=null, explicitHour=15, minute=0.",
"in like 15 min -> relativeMinutes=15, dayReference=null, weekday=null, weekOffset=null, explicitHour=null, minute=null, preferredWindow=null.",
"Friday morning -> dayReference='weekday', weekday='friday', weekOffset=0, explicitHour=null, preferredWindow='morning'.",
"at 3pm -> dayReference=null, weekday=null, weekOffset=null, explicitHour=15, minute=0.",
"today from 11:05 to 11:09 -> dayReference='today', explicitHour=11, minute=5, endExplicitHour=11, endMinute=9.",
"If context.referenceTime is Wednesday, March 18, 2026 in America/Los_Angeles, then Friday at 10am -> dayReference='weekday', weekday='friday', weekOffset=0, explicitHour=10, minute=0.",
"If context.referenceTime is Wednesday, March 18, 2026 in America/Los_Angeles, then next Friday at 10am -> dayReference='weekday', weekday='friday', weekOffset=1, explicitHour=10, minute=0.",
"If the user says 'journal is done' and the provided task context includes one journaling task alias, emit exactly one complete_task action for that existing task alias.",
"If the user says 'schedule an oil change' with no specific timing detail, emit create_task plus create_schedule_block with scheduleConstraint=null.",
"If the user says 'schedule it for me and just pick an opening' for one clear task, emit a schedule action with scheduleConstraint=null.",
"If the user says 'tomorrow morning but not too early', emit a concrete late-morning time such as explicitHour=10 and minute=30.",
"If the user says 'schedule an oil change' with no specific timing detail, emit create_task plus create_schedule_block.",
"If the user says 'move that to Friday and add a grocery task' but the existing referent is not clear, emit exactly one clarify action.",
"If the user says 'if tomorrow is slammed push the workout to Friday', emit exactly one clarify action.",
],
Expand Down