@scira/cli 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,12 @@
1
- // Regex patterns for markdown matching
2
1
  const LINK_PATTERN = /^\[.*?\]\(.*?\)$/;
3
2
  const BOLD_PATTERN = /^\*\*.*?\*\*$/;
4
- // Matches *text* but NOT **text** (negative lookahead ensures second char isn't *)
5
3
  const ITALIC_PATTERN = /^\*(?!\*).+\*$/;
6
4
  const TABLE_ROW_PATTERN = /^\|.+\|$/;
7
- // Matches markdown table delimiter rows like: | --- | ---: | :-: |
8
5
  const TABLE_DELIMITER_PATTERN = /^\|\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|\s*$/;
9
6
  const WHITESPACE_PATTERN = /\s/;
10
- // Rich XML tags that must be passed through intact
11
7
  const RICH_TAGS = ['app_preview', 'download'];
12
- // Matches an opening rich tag e.g. <app_preview> or <download>
13
8
  const RICH_TAG_OPEN_RE = new RegExp(`<(${RICH_TAGS.join('|')})>`, 'i');
14
- // Inline buffer cap: flush as raw text if a markdown element doesn't close within this many chars
15
9
  const MAX_INLINE_BUFFER = 512;
16
- // Rich-tag buffer cap: safety valve for malformed/missing closing tags
17
10
  const MAX_RICH_TAG_BUFFER = 65536;
