parallelclaw 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
package/lib/tasks.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-to-agent task ledger (Foreman tracer-bullet, v0).
|
|
3
|
+
*
|
|
4
|
+
* A task is a MESSAGE with source='agent-task' — so tasks ride the existing
|
|
5
|
+
* sync (which replicates the messages table) with ZERO new transport. Status
|
|
6
|
+
* is event-sourced: every state change is a new append-only row. The current
|
|
7
|
+
* status of a task is its latest-ts event. See docs/design/agent-tasks.md.
|
|
8
|
+
*
|
|
9
|
+
* Row encoding (one messages row per event):
|
|
10
|
+
* source = 'agent-task'
|
|
11
|
+
* conversation_id = 'task-<id>' (groups a task's events)
|
|
12
|
+
* msg_id = '<id>.<status>.<ts>' (unique, append-only)
|
|
13
|
+
* role = 'task'
|
|
14
|
+
* text = human-readable (prompt on submit, result on done)
|
|
15
|
+
* metadata(JSON) = { task_id, status, from, to, kind, prompt, result }
|
|
16
|
+
* origin = who wrote THIS event (v0.14 provenance)
|
|
17
|
+
*
|
|
18
|
+
* Status model (A2A subset): submitted → working → done | failed.
|
|
19
|
+
*
|
|
20
|
+
* CLI (brand-neutral, mirrors sync-* — survives the parallelclaw rename):
|
|
21
|
+
* task-delegate "<prompt>" [--to <origin>] [--kind <c>] [--content <t>]
|
|
22
|
+
* task-list [--for <origin>] [--status <s>] [--mine] [--inbox]
|
|
23
|
+
* task-update <id> <status> [--result <text>]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import Database from 'better-sqlite3';
|
|
29
|
+
|
|
30
|
+
import { getOrigin } from './config.js';
|
|
31
|
+
|
|
32
|
+
const SOURCE = 'agent-task';
|
|
33
|
+
const STATUSES = ['submitted', 'working', 'done', 'failed'];
|
|
34
|
+
|
|
35
|
+
function dbPath() {
|
|
36
|
+
const dir = process.env.MEMEX_DIR || join(homedir(), '.memex');
|
|
37
|
+
return join(dir, 'data', 'memex.db');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openDb() {
|
|
41
|
+
const db = new Database(dbPath());
|
|
42
|
+
db.pragma('journal_mode = WAL');
|
|
43
|
+
db.pragma('busy_timeout = 10000');
|
|
44
|
+
return db;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Append one event row for a task. Returns the row's msg_id. */
|
|
48
|
+
function writeEvent(db, { taskId, status, envelope, text, origin, now }) {
|
|
49
|
+
const ts = Math.floor(now / 1000);
|
|
50
|
+
const msgId = `${taskId}.${status}.${now}`;
|
|
51
|
+
db.prepare(
|
|
52
|
+
`INSERT OR IGNORE INTO messages
|
|
53
|
+
(source, conversation_id, msg_id, role, sender, text, ts, metadata, origin)
|
|
54
|
+
VALUES (?, ?, ?, 'task', ?, ?, ?, ?, ?)`
|
|
55
|
+
).run(SOURCE, `task-${taskId}`, msgId, origin, text || '', ts,
|
|
56
|
+
JSON.stringify(envelope), origin);
|
|
57
|
+
return msgId;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create (submit) a task. opts: { prompt (req), to, kind, content, db?, now? }.
|
|
62
|
+
* Returns { id, to }.
|
|
63
|
+
*/
|
|
64
|
+
export function createTask({ prompt, to = 'any', kind = 'general', content = null, db = null, now = Date.now() } = {}) {
|
|
65
|
+
if (!prompt || !String(prompt).trim()) throw new Error('createTask: prompt required');
|
|
66
|
+
const ownDb = !db;
|
|
67
|
+
db = db || openDb();
|
|
68
|
+
const from = getOrigin();
|
|
69
|
+
// Short, sortable, collision-resistant id (Node CLI — Date.now/random ok).
|
|
70
|
+
const id = `t${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
71
|
+
try {
|
|
72
|
+
writeEvent(db, {
|
|
73
|
+
taskId: id, status: 'submitted', origin: from, now,
|
|
74
|
+
text: String(prompt),
|
|
75
|
+
envelope: { task_id: id, status: 'submitted', from, to, kind,
|
|
76
|
+
prompt: String(prompt), content: content || null, result: null },
|
|
77
|
+
});
|
|
78
|
+
} finally { if (ownDb) db.close(); }
|
|
79
|
+
return { id, to };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Append a status transition. opts: { result, db?, now? }.
|
|
84
|
+
* Carries prompt/to/kind forward from the latest known event so the envelope
|
|
85
|
+
* stays self-describing. Returns the updated envelope.
|
|
86
|
+
*/
|
|
87
|
+
export function updateTask(id, status, { result = null, db = null, now = Date.now() } = {}) {
|
|
88
|
+
if (!STATUSES.includes(status)) throw new Error(`updateTask: status must be one of ${STATUSES.join('/')}`);
|
|
89
|
+
const ownDb = !db;
|
|
90
|
+
db = db || openDb();
|
|
91
|
+
try {
|
|
92
|
+
const prev = latestEvent(db, id);
|
|
93
|
+
if (!prev) throw new Error(`updateTask: no task "${id}"`);
|
|
94
|
+
const env = { ...prev.envelope, status, result: result ?? prev.envelope.result ?? null };
|
|
95
|
+
writeEvent(db, {
|
|
96
|
+
taskId: id, status, origin: getOrigin(), now,
|
|
97
|
+
text: status === 'done' || status === 'failed' ? (result || '') : (env.prompt || ''),
|
|
98
|
+
envelope: env,
|
|
99
|
+
});
|
|
100
|
+
return env;
|
|
101
|
+
} finally { if (ownDb) db.close(); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Latest event for one task id, or null. Returns { envelope, ts, origin }. */
|
|
105
|
+
function latestEvent(db, id) {
|
|
106
|
+
const rows = db.prepare(
|
|
107
|
+
`SELECT metadata, ts, origin FROM messages
|
|
108
|
+
WHERE source = ? AND conversation_id = ? ORDER BY ts DESC, id DESC LIMIT 1`
|
|
109
|
+
).all(SOURCE, `task-${id}`);
|
|
110
|
+
if (!rows.length) return null;
|
|
111
|
+
let envelope = {};
|
|
112
|
+
try { envelope = JSON.parse(rows[0].metadata || '{}'); } catch (_) {}
|
|
113
|
+
return { envelope, ts: rows[0].ts, origin: rows[0].origin };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* List tasks with their CURRENT (latest) status.
|
|
118
|
+
* opts: { forOrigin, status, mine, inbox, limit, db? }.
|
|
119
|
+
* mine — tasks I submitted (from === my origin)
|
|
120
|
+
* inbox — tasks addressed to me AND currently 'submitted' (ready to take)
|
|
121
|
+
* Returns [{ id, from, to, kind, status, prompt, result, ts }] newest-first.
|
|
122
|
+
*/
|
|
123
|
+
export function listTasks({ forOrigin = null, status = null, mine = false, inbox = false, limit = 50, db = null } = {}) {
|
|
124
|
+
const ownDb = !db;
|
|
125
|
+
db = db || openDb();
|
|
126
|
+
try {
|
|
127
|
+
const me = getOrigin();
|
|
128
|
+
// All events; reduce to latest per task in JS (low volume; robust to
|
|
129
|
+
// cross-node id ordering — we key on logical ts, not local rowid).
|
|
130
|
+
const rows = db.prepare(
|
|
131
|
+
`SELECT conversation_id, metadata, ts FROM messages
|
|
132
|
+
WHERE source = ? ORDER BY ts ASC, id ASC`
|
|
133
|
+
).all(SOURCE);
|
|
134
|
+
const latest = new Map();
|
|
135
|
+
for (const r of rows) {
|
|
136
|
+
let env; try { env = JSON.parse(r.metadata || '{}'); } catch (_) { continue; }
|
|
137
|
+
if (!env.task_id) continue;
|
|
138
|
+
latest.set(env.task_id, { ...env, ts: r.ts }); // later rows overwrite → latest wins
|
|
139
|
+
}
|
|
140
|
+
let out = [...latest.values()];
|
|
141
|
+
if (inbox) out = out.filter((t) => (t.to === me) && t.status === 'submitted');
|
|
142
|
+
if (mine) out = out.filter((t) => t.from === me);
|
|
143
|
+
if (forOrigin) out = out.filter((t) => t.to === forOrigin || t.from === forOrigin);
|
|
144
|
+
if (status) out = out.filter((t) => t.status === status);
|
|
145
|
+
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
146
|
+
return out.slice(0, Math.max(1, limit)).map((t) => ({
|
|
147
|
+
id: t.task_id, from: t.from, to: t.to, kind: t.kind,
|
|
148
|
+
status: t.status, prompt: t.prompt, result: t.result || null, ts: t.ts,
|
|
149
|
+
}));
|
|
150
|
+
} finally { if (ownDb) db.close(); }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function parseFlags(argv) {
|
|
156
|
+
const out = {};
|
|
157
|
+
for (let i = 0; i < argv.length; i++) {
|
|
158
|
+
const a = argv[i];
|
|
159
|
+
if (!a.startsWith('--')) { (out._ ||= []).push(a); continue; }
|
|
160
|
+
const next = argv[i + 1];
|
|
161
|
+
if (next != null && !next.startsWith('--')) { out[a] = next; i++; }
|
|
162
|
+
else out[a] = true;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function cmdTaskDelegate() {
|
|
168
|
+
const args = parseFlags(process.argv.slice(3));
|
|
169
|
+
const prompt = (args._ || [])[0];
|
|
170
|
+
if (!prompt) {
|
|
171
|
+
console.error('usage: task-delegate "<prompt>" [--to <origin>] [--kind <class>] [--content <text>]');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const { id, to } = createTask({
|
|
175
|
+
prompt, to: args['--to'] || 'any', kind: args['--kind'] || 'general',
|
|
176
|
+
content: typeof args['--content'] === 'string' ? args['--content'] : null,
|
|
177
|
+
});
|
|
178
|
+
console.log(`✓ delegated task ${id} → ${to} (from ${getOrigin()})`);
|
|
179
|
+
console.log(` track: task-list --mine`);
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function cmdTaskUpdate() {
|
|
184
|
+
const args = parseFlags(process.argv.slice(3));
|
|
185
|
+
const [id, status] = args._ || [];
|
|
186
|
+
if (!id || !status) {
|
|
187
|
+
console.error('usage: task-update <id> <submitted|working|done|failed> [--result <text>]');
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const env = updateTask(id, status, { result: typeof args['--result'] === 'string' ? args['--result'] : null });
|
|
192
|
+
console.log(`✓ task ${id} → ${env.status}${env.result ? ' (result attached)' : ''}`);
|
|
193
|
+
} catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function cmdTaskList() {
|
|
198
|
+
const args = parseFlags(process.argv.slice(3));
|
|
199
|
+
const tasks = listTasks({
|
|
200
|
+
forOrigin: args['--for'] || null,
|
|
201
|
+
status: args['--status'] || null,
|
|
202
|
+
mine: '--mine' in args,
|
|
203
|
+
inbox: '--inbox' in args,
|
|
204
|
+
limit: parseInt(args['--limit'] || '', 10) || 50,
|
|
205
|
+
});
|
|
206
|
+
if (!tasks.length) { console.log('No tasks.'); process.exit(0); }
|
|
207
|
+
const icon = { submitted: '○', working: '◐', done: '✓', failed: '✗' };
|
|
208
|
+
for (const t of tasks) {
|
|
209
|
+
const when = t.ts ? new Date(t.ts * 1000).toISOString().slice(0, 16).replace('T', ' ') : '?';
|
|
210
|
+
console.log(`${icon[t.status] || '?'} ${t.id} ${t.from} → ${t.to} [${t.status}] ${when}`);
|
|
211
|
+
console.log(` ${String(t.prompt || '').slice(0, 80)}`);
|
|
212
|
+
if (t.result) console.log(` └ result: ${String(t.result).slice(0, 80)}`);
|
|
213
|
+
}
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-first per-chat decisions for Telegram imports.
|
|
3
|
+
*
|
|
4
|
+
* Stored at ~/.memex/telegram-decisions.json:
|
|
5
|
+
* {
|
|
6
|
+
* version: 1,
|
|
7
|
+
* mode: "pick" | "auto" | "manual",
|
|
8
|
+
* allowed_chats: [{ title, first_imported }],
|
|
9
|
+
* skipped_chats: [{ title, skipped_at }],
|
|
10
|
+
* blocked_patterns: [{ pattern, added_at, note }] // glob-ish on title
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Reads are cheap; writes are atomic (tmp + rename).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
export const DECISIONS_PATH = join(homedir(), '.memex', 'telegram-decisions.json');
|
|
21
|
+
export const VALID_MODES = ['pick', 'auto', 'manual'];
|
|
22
|
+
|
|
23
|
+
const DEFAULT_STATE = () => ({
|
|
24
|
+
version: 1,
|
|
25
|
+
mode: 'pick',
|
|
26
|
+
allowed_chats: [],
|
|
27
|
+
skipped_chats: [],
|
|
28
|
+
blocked_patterns: [],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function loadDecisions(path = DECISIONS_PATH) {
|
|
32
|
+
if (!existsSync(path)) return DEFAULT_STATE();
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(path, 'utf-8');
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
// Normalize old / partial files
|
|
37
|
+
return {
|
|
38
|
+
...DEFAULT_STATE(),
|
|
39
|
+
...parsed,
|
|
40
|
+
allowed_chats: Array.isArray(parsed.allowed_chats) ? parsed.allowed_chats : [],
|
|
41
|
+
skipped_chats: Array.isArray(parsed.skipped_chats) ? parsed.skipped_chats : [],
|
|
42
|
+
blocked_patterns: Array.isArray(parsed.blocked_patterns) ? parsed.blocked_patterns : [],
|
|
43
|
+
};
|
|
44
|
+
} catch (_) {
|
|
45
|
+
return DEFAULT_STATE();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveDecisions(state, path = DECISIONS_PATH) {
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
51
|
+
const tmp = path + '.tmp';
|
|
52
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
53
|
+
renameSync(tmp, path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -------------------- Predicates --------------------
|
|
57
|
+
|
|
58
|
+
function norm(s) { return String(s || '').trim().toLowerCase(); }
|
|
59
|
+
|
|
60
|
+
export function isAllowed(state, chatTitle) {
|
|
61
|
+
const n = norm(chatTitle);
|
|
62
|
+
return state.allowed_chats.some((c) => norm(c.title) === n);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isSkipped(state, chatTitle) {
|
|
66
|
+
const n = norm(chatTitle);
|
|
67
|
+
return state.skipped_chats.some((c) => norm(c.title) === n);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isBlocked(state, chatTitle) {
|
|
71
|
+
const n = norm(chatTitle);
|
|
72
|
+
for (const b of state.blocked_patterns) {
|
|
73
|
+
const p = norm(b.pattern);
|
|
74
|
+
if (!p) continue;
|
|
75
|
+
// glob: "*" → match anywhere; otherwise substring on lowercased title
|
|
76
|
+
if (p.includes('*')) {
|
|
77
|
+
// Convert glob → regex: split on '*', escape each part, rejoin with '.*'
|
|
78
|
+
const escaped = p
|
|
79
|
+
.split('*')
|
|
80
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
81
|
+
.join('.*');
|
|
82
|
+
const rx = new RegExp('^' + escaped + '$');
|
|
83
|
+
if (rx.test(n)) return true;
|
|
84
|
+
} else if (n.includes(p)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide what to do with a freshly-detected chat.
|
|
93
|
+
*
|
|
94
|
+
* Returns one of:
|
|
95
|
+
* 'import' — go ahead and add to memex.db
|
|
96
|
+
* 'skip' — user explicitly said no before
|
|
97
|
+
* 'block' — matches a block pattern, never import
|
|
98
|
+
* 'pending' — first time we've seen this chat, wait for user decision
|
|
99
|
+
*
|
|
100
|
+
* Mode controls default for unknown chats:
|
|
101
|
+
* pick → 'pending'
|
|
102
|
+
* auto → 'pending' (auto only auto-imports KNOWN allowed; new chats still wait)
|
|
103
|
+
* manual → 'pending' (manual disables the watcher entirely; this fn unused)
|
|
104
|
+
*/
|
|
105
|
+
export function decideForChat(state, chatTitle) {
|
|
106
|
+
if (isBlocked(state, chatTitle)) return 'block';
|
|
107
|
+
if (isSkipped(state, chatTitle)) return 'skip';
|
|
108
|
+
if (isAllowed(state, chatTitle)) return 'import';
|
|
109
|
+
return 'pending';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -------------------- Mutations --------------------
|
|
113
|
+
|
|
114
|
+
export function allowChat(state, title, now = new Date()) {
|
|
115
|
+
if (isAllowed(state, title)) return state;
|
|
116
|
+
// Move it out of skipped if it was there
|
|
117
|
+
state.skipped_chats = state.skipped_chats.filter((c) => norm(c.title) !== norm(title));
|
|
118
|
+
state.allowed_chats.push({ title, first_imported: now.toISOString() });
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Skip a chat title — record a per-chat decision so future re-exports
|
|
124
|
+
* of this chat are auto-skipped.
|
|
125
|
+
*
|
|
126
|
+
* IMPORTANT: if the chat is ALREADY in allowed_chats, this is a no-op
|
|
127
|
+
* on the decisions state. The user has previously committed to indexing
|
|
128
|
+
* this chat; "skip" in this case means "throw away THIS export file"
|
|
129
|
+
* (the file removal is the caller's job), NOT "block this chat forever".
|
|
130
|
+
* If the user truly wants to stop indexing a previously-allowed chat,
|
|
131
|
+
* they should use `memex telegram remove <title>` (purges from memex.db
|
|
132
|
+
* AND marks as skipped).
|
|
133
|
+
*/
|
|
134
|
+
export function skipChat(state, title, now = new Date()) {
|
|
135
|
+
if (isAllowed(state, title)) return state; // preserve prior import decision
|
|
136
|
+
if (isSkipped(state, title)) return state;
|
|
137
|
+
state.skipped_chats.push({ title, skipped_at: now.toISOString() });
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function unskipChat(state, title) {
|
|
142
|
+
state.skipped_chats = state.skipped_chats.filter((c) => norm(c.title) !== norm(title));
|
|
143
|
+
return state;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function blockPattern(state, pattern, note = '', now = new Date()) {
|
|
147
|
+
const p = String(pattern || '').trim();
|
|
148
|
+
if (!p) return state;
|
|
149
|
+
if (state.blocked_patterns.some((b) => norm(b.pattern) === norm(p))) return state;
|
|
150
|
+
state.blocked_patterns.push({ pattern: p, added_at: now.toISOString(), note });
|
|
151
|
+
return state;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function unblockPattern(state, pattern) {
|
|
155
|
+
state.blocked_patterns = state.blocked_patterns.filter((b) => norm(b.pattern) !== norm(pattern));
|
|
156
|
+
return state;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function setMode(state, mode) {
|
|
160
|
+
if (!VALID_MODES.includes(mode)) {
|
|
161
|
+
throw new Error(`Invalid mode '${mode}'. Valid: ${VALID_MODES.join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
state.mode = mode;
|
|
164
|
+
return state;
|
|
165
|
+
}
|