@rubytech/taskmaster 1.0.4 → 1.0.6
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/README.md +1 -1
- package/dist/agents/system-prompt.js +2 -2
- package/dist/agents/tool-display.json +1 -1
- package/dist/agents/tools/cron-tool.js +19 -14
- package/dist/build-info.json +3 -3
- package/dist/cli/provision-seed.js +5 -1
- package/dist/config/version.js +10 -0
- package/dist/filler/config.js +69 -0
- package/dist/filler/generator.js +237 -0
- package/dist/filler/index.js +8 -0
- package/dist/filler/trigger.js +163 -0
- package/dist/filler/types.js +7 -0
- package/dist/gateway/protocol/schema/cron.js +4 -0
- package/dist/gateway/server-methods/cron.js +4 -0
- package/dist/gateway/server.impl.js +12 -0
- package/dist/license/device-id.js +61 -0
- package/dist/license/keys.js +61 -0
- package/dist/license/revalidation.js +52 -0
- package/dist/license/state.js +12 -0
- package/dist/license/validate.js +59 -0
- package/dist/logging/logger.js +23 -11
- package/dist/records/records-manager.js +92 -0
- package/package.json +5 -2
- package/scripts/install.sh +2 -2
- package/scripts/postinstall.js +7 -1
- package/skills/business-assistant/SKILL.md +2 -2
- package/skills/event-management/SKILL.md +15 -0
- package/skills/event-management/references/events.md +120 -0
- package/taskmaster-docs/USER-GUIDE.md +3 -3
- package/templates/taskmaster/agents/admin/AGENTS.md +27 -6
- package/templates/taskmaster/skills/business-assistant/SKILL.md +80 -0
|
@@ -28,6 +28,10 @@ export const cronHandlers = {
|
|
|
28
28
|
return jobAgent !== undefined && normalized.includes(jobAgent);
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
if (typeof p.accountId === "string" && p.accountId.trim()) {
|
|
32
|
+
const target = p.accountId.trim();
|
|
33
|
+
jobs = jobs.filter((job) => job.accountId === target);
|
|
34
|
+
}
|
|
31
35
|
respond(true, { jobs }, undefined);
|
|
32
36
|
},
|
|
33
37
|
"cron.status": async ({ params, respond, context }) => {
|
|
@@ -5,6 +5,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
|
|
|
5
5
|
import { createDefaultDeps } from "../cli/deps.js";
|
|
6
6
|
import { formatCliCommand } from "../cli/command-format.js";
|
|
7
7
|
import { CONFIG_PATH_TASKMASTER, isNixMode, loadConfig, migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js";
|
|
8
|
+
import { VERSION } from "../version.js";
|
|
8
9
|
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
|
9
10
|
import { logAcceptedEnvOption } from "../infra/env.js";
|
|
10
11
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
|
@@ -116,6 +117,17 @@ export async function startGatewayServer(port = 18789, opts = {}) {
|
|
|
116
117
|
log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`);
|
|
117
118
|
}
|
|
118
119
|
}
|
|
120
|
+
// Stamp config with running version on startup so upgrades keep the stamp current.
|
|
121
|
+
const storedVersion = configSnapshot.config.meta?.lastTouchedVersion;
|
|
122
|
+
if (configSnapshot.exists && storedVersion !== VERSION) {
|
|
123
|
+
try {
|
|
124
|
+
await writeConfigFile(configSnapshot.config);
|
|
125
|
+
log.info(`gateway: updated config version stamp from ${storedVersion ?? "(none)"} to ${VERSION}`);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
log.warn(`gateway: failed to update config version stamp: ${String(err)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
119
131
|
const cfgAtStart = loadConfig();
|
|
120
132
|
// License check — gateway always starts (so setup UI is reachable),
|
|
121
133
|
// but pages other than /setup will redirect until a license is activated.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
const SALT = "taskmaster-device-v1";
|
|
6
|
+
const PREFIX = "tm_dev_";
|
|
7
|
+
let cachedDeviceId = null;
|
|
8
|
+
function hashWithSalt(input) {
|
|
9
|
+
return crypto.createHash("sha256").update(`${SALT}:${input}`).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
function getMacSerial() {
|
|
12
|
+
try {
|
|
13
|
+
const output = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
timeout: 5000,
|
|
16
|
+
});
|
|
17
|
+
const match = output.match(/"IOPlatformSerialNumber"\s*=\s*"([^"]+)"/);
|
|
18
|
+
return match?.[1] ?? null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function getLinuxSerial() {
|
|
25
|
+
try {
|
|
26
|
+
const cpuinfo = fs.readFileSync("/proc/cpuinfo", "utf8");
|
|
27
|
+
const match = cpuinfo.match(/^Serial\s*:\s*(\S+)/m);
|
|
28
|
+
return match?.[1] ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function getFallbackId() {
|
|
35
|
+
const cpuModel = os.cpus()[0]?.model ?? "unknown";
|
|
36
|
+
return `${os.hostname()}:${os.platform()}:${os.arch()}:${cpuModel}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Generate a stable hardware fingerprint for this device.
|
|
40
|
+
* - macOS: SHA-256 of IOPlatformSerialNumber
|
|
41
|
+
* - Linux (Pi): SHA-256 of /proc/cpuinfo Serial
|
|
42
|
+
* - Fallback: SHA-256 of hostname + platform + arch + cpu model
|
|
43
|
+
*
|
|
44
|
+
* Result is cached in memory (won't change during runtime).
|
|
45
|
+
*/
|
|
46
|
+
export function getDeviceId() {
|
|
47
|
+
if (cachedDeviceId)
|
|
48
|
+
return cachedDeviceId;
|
|
49
|
+
let raw = null;
|
|
50
|
+
if (os.platform() === "darwin") {
|
|
51
|
+
raw = getMacSerial();
|
|
52
|
+
}
|
|
53
|
+
else if (os.platform() === "linux") {
|
|
54
|
+
raw = getLinuxSerial();
|
|
55
|
+
}
|
|
56
|
+
if (!raw) {
|
|
57
|
+
raw = getFallbackId();
|
|
58
|
+
}
|
|
59
|
+
cachedDeviceId = `${PREFIX}${hashWithSalt(raw)}`;
|
|
60
|
+
return cachedDeviceId;
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Ed25519 public key for verifying license tokens.
|
|
4
|
+
*
|
|
5
|
+
* The corresponding private key is kept secret and used to sign
|
|
6
|
+
* tokens when a license is issued (via our Taskmaster WhatsApp agent
|
|
7
|
+
* or manually).
|
|
8
|
+
*
|
|
9
|
+
* Token format: TM1-<base64url(payload)>.<base64url(signature)>
|
|
10
|
+
* Payload JSON: { did, tier, exp, cid, iat }
|
|
11
|
+
* Note: `tier` is always "standard" — no tier differentiation exists.
|
|
12
|
+
*/
|
|
13
|
+
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
14
|
+
MCowBQYDK2VwAyEA/t/C4A4I0rDlj5rEqv6Hy6VdHJr7WiJHWUxgwGz9HcM=
|
|
15
|
+
-----END PUBLIC KEY-----`;
|
|
16
|
+
const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
|
|
17
|
+
/** Token version prefix. */
|
|
18
|
+
const TOKEN_PREFIX = "TM1-";
|
|
19
|
+
/**
|
|
20
|
+
* Verify a license token's cryptographic signature and decode its payload.
|
|
21
|
+
* Does NOT check device binding or expiry — caller handles that.
|
|
22
|
+
*/
|
|
23
|
+
export function verifyLicenseToken(token) {
|
|
24
|
+
if (!token.startsWith(TOKEN_PREFIX)) {
|
|
25
|
+
return { valid: false, message: "Invalid token format" };
|
|
26
|
+
}
|
|
27
|
+
const body = token.slice(TOKEN_PREFIX.length);
|
|
28
|
+
const dotIndex = body.indexOf(".");
|
|
29
|
+
if (dotIndex === -1) {
|
|
30
|
+
return { valid: false, message: "Invalid token format" };
|
|
31
|
+
}
|
|
32
|
+
const payloadB64 = body.slice(0, dotIndex);
|
|
33
|
+
const signatureB64 = body.slice(dotIndex + 1);
|
|
34
|
+
if (!payloadB64 || !signatureB64) {
|
|
35
|
+
return { valid: false, message: "Invalid token format" };
|
|
36
|
+
}
|
|
37
|
+
// Verify signature
|
|
38
|
+
let signatureValid;
|
|
39
|
+
try {
|
|
40
|
+
const signature = Buffer.from(signatureB64, "base64url");
|
|
41
|
+
signatureValid = crypto.verify(null, Buffer.from(payloadB64), publicKey, signature);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { valid: false, message: "Signature verification failed" };
|
|
45
|
+
}
|
|
46
|
+
if (!signatureValid) {
|
|
47
|
+
return { valid: false, message: "Invalid license key" };
|
|
48
|
+
}
|
|
49
|
+
// Decode payload
|
|
50
|
+
try {
|
|
51
|
+
const payloadJson = Buffer.from(payloadB64, "base64url").toString("utf8");
|
|
52
|
+
const payload = JSON.parse(payloadJson);
|
|
53
|
+
if (!payload.did || !payload.tier || !payload.exp || !payload.cid || !payload.iat) {
|
|
54
|
+
return { valid: false, message: "Incomplete license data" };
|
|
55
|
+
}
|
|
56
|
+
return { valid: true, payload };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { valid: false, message: "Invalid license data" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { loadConfig, writeConfigFile } from "../config/config.js";
|
|
2
|
+
import { getDeviceId } from "./device-id.js";
|
|
3
|
+
import { validateLicenseKey } from "./validate.js";
|
|
4
|
+
/** Check every 24 hours during gateway uptime. */
|
|
5
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
let revalidationTimer = null;
|
|
7
|
+
function revalidate(log, onChange) {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
const lic = config.license;
|
|
10
|
+
// Nothing to revalidate if there's no stored key
|
|
11
|
+
if (!lic?.key || !lic.validatedAt)
|
|
12
|
+
return;
|
|
13
|
+
// Re-verify the stored token locally (signature + device + expiry)
|
|
14
|
+
const deviceId = getDeviceId();
|
|
15
|
+
const result = validateLicenseKey(lic.key, deviceId);
|
|
16
|
+
if (result.valid) {
|
|
17
|
+
log.info("license still valid");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Token is no longer valid (expired, wrong device, bad signature)
|
|
21
|
+
log.warn(`license revoked: ${result.message}`);
|
|
22
|
+
try {
|
|
23
|
+
void writeConfigFile({
|
|
24
|
+
...config,
|
|
25
|
+
license: {
|
|
26
|
+
key: lic.key,
|
|
27
|
+
deviceId: lic.deviceId,
|
|
28
|
+
// Clear validation fields to force re-activation
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
log.warn(`failed to clear revoked license: ${String(err)}`);
|
|
34
|
+
}
|
|
35
|
+
onChange(false);
|
|
36
|
+
}
|
|
37
|
+
export function startLicenseRevalidation(log, onChange) {
|
|
38
|
+
stopLicenseRevalidation();
|
|
39
|
+
revalidationTimer = setInterval(() => {
|
|
40
|
+
revalidate(log, onChange);
|
|
41
|
+
}, CHECK_INTERVAL_MS);
|
|
42
|
+
// Don't prevent process exit
|
|
43
|
+
if (revalidationTimer && typeof revalidationTimer === "object" && "unref" in revalidationTimer) {
|
|
44
|
+
revalidationTimer.unref();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function stopLicenseRevalidation() {
|
|
48
|
+
if (revalidationTimer) {
|
|
49
|
+
clearInterval(revalidationTimer);
|
|
50
|
+
revalidationTimer = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory license state flag.
|
|
3
|
+
* Updated at startup and when the license is activated or revoked.
|
|
4
|
+
* Defaults to true in test environments so tests don't need to mock licensing.
|
|
5
|
+
*/
|
|
6
|
+
let licensed = process.env.VITEST === "true";
|
|
7
|
+
export function isLicensed() {
|
|
8
|
+
return licensed;
|
|
9
|
+
}
|
|
10
|
+
export function setLicensed(value) {
|
|
11
|
+
licensed = value;
|
|
12
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { verifyLicenseToken } from "./keys.js";
|
|
2
|
+
/** Max age for a stored validation before we require re-checking (30 days). */
|
|
3
|
+
const MAX_VALIDATION_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Validate a license token locally using Ed25519 signature verification.
|
|
6
|
+
* Checks: signature valid, device ID matches, not expired.
|
|
7
|
+
*/
|
|
8
|
+
export function validateLicenseKey(key, deviceId) {
|
|
9
|
+
const result = verifyLicenseToken(key);
|
|
10
|
+
if (!result.valid) {
|
|
11
|
+
return { valid: false, message: result.message };
|
|
12
|
+
}
|
|
13
|
+
const { payload } = result;
|
|
14
|
+
// Check device binding ("*" = any device, used for dev/master keys)
|
|
15
|
+
if (payload.did !== "*" && payload.did !== deviceId) {
|
|
16
|
+
return {
|
|
17
|
+
valid: false,
|
|
18
|
+
message: "This license key is bound to a different device",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// Check expiry
|
|
22
|
+
const expiresAt = new Date(payload.exp).getTime();
|
|
23
|
+
if (Number.isNaN(expiresAt) || Date.now() > expiresAt) {
|
|
24
|
+
return { valid: false, message: "License has expired" };
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
valid: true,
|
|
28
|
+
message: "License activated",
|
|
29
|
+
expiresAt: payload.exp,
|
|
30
|
+
tier: payload.tier,
|
|
31
|
+
customerId: payload.cid,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check whether the stored license in config is currently valid.
|
|
36
|
+
* Does NOT re-verify the signature — only checks local state.
|
|
37
|
+
*/
|
|
38
|
+
export function isLicenseValid(config) {
|
|
39
|
+
const lic = config.license;
|
|
40
|
+
if (!lic?.key)
|
|
41
|
+
return false;
|
|
42
|
+
if (!lic.validatedAt)
|
|
43
|
+
return false;
|
|
44
|
+
// Check validation age
|
|
45
|
+
const validatedAt = new Date(lic.validatedAt).getTime();
|
|
46
|
+
if (Number.isNaN(validatedAt))
|
|
47
|
+
return false;
|
|
48
|
+
if (Date.now() - validatedAt > MAX_VALIDATION_AGE_MS)
|
|
49
|
+
return false;
|
|
50
|
+
// Check expiry (absent expiresAt = perpetual)
|
|
51
|
+
if (lic.expiresAt) {
|
|
52
|
+
const expiresAt = new Date(lic.expiresAt).getTime();
|
|
53
|
+
if (Number.isNaN(expiresAt))
|
|
54
|
+
return false;
|
|
55
|
+
if (Date.now() > expiresAt)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/dist/logging/logger.js
CHANGED
|
@@ -26,20 +26,32 @@ function attachExternalTransport(logger, transport) {
|
|
|
26
26
|
}
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
let resolvingSettings = false;
|
|
29
30
|
function resolveSettings() {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
// Guard against recursion: loadConfig() may trigger logging during
|
|
32
|
+
// validation, which calls getLogger() → resolveSettings() again.
|
|
33
|
+
if (resolvingSettings) {
|
|
34
|
+
return { level: "info", file: defaultRollingPathForToday() };
|
|
35
|
+
}
|
|
36
|
+
resolvingSettings = true;
|
|
37
|
+
try {
|
|
38
|
+
let cfg = loggingState.overrideSettings ?? readLoggingConfig();
|
|
39
|
+
if (!cfg) {
|
|
40
|
+
try {
|
|
41
|
+
const loaded = requireConfig("../config/config.js");
|
|
42
|
+
cfg = loaded.loadConfig?.().logging;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
cfg = undefined;
|
|
46
|
+
}
|
|
38
47
|
}
|
|
48
|
+
const level = normalizeLogLevel(cfg?.level, "info");
|
|
49
|
+
const file = cfg?.file ?? defaultRollingPathForToday();
|
|
50
|
+
return { level, file };
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
resolvingSettings = false;
|
|
39
54
|
}
|
|
40
|
-
const level = normalizeLogLevel(cfg?.level, "info");
|
|
41
|
-
const file = cfg?.file ?? defaultRollingPathForToday();
|
|
42
|
-
return { level, file };
|
|
43
55
|
}
|
|
44
56
|
function settingsChanged(a, b) {
|
|
45
57
|
if (!a)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
const RECORDS_DIR = path.join(os.homedir(), ".taskmaster");
|
|
5
|
+
const RECORDS_PATH = path.join(RECORDS_DIR, "records.json");
|
|
6
|
+
function readFile() {
|
|
7
|
+
try {
|
|
8
|
+
const raw = fs.readFileSync(RECORDS_PATH, "utf-8");
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (parsed.version === 1 && typeof parsed.records === "object" && parsed.records !== null) {
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// File doesn't exist or is invalid — return empty
|
|
16
|
+
}
|
|
17
|
+
return { version: 1, records: {} };
|
|
18
|
+
}
|
|
19
|
+
function writeFile(data) {
|
|
20
|
+
fs.mkdirSync(RECORDS_DIR, { recursive: true });
|
|
21
|
+
const tmp = RECORDS_PATH + ".tmp";
|
|
22
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
23
|
+
fs.renameSync(tmp, RECORDS_PATH);
|
|
24
|
+
}
|
|
25
|
+
export function listRecords(workspace) {
|
|
26
|
+
const data = readFile();
|
|
27
|
+
let records = Object.values(data.records);
|
|
28
|
+
if (workspace) {
|
|
29
|
+
// Strict workspace filter — only show records tagged for this workspace.
|
|
30
|
+
// Untagged records belong to the default workspace.
|
|
31
|
+
records = records.filter((r) => (r.workspace ?? "taskmaster") === workspace);
|
|
32
|
+
}
|
|
33
|
+
return records.sort((a, b) => a.name.localeCompare(b.name));
|
|
34
|
+
}
|
|
35
|
+
export function getRecord(id) {
|
|
36
|
+
const data = readFile();
|
|
37
|
+
return data.records[id] ?? null;
|
|
38
|
+
}
|
|
39
|
+
export function searchRecords(query, workspace) {
|
|
40
|
+
const q = query.toLowerCase();
|
|
41
|
+
const data = readFile();
|
|
42
|
+
let records = Object.values(data.records);
|
|
43
|
+
if (workspace) {
|
|
44
|
+
// Strict workspace filter — same as listRecords
|
|
45
|
+
records = records.filter((r) => (r.workspace ?? "taskmaster") === workspace);
|
|
46
|
+
}
|
|
47
|
+
return records.filter((r) => r.id.includes(q) || r.name.toLowerCase().includes(q));
|
|
48
|
+
}
|
|
49
|
+
export function setRecord(id, input) {
|
|
50
|
+
const data = readFile();
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const existing = data.records[id];
|
|
53
|
+
const record = {
|
|
54
|
+
id,
|
|
55
|
+
name: input.name,
|
|
56
|
+
workspace: input.workspace ?? existing?.workspace,
|
|
57
|
+
createdAt: existing?.createdAt ?? now,
|
|
58
|
+
updatedAt: now,
|
|
59
|
+
fields: input.fields,
|
|
60
|
+
};
|
|
61
|
+
data.records[id] = record;
|
|
62
|
+
writeFile(data);
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
export function deleteRecord(id) {
|
|
66
|
+
const data = readFile();
|
|
67
|
+
if (!data.records[id])
|
|
68
|
+
return false;
|
|
69
|
+
delete data.records[id];
|
|
70
|
+
writeFile(data);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
export function setRecordField(id, key, value) {
|
|
74
|
+
const data = readFile();
|
|
75
|
+
const record = data.records[id];
|
|
76
|
+
if (!record)
|
|
77
|
+
return null;
|
|
78
|
+
record.fields[key] = value;
|
|
79
|
+
record.updatedAt = new Date().toISOString();
|
|
80
|
+
writeFile(data);
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
83
|
+
export function deleteRecordField(id, key) {
|
|
84
|
+
const data = readFile();
|
|
85
|
+
const record = data.records[id];
|
|
86
|
+
if (!record)
|
|
87
|
+
return null;
|
|
88
|
+
delete record.fields[key];
|
|
89
|
+
record.updatedAt = new Date().toISOString();
|
|
90
|
+
writeFile(data);
|
|
91
|
+
return record;
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubytech/taskmaster",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "AI-powered business assistant for small businesses",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -72,7 +72,10 @@
|
|
|
72
72
|
"dist/markdown/**",
|
|
73
73
|
"dist/node-host/**",
|
|
74
74
|
"dist/pairing/**",
|
|
75
|
-
"dist/whatsapp/**"
|
|
75
|
+
"dist/whatsapp/**",
|
|
76
|
+
"dist/records/**",
|
|
77
|
+
"dist/filler/**",
|
|
78
|
+
"dist/license/**"
|
|
76
79
|
],
|
|
77
80
|
"scripts": {
|
|
78
81
|
"dev": "node scripts/run-node.mjs",
|
package/scripts/install.sh
CHANGED
|
@@ -4,10 +4,10 @@ set -euo pipefail
|
|
|
4
4
|
# Taskmaster — one-command install for fresh devices (Pi or Mac).
|
|
5
5
|
#
|
|
6
6
|
# Usage:
|
|
7
|
-
# curl -fsSL https://taskmaster.bot/install.sh | bash
|
|
7
|
+
# curl -fsSL https://taskmaster.bot/install.sh | sudo bash
|
|
8
8
|
#
|
|
9
9
|
# With custom port:
|
|
10
|
-
# curl -fsSL https://taskmaster.bot/install.sh | bash -s -- --port 19000
|
|
10
|
+
# curl -fsSL https://taskmaster.bot/install.sh | sudo bash -s -- --port 19000
|
|
11
11
|
|
|
12
12
|
PORT=""
|
|
13
13
|
for arg in "$@"; do
|
package/scripts/postinstall.js
CHANGED
|
@@ -2,7 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
|
|
5
|
+
let setupGitHooks = () => {};
|
|
6
|
+
try {
|
|
7
|
+
const mod = await import("./setup-git-hooks.js");
|
|
8
|
+
setupGitHooks = mod.setupGitHooks;
|
|
9
|
+
} catch {
|
|
10
|
+
// setup-git-hooks.js is not shipped in the npm package — skip in production
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") {
|
|
8
14
|
// Examples:
|
|
@@ -214,7 +214,7 @@ Always ask the business owner before reshuffling. Never move a job without appro
|
|
|
214
214
|
**Real-time location (GPS):**
|
|
215
215
|
If the business owner's phone is paired as a node, you can query their GPS location on demand using `location.get`. Use this for:
|
|
216
216
|
- **Customer asks "where is he?"** → Check GPS, compare to next appointment location, give an ETA: "[name] left his last job about 15 minutes ago. He should be with you in about 20 minutes."
|
|
217
|
-
- **Late detection** → If you have a
|
|
217
|
+
- **Late detection** → If you have a scheduled event check 10 minutes before each appointment, query GPS. If the business owner is still far away, proactively message the customer: "Hi! [name]'s running about 15 minutes late — he's on his way. Sorry for the wait!" This turns a complaint into a professional courtesy.
|
|
218
218
|
- **Business owner or their partner asks "where is [name]?"** → Report current location and next job ETA.
|
|
219
219
|
- **End-of-day** → If the business owner is near their home postcode, stop offering same-day appointments.
|
|
220
220
|
- Do NOT track location continuously. Only query when you have a specific reason.
|
|
@@ -339,7 +339,7 @@ Customers can always request to speak to the business owner directly. The agent
|
|
|
339
339
|
|
|
340
340
|
## Morning Briefing
|
|
341
341
|
|
|
342
|
-
If a
|
|
342
|
+
If a scheduled event is set up for morning briefings, send a daily summary. **Group jobs by area, not just time.** Include postcodes, estimated travel, and total driving time. See Schedule Optimisation section for full format.
|
|
343
343
|
|
|
344
344
|
> **Morning [name]! Here's your Tuesday:**
|
|
345
345
|
>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Event Management
|
|
2
|
+
|
|
3
|
+
Applies when handling anything time-bound: appointments, meetings, reminders, follow-ups, callbacks, deadlines — any commitment or scheduled action between one or more parties.
|
|
4
|
+
|
|
5
|
+
## When to activate
|
|
6
|
+
|
|
7
|
+
- Customer requests or confirms an appointment
|
|
8
|
+
- Business owner asks to schedule, reschedule, or cancel something
|
|
9
|
+
- A reminder, follow-up, or callback needs to be recorded
|
|
10
|
+
- Someone asks "what's on [day/week]?" or "show me the schedule"
|
|
11
|
+
- A deadline or time-sensitive commitment is mentioned
|
|
12
|
+
|
|
13
|
+
## References
|
|
14
|
+
|
|
15
|
+
Load `references/events.md` for the standard event template, file naming, dual-write pattern, and calendar query instructions.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Events
|
|
2
|
+
|
|
3
|
+
Events are any time-bound information: appointments, meetings, reminders, follow-ups, callbacks, deadlines. Anything that represents a commitment, agreement, or scheduled action between one or more parties.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Standard Event Format
|
|
8
|
+
|
|
9
|
+
Every event file follows this structure. All fields above the `## Notes` section are required unless marked optional.
|
|
10
|
+
|
|
11
|
+
```markdown
|
|
12
|
+
# Event: [Short descriptive title]
|
|
13
|
+
|
|
14
|
+
- **Date:** YYYY-MM-DD
|
|
15
|
+
- **Time:** HH:MM (24h, local timezone)
|
|
16
|
+
- **Duration:** [number] min
|
|
17
|
+
- **Customer:** [Name] ([phone])
|
|
18
|
+
- **Service:** [what is being done]
|
|
19
|
+
- **Location:** [address or "Remote" / "Phone"]
|
|
20
|
+
- **Status:** confirmed | tentative | cancelled | completed
|
|
21
|
+
- **Created by:** [agent id — e.g. public, admin]
|
|
22
|
+
- **Created:** YYYY-MM-DDTHH:MM:SSZ
|
|
23
|
+
|
|
24
|
+
## Notes
|
|
25
|
+
[Free text — special instructions, context, history of changes]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Field guidance
|
|
29
|
+
|
|
30
|
+
- **Date/Time** — always local timezone. If the business owner's timezone is known, use it consistently.
|
|
31
|
+
- **Duration** — estimated in minutes. If unknown, use a reasonable default for the service type and note the assumption.
|
|
32
|
+
- **Customer** — full name and phone in international format. For internal events (admin meetings, personal reminders) use the owner's name and number.
|
|
33
|
+
- **Status** — `confirmed` when both parties have agreed. `tentative` when proposed but not confirmed. `cancelled` when called off. `completed` after the event has passed and the work is done.
|
|
34
|
+
- **Service** — brief description of the work or purpose. Not a category — use plain language ("boiler service", "quote for kitchen extension", "team meeting").
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## File Naming
|
|
39
|
+
|
|
40
|
+
Files are named for sort order and human readability:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
YYYY-MM-DD-slug.md
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
- `2026-02-20-boiler-service-john-smith.md`
|
|
48
|
+
- `2026-02-21-quote-mrs-jenkins.md`
|
|
49
|
+
- `2026-03-01-team-meeting.md`
|
|
50
|
+
|
|
51
|
+
The slug should be lowercase, hyphenated, and include enough context to identify the event without opening the file.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Where to Write
|
|
56
|
+
|
|
57
|
+
Every event must be written to **two** locations:
|
|
58
|
+
|
|
59
|
+
1. **Per-customer record:** `memory/users/{phone}/events/YYYY-MM-DD-slug.md`
|
|
60
|
+
- Scoped to the customer. Visible when reviewing that customer's history.
|
|
61
|
+
2. **Shared calendar:** `memory/shared/events/YYYY-MM-DD-slug.md`
|
|
62
|
+
- Visible to all agents in the account. The admin agent uses this for calendar queries.
|
|
63
|
+
|
|
64
|
+
Both files have identical content. Write to both in the same operation.
|
|
65
|
+
|
|
66
|
+
For events that have no external customer (internal meetings, personal reminders), write only to `memory/shared/events/`.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Updating Events
|
|
71
|
+
|
|
72
|
+
When an event changes (rescheduled, cancelled, details updated):
|
|
73
|
+
|
|
74
|
+
1. Update **both** copies — the per-customer file and the shared calendar file.
|
|
75
|
+
2. Change the **Status** field appropriately.
|
|
76
|
+
3. Add a note in the `## Notes` section explaining the change and when it happened.
|
|
77
|
+
4. If the date changes, rename both files to reflect the new date. Delete the old files.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Querying the Calendar
|
|
82
|
+
|
|
83
|
+
To answer "what's on [day/week]?" or "show me the schedule":
|
|
84
|
+
|
|
85
|
+
1. List files in `memory/shared/events/` — filenames are date-prefixed, so lexicographic order is chronological order.
|
|
86
|
+
2. Filter by date prefix (e.g., `2026-02-20` for a specific day, `2026-02-` for a month).
|
|
87
|
+
3. Read the matching files for details.
|
|
88
|
+
|
|
89
|
+
This is a directory listing + read operation, not a search. It is fast and complete.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Linking Events to Scheduled Reminders
|
|
94
|
+
|
|
95
|
+
Events (calendar data) and scheduled reminders (the `cron` tool) serve different purposes:
|
|
96
|
+
|
|
97
|
+
- **Event file** — the record of what is happening, when, and with whom. Source of truth for the calendar.
|
|
98
|
+
- **Scheduled reminder** (`cron` tool) — an automated trigger that fires at a specific time to notify or act. Optional.
|
|
99
|
+
|
|
100
|
+
When you create an event that also needs a reminder (e.g., "remind me 30 minutes before"), create both:
|
|
101
|
+
1. The event file (in both locations, as above)
|
|
102
|
+
2. A scheduled reminder via the `cron` tool, referencing the event
|
|
103
|
+
|
|
104
|
+
The reminder's payload text should reference the event clearly so the recipient has full context when it fires.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Event Types
|
|
109
|
+
|
|
110
|
+
This format covers all time-bound data. Examples:
|
|
111
|
+
|
|
112
|
+
| Type | Customer field | Location | Notes |
|
|
113
|
+
|------|---------------|----------|-------|
|
|
114
|
+
| Customer appointment | Customer name + phone | Job site address | Service details, access instructions |
|
|
115
|
+
| Quote visit | Customer name + phone | Site address | What to quote, measurements needed |
|
|
116
|
+
| Callback | Customer name + phone | "Phone" | Why they're expecting a call, promised window |
|
|
117
|
+
| Follow-up | Customer name + phone | n/a | What to follow up on, last interaction context |
|
|
118
|
+
| Internal meeting | Owner name + phone | Meeting link or office | Agenda, attendees |
|
|
119
|
+
| Personal reminder | Owner name + phone | n/a | What to remember, deadline context |
|
|
120
|
+
| Deadline | Owner or customer | n/a | What's due, consequences of missing it |
|
|
@@ -39,7 +39,7 @@ You'll need a monitor, keyboard, and mouse connected to the Pi.
|
|
|
39
39
|
2. Run:
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
-
curl -fsSL https://taskmaster.bot/install.sh | bash
|
|
42
|
+
curl -fsSL https://taskmaster.bot/install.sh | sudo bash
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
3. Wait for it to finish (a few minutes — it installs Node.js if needed)
|
|
@@ -54,7 +54,7 @@ You can also access the setup page from any other device on the same network at
|
|
|
54
54
|
Open **Terminal** (search for "Terminal" in Spotlight) and run:
|
|
55
55
|
|
|
56
56
|
```
|
|
57
|
-
curl -fsSL https://taskmaster.bot/install.sh | bash
|
|
57
|
+
curl -fsSL https://taskmaster.bot/install.sh | sudo bash
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
This installs Node.js (if needed), Taskmaster, and sets up the background service. Once finished, open your browser and go to: **http://localhost:18789/setup**
|
|
@@ -62,7 +62,7 @@ This installs Node.js (if needed), Taskmaster, and sets up the background servic
|
|
|
62
62
|
**Custom port:** If you're running multiple Taskmaster instances (e.g., one on a Pi and one on a Mac), give each a different port:
|
|
63
63
|
|
|
64
64
|
```
|
|
65
|
-
curl -fsSL https://taskmaster.bot/install.sh | bash -s -- --port 19000
|
|
65
|
+
curl -fsSL https://taskmaster.bot/install.sh | sudo bash -s -- --port 19000
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
### Option D: Install from a package file
|