18
11
  class MarkdownJoiner {
19
12
  buffer = '';
@@ -27,10 +20,8 @@ class MarkdownJoiner {
27
20
  processText(text) {
28
21
  let output = '';
29
22
  for (const char of text) {
30
- // Rich-tag passthrough mode: buffer everything until closing tag
31
23
  if (this.bufferMode === 'rich-tag') {
32
24
  this.buffer += char;
33
- // Safety cap: if the rich tag never closes, flush as raw text
34
25
  if (this.buffer.length > MAX_RICH_TAG_BUFFER) {
35
26
  output += this.buffer;
36
27
  this.richTagName = null;
@@ -58,42 +49,34 @@ class MarkdownJoiner {
58
49
  }
59
50
  else if (this.bufferMode === 'inline') {
60
51
  this.buffer += char;
61
- // Cap inline buffer to prevent quadratic regex cost on unbounded input
62
52
  if (this.buffer.length > MAX_INLINE_BUFFER) {
63
53
  output += this.buffer;
64
54
  this.clearBuffer();
65
55
  this.isAtLineStart = char === '\n';
66
56
  continue;
67
57
  }
68
- // Check if buffer has grown into a rich tag opener
69
58
  if (this.buffer.startsWith('<')) {
70
59
  const match = RICH_TAG_OPEN_RE.exec(this.buffer);
71
60
  if (match && this.buffer.endsWith('>')) {
72
- // Confirmed rich tag open — switch to rich-tag mode
73
61
  this.richTagName = match[1];
74
62
  this.bufferMode = 'rich-tag';
75
63
  this.isAtLineStart = false;
76
64
  continue;
77
65
  }
78
- // Still potentially building a rich tag — keep buffering until > or mismatch
79
66
  if (!this.isFalsePositiveTag(char)) {
80
67
  this.isAtLineStart = char === '\n';
81
68
  continue;
82
69
  }
83
- // Not a rich tag — flush as raw text
84
70
  output += this.buffer;
85
71
  this.clearBuffer();
86
72
  this.isAtLineStart = char === '\n';
87
73
  continue;
88
74
  }
89
- // Check for complete markdown elements or false positives
90
75
  if (this.isCompleteLink() || this.isCompleteBold() || this.isCompleteItalic()) {
91
- // Complete markdown element - flush buffer as is
92
76
  output += this.buffer;
93
77
  this.clearBuffer();
94
78
  }
95
79
  else if (this.isFalsePositive(char)) {
96
- // False positive - flush buffer as raw text
97
80
  output += this.buffer;
98
81
  this.clearBuffer();
99
82
  }
@@ -105,7 +88,6 @@ class MarkdownJoiner {
105
88
  if (char !== '|') {
106
89
  output += this.pendingTableHeaderLine;
107
90
  this.pendingTableHeaderLine = null;
108
- // fall through to handle this char normally
109
91
  }
110
92
  else {
111
93
  this.tableLineMode = 'delimiter';
@@ -135,7 +117,6 @@ class MarkdownJoiner {
135
117
  this.isAtLineStart = false;
136
118
  continue;
137
119
  }
138
- // Pass through character directly
139
120
  output += char;
140
121
  this.isAtLineStart = char === '\n';
141
122
  }
@@ -150,7 +131,6 @@ class MarkdownJoiner {
150
131
  this.tableLineMode = null;
151
132
  if (mode === 'header') {
152
133
  if (this.isTableHeaderCandidate(line)) {
153
- // Hold header line until we see whether next line is a delimiter row
154
134
  this.pendingTableHeaderLine = lineWithNewline;
155
135
  return '';
156
136
  }
@@ -169,19 +149,15 @@ class MarkdownJoiner {
169
149
  return TABLE_ROW_PATTERN.test(line) && !TABLE_DELIMITER_PATTERN.test(line);
170
150
  }
171
151
  isCompleteLink() {
172
- // Match [text](url) pattern
173
152
  return LINK_PATTERN.test(this.buffer);
174
153
  }
175
154
  isCompleteBold() {
176
- // Match **text** pattern
177
155
  return BOLD_PATTERN.test(this.buffer);
178
156
  }
179
157
  isCompleteItalic() {
180
- // Match *text* pattern (but not **text**)
181
158
  return ITALIC_PATTERN.test(this.buffer);
182
159
  }
183
160
  isFalsePositiveTag(char) {
184
- // A < buffer is a false positive if we hit newline, another <, or > without matching a rich tag
185
161
  if (char === '\n' || (char === '<' && this.buffer.length > 1))
186
162
  return true;
187
163
  if (char === '>' && !RICH_TAG_OPEN_RE.test(this.buffer))
@@ -189,19 +165,13 @@ class MarkdownJoiner {
189
165
  return false;
190
166
  }
191
167
  isFalsePositive(char) {
192
- // For links: if we see [ followed by something other than valid link syntax
193
168
  if (this.buffer.startsWith('[')) {
194
- // If we hit a newline or another [ without completing the link, it's false positive
195
169
  return char === '\n' || (char === '[' && this.buffer.length > 1);
196
170
  }
197
- // For emphasis: if we see * or ** followed by whitespace or newline
198
171
  if (this.buffer.startsWith('*')) {
199
- // Single * followed by whitespace is likely a list item or not emphasis
200
- // (buffer already includes char, so length 2 means just "*" + the whitespace char)
201
172
  if (this.buffer.length === 2 && WHITESPACE_PATTERN.test(char)) {
202
173
  return true;
203
174
  }
204
- // If we hit newline without completing emphasis, it's false positive
205
175
  return char === '\n';
206
176
  }
207
177
  return false;
@@ -0,0 +1,12 @@
1
+ /** True if a process with this pid is currently running. Signal 0 is an existence check — it never kills. */
2
+ export function isProcessRunning(pid) {
3
+ if (pid <= 0)
4
+ return false;
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
@@ -0,0 +1,21 @@
1
+ /** Compact relative time, e.g. "now", "5m ago", "3h ago", "2d ago", "3w ago". */
2
+ export function relativeTime(ms) {
3
+ if (!ms)
4
+ return "";
5
+ const diff = Date.now() - ms;
6
+ if (diff < 60_000)
7
+ return "now";
8
+ const mins = Math.floor(diff / 60_000);
9
+ if (mins < 60)
10
+ return `${mins}m ago`;
11
+ const hours = Math.floor(mins / 60);
12
+ if (hours < 24)
13
+ return `${hours}h ago`;
14
+ const days = Math.floor(hours / 24);
15
+ if (days < 7)
16
+ return `${days}d ago`;
17
+ const weeks = Math.floor(days / 7);
18
+ if (weeks < 5)
19
+ return `${weeks}w ago`;
20
+ return new Date(ms).toLocaleDateString();
21
+ }
@@ -0,0 +1,40 @@
1
+ import { peekUnreadResults, commitReadCursor } from "../watch/store.js";
2
+ import { formatDiffCounts } from "../watch/format.js";
3
+ import { relativeTime } from "./time.js";
4
+ /**
5
+ * Unread background-watch results plus the cursor offset to commit once shown.
6
+ * Read-only — the cursor is committed separately (commitWatchNotices) only after
7
+ * the banner is actually printed, so a crash can't silently mark results read.
8
+ * Returns an empty set on any failure — this must never break the CLI.
9
+ */
10
+ export async function checkWatchNotices() {
11
+ try {
12
+ return await peekUnreadResults();
13
+ }
14
+ catch {
15
+ return { results: [], nextOffset: 0 };
16
+ }
17
+ }
18
+ /** Mark results read up to `byteOffset` — call only after the banner has been shown. */
19
+ export async function commitWatchNotices(byteOffset) {
20
+ try {
21
+ await commitReadCursor(byteOffset);
22
+ }
23
+ catch {
24
+ /* best-effort */
25
+ }
26
+ }
27
+ function briefDiff(r) {
28
+ return formatDiffCounts(r.added, r.removed) || "no changes";
29
+ }
30
+ /** Cyan banner summarizing unread background-watch results, shown after a CLI command. */
31
+ export function formatWatchNotice(results) {
32
+ const lines = [`${results.length} watch result${results.length > 1 ? "s" : ""} ready:`];
33
+ for (const r of results) {
34
+ lines.push(r.status === "ok"
35
+ ? ` • ${r.watchName} — ran ${relativeTime(new Date(r.ranAt).getTime())}, ${briefDiff(r)}`
36
+ : ` • ${r.watchName} — failed: ${r.errorMessage ?? "unknown error"}`);
37
+ }
38
+ lines.push(`Run "scira watch list" to view scheduled watches`);
39
+ return lines.join("\n");
40
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from "node:path";
2
+ import { WATCH_DIR } from "./store.js";
3
+ export { isProcessRunning } from "../utils/process.js";
4
+ export const DAEMON_PID_FILE = join(WATCH_DIR, "daemon.pid.json");
5
+ export const DAEMON_LOG_FILE = join(WATCH_DIR, "daemon.log");
6
+ export async function readLock() {
7
+ try {
8
+ return (await Bun.file(DAEMON_PID_FILE).json());
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bun
2
+ // Background scheduler. Spawned detached by `scira watch start`; ticks every
3
+ // 60s, firing any scheduled watch that isDue(). A PID lock prevents duplicate
4
+ // daemons. Paths and lock helpers are shared with the start/stop/status
5
+ // commands via ./daemon-lock.js.
6
+ import { appendFile, mkdir, rm } from "node:fs/promises";
7
+ import { loadWatches, markWatchesRan, WATCH_DIR } from "./store.js";
8
+ import { isDue } from "./scheduler.js";
9
+ import { executeWatch } from "./executor.js";
10
+ import { DAEMON_LOG_FILE, DAEMON_PID_FILE, isProcessRunning, readLock } from "./daemon-lock.js";
11
+ const TICK_MS = 60_000;
12
+ async function log(msg) {
13
+ await appendFile(DAEMON_LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`).catch(() => { });
14
+ }
15
+ async function runTick() {
16
+ // Reload each tick so watches added/removed while running take effect.
17
+ const watches = await loadWatches();
18
+ const now = new Date();
19
+ const due = watches.filter((w) => w.enabled && isDue(w, now));
20
+ if (due.length === 0)
21
+ return;
22
+ // Stamp every due watch in a single write BEFORE firing any. This makes the
23
+ // tick the sole writer of lastRunAt, so the next tick (or a still-in-flight
24
+ // run) sees them as already-run and won't double-fire — no read-modify-write
25
+ // race between the concurrent fire-and-forget executions below.
26
+ await markWatchesRan(due.map((w) => w.id), now.toISOString());
27
+ for (const watch of due) {
28
+ void log(`starting watch "${watch.name}" (${watch.id})`);
29
+ // Fire-and-forget: a long research run shouldn't block other due watches.
30
+ void executeWatch(watch)
31
+ .then((r) => log(`completed watch "${watch.name}" (${r.status})`))
32
+ .catch((err) => log(`watch "${watch.name}" failed: ${err instanceof Error ? err.message : String(err)}`));
33
+ }
34
+ }
35
+ async function main() {
36
+ // Refuse to start if a live daemon already holds the lock.
37
+ const existing = await readLock();
38
+ if (existing && isProcessRunning(existing.pid)) {
39
+ process.stderr.write(`Daemon already running (pid ${existing.pid})\n`);
40
+ process.exit(1);
41
+ }
42
+ await mkdir(WATCH_DIR, { recursive: true });
43
+ await Bun.write(DAEMON_PID_FILE, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
44
+ const cleanup = () => {
45
+ rm(DAEMON_PID_FILE, { force: true }).catch(() => { });
46
+ process.exit(0);
47
+ };
48
+ process.on("SIGTERM", cleanup);
49
+ process.on("SIGINT", cleanup);
50
+ await log(`started (pid ${process.pid})`);
51
+ await runTick(); // run an initial tick immediately, then on the interval
52
+ setInterval(() => {
53
+ void runTick().catch((e) => log(`tick error: ${e instanceof Error ? e.message : String(e)}`));
54
+ }, TICK_MS);
55
+ }
56
+ main().catch((err) => {
57
+ process.stderr.write(`[daemon] fatal: ${err instanceof Error ? err.message : String(err)}\n`);
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,43 @@
1
+ import { loadSciraEnv } from "../config/env-store.js";
2
+ import { loadConfig } from "../config/load-config.js";
3
+ import { runOnceAndDiff } from "./runner.js";
4
+ import { appendResult } from "./store.js";
5
+ import { formatDiffCounts } from "./format.js";
6
+ import { tryDesktopNotify } from "../utils/desktop-notify.js";
7
+ function resultId(watchId, ranAt) {
8
+ return `result_${watchId}_${ranAt.replace(/[:.]/gu, "-")}`;
9
+ }
10
+ function notifyBody(added, removed) {
11
+ const counts = formatDiffCounts(added, removed);
12
+ return counts ? `Research complete — ${counts}.` : "Research complete — no changes.";
13
+ }
14
+ /**
15
+ * Execute one scheduled watch: run a fresh research pass, diff against the prior
16
+ * report, record the result, and fire a desktop notification. Records an error
17
+ * result (never throws) so the daemon's tick loop and the unread-results banner
18
+ * stay consistent regardless of how the run went. The daemon stamps lastRunAt
19
+ * before calling this, so we don't touch it here.
20
+ */
21
+ export async function executeWatch(watch) {
22
+ // The detached daemon doesn't run the CLI bootstrap, so load this watch's
23
+ // project-scoped API keys (~/.scira/.env + <projectRoot>/.scira/.env) before
24
+ // resolving config — otherwise a watch for a project other than the daemon's
25
+ // launch dir would run without its keys.
26
+ loadSciraEnv(watch.projectRoot);
27
+ const config = await loadConfig(watch.projectRoot);
28
+ const ranAt = new Date().toISOString();
29
+ const base = { id: resultId(watch.id, ranAt), watchId: watch.id, watchName: watch.name, goal: watch.goal, ranAt };
30
+ let result;
31
+ try {
32
+ const { runPath, diff } = await runOnceAndDiff(watch.goal, config, watch.projectRoot);
33
+ result = { ...base, runPath, added: diff.added.length, removed: diff.removed.length, status: "ok" };
34
+ }
35
+ catch (err) {
36
+ result = { ...base, runPath: "", added: 0, removed: 0, status: "error", errorMessage: err instanceof Error ? err.message : String(err) };
37
+ }
38
+ await appendResult(result);
39
+ if (result.status === "ok") {
40
+ await tryDesktopNotify(`Scira: ${watch.name}`, notifyBody(result.added, result.removed));
41
+ }
42
+ return result;
43
+ }
@@ -0,0 +1,9 @@
1
+ /** "3 added, 1 removed" — or "" when nothing changed. Shared by the notifier and the CLI banner. */
2
+ export function formatDiffCounts(added, removed) {
3
+ const parts = [];
4
+ if (added > 0)
5
+ parts.push(`${added} added`);
6
+ if (removed > 0)
7
+ parts.push(`${removed} removed`);
8
+ return parts.join(", ");
9
+ }
@@ -2,29 +2,54 @@ import { readFile } from "node:fs/promises";
2
2
  import { diffLines } from "diff";
3
3
  import { createRun, listRuns, getRunPaths } from "../storage/run-store.js";
4
4
  import { runResearchAgent } from "../agent/main-agent.js";
5
- /** Compare two report.md texts and return a human-readable diff summary. */
6
- export function diffReports(prev, next) {
5
+ export function computeReportDiff(prev, next) {
7
6
  const changes = diffLines(prev, next);
8
7
  const added = changes.filter((c) => c.added).map((c) => c.value.trim()).filter(Boolean);
9
8
  const removed = changes.filter((c) => c.removed).map((c) => c.value.trim()).filter(Boolean);
10
- if (added.length === 0 && removed.length === 0)
11
- return "No changes detected.";
12
- const lines = [];
13
- if (added.length > 0) {
14
- lines.push(`+++ ${added.length} added section(s):\n${added.map((a) => ` + ${a.slice(0, 120)}`).join("\n")}`);
9
+ let text;
10
+ if (added.length === 0 && removed.length === 0) {
11
+ text = "No changes detected.";
15
12
  }
16
- if (removed.length > 0) {
17
- lines.push(`--- ${removed.length} removed section(s):\n${removed.map((r) => ` - ${r.slice(0, 120)}`).join("\n")}`);
13
+ else {
14
+ const lines = [];
15
+ if (added.length > 0) {
16
+ lines.push(`+++ ${added.length} added section(s):\n${added.map((a) => ` + ${a.slice(0, 120)}`).join("\n")}`);
17
+ }
18
+ if (removed.length > 0) {
19
+ lines.push(`--- ${removed.length} removed section(s):\n${removed.map((r) => ` - ${r.slice(0, 120)}`).join("\n")}`);
20
+ }
21
+ text = lines.join("\n");
18
22
  }
19
- return lines.join("\n");
23
+ return { added, removed, text };
24
+ }
25
+ /** Thrown when creating the run directory fails — a fatal, non-retryable error for the watch loop. */
26
+ export class CreateRunError extends Error {
20
27
  }
21
28
  async function getLastReport(goal, config, projectRoot) {
22
29
  const runs = await listRuns(config, projectRoot);
23
- const last = runs.find((r) => r.goal === goal || r.goal.includes(goal));
30
+ // Exact goal match only a substring fallback could diff against an unrelated
31
+ // run whose goal merely contains this one (e.g. "AI" vs "AI safety").
32
+ const last = runs.find((r) => r.goal === goal);
24
33
  if (!last)
25
34
  return "";
26
35
  return readFile(getRunPaths(last.path).report, "utf8").catch(() => "");
27
36
  }
37
+ /**
38
+ * Run a single research pass for `goal` and diff its report against the most
39
+ * recent prior run of the same goal. The shared primitive behind both watch
40
+ * modes — foreground (watchLoop) and background (the daemon's executor) — so
41
+ * neither reimplements create-run → run-agent → diff.
42
+ */
43
+ export async function runOnceAndDiff(goal, config, projectRoot = process.cwd(), onRunStart) {
44
+ const prevReport = await getLastReport(goal, config, projectRoot);
45
+ const state = await createRun(goal, config, projectRoot).catch((err) => {
46
+ throw new CreateRunError(err instanceof Error ? err.message : String(err));
47
+ });
48
+ onRunStart?.(state.path);
49
+ await runResearchAgent(state.path, goal, config);
50
+ const nextReport = await Bun.file(getRunPaths(state.path).report).text().catch(() => "");
51
+ return { runPath: state.path, diff: computeReportDiff(prevReport, nextReport) };
52
+ }
28
53
  /**
29
54
  * Run the watch loop. Resolves when maxRuns is reached or signal is aborted.
30
55
  */
@@ -34,25 +59,17 @@ export async function watchLoop(opts, signal) {
34
59
  while (!signal?.aborted) {
35
60
  if (maxRuns !== undefined && tick >= maxRuns)
36
61
  break;
37
- const prevReport = await getLastReport(goal, config, projectRoot);
38
- let runPath;
39
- try {
40
- const state = await createRun(goal, config, projectRoot);
41
- runPath = state.path;
42
- }
43
- catch (err) {
44
- opts.onError?.(err instanceof Error ? err : new Error(String(err)), tick);
45
- break;
46
- }
47
- opts.onRunStart?.(runPath, tick);
48
62
  try {
49
- await runResearchAgent(runPath, goal, config);
50
- const nextReport = await Bun.file(getRunPaths(runPath).report).text().catch(() => "");
51
- const diffText = diffReports(prevReport, nextReport);
52
- opts.onRunComplete?.(runPath, diffText, tick);
63
+ const { runPath, diff } = await runOnceAndDiff(goal, config, projectRoot, (runPath) => opts.onRunStart?.(runPath, tick));
64
+ opts.onRunComplete?.(runPath, diff, tick);
53
65
  }
54
66
  catch (err) {
55
- opts.onError?.(err instanceof Error ? err : new Error(String(err)), tick);
67
+ const error = err instanceof Error ? err : new Error(String(err));
68
+ opts.onError?.(error, tick);
69
+ // A failed run dir creation is fatal (disk full, bad permissions) — retrying
70
+ // every interval would spin forever. Agent/run failures are transient: continue.
71
+ if (error instanceof CreateRunError)
72
+ break;
56
73
  }
57
74
  tick++;
58
75
  if (maxRuns !== undefined && tick >= maxRuns)
@@ -0,0 +1,58 @@
1
+ // All scheduling is in local wall-clock time: "run at 09:00" means the user's
2
+ // 09:00. Day-of-week is 0=Sun … 6=Sat.
3
+ function startOfDay(d) {
4
+ const s = new Date(d);
5
+ s.setHours(0, 0, 0, 0);
6
+ return s;
7
+ }
8
+ function parseHHMM(atHHMM) {
9
+ const [h, m] = atHHMM.split(":").map(Number);
10
+ return { hours: h ?? 0, minutes: m ?? 0 };
11
+ }
12
+ function atTimeOn(day, atHHMM) {
13
+ const { hours, minutes } = parseHHMM(atHHMM);
14
+ const t = new Date(day);
15
+ t.setHours(hours, minutes, 0, 0);
16
+ return t;
17
+ }
18
+ // Weekly watches fire on the weekday they were created on.
19
+ function weeklyDow(watch) {
20
+ return new Date(watch.createdAt).getDay();
21
+ }
22
+ function firesOn(watch, dow) {
23
+ switch (watch.frequency) {
24
+ case "day":
25
+ return true;
26
+ case "weekday":
27
+ return dow !== 0 && dow !== 6;
28
+ case "week":
29
+ return dow === weeklyDow(watch);
30
+ }
31
+ }
32
+ /**
33
+ * True when the watch should run at `now`: today is an eligible day, the trigger
34
+ * time has passed, and it hasn't already run today. The already-ran-today guard
35
+ * is what makes the daemon's 60s tick idempotent.
36
+ */
37
+ export function isDue(watch, now) {
38
+ if (!firesOn(watch, now.getDay()))
39
+ return false;
40
+ const target = atTimeOn(now, watch.atHHMM);
41
+ if (now < target)
42
+ return false; // trigger time hasn't arrived yet today
43
+ if (watch.lastRunAt === null)
44
+ return true;
45
+ return new Date(watch.lastRunAt) < startOfDay(target);
46
+ }
47
+ /** The next wall-clock time this watch will fire, scanning up to two weeks out. */
48
+ export function nextRunAt(watch, from) {
49
+ const cursor = new Date(from);
50
+ for (let i = 0; i < 14; i++) {
51
+ const candidate = atTimeOn(cursor, watch.atHHMM);
52
+ if (firesOn(watch, candidate.getDay()) && candidate > from)
53
+ return candidate;
54
+ cursor.setDate(cursor.getDate() + 1);
55
+ cursor.setHours(0, 0, 0, 0);
56
+ }
57
+ return atTimeOn(cursor, watch.atHHMM);
58
+ }
@@ -0,0 +1,110 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { SavedWatchSchema } from "../types/index.js";
5
+ import { appendJsonl } from "../storage/jsonl.js";
6
+ // Scheduled-watch state lives under ~/.scira/watch/, independent of any
7
+ // project's run directory — the daemon must reach it without a cwd.
8
+ export const WATCH_DIR = join(homedir(), ".scira", "watch");
9
+ const WATCHES_FILE = join(WATCH_DIR, "watches.json");
10
+ const RESULTS_FILE = join(WATCH_DIR, "results.jsonl");
11
+ const CURSOR_FILE = join(WATCH_DIR, "read-cursor.json");
12
+ // --- Scheduled watch definitions -----------------------------------------
13
+ export async function loadWatches() {
14
+ try {
15
+ const raw = (await Bun.file(WATCHES_FILE).json());
16
+ // Drop entries that no longer match the schema rather than throwing — one
17
+ // corrupt watch shouldn't break the daemon or the CLI banner.
18
+ return raw.flatMap((w) => {
19
+ const parsed = SavedWatchSchema.safeParse(w);
20
+ return parsed.success ? [parsed.data] : [];
21
+ });
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ async function saveWatches(list) {
28
+ await mkdir(WATCH_DIR, { recursive: true });
29
+ await Bun.write(WATCHES_FILE, `${JSON.stringify(list, null, 2)}\n`);
30
+ }
31
+ export function nextWatchId(existing) {
32
+ const nums = existing
33
+ .map((w) => /^watch_(\d+)$/u.exec(w.id)?.[1])
34
+ .filter((n) => Boolean(n))
35
+ .map((n) => Number.parseInt(n, 10));
36
+ const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
37
+ return `watch_${String(next).padStart(3, "0")}`;
38
+ }
39
+ export async function addWatch(w) {
40
+ await saveWatches([...(await loadWatches()), w]);
41
+ }
42
+ export async function removeWatch(id) {
43
+ await saveWatches((await loadWatches()).filter((w) => w.id !== id));
44
+ }
45
+ export async function findWatch(idOrName) {
46
+ return (await loadWatches()).find((w) => w.id === idOrName || w.name === idOrName);
47
+ }
48
+ /**
49
+ * Stamp lastRunAt on several watches in a single read-modify-write. The daemon
50
+ * calls this once per tick for all due watches, so concurrent (fire-and-forget)
51
+ * executions can't race on watches.json and drop each other's stamps.
52
+ */
53
+ export async function markWatchesRan(ids, ranAtIso) {
54
+ if (ids.length === 0)
55
+ return;
56
+ const set = new Set(ids);
57
+ const list = await loadWatches();
58
+ await saveWatches(list.map((w) => (set.has(w.id) ? { ...w, lastRunAt: ranAtIso } : w)));
59
+ }
60
+ // --- Execution results ---------------------------------------------------
61
+ export async function appendResult(r) {
62
+ await appendJsonl(RESULTS_FILE, r);
63
+ }
64
+ /**
65
+ * Results appended since the user last saw them, plus the byte offset to commit
66
+ * once they've actually been shown. This is read-only — it does NOT advance the
67
+ * cursor — so a crash before display can't silently mark results read. The
68
+ * cursor is a byte offset into the append-only file, so we parse only the new
69
+ * tail rather than the whole history on every CLI invocation.
70
+ */
71
+ export async function peekUnreadResults() {
72
+ let offset = 0;
73
+ try {
74
+ const raw = (await Bun.file(CURSOR_FILE).json());
75
+ offset = raw.byteOffset ?? 0;
76
+ }
77
+ catch {
78
+ /* no cursor yet — read from the start */
79
+ }
80
+ const file = Bun.file(RESULTS_FILE);
81
+ const size = file.size; // 0 when the file doesn't exist yet
82
+ if (size === 0)
83
+ return { results: [], nextOffset: 0 };
84
+ if (offset > size)
85
+ offset = 0; // file was truncated/rotated — re-read from start
86
+ let text = "";
87
+ try {
88
+ text = await file.slice(offset).text();
89
+ }
90
+ catch {
91
+ return { results: [], nextOffset: offset };
92
+ }
93
+ const results = [];
94
+ for (const line of text.split("\n")) {
95
+ if (!line.trim())
96
+ continue;
97
+ try {
98
+ results.push(JSON.parse(line));
99
+ }
100
+ catch {
101
+ /* skip a partially-written trailing line */
102
+ }
103
+ }
104
+ return { results, nextOffset: size };
105
+ }
106
+ /** Advance the read cursor — call only after the results have actually been shown. */
107
+ export async function commitReadCursor(byteOffset) {
108
+ await mkdir(WATCH_DIR, { recursive: true });
109
+ await Bun.write(CURSOR_FILE, JSON.stringify({ byteOffset }));
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Scira — terminal-native AI research agent with grounded sources, verified claims, and local run storage.",
5
5
  "license": "MIT",
6
6
  "type": "module",