@scira/cli 0.1.7 → 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.
- package/dist/cli/commands/watch.js +196 -0
- package/dist/cli/index.js +60 -35
- package/dist/tools/background-tasks.js +1 -12
- package/dist/types/index.js +30 -0
- package/dist/ui/ink/SciraApp.js +0 -16
- package/dist/ui/ink/components/effects.js +2 -7
- package/dist/ui/ink/components/home-screen.js +0 -2
- package/dist/ui/ink/components/overlays.js +3 -10
- package/dist/ui/ink/constants.js +0 -3
- package/dist/ui/ink/hooks/use-agent-turn.js +3 -8
- package/dist/ui/ink/hooks/use-feed-lines.js +2 -19
- package/dist/ui/ink/hooks/use-feed.js +0 -1
- package/dist/ui/ink/hooks/use-keyboard.js +0 -5
- package/dist/ui/ink/hooks/use-session.js +0 -2
- package/dist/ui/ink/hooks/use-suggestions.js +0 -1
- package/dist/ui/ink/lib/tool-result.js +2 -36
- package/dist/ui/ink/lib/utils.js +4 -35
- package/dist/ui/ink/session-manager.js +0 -8
- package/dist/ui/ink/theme.js +1 -12
- package/dist/utils/desktop-notify.js +26 -0
- package/dist/utils/markdown-joiner.js +0 -30
- package/dist/utils/process.js +12 -0
- package/dist/utils/time.js +21 -0
- package/dist/utils/watch-notice.js +40 -0
- package/dist/watch/daemon-lock.js +13 -0
- package/dist/watch/daemon.js +59 -0
- package/dist/watch/executor.js +43 -0
- package/dist/watch/format.js +9 -0
- package/dist/watch/runner.js +44 -27
- package/dist/watch/scheduler.js +58 -0
- package/dist/watch/store.js +110 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/watch/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
lines
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
+
}
|