Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ and versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- **Recurring tasks remember their last runs:** each run of a recurring task now reads a short note of what its last
three runs found, sent or decided, plus the task's own notes, so it tells you only what is new instead of repeating
yesterday's news. Runs keep those notes up to date themselves; see, edit or clear them under **Notes between runs**
on the task.
- **Watches that only speak up when something important changed:** a check script can now **Tell me when something
important changes**. Each check is compared with the last one without any AI, so an unchanged page costs nothing
and stays silent; only when something changed does your fast AI model decide whether it matters, and then you get
Expand Down
70 changes: 70 additions & 0 deletions desktop/src/features/tasks/detail/NotepadCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** Recurring tasks (docs/API.md §4 "Recurring tasks remember"): the notes its runs keep for the next run. */
import { IconNotes, IconPencil, IconTrash } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import { Button, Textarea } from '@/components/ui'
import type { Task } from '@/lib/types'

const MAX_CHARS = 1500

export function NotepadCard({ task, onSave }: { task: Task; onSave: (notepad: string | null) => Promise<unknown> }) {
const notepad = task.notepad ?? ''
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(notepad)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!editing) setDraft(notepad)
}, [notepad, editing])

const save = async (value: string | null) => {
setSaving(true)
try {
await onSave(value)
setEditing(false)
} finally {
setSaving(false)
}
}

