mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38eb5a44df | |||
| 6d397fc116 | |||
| cb2bbcc7aa | |||
| b9998a4bf1 | |||
| 0d41f0ef94 | |||
| 3363a0be0f | |||
| 05cf44035e | |||
| c1eb1079cc | |||
| 1725d86fbd | |||
| e3bd22f96f | |||
| c2e5b14bf8 | |||
| 1ccef326dc | |||
| 39e7604526 | |||
| df4929d61d |
@@ -18,6 +18,12 @@ function normTs(ts) {
|
||||
return `${ts.replace(' ', 'T')}Z`;
|
||||
}
|
||||
|
||||
// Local calendar day "YYYY-MM-DD" — chart labels are read by a human, so
|
||||
// bucket by the server's local day, not the UTC date prefix.
|
||||
function localDay(date) {
|
||||
return date.toLocaleDateString('sv-SE');
|
||||
}
|
||||
|
||||
function readTable(dbPath, table) {
|
||||
let db;
|
||||
try {
|
||||
@@ -28,7 +34,7 @@ function readTable(dbPath, table) {
|
||||
for (const r of rows) {
|
||||
const ts = normTs(r.timestamp);
|
||||
if (!ts) continue;
|
||||
const day = ts.slice(0, 10); // ISO date prefix
|
||||
const day = localDay(new Date(ts));
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
if (last === null || ts > last) last = ts;
|
||||
}
|
||||
@@ -52,12 +58,12 @@ function listDirs(path) {
|
||||
* Aggregate message activity across all session DBs under `sessionsRoot`.
|
||||
* @returns {{ sessions: Array, series: Array<{date,in,out}> }}
|
||||
* sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
|
||||
* series — one bucket per day for the last `days` days (UTC, newest last)
|
||||
* series — one bucket per day for the last `days` days (local time, newest last)
|
||||
*/
|
||||
export function collectActivity(sessionsRoot, days, now) {
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10));
|
||||
dates.push(localDay(new Date(now.getTime() - i * 86_400_000)));
|
||||
}
|
||||
const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
|
||||
const sessions = [];
|
||||
|
||||
@@ -71,6 +71,14 @@ function icon(name) {
|
||||
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
||||
|
||||
// Local wall time "YYYY-MM-DD HH:mm" — the raw ISO string is UTC; slicing it
|
||||
// would display UTC wall time with no marker, masquerading as local.
|
||||
function absTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function relTime(iso) {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
@@ -127,7 +135,7 @@ function cellFor(value) {
|
||||
if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
|
||||
if (f.iso) {
|
||||
return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
|
||||
relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')),
|
||||
relTime(f.iso), el('span', { class: 'abs' }, absTime(f.iso)),
|
||||
]));
|
||||
}
|
||||
if (f.text.length > 42) {
|
||||
@@ -142,7 +150,7 @@ function kvRows(obj) {
|
||||
return Object.entries(obj ?? {}).map(([k, v]) => {
|
||||
let valEl;
|
||||
if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
|
||||
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`);
|
||||
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${absTime(v)})`);
|
||||
else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
|
||||
else valEl = el('span', {}, String(v));
|
||||
return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
|
||||
|
||||
@@ -11,9 +11,14 @@ before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
|
||||
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
|
||||
const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]);
|
||||
// Seed with instants moments ago: they land in the LOCAL-today bucket
|
||||
// (series.at(-1)) regardless of the machine's timezone.
|
||||
const now = Date.now();
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [
|
||||
new Date(now - 120_000).toISOString(),
|
||||
new Date(now - 60_000).toISOString(),
|
||||
]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [new Date(now - 90_000).toISOString()]);
|
||||
});
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
|
||||
@@ -45,20 +45,30 @@ test('collectActivity: per-session in/out totals + last activity', () => {
|
||||
assert.equal(s2.out, 0);
|
||||
});
|
||||
|
||||
// Buckets are LOCAL calendar days — derive expectations with the same
|
||||
// mapping so the assertions hold in any machine timezone.
|
||||
const day = (t) => new Date(t).toLocaleDateString('sv-SE');
|
||||
|
||||
test('collectActivity: series has one bucket per day for `days`, newest last', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
assert.equal(series.length, 14);
|
||||
assert.equal(series[0].date, '2026-06-01');
|
||||
assert.equal(series[13].date, '2026-06-14');
|
||||
assert.equal(series[0].date, day(new Date(NOW.getTime() - 13 * 86_400_000)));
|
||||
assert.equal(series[13].date, day(NOW));
|
||||
});
|
||||
|
||||
test('collectActivity: counts land in the right day buckets', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
|
||||
assert.equal(byDate['2026-06-14'].in, 2);
|
||||
assert.equal(byDate['2026-06-14'].out, 2);
|
||||
assert.equal(byDate['2026-06-13'].in, 1);
|
||||
assert.equal(byDate['2026-06-13'].out, 0);
|
||||
const expIn = {};
|
||||
const expOut = {};
|
||||
for (const t of ['2026-06-14T09:01:23Z', '2026-06-14T10:00:00Z', '2026-06-13T08:00:00Z']) {
|
||||
expIn[day(t)] = (expIn[day(t)] ?? 0) + 1;
|
||||
}
|
||||
for (const t of ['2026-06-14T09:05:00Z', '2026-06-14T10:05:00Z']) {
|
||||
expOut[day(t)] = (expOut[day(t)] ?? 0) + 1;
|
||||
}
|
||||
for (const [d, n] of Object.entries(expIn)) assert.equal(byDate[d].in, n);
|
||||
for (const [d, n] of Object.entries(expOut)) assert.equal(byDate[d].out, n);
|
||||
});
|
||||
|
||||
test('collectActivity: messages outside the window are counted in totals but not the series', () => {
|
||||
|
||||
@@ -427,12 +427,20 @@ function collectContextWindows() {
|
||||
return results;
|
||||
}
|
||||
|
||||
// "YYYY-MM-DDTHH" in the host's local time — the chart's hour labels are read
|
||||
// by a human, so bucket by local hour, not UTC. sv-SE renders "YYYY-MM-DD HH".
|
||||
function localHourKey(d: Date): string {
|
||||
return d
|
||||
.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false })
|
||||
.replace(' ', 'T');
|
||||
}
|
||||
|
||||
function collectActivity() {
|
||||
const now = Date.now();
|
||||
const buckets: Record<string, { inbound: number; outbound: number }> = {};
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const key = new Date(now - i * 3600000).toISOString().slice(0, 13);
|
||||
const key = localHourKey(new Date(now - i * 3600000));
|
||||
buckets[key] = { inbound: 0, outbound: 0 };
|
||||
}
|
||||
|
||||
@@ -453,7 +461,7 @@ function collectActivity() {
|
||||
const table = direction === 'outbound' ? 'messages_out' : 'messages_in';
|
||||
const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[];
|
||||
for (const row of rows) {
|
||||
const key = row.timestamp.slice(0, 13);
|
||||
const key = localHourKey(new Date(row.timestamp));
|
||||
if (buckets[key]) buckets[key][direction]++;
|
||||
}
|
||||
db.close();
|
||||
|
||||
@@ -18,7 +18,7 @@ There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https:
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
```
|
||||
|
||||
|
||||
@@ -168,11 +168,11 @@ HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
|
||||
@@ -112,11 +112,11 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
```
|
||||
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
@@ -128,7 +128,7 @@ When using `strict`, add each GitHub user who should be able to trigger the agen
|
||||
```sql
|
||||
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
|
||||
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
|
||||
VALUES ('github:<user-id>', 'github', '<username>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
|
||||
-- Grant membership to the agent group
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
|
||||
|
||||
@@ -26,7 +26,7 @@ GROUP_ID='<group-id>'
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
|
||||
@@ -189,11 +189,11 @@ HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
|
||||
@@ -120,11 +120,11 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
|
||||
@@ -18,6 +18,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `container/skills/slack-formatting/SKILL.md` exists
|
||||
- `@chat-adapter/slack` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
@@ -33,8 +34,15 @@ git fetch origin channels
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
```
|
||||
|
||||
The `slack-formatting` container skill is part of the channel payload: it
|
||||
reaches agents via `~/.claude/skills` (synced at spawn) and teaches Slack's
|
||||
mrkdwn syntax. Trunk does not ship it — without this copy step agents send
|
||||
Slack messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
@@ -156,8 +156,8 @@ async function main(): Promise<void> {
|
||||
db.prepare(`
|
||||
INSERT INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now'))
|
||||
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
|
||||
VALUES (?, ?, ?, '', 'all', ?, 10, ?)
|
||||
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode, new Date().toISOString());
|
||||
});
|
||||
tx();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `container/skills/whatsapp-formatting/instructions.md` exists
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
@@ -40,8 +41,17 @@ git show origin/channels:src/channels/whatsapp-registration.test.ts > src/cha
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show origin/channels:container/skills/whatsapp-formatting/SKILL.md > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show origin/channels:container/skills/whatsapp-formatting/instructions.md > container/skills/whatsapp-formatting/instructions.md
|
||||
```
|
||||
|
||||
The `whatsapp-formatting` container skill is part of the channel payload: its
|
||||
`instructions.md` becomes the `skill-whatsapp-formatting.md` fragment in every
|
||||
group's composed CLAUDE.md (see `src/claude-md-compose.ts`), teaching agents
|
||||
WhatsApp's formatting syntax. Trunk does not ship it — without this copy step
|
||||
agents format WhatsApp messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
|
||||
@@ -79,7 +79,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` install with their channel) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
@@ -183,7 +183,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` are copied in by their `/add-<channel>` skill).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
@@ -252,6 +252,13 @@ Check these first when something goes wrong:
|
||||
|
||||
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
|
||||
|
||||
## Timestamps
|
||||
|
||||
Two rules, no exceptions:
|
||||
|
||||
- **Storage**: every timestamp written from JS is `new Date().toISOString()` (ISO-8601 UTC with `Z`). Never `datetime('now')` — its naive `YYYY-MM-DD HH:MM:SS` shape is misparsed as local time by `new Date()` and breaks string comparisons against ISO values. In pure-SQL contexts (skill snippets) use `strftime('%Y-%m-%dT%H:%M:%fZ','now')`. SQL-side *comparisons* wrap both sides in `datetime()`.
|
||||
- **Display**: anything shown to an agent or a user renders in the install timezone — `formatLocalTime` (prose) or `formatLocalStamp` (log lines) from `src/timezone.ts` / `container/agent-runner/src/timezone.ts`. `--json` output, DB values, and operator logs stay ISO.
|
||||
|
||||
## Supply Chain Security (pnpm)
|
||||
|
||||
This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them.
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ Skills that run inside the agent container, not on the host. These teach the Nan
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `vercel-cli`, `welcome`; channel-specific: `slack-formatting` (Slack mrkdwn syntax) and `whatsapp-formatting` (channels branch; installed by `/add-slack` / `/add-whatsapp`)
|
||||
|
||||
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
|
||||
|
||||
|
||||
@@ -65,10 +65,11 @@ function writeRequest(req: RequestFrame): void {
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
|
||||
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
|
||||
VALUES ($id, $seq, $timestamp, 'system', $content)`,
|
||||
).run({
|
||||
$id: req.id,
|
||||
$seq: nextSeq,
|
||||
$timestamp: new Date().toISOString(),
|
||||
$content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: req.id,
|
||||
@@ -110,9 +111,9 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul
|
||||
outDb.exec('PRAGMA busy_timeout = 5000');
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)",
|
||||
)
|
||||
.run(row.id);
|
||||
.run(row.id, new Date().toISOString());
|
||||
outDb.close();
|
||||
|
||||
const parsed = JSON.parse(row.content);
|
||||
@@ -184,12 +185,46 @@ function printUsage(): void {
|
||||
// Formatting (mirrors src/cli/format.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mirrors localizeIsoTimestamps in src/cli/format.ts (self-contained — no
|
||||
// imports from agent-runner). Human display shows local time (container TZ
|
||||
// env = install timezone); --json keeps the ISO machine contract. Only whole
|
||||
// ISO-instant strings convert — embedded occurrences may be machine payloads.
|
||||
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
|
||||
|
||||
// "YYYY-MM-DD HH:mm" — round-trips through parseZonedToUtc (naive = local
|
||||
// wall-clock), so a value copied from human output into --process-after
|
||||
// means what it shows.
|
||||
function localTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('sv-SE', {
|
||||
timeZone: process.env.TZ || 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function localizeIsoTimestamps(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return ISO_UTC_RE.test(value) ? localTime(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatHuman(resp: ResponseFrame): string {
|
||||
if (!resp.ok) {
|
||||
return `error (${resp.error.code}): ${resp.error.message}\n`;
|
||||
}
|
||||
|
||||
const data = resp.data;
|
||||
const data = localizeIsoTimestamps(resp.data);
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return JSON.stringify(data, null, 2) + '\n';
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ export function markProcessing(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', ?)",
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const id of ids) stmt.run(id);
|
||||
for (const id of ids) stmt.run(id, new Date().toISOString());
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -113,10 +113,10 @@ export function markCompleted(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)",
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const id of ids) stmt.run(id);
|
||||
for (const id of ids) stmt.run(id, new Date().toISOString());
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -131,10 +131,10 @@ export function markScriptSkipped(skips: Array<{ id: string; reason: string }>):
|
||||
if (skips.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
|
||||
'INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, ?)',
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed');
|
||||
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed', new Date().toISOString());
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -142,9 +142,9 @@ export function markScriptSkipped(skips: Array<{ id: string; reason: string }>):
|
||||
export function markFailed(id: string): void {
|
||||
getOutboundDb()
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))",
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', ?)",
|
||||
)
|
||||
.run(id);
|
||||
.run(id, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Get a message by ID (read from inbound.db). */
|
||||
|
||||
@@ -58,11 +58,12 @@ export function writeMessageOut(msg: WriteMessageOut): number {
|
||||
outbound
|
||||
.prepare(
|
||||
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
|
||||
VALUES ($id, $seq, $in_reply_to, $timestamp, $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
|
||||
)
|
||||
.run({
|
||||
$id: msg.id,
|
||||
$seq: nextSeq,
|
||||
$timestamp: new Date().toISOString(),
|
||||
$in_reply_to: msg.in_reply_to ?? null,
|
||||
$deliver_after: msg.deliver_after ?? null,
|
||||
$recurrence: msg.recurrence ?? null,
|
||||
@@ -136,7 +137,7 @@ export function getUndeliveredMessages(): MessageOutRow[] {
|
||||
return getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT * FROM messages_out
|
||||
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
||||
WHERE (deliver_after IS NULL OR datetime(deliver_after) <= datetime('now'))
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.all() as MessageOutRow[];
|
||||
|
||||
@@ -67,7 +67,7 @@ ncl tasks list
|
||||
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
|
||||
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
|
||||
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
|
||||
# At the END of every task run, record one line of history. The host stamps the UTC time — you supply only the summary.
|
||||
# At the END of every task run, record one line of history. The host stamps the local time — you supply only the summary.
|
||||
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.
|
||||
ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js';
|
||||
import { TIMEZONE, formatLocalStamp } from '../timezone.js';
|
||||
import { registerProvider } from './provider-registry.js';
|
||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||
|
||||
@@ -223,7 +224,9 @@ function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: st
|
||||
|
||||
const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
||||
// Local calendar date — the fallback `name` above already uses local
|
||||
// hours, and the agent navigates conversations/ by these date prefixes.
|
||||
const filename = `${formatLocalStamp(new Date(), TIMEZONE).slice(0, 10)}-${name}.md`;
|
||||
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
||||
log(`Archived conversation to ${filename}`);
|
||||
return true;
|
||||
|
||||
@@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
|
||||
* (sv-SE is the one locale whose default rendering is this exact shape.)
|
||||
*/
|
||||
export function formatLocalStamp(date: Date, timezone: string): string {
|
||||
return date.toLocaleString('sv-SE', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveContainerTimezone(): string {
|
||||
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
for (const tz of candidates) {
|
||||
|
||||
@@ -92,6 +92,41 @@ agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
```
|
||||
|
||||
### Waiting for a custom condition — ALWAYS bound it
|
||||
|
||||
Prefer the built-in `wait` subcommands above. Only fall back to `eval`-polling
|
||||
when you must wait on a custom JS condition (e.g. a spinner disappearing or a
|
||||
"Send" button re-enabling in a chat UI).
|
||||
|
||||
**Never write an unbounded wait loop.** A bare `until … do sleep; done` that
|
||||
polls a page condition will loop *forever* if the condition never becomes true
|
||||
(page failed to load, selector changed, network stalled). That does not just
|
||||
fail the command — it wedges the entire agent turn: the runner keeps the model
|
||||
stream open, later messages get silently swallowed, and the container can hang
|
||||
for hours without the host's stuck-detection firing.
|
||||
|
||||
Always cap the wait with BOTH a wall-clock `timeout` and a max-attempts counter,
|
||||
and always exit the loop (never leave a `sleep` loop as the last thing running):
|
||||
|
||||
```bash
|
||||
# Bounded wait: succeeds when the condition is met, gives up after ~90s.
|
||||
timeout 90 bash -c '
|
||||
for i in $(seq 1 30); do
|
||||
if agent-browser eval "document.querySelector(\".loading\") === null" 2>/dev/null | grep -q true; then
|
||||
echo READY; exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo TIMEOUT; exit 1
|
||||
'
|
||||
# Check the exit status / output: on TIMEOUT, snapshot the page and decide —
|
||||
# do NOT re-enter another unbounded wait.
|
||||
```
|
||||
|
||||
If the wait times out, treat it as a real failure: take a `snapshot -i` or
|
||||
`screenshot` to see the actual page state, report what you found, and move on.
|
||||
Retrying the same unbounded wait is what causes the hang.
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: slack-formatting
|
||||
description: Format messages for Slack using mrkdwn syntax. Use when responding to Slack channels (folder starts with "slack_" or JID contains slack identifiers).
|
||||
---
|
||||
|
||||
# Slack Message Formatting (mrkdwn)
|
||||
|
||||
When responding to Slack channels, use Slack's mrkdwn syntax instead of standard Markdown.
|
||||
|
||||
## How to detect Slack context
|
||||
|
||||
Check your group folder name or workspace path:
|
||||
- Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`)
|
||||
- Or check `/workspace/group/` path for `slack_` prefix
|
||||
|
||||
## Formatting reference
|
||||
|
||||
### Text styles
|
||||
|
||||
| Style | Syntax | Example |
|
||||
|-------|--------|---------|
|
||||
| Bold | `*text*` | *bold text* |
|
||||
| Italic | `_text_` | _italic text_ |
|
||||
| Strikethrough | `~text~` | ~strikethrough~ |
|
||||
| Code (inline) | `` `code` `` | `inline code` |
|
||||
| Code block | ` ```code``` ` | Multi-line code |
|
||||
|
||||
### Links and mentions
|
||||
|
||||
```
|
||||
<https://example.com|Link text> # Named link
|
||||
<https://example.com> # Auto-linked URL
|
||||
<@U1234567890> # Mention user by ID
|
||||
<#C1234567890> # Mention channel by ID
|
||||
<!here> # @here
|
||||
<!channel> # @channel
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
Slack supports simple bullet lists but NOT numbered lists:
|
||||
|
||||
```
|
||||
• First item
|
||||
• Second item
|
||||
• Third item
|
||||
```
|
||||
|
||||
Use `•` (bullet character) or `- ` or `* ` for bullets.
|
||||
|
||||
### Block quotes
|
||||
|
||||
```
|
||||
> This is a block quote
|
||||
> It can span multiple lines
|
||||
```
|
||||
|
||||
### Emoji
|
||||
|
||||
Use standard emoji shortcodes: `:white_check_mark:`, `:x:`, `:rocket:`, `:tada:`
|
||||
|
||||
## What NOT to use
|
||||
|
||||
- **NO** `##` headings (use `*Bold text*` for headers instead)
|
||||
- **NO** `**double asterisks**` for bold (use `*single asterisks*`)
|
||||
- **NO** `[text](url)` links (use `<url|text>` instead)
|
||||
- **NO** `1.` numbered lists (use bullets with numbers: `• 1. First`)
|
||||
- **NO** tables (use code blocks or plain text alignment)
|
||||
- **NO** `---` horizontal rules
|
||||
|
||||
## Example message
|
||||
|
||||
```
|
||||
*Daily Standup Summary*
|
||||
|
||||
_March 21, 2026_
|
||||
|
||||
• *Completed:* Fixed authentication bug in login flow
|
||||
• *In Progress:* Building new dashboard widgets
|
||||
• *Blocked:* Waiting on API access from DevOps
|
||||
|
||||
> Next sync: Monday 10am
|
||||
|
||||
:white_check_mark: All tests passing | <https://ci.example.com/builds/123|View Build>
|
||||
```
|
||||
|
||||
## Quick rules
|
||||
|
||||
1. Use `*bold*` not `**bold**`
|
||||
2. Use `<url|text>` not `[text](url)`
|
||||
3. Use `•` bullets, avoid numbered lists
|
||||
4. Use `:emoji:` shortcodes
|
||||
5. Quote blocks with `>`
|
||||
6. Skip headings — use bold text instead
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
name: whatsapp-formatting
|
||||
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
|
||||
---
|
||||
|
||||
# WhatsApp Message Formatting
|
||||
|
||||
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
|
||||
|
||||
## How to detect WhatsApp context
|
||||
|
||||
You're in a WhatsApp conversation when any of these are true:
|
||||
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
|
||||
- The chat JID / platform id ends with `@g.us` (group)
|
||||
- Your inbound message metadata has `chatJid` matching the above
|
||||
|
||||
## Mentions — the important part
|
||||
|
||||
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
|
||||
|
||||
```
|
||||
@15551234567 can you confirm?
|
||||
```
|
||||
|
||||
The adapter scans your outgoing text for `@<digits>` (5–15 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
|
||||
|
||||
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
|
||||
|
||||
### Wrong vs right
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
|
||||
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
|
||||
|
||||
### Picking who to tag
|
||||
|
||||
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
|
||||
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
|
||||
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
|
||||
|
||||
## Text styles
|
||||
|
||||
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
|
||||
|
||||
| Style | Syntax | Renders as |
|
||||
|-------|--------|------------|
|
||||
| Bold | `*bold*` | **bold** |
|
||||
| Italic | `_italic_` | *italic* |
|
||||
| Strikethrough | `~strike~` | ~strike~ |
|
||||
| Monospace | `` `code` `` | `code` |
|
||||
| Block monospace | ```` ```block``` ```` | preformatted block |
|
||||
|
||||
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
|
||||
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
|
||||
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
|
||||
@@ -1,19 +0,0 @@
|
||||
## WhatsApp mentions — always use phone digits
|
||||
|
||||
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
|
||||
|
||||
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam, can you...` | Plain text. No tag, no notification. |
|
||||
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
|
||||
|
||||
The host adapter scans your outbound text for `@<5–15 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
|
||||
|
||||
### In groups
|
||||
|
||||
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
|
||||
|
||||
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.42",
|
||||
"version": "2.1.44",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="224k tokens, 112% of context window">
|
||||
<title>224k tokens, 112% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="225k tokens, 113% of context window">
|
||||
<title>225k tokens, 113% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">224k</text>
|
||||
<text x="71" y="14">224k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">225k</text>
|
||||
<text x="71" y="14">225k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -34,9 +34,10 @@ db.exec(`
|
||||
|
||||
// Insert test message
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', ?, 'pending', ?)`,
|
||||
).run(
|
||||
'test-1',
|
||||
new Date().toISOString(),
|
||||
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
||||
);
|
||||
console.log('✓ Session DB created with test message');
|
||||
|
||||
@@ -51,6 +51,7 @@ fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
[ ! -f container/skills/slack-formatting/SKILL.md ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
@@ -67,6 +68,10 @@ if need_install; then
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Slack formatting container skill — reaches agents via ~/.claude/skills.
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show "${CHANNELS_BRANCH}:container/skills/slack-formatting/SKILL.md" > container/skills/slack-formatting/SKILL.md
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
|
||||
@@ -43,6 +43,7 @@ log() { echo "[add-whatsapp] $*" >&2; }
|
||||
need_install() {
|
||||
[ ! -f src/channels/whatsapp.ts ] && return 0
|
||||
[ ! -f setup/groups.ts ] && return 0
|
||||
[ ! -f container/skills/whatsapp-formatting/instructions.md ] && return 0
|
||||
! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0
|
||||
! grep -q "^ groups:" setup/index.ts 2>/dev/null && return 0
|
||||
@@ -64,6 +65,12 @@ if need_install; then
|
||||
git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts
|
||||
git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts
|
||||
|
||||
# WhatsApp formatting container skill — feeds the composed CLAUDE.md
|
||||
# (skill-whatsapp-formatting.md fragment) and ~/.claude/skills.
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/SKILL.md" > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/instructions.md" > container/skills/whatsapp-formatting/instructions.md
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then
|
||||
echo "import './whatsapp.js';" >> src/channels/index.ts
|
||||
|
||||
@@ -15,6 +15,7 @@ echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/slack.ts ]] || needs_install=true
|
||||
[[ -f container/skills/slack-formatting/SKILL.md ]] || needs_install=true
|
||||
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
|
||||
@@ -30,6 +31,8 @@ git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
|
||||
@@ -20,6 +20,8 @@ CHANNEL_FILES=(
|
||||
src/channels/whatsapp.ts
|
||||
setup/whatsapp-auth.ts
|
||||
setup/groups.ts
|
||||
container/skills/whatsapp-formatting/SKILL.md
|
||||
container/skills/whatsapp-formatting/instructions.md
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
@@ -45,6 +47,7 @@ git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
mkdir -p "$(dirname "$f")"
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ export function backupEnv(envPath: string): string {
|
||||
const dir = path.dirname(envPath);
|
||||
let backup = path.join(dir, '.env.bak');
|
||||
if (fs.existsSync(backup)) {
|
||||
// Local time (system TZ) — the stamp is read by the human running the
|
||||
// uninstall. sv-SE renders "YYYY-MM-DD HH:mm:ss".
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.toLocaleString('sv-SE', { hour12: false })
|
||||
.replace(/[-:]/g, '')
|
||||
.replace('T', '-')
|
||||
.replace(' ', '-')
|
||||
.slice(0, 15);
|
||||
backup = path.join(dir, `.env.bak.${stamp}`);
|
||||
}
|
||||
|
||||
+3
-1
@@ -11,6 +11,7 @@ import { getAgentGroup } from '../db/agent-groups.js';
|
||||
import { getSession } from '../db/sessions.js';
|
||||
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { localizeIsoTimestamps } from './format.js';
|
||||
import { getResource } from './crud.js';
|
||||
import { listVerbs, renderVerbHelp } from './help-render.js';
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
|
||||
@@ -221,7 +222,8 @@ registerApprovalHandler('cli_command', async ({ payload, notify }) => {
|
||||
const response = await dispatch(frame, callerContext, { approved: true });
|
||||
|
||||
if (response.ok) {
|
||||
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||
const localized = localizeIsoTimestamps(response.data);
|
||||
const data = typeof localized === 'string' ? localized : JSON.stringify(localized, null, 2);
|
||||
notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`);
|
||||
} else {
|
||||
notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`);
|
||||
|
||||
+29
-1
@@ -8,10 +8,37 @@
|
||||
* itself. The DB transport (when it lands) skips this layer entirely —
|
||||
* the agent sees frames directly.
|
||||
*/
|
||||
import { TIMEZONE } from '../config.js';
|
||||
import { formatLocalStamp } from '../timezone.js';
|
||||
import type { ResponseFrame } from './frame.js';
|
||||
|
||||
export type FormatMode = 'human' | 'json';
|
||||
|
||||
// A string is treated as a display timestamp only when the WHOLE value is a
|
||||
// UTC ISO instant; embedded occurrences inside longer strings may be machine
|
||||
// payloads and stay raw. Mirrored in container/agent-runner/src/cli/ncl.ts
|
||||
// (the two runtimes share no modules).
|
||||
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
|
||||
|
||||
/**
|
||||
* Human display shows local time; --json keeps the ISO machine contract.
|
||||
* The "YYYY-MM-DD HH:mm" stamp shape round-trips: parseZonedToUtc reads a
|
||||
* naive string as local wall-clock time, so a value copied from `ncl tasks
|
||||
* get` output into `--process-after` means what it shows.
|
||||
*/
|
||||
export function localizeIsoTimestamps(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return ISO_UTC_RE.test(value) ? formatLocalStamp(new Date(value), TIMEZONE) : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
|
||||
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
|
||||
|
||||
@@ -21,7 +48,8 @@ export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
|
||||
return formatHuman(res.data) + '\n';
|
||||
}
|
||||
|
||||
function formatHuman(data: unknown): string {
|
||||
function formatHuman(rawData: unknown): string {
|
||||
const data = localizeIsoTimestamps(rawData);
|
||||
if (data === null || data === undefined) return '';
|
||||
if (typeof data === 'string') return data;
|
||||
if (Array.isArray(data) && data.every(isFlatRecord)) {
|
||||
|
||||
@@ -110,9 +110,9 @@ registerResource({
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(agentGroupId, localName, targetType, targetId);
|
||||
.run(agentGroupId, localName, targetType, targetId, new Date().toISOString());
|
||||
await projectDestinationsToSessions(agentGroupId);
|
||||
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
|
||||
},
|
||||
|
||||
@@ -41,9 +41,9 @@ registerResource({
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(userId, groupId, addedBy);
|
||||
.run(userId, groupId, addedBy, new Date().toISOString());
|
||||
return { user_id: userId, agent_group_id: groupId };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,9 +41,9 @@ registerResource({
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(userId, role, groupId, grantedBy);
|
||||
.run(userId, role, groupId, grantedBy, new Date().toISOString());
|
||||
return { user_id: userId, role, agent_group_id: groupId };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -455,7 +455,8 @@ describe('tasks CLI resource', () => {
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim();
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z — did the thing; it worked$/);
|
||||
// Local-time stamp (formatLocalStamp): "YYYY-MM-DD HH:mm".
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} — did the thing; it worked$/);
|
||||
});
|
||||
|
||||
it('derives the series from the caller task session when --id is omitted', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type TaskUpdate,
|
||||
} from '../../modules/scheduling/db.js';
|
||||
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
|
||||
import { parseZonedToUtc } from '../../timezone.js';
|
||||
import { formatLocalStamp, parseZonedToUtc } from '../../timezone.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
@@ -288,7 +288,7 @@ function createTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
|
||||
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
|
||||
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
|
||||
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the UTC time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
|
||||
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the local time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
|
||||
`Need context from past runs? Read tasks/${id}.md first.]`;
|
||||
|
||||
const created = withInbound(session, (db) => {
|
||||
@@ -339,7 +339,7 @@ function appendTaskLog(
|
||||
const ag = getAgentGroup(group);
|
||||
if (!ag) throw new Error(`agent group not found: ${group}`);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const timestamp = formatLocalStamp(new Date(), TIMEZONE);
|
||||
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
|
||||
const file = `${dir}/${series}.md`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -659,7 +659,7 @@ registerResource({
|
||||
'append-log': {
|
||||
access: 'open',
|
||||
description:
|
||||
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
|
||||
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the local timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
|
||||
examples: [
|
||||
`# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`,
|
||||
],
|
||||
@@ -668,7 +668,7 @@ registerResource({
|
||||
name: 'msg',
|
||||
type: 'string',
|
||||
description:
|
||||
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the UTC timestamp; this is logged, never sent to the user.',
|
||||
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the local timestamp; this is logged, never sent to the user.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -151,9 +151,8 @@ export function markMessageFailed(db: Database.Database, messageId: string): voi
|
||||
}
|
||||
|
||||
export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void {
|
||||
db.prepare(
|
||||
`UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
|
||||
).run(messageId);
|
||||
const processAfter = new Date(Date.now() + backoffSec * 1000).toISOString();
|
||||
db.prepare('UPDATE messages_in SET tries = tries + 1, process_after = ? WHERE id = ?').run(processAfter, messageId);
|
||||
}
|
||||
|
||||
export function getMessageForRetry(
|
||||
@@ -261,7 +260,7 @@ export function getDueOutboundMessages(db: Database.Database): OutboundMessage[]
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM messages_out
|
||||
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
||||
WHERE (deliver_after IS NULL OR datetime(deliver_after) <= datetime('now'))
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.all() as OutboundMessage[];
|
||||
@@ -281,14 +280,14 @@ export function getDeliveredIds(db: Database.Database): Set<string> {
|
||||
|
||||
export function markDelivered(db: Database.Database, messageOutId: string, platformMessageId: string | null): void {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))",
|
||||
).run(messageOutId, platformMessageId ?? null);
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', ?)",
|
||||
).run(messageOutId, platformMessageId ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function markDeliveryFailed(db: Database.Database, messageOutId: string): void {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))",
|
||||
).run(messageOutId);
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', ?)",
|
||||
).run(messageOutId, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Ensure the delivered table has columns added after initial schema. */
|
||||
|
||||
@@ -18,6 +18,7 @@ import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { formatLocalStamp } from '../../timezone.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
@@ -44,7 +45,7 @@ function appendHostTaskNote(agentGroupId: string, seriesId: string, note: string
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return;
|
||||
const dir = path.join(GROUPS_DIR, ag.folder, 'tasks');
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const timestamp = formatLocalStamp(new Date(), TIMEZONE);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, `${seriesId}.md`), `${timestamp} — ${note}\n`);
|
||||
}
|
||||
|
||||
+10
-2
@@ -426,8 +426,16 @@ export function writeOutboundDirect(
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), datetime('now'), ?, ?, ?, ?, ?)`,
|
||||
).run(message.id, message.kind, message.platformId, message.channelType, message.threadId, message.content);
|
||||
VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
message.id,
|
||||
new Date().toISOString(),
|
||||
message.kind,
|
||||
message.platformId,
|
||||
message.channelType,
|
||||
message.threadId,
|
||||
message.content,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
|
||||
* (sv-SE is the one locale whose default rendering is this exact shape.)
|
||||
*/
|
||||
export function formatLocalStamp(date: Date, timezone: string): string {
|
||||
return date.toLocaleString('sv-SE', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
|
||||
* time in `tz` and return the corresponding UTC Date. Strings that already carry
|
||||
|
||||
Reference in New Issue
Block a user