ace-wake 0.1.0

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.
@@ -0,0 +1,112 @@
1
+ // Shared contracts for the ace-wake Pi extension.
2
+ //
3
+ // The extension wakes a Pi agent through queue/sendUserMessage only. It never
4
+ // executes tasks, never spawns external timers or services, and never injects
5
+ // text into an in-flight tool operation. Host capabilities (clock, interval
6
+ // scheduling, filesystem watching, message queueing, session state, status
7
+ // reporting) are injected as plain function ports so the registry can be
8
+ // tested deterministically without Pi.
9
+
10
+ /** Prefix for wake source keys originating from a named loop. */
11
+ export const LOOP_SOURCE_PREFIX = "loop:";
12
+
13
+ /** Prefix for wake source keys originating from a named watch. */
14
+ export const WATCH_SOURCE_PREFIX = "watch:";
15
+
16
+ /** Hard upper bound for a delivered wake message, in characters. */
17
+ export const MAX_WAKE_MESSAGE_CHARS = 4000;
18
+
19
+ /** Upper bound for a subscription name, in characters. */
20
+ export const MAX_NAME_CHARS = 64;
21
+
22
+ /** customType used for pi.appendEntry persistence of wake definitions. */
23
+ export const SESSION_ENTRY_TYPE = "ace-wake";
24
+
25
+ /**
26
+ * A configured recurring timer.
27
+ *
28
+ * @typedef {object} LoopDefinition
29
+ * @property {string} name Unique within loop subscriptions for the session.
30
+ * @property {number} intervalSeconds Finite number greater than zero.
31
+ * @property {string} message Wake text delivered on each fire.
32
+ */
33
+
34
+ /**
35
+ * A configured filesystem watch.
36
+ *
37
+ * @typedef {object} WatchDefinition
38
+ * @property {string} name Unique within watch subscriptions for the session.
39
+ * @property {string} path Canonical absolute path of the watched file.
40
+ * @property {string} message Wake text delivered on change.
41
+ */
42
+
43
+ /**
44
+ * One changed target of a watch wake: the watched path, the change kind, the
45
+ * BEFORE→AFTER change signatures, and — when the file content was readable —
46
+ * a short capped sanitized excerpt of the new content.
47
+ *
48
+ * @typedef {object} WatchDelta
49
+ * @property {string} path Absolute path of the changed target.
50
+ * @property {"created" | "modified" | "deleted"} kind Change kind.
51
+ * @property {string} before Signature of the last delivered state.
52
+ * @property {string} after Signature of the current state.
53
+ * @property {string} [excerpt] Sanitized single-line head of the new content.
54
+ */
55
+
56
+ /**
57
+ * Persisted session snapshot handed to the state port.
58
+ *
59
+ * @typedef {object} WakeSnapshot
60
+ * @property {LoopDefinition[]} loops
61
+ * @property {WatchDefinition[]} watches
62
+ */
63
+
64
+ /**
65
+ * Raised for user-facing validation and lookup failures.
66
+ */
67
+ export class WakeError extends Error {
68
+ /**
69
+ * @param {string} message
70
+ */
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = "WakeError";
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Truncate a message to the bounded length, reserving space for the
79
+ * truncation marker so the delivered message never exceeds the bound.
80
+ *
81
+ * @param {string} text
82
+ * @returns {string}
83
+ */
84
+ export function boundMessage(text) {
85
+ const trimmed = text.trim();
86
+ const marker = "… [ace-wake: message truncated]";
87
+ if (trimmed.length <= MAX_WAKE_MESSAGE_CHARS) {
88
+ return trimmed;
89
+ }
90
+ return `${trimmed.slice(0, MAX_WAKE_MESSAGE_CHARS - marker.length)}${marker}`;
91
+ }
92
+
93
+ /**
94
+ * Append the watch change delta to a watch wake message: one line per changed
95
+ * target with path, change kind, and BEFORE→AFTER signatures, plus an
96
+ * `excerpt:` line when the file content was readable. Loop wakes and watch
97
+ * wakes without a delta keep the bare message.
98
+ *
99
+ * @param {string} message
100
+ * @param {Array<WatchDelta>} deltas
101
+ * @returns {string}
102
+ */
103
+ export function formatWatchDelta(message, deltas) {
104
+ if (!Array.isArray(deltas) || deltas.length === 0) {
105
+ return message;
106
+ }
107
+ const lines = deltas.map((delta) => {
108
+ const head = `changed: ${delta.path} (${delta.kind}) ${delta.before} → ${delta.after}`;
109
+ return delta.excerpt === undefined ? head : `${head}\nexcerpt: ${delta.excerpt}`;
110
+ });
111
+ return `${message}\n${lines.join("\n")}`;
112
+ }
@@ -0,0 +1,169 @@
1
+ import { WakeError } from "./types.js";
2
+
3
+ /**
4
+ * Parse the argument string of a /loop or /watch command.
5
+ *
6
+ * Supported forms:
7
+ * add NAME --interval SECONDS --message TEXT
8
+ * add NAME --path PATH --message TEXT
9
+ * list
10
+ * remove NAME
11
+ *
12
+ * Parsing is token-based: quoted values (including values that contain text
13
+ * resembling options, e.g. --path "logs/--message.txt") stay single tokens,
14
+ * quoted spans embedded in --flag=value tokens (e.g. --message="check the
15
+ * build") keep their full value, and option names match exactly, so
16
+ * "--message-prefix" never satisfies "--message". Unquoted message text may
17
+ * span the remaining tokens; unterminated quotes are rejected.
18
+ *
19
+ * @param {string} kind "loop" or "watch", used for error text only.
20
+ * @param {string} input Raw argument string after the command name.
21
+ * @returns {{subcommand: string, name?: string, flags: Record<string, string>, message?: string}}
22
+ */
23
+ export function parseWakeCommand(kind, input) {
24
+ const tokens = tokenize((input ?? "").trim());
25
+ const subcommand = tokens[0]?.text;
26
+ if (!subcommand) {
27
+ throw new WakeError(usage(kind));
28
+ }
29
+
30
+ if (subcommand === "list") {
31
+ if (tokens.length > 1) {
32
+ throw new WakeError(`${kind} list takes no arguments`);
33
+ }
34
+ return { subcommand };
35
+ }
36
+
37
+ if (subcommand === "remove") {
38
+ if (tokens.length !== 2) {
39
+ throw new WakeError(`usage: /${kind} remove NAME`);
40
+ }
41
+ return { subcommand, name: tokens[1].text };
42
+ }
43
+
44
+ if (subcommand !== "add") {
45
+ throw new WakeError(`unknown ${kind} subcommand: ${subcommand}; ${usage(kind)}`);
46
+ }
47
+
48
+ if (tokens.length < 2) {
49
+ throw new WakeError(usage(kind));
50
+ }
51
+
52
+ const name = tokens[1].text;
53
+ const { flags, message } = parseFlags(kind, tokens.slice(2));
54
+
55
+ return { subcommand, name, flags, message };
56
+ }
57
+
58
+ function usage(kind) {
59
+ if (kind === "loop") {
60
+ return "usage: /loop add NAME --interval SECONDS --message TEXT | /loop list | /loop remove NAME";
61
+ }
62
+ return "usage: /watch add NAME --path PATH --message TEXT | /watch list | /watch remove NAME";
63
+ }
64
+
65
+ /**
66
+ * Split an argument string into tokens. A quote opens a quoted span only at
67
+ * the start of a token or immediately after `=` — so `--message="check the
68
+ * build"` and `--path="my file.txt"` keep their full values, while prose
69
+ * apostrophes mid-token (`don't`) stay literal. Inside a span, backslash
70
+ * escapes are honored and the span may contain whitespace and text
71
+ * resembling options; quotes are stripped. Unterminated spans are rejected
72
+ * instead of silently accepting the partial fragment.
73
+ *
74
+ * @param {string} raw
75
+ * @returns {Array<{text: string, quoted: boolean}>}
76
+ */
77
+ function tokenize(raw) {
78
+ const tokens = [];
79
+ let index = 0;
80
+ while (index < raw.length) {
81
+ if (/\s/.test(raw[index])) {
82
+ index += 1;
83
+ continue;
84
+ }
85
+ const tokenStart = index;
86
+ const leadingQuote = raw[tokenStart];
87
+ let text = "";
88
+ while (index < raw.length && !/\s/.test(raw[index])) {
89
+ const character = raw[index];
90
+ const opensSpan = (character === "\"" || character === "'")
91
+ && (index === tokenStart || raw[index - 1] === "=");
92
+ if (!opensSpan) {
93
+ text += character;
94
+ index += 1;
95
+ continue;
96
+ }
97
+ index += 1;
98
+ let value = "";
99
+ while (index < raw.length && raw[index] !== character) {
100
+ if (raw[index] === "\\" && index + 1 < raw.length) {
101
+ value += raw[index + 1];
102
+ index += 2;
103
+ continue;
104
+ }
105
+ value += raw[index];
106
+ index += 1;
107
+ }
108
+ if (index >= raw.length) {
109
+ throw new WakeError(`unterminated quoted value starting at position ${tokenStart}`);
110
+ }
111
+ index += 1;
112
+ text += value;
113
+ }
114
+ tokens.push({ text, quoted: leadingQuote === "\"" || leadingQuote === "'" });
115
+ }
116
+ return tokens;
117
+ }
118
+
119
+ /**
120
+ * Extract --flag value pairs and the trailing message from add-tokens.
121
+ * Option names match exactly; an unquoted --message value spans all
122
+ * remaining tokens (joined with single spaces) since it is documented last.
123
+ *
124
+ * @param {string} kind
125
+ * @param {Array<{text: string, quoted: boolean}>} tokens
126
+ */
127
+ function parseFlags(kind, tokens) {
128
+ const valueFlags = kind === "loop" ? ["interval", "message"] : ["path", "message"];
129
+ const flags = {};
130
+ let message;
131
+ let index = 0;
132
+ while (index < tokens.length) {
133
+ const token = tokens[index];
134
+ if (!token.text.startsWith("--") || token.quoted) {
135
+ index += 1;
136
+ continue;
137
+ }
138
+ const [flagName, inlineValue] = token.text.slice(2).split(/=(.*)/s, 2);
139
+ if (!valueFlags.includes(flagName)) {
140
+ index += 1;
141
+ continue;
142
+ }
143
+ if (inlineValue !== undefined) {
144
+ if (flagName === "message") {
145
+ message = inlineValue;
146
+ } else {
147
+ flags[flagName] = inlineValue;
148
+ }
149
+ index += 1;
150
+ continue;
151
+ }
152
+ if (flagName === "message") {
153
+ const rest = tokens.slice(index + 1);
154
+ if (rest.length === 0) {
155
+ throw new WakeError("message must not be empty");
156
+ }
157
+ message = rest.map((token_) => token_.text).join(" ");
158
+ index = tokens.length;
159
+ continue;
160
+ }
161
+ const value = tokens[index + 1];
162
+ if (value === undefined) {
163
+ throw new WakeError(`${flagName} requires a value`);
164
+ }
165
+ flags[flagName] = value.text;
166
+ index += 2;
167
+ }
168
+ return { flags, message };
169
+ }
@@ -0,0 +1,86 @@
1
+ import { boundMessage } from "./types.js";
2
+
3
+ /**
4
+ * Delivers wake messages with per-source coalescing.
5
+ *
6
+ * A wake is delivered through the injected queue port only; the dispatcher
7
+ * never interprets the message or triggers work itself. While a source has a
8
+ * wake queued (delivered but not yet settled), repeated triggers for the same
9
+ * source coalesce into the pending one. Distinct sources never coalesce.
10
+ *
11
+ * The adapter settles sources when Pi reports the agent fully settled, so a
12
+ * busy agent receives at most one queued follow-up per source per run cycle.
13
+ * Pending entries keep their message text so a delivery boundary that never
14
+ * sees a settlement (e.g. manual compaction rejecting prompts without an
15
+ * extension-visible signal) can reconcile unacknowledged attempts.
16
+ */
17
+ export class WakeDispatcher {
18
+ /** @private @type {(sourceKey: string, text: string) => boolean} */
19
+ #deliver;
20
+
21
+ /** @private @type {Map<string, string>} */
22
+ pending = new Map();
23
+
24
+ /**
25
+ * @param {object} ports
26
+ * @param {(sourceKey: string, text: string) => boolean} ports.deliver
27
+ * Queue the wake for the source. Returns true when Pi accepted it.
28
+ */
29
+ constructor({ deliver }) {
30
+ this.#deliver = deliver;
31
+ }
32
+
33
+ /**
34
+ * Queue a wake for the source, coalescing while one is already pending.
35
+ *
36
+ * @param {string} sourceKey Stable source identity, e.g. "loop:heartbeat".
37
+ * @param {string} text Wake text; bounded before delivery.
38
+ * @returns {{delivered: boolean, reason?: string}}
39
+ */
40
+ wake(sourceKey, text) {
41
+ if (this.pending.has(sourceKey)) {
42
+ return { delivered: false, reason: "coalesced" };
43
+ }
44
+
45
+ const bounded = boundMessage(text);
46
+ const accepted = this.#deliver(sourceKey, bounded);
47
+ if (!accepted) {
48
+ return { delivered: false, reason: "rejected" };
49
+ }
50
+
51
+ this.pending.set(sourceKey, bounded);
52
+ return { delivered: true };
53
+ }
54
+
55
+ /**
56
+ * Mark one source's queued wake as consumed.
57
+ *
58
+ * @param {string} sourceKey
59
+ */
60
+ settle(sourceKey) {
61
+ this.pending.delete(sourceKey);
62
+ }
63
+
64
+ /** Mark every source's queued wake as consumed (agent fully settled). */
65
+ settleAll() {
66
+ this.pending.clear();
67
+ }
68
+
69
+ /**
70
+ * Pending wake attempts as [sourceKey, text] pairs, for reconciliation at
71
+ * delivery boundaries that bypass the normal settlement signal.
72
+ *
73
+ * @returns {Array<[string, string]>}
74
+ */
75
+ pendingEntries() {
76
+ return [...this.pending.entries()];
77
+ }
78
+
79
+ /**
80
+ * @param {string} sourceKey
81
+ * @returns {boolean}
82
+ */
83
+ isPending(sourceKey) {
84
+ return this.pending.has(sourceKey);
85
+ }
86
+ }