return (
<section className="group space-y-2">
<div className="flex items-center gap-2">
<IconNotes size={16} className="text-fg-subtle" />
<h3 className="text-sm font-semibold text-fg">Notes between runs</h3>
<span className="flex-1" />
{editing ? (
<div className="flex items-center gap-1.5">
<Button size="xs" variant="ghost" onClick={() => (setDraft(notepad), setEditing(false))} disabled={saving}>
Cancel
</Button>
<Button size="xs" variant="primary" loading={saving} onClick={() => void save(draft.trim() || null)}>
Save
</Button>
</div>
) : (
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{notepad && (
<Button size="xs" variant="ghost" leftIcon={<IconTrash size={13} />} loading={saving} onClick={() => void save(null)}>
Clear
</Button>
)}
<Button size="xs" variant="ghost" leftIcon={<IconPencil size={13} />} onClick={() => setEditing(true)}>
Edit
</Button>
</div>
)}
</div>
{editing ? (
<Textarea autoFocus autoGrow minHeight={80} maxLength={MAX_CHARS} value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setEditing(false)} />
) : (
<div className="rounded-xl border border-border bg-sunken/40 px-4 py-3">
{notepad ? (
<p className="selectable whitespace-pre-wrap text-sm leading-relaxed text-fg-muted">{notepad}</p>
) : (
<p className="text-sm text-fg-subtle">Nothing yet. Each run leaves a short note here for the next one.</p>
)}
<p className="mt-2 text-2xs text-fg-subtle">Every run reads these notes and what the last few runs found, so it tells you only what’s new.</p>
</div>
)}
</section>
)
}
5 changes: 5 additions & 0 deletions desktop/src/features/tasks/detail/TaskDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { HookName } from '@/features/automations/HookLabel'
import { useBrowserProfiles } from '@/features/browser/state'
import { ScriptJobSection } from './ScriptJob'
import { DeliverToRow } from './DeliverTo'
import { NotepadCard } from './NotepadCard'
import { Clarifications } from './Clarifications'
import { RunQuestion } from './RunQuestion'
import { PlanSection } from './PlanSection'
Expand Down Expand Up @@ -519,6 +520,10 @@ function Overview({ task, tz, ops, missing, layout, onShowRuns }: { task: Task;

<ScheduleCard task={task} tz={tz} onSave={(schedule) => ops.update(id, { schedule }, 'Schedule updated')} saving={ops.isBusy('update', id)} />

{task.schedule?.type === 'recurring' && task.task_type !== 'swarm' && (!isScriptJob(task) || scriptOf(task)?.then === 'run') && (
<NotepadCard task={task} onSave={(notepad) => ops.update(id, { notepad }, notepad ? 'Notes saved' : 'Notes cleared')} />
)}

{task.task_type === 'swarm' && task.swarm_details && <SwarmSection swarm={task.swarm_details} tz={tz} running={task.status === 'processing' || task.status === 'planning'} />}

{isScriptJob(task) && <ScriptJobSection task={task} />}
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,8 @@ export interface Task {
browser_profile?: string | null
/** Where its notifications go besides the app (§4 "Where results go"). Older engines leave it out (default). */
deliver_to?: TaskDeliverTo
/** Recurring tasks: short notes its runs keep for the next run (§4 "Recurring tasks remember"). */
notepad?: string | null
original_context: { source: 'manual_creation' | 'chat' | 'proactive' | 'trigger' | string; [k: string]: unknown }
/** Last planning/run failure message (v2 `task.error`). */
error: string | null
Expand Down Expand Up @@ -1432,7 +1434,7 @@ export interface DeliveryChat {
/** `default`: paired chats with delivery on; `desktop`: the app only; a list: only those chats. */
export type TaskDeliverTo = 'default' | 'desktop' | DeliveryChat[]

export type TaskPatch = Partial<Pick<Task, 'name' | 'description' | 'priority' | 'schedule' | 'plan' | 'enabled' | 'status' | 'model' | 'browser_profile' | 'deliver_to'>>
export type TaskPatch = Partial<Pick<Task, 'name' | 'description' | 'priority' | 'schedule' | 'plan' | 'enabled' | 'status' | 'model' | 'browser_profile' | 'deliver_to' | 'notepad'>>

export interface ClarificationAnswer {
question_id: string
Expand Down
16 changes: 15 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ Routes:
"aggregated_results": []} | null,
"enabled": true, "model": null, "browser_profile": "x-posting|null",
"deliver_to": "default" | "desktop" | [{"channel": "telegram|discord|whatsapp", "chat_id": "..."}],
"notepad": "...|null",
"original_context": {"source": "manual_creation|chat|proactive|trigger", "...": "..."},
"error": "...|null",
"next_execution_at": "...|null", "last_execution_at": "...|null", "created_at": "...", "updated_at": "..."
Expand All @@ -528,6 +529,7 @@ Routes:
`swarm_details` is `null` for single tasks. `script` is `null` unless `task_type` is `script` (section 16).
`browser_profile` is the browser profile its runs use (section 12); `null` means `default`.
`deliver_to` is where the task's notifications go besides the app (see "Where results go" below).
`notepad` is the notes a recurring task's runs keep for the next run (see "Recurring tasks remember" below); `null` when empty.
`error` holds the last planning/run failure message (v2 `task.error`).
Each run embeds its most recent 200 progress updates; the full log is at the `/events` endpoint.
`interval` schedules (every `interval_minutes`, minimum 5; `frequency: "hourly"` is normalized to 60) are additive; the
Expand Down Expand Up @@ -581,7 +583,8 @@ notifies skips the planner and goes straight to `approval_pending` with a one-st
- `GET /api/tasks/{id}` → `Task`
- `POST /api/tasks` `{prompt, is_swarm?: bool, assignee?: "ai", model?, browser_profile?}` → `Task` (status `planning`; refinement + planning continue in the background and arrive as `task.updated`)
- `POST /api/tasks/preview` `{prompt}` → `{name, description, priority, schedule}` (v2 generate-plan)
- `PATCH /api/tasks/{id}` any of `{name, description, priority, schedule, plan, enabled, status, model, script, browser_profile, deliver_to}` → `Task`
- `PATCH /api/tasks/{id}` any of `{name, description, priority, schedule, plan, enabled, status, model, script, browser_profile, deliver_to, notepad}` → `Task`
(`notepad`: text, trimmed and cut to 1500 characters; `null` or `""` clears it)
(`browser_profile`: a name from `browser.profiles`, 400 for an unknown one; `null`, `""` or `"default"` clear it)
(`deliver_to`: `"default"` (or `null`), `"desktop"`, or a list of up to 10 `{channel, chat_id}`; an empty list is
`"desktop"`; 400 for anything else, an unknown channel or a missing `chat_id`)
Expand Down Expand Up @@ -649,6 +652,17 @@ tasks, the brief itself. The app always gets them.
can't be read, nothing goes to a messaging app.
`rerun` keeps it. Backend API: `await app.tasks.delivery_for(task_id)` → `"default" | "desktop" | [...]`.

### Recurring tasks remember
Each model run of a recurring task (`schedule.type: "recurring"`; not swarm or one-call tasks) gets a short section in
its executor prompt: the last 3 finished runs (`completed`/`completed_with_errors`), each as its start time and what it
told the user (its final answer, or `result.summary` when it has none) cut to 400 characters, then the task's
`notepad`, then an instruction to report only what is new or still open and to save the full list of items reported so
far with `task_notepad`. The first run is told it is the first. When there is anything to remember, the run's log starts with
`Remembering N earlier runs and its notepad, so it reports only what is new.` One-off and triggered runs get none of it.
- Inside those runs only, the executor can call `task_notepad(text: str)` (plugin `task_notepad`, scoped, risk `write`,
internal): it replaces the task's `notepad` (trimmed, at most 1500 characters). It is left out of `result.tools_used`.
- The user can edit or clear the notepad with `PATCH {notepad}`. `rerun` copies start with an empty notepad.

### Tasks that ask you a question
- Inside a task run (never in chat, subagents, swarm workers or the planner) the executor can call
`ask_user(question: str, options: list[str] | None = None)` (plugin `task_questions`, risk `write`, internal). It is meant
Expand Down
117 changes: 117 additions & 0 deletions sentient/tasks/continuity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Recurring tasks remember their last runs (docs/API.md section 4, "Recurring tasks remember").

Each run of a recurring task sees a short note of what its last few runs found, sent or decided, plus the task's
notepad: a few lines the runs keep for each other (the items reported so far, what is still open). The run reports
only what is new instead of repeating itself every day.

Kept compact for small local models: at most ``EARLIER_RUNS`` runs of ``RUN_NOTE_CHARS`` each and a notepad of
``MAX_NOTEPAD_CHARS``. A run's note is its final answer (what the user was told), or its result summary when it has
none.
The run updates its notepad with ``task_notepad`` (plugin ``task_notepad``, scoped: offered only inside runs of
recurring tasks).
"""

from __future__ import annotations

from datetime import tzinfo
from typing import Any

from sentient.tasks.schedule import parse_iso
from sentient.tools.base import Risk, ToolContext, ToolPlugin, tool

EARLIER_RUNS = 3
RUN_NOTE_CHARS = 400
MAX_NOTEPAD_CHARS = 1500
NOTEPAD_TOOL = "task_notepad"
PLUGIN_ID = "task_notepad"
FINISHED = ("completed", "completed_with_errors")
KICKOFF_NOTE = (
" This task ran before, and the user was already told what is listed under \"What earlier runs told the user\". "
"Your final answer must report only what is new since then, or say in one sentence that nothing is new."
)


def applies(task: dict) -> bool:
"""Runs of recurring tasks carry what came before. One-off and triggered runs stand alone."""
return (task.get("schedule") or {}).get("type") == "recurring"


def clip(text: Any, limit: int) -> str:
"""One paragraph of at most ``limit`` characters."""
flat = " ".join(str(text or "").split())
return flat if len(flat) <= limit else flat[: limit - 3].rstrip() + "..."


def clean_notepad(text: Any) -> str | None:
"""The stored notepad: trimmed lines, at most ``MAX_NOTEPAD_CHARS``; None when empty."""
lines = [line.rstrip() for line in str(text or "").strip().splitlines()]
out = "\n".join(lines).strip()
if not out:
return None
return out if len(out) <= MAX_NOTEPAD_CHARS else out[: MAX_NOTEPAD_CHARS - 3].rstrip() + "..."


async def earlier_runs(repo: Any, task_id: str, current_run_id: str, tz: tzinfo) -> list[dict]:
"""``[{when, note}]`` for the last finished runs before ``current_run_id``, oldest first."""
runs = [r for r in await repo.runs_for(task_id) if r["id"] != current_run_id and r["status"] in FINISHED]
out: list[dict] = []
for run in runs[-EARLIER_RUNS:]:
note = ""
for update in reversed(await repo.events(run["id"], limit=20)): # what the user was told
msg = (update or {}).get("message") or {}
if msg.get("type") == "final_answer" and str(msg.get("content") or "").strip():
note = str(msg["content"]).strip()
break
if not note:
result = run.get("result") if isinstance(run.get("result"), dict) else {}
note = str(result.get("summary") or "").strip()
if not note:
continue
started = parse_iso(run.get("started_at") or run.get("created_at"))
when = started.astimezone(tz).strftime("%a %d %b %H:%M") if started else "earlier"
out.append({"when": when, "note": clip(note, RUN_NOTE_CHARS)})
return out


def prompt_section(runs: list[dict], notepad: str | None) -> str:
"""The executor prompt's section about earlier runs (a one-line note for the first run)."""
if not runs and not notepad:
return (
"**Earlier runs:** None, this is the first run of this recurring task. Before your final answer, call "
"`task_notepad` once with a short list of the items you reported, so the next run can compare.\n\n"
)
parts = ["**What earlier runs told the user (this task repeats; most recent last):**"]
parts += [f"- {r['when']}: {r['note']}" for r in runs] or ["- (no finished runs yet)"]
if notepad:
parts.append("**Notepad kept by earlier runs:**\n" + notepad)
parts.append(
"**Report only what is new.** Compare what you find now with the above. Leave out items the user was already "
"told about, unless they changed or still need the user's attention (then say it was reported before). If "
"nothing is new, your final answer is one sentence saying so. Before your final answer, call `task_notepad` "
"once with the full list of items reported so far, old and new, so the next run can compare."
)
return "\n".join(parts) + "\n\n"


@tool(NOTEPAD_TOOL, risk=Risk.write, internal=True)
async def task_notepad(ctx: ToolContext, text: str) -> dict:
"""Replace this recurring task's notepad: a few short lines the next run will read, such as what you reported
today and what is still open. Keep it under 1500 characters; it replaces the previous notepad."""
task_id = ctx.extra.get("task_id")
if not task_id or not ctx.extra.get(PLUGIN_ID):
return {"error": "The notepad only works inside a run of a recurring task."}
from sentient.tasks.repo import TaskRepo

note = clean_notepad(text)
await TaskRepo(ctx.store).update_task(str(task_id), {"notepad": note})
return {"saved": True, "next": "Notepad saved. Do not call it again; reply now with your final answer for the user."}


class TaskNotepadPlugin(ToolPlugin):
id = PLUGIN_ID
display_name = "Task notepad"
description = "Lets a recurring task keep short notes for its next run."
category = "core"
icon = "IconNotes"
scoped = True
tools = [task_notepad]
Loading
Loading