atris 3.58.5 → 3.58.7
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 +8 -0
- package/atris/policies/engineering-principles.md +129 -0
- package/atris/policies/genesis.md +112 -0
- package/atris/policies/product-design-principles.md +100 -0
- package/atris/skills/design/SKILL.md +3 -1
- package/atris/skills/engines/SKILL.md +3 -3
- package/atris/skills/x-search/SKILL.md +2 -2
- package/atris/skills/youtube/SKILL.md +44 -28
- package/bin/atris.js +56 -3
- package/commands/auth.js +58 -24
- package/commands/brain.js +1 -0
- package/commands/design.js +362 -0
- package/commands/doc-health.js +329 -0
- package/commands/drive.js +32 -0
- package/commands/improve.js +67 -1
- package/commands/land.js +144 -4
- package/commands/learn.js +211 -40
- package/commands/member.js +65 -11
- package/commands/mission.js +37 -7
- package/commands/pulse.js +38 -0
- package/commands/rsi.js +156 -0
- package/commands/task.js +41 -1
- package/commands/workflow.js +15 -14
- package/commands/x-search.js +9 -10
- package/commands/youtube.js +518 -107
- package/lib/apply-gate.js +22 -4
- package/lib/daily-log.js +88 -0
- package/lib/design-api.js +130 -0
- package/lib/engine-ask.js +1 -1
- package/lib/first-minute.js +1 -6
- package/lib/known-commands.js +3 -3
- package/lib/member-context.js +42 -0
- package/lib/rsi-record.js +335 -0
- package/lib/state-detection.js +8 -8
- package/lib/task-db.js +71 -51
- package/lib/task-list-keeper.js +192 -0
- package/lib/todo-fallback.js +9 -3
- package/lib/todo.js +22 -10
- package/mcp/atris-mcp/index.mjs +174 -0
- package/package.json +8 -3
- package/scripts/det/ytnotes +122 -10
- package/utils/auth.js +109 -13
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Puts finished and untouched tasks off the visible list. History stays
|
|
4
|
+
// archived. A current list costs one indexed lookup and no writes.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const taskDb = require('./task-db');
|
|
9
|
+
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
|
|
12
|
+
|
|
13
|
+
// Failed rows already leave the today screen after 7 days. They stay
|
|
14
|
+
// findable until 30 days, then they leave the list too.
|
|
15
|
+
const IDLE_LIMITS_MS = {
|
|
16
|
+
claimed: 3 * DAY_MS,
|
|
17
|
+
open: 14 * DAY_MS,
|
|
18
|
+
review: 14 * DAY_MS,
|
|
19
|
+
failed: 30 * DAY_MS,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const REASONS = {
|
|
23
|
+
done: 'finished work left the list',
|
|
24
|
+
claimed: 'claimed work sat untouched for 3 days',
|
|
25
|
+
open: 'waiting work sat untouched for 14 days',
|
|
26
|
+
review: 'review sat untouched for 14 days',
|
|
27
|
+
failed: 'failed work sat untouched for 30 days',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CANDIDATE_SQL = `
|
|
31
|
+
SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
|
|
32
|
+
FROM tasks
|
|
33
|
+
WHERE workspace_root = ? AND status = 'done'
|
|
34
|
+
UNION ALL
|
|
35
|
+
SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
|
|
36
|
+
FROM tasks
|
|
37
|
+
WHERE workspace_root = ? AND status = 'claimed' AND updated_at < ?
|
|
38
|
+
UNION ALL
|
|
39
|
+
SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
|
|
40
|
+
FROM tasks
|
|
41
|
+
WHERE workspace_root = ? AND status = 'open' AND updated_at < ?
|
|
42
|
+
UNION ALL
|
|
43
|
+
SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
|
|
44
|
+
FROM tasks
|
|
45
|
+
WHERE workspace_root = ? AND status = 'review' AND updated_at < ?
|
|
46
|
+
UNION ALL
|
|
47
|
+
SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
|
|
48
|
+
FROM tasks
|
|
49
|
+
WHERE workspace_root = ? AND status = 'failed' AND updated_at < ?
|
|
50
|
+
UNION ALL
|
|
51
|
+
SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
|
|
52
|
+
FROM tasks
|
|
53
|
+
WHERE workspace_root = ? AND tag = 'mission-blocker' AND status IN ('open', 'claimed', 'review')
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
function actorName(actor) {
|
|
57
|
+
return actor || process.env.ATRIS_AGENT_ID || process.env.USER || 'task-list';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function decisionFor(row, now) {
|
|
61
|
+
if (!row || row.status === 'archived') return null;
|
|
62
|
+
if (row.status === 'done') {
|
|
63
|
+
return { reason: REASONS.done, fromDone: true, fromFailed: false };
|
|
64
|
+
}
|
|
65
|
+
if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
|
|
66
|
+
const at = Number(row.updated_at || row.created_at || 0);
|
|
67
|
+
if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
|
|
68
|
+
return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function selectCandidates(db, workspaceRoot, now) {
|
|
72
|
+
return db.prepare(CANDIDATE_SQL).all(
|
|
73
|
+
workspaceRoot,
|
|
74
|
+
workspaceRoot, now - IDLE_LIMITS_MS.claimed,
|
|
75
|
+
workspaceRoot, now - IDLE_LIMITS_MS.open,
|
|
76
|
+
workspaceRoot, now - IDLE_LIMITS_MS.review,
|
|
77
|
+
workspaceRoot, now - IDLE_LIMITS_MS.failed,
|
|
78
|
+
workspaceRoot,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function blockerFrom(row) {
|
|
83
|
+
if (!row || row.tag !== 'mission-blocker' || !OPEN_STATUSES.has(row.status)) return null;
|
|
84
|
+
let metadata = row.metadata;
|
|
85
|
+
if (typeof metadata === 'string') {
|
|
86
|
+
try { metadata = JSON.parse(metadata); } catch { metadata = null; }
|
|
87
|
+
}
|
|
88
|
+
if (!metadata || !metadata.mission_id || !metadata.mission_blocker_class) return null;
|
|
89
|
+
return { ...row, metadata };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function loadMissions(workspaceRoot) {
|
|
93
|
+
try {
|
|
94
|
+
return require('../commands/mission').listMissions(workspaceRoot) || [];
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function noteKeep(workspaceRoot, actor, count) {
|
|
101
|
+
if (!count || !workspaceRoot || !fs.existsSync(path.join(workspaceRoot, 'atris'))) return;
|
|
102
|
+
try {
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const year = String(now.getFullYear());
|
|
105
|
+
const dir = path.join(workspaceRoot, 'atris', 'logs', year);
|
|
106
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
107
|
+
const name = `${year}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
|
|
108
|
+
const stamp = now.toTimeString().slice(0, 5);
|
|
109
|
+
fs.appendFileSync(path.join(dir, name), `## ${stamp} · Task list kept\n- count: ${count}\n- actor: ${actor}\n\n`);
|
|
110
|
+
} catch {
|
|
111
|
+
// The rows are already off the list. A missed note should not put them back.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } = {}) {
|
|
116
|
+
const who = actorName(actor);
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const toArchive = [];
|
|
119
|
+
const blockers = [];
|
|
120
|
+
for (const row of selectCandidates(db, workspaceRoot, now)) {
|
|
121
|
+
if (!row || seen.has(row.id)) continue;
|
|
122
|
+
seen.add(row.id);
|
|
123
|
+
const decision = decisionFor(row, now);
|
|
124
|
+
if (decision) {
|
|
125
|
+
toArchive.push({ id: row.id, previous_status: row.status, ...decision });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const blocker = blockerFrom(row);
|
|
129
|
+
if (blocker) blockers.push(blocker);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const missionList = Array.isArray(missions)
|
|
133
|
+
? missions
|
|
134
|
+
: (blockers.length ? loadMissions(workspaceRoot) : []);
|
|
135
|
+
if (!toArchive.length && !(blockers.length && missionList.length)) {
|
|
136
|
+
return { put_away: [], reaped: [] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const putAway = [];
|
|
140
|
+
let reaped = [];
|
|
141
|
+
db.exec('BEGIN IMMEDIATE');
|
|
142
|
+
try {
|
|
143
|
+
for (const item of toArchive) {
|
|
144
|
+
const result = taskDb.archiveTask(db, {
|
|
145
|
+
id: item.id,
|
|
146
|
+
actor: who,
|
|
147
|
+
reason: item.reason,
|
|
148
|
+
fromDone: item.fromDone,
|
|
149
|
+
fromFailed: item.fromFailed,
|
|
150
|
+
skipLogs: true,
|
|
151
|
+
});
|
|
152
|
+
if (result.archived) {
|
|
153
|
+
putAway.push({ id: item.id, previous_status: item.previous_status, reason: item.reason });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (blockers.length && missionList.length) {
|
|
157
|
+
reaped = taskDb.reapMissionBlockerTasks(db, {
|
|
158
|
+
workspaceRoot,
|
|
159
|
+
missions: missionList,
|
|
160
|
+
actor: who,
|
|
161
|
+
rows: blockers,
|
|
162
|
+
skipLogs: true,
|
|
163
|
+
}).closed || [];
|
|
164
|
+
}
|
|
165
|
+
db.exec('COMMIT');
|
|
166
|
+
} catch (error) {
|
|
167
|
+
try { db.exec('ROLLBACK'); } catch { /* the original error is the one to surface */ }
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
noteKeep(workspaceRoot, who, putAway.length + reaped.length);
|
|
171
|
+
return { put_away: putAway, reaped };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
|
|
175
|
+
const dbPath = taskDb.getDbPath();
|
|
176
|
+
if (!dbPath || !fs.existsSync(dbPath)) {
|
|
177
|
+
return { put_away: [], reaped: [], skipped: 'no_db' };
|
|
178
|
+
}
|
|
179
|
+
const db = taskDb.open();
|
|
180
|
+
return keepTaskList(db, {
|
|
181
|
+
workspaceRoot: taskDb.workspaceRoot(cwd),
|
|
182
|
+
actor,
|
|
183
|
+
now: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
DAY_MS,
|
|
189
|
+
IDLE_LIMITS_MS,
|
|
190
|
+
keepTaskList,
|
|
191
|
+
keepWorkspaceTaskList,
|
|
192
|
+
};
|
package/lib/todo-fallback.js
CHANGED
|
@@ -80,15 +80,21 @@ function parseSection(content, sectionName) {
|
|
|
80
80
|
const bracketTaskMatch = line.match(/^- \*\*\[([^\]\n]+)\]\*\*\s+(.+)$/);
|
|
81
81
|
if (bracketTaskMatch) {
|
|
82
82
|
if (current) tasks.push(current);
|
|
83
|
-
|
|
83
|
+
// Compact rows carry display-only verification previews. Never execute
|
|
84
|
+
// a clipped command; the full command lives in task show or the DB.
|
|
85
|
+
const titleOnly = content.includes('<!-- ATRIS_TODO_COMPACT:2 -->') && /^(Backlog|Completed)$/i.test(sectionName);
|
|
86
|
+
const compact = !titleOnly && bracketTaskMatch[2].match(/^(.*?) · ([^·]+?)(?: · verify: (.*))?$/);
|
|
87
|
+
const title = compact ? compact[1] : bracketTaskMatch[2];
|
|
88
|
+
const { allTags, tag } = tagsFromText(title);
|
|
84
89
|
current = {
|
|
85
90
|
id: bracketTaskMatch[1],
|
|
86
|
-
title: cleanTaskTitle(
|
|
91
|
+
title: cleanTaskTitle(title),
|
|
87
92
|
tag,
|
|
88
93
|
tags: allTags,
|
|
89
|
-
claimed: null,
|
|
94
|
+
claimed: compact && compact[2] !== 'unassigned' ? compact[2].trim() : null,
|
|
90
95
|
stage: null,
|
|
91
96
|
verify: null,
|
|
97
|
+
...(compact || titleOnly ? { verify_preview: compact ? compact[3] || null : null } : {}),
|
|
92
98
|
};
|
|
93
99
|
continue;
|
|
94
100
|
}
|
package/lib/todo.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SHIM. Same export surface as the legacy markdown parser, but reads from the
|
|
2
2
|
// SQLite task store first when ATRIS_TASK_DB=1 is set, falling back to the
|
|
3
|
-
// pure markdown parser at lib/todo-fallback.js.
|
|
3
|
+
// pure markdown parser at lib/todo-fallback.js. Compact generated boards also
|
|
4
|
+
// consult an existing store so verification previews never replace commands.
|
|
4
5
|
//
|
|
5
6
|
// All 3 callers (commands/autopilot.js, commands/status.js, commands/run.js)
|
|
6
7
|
// inherit the strangler without changing their import path.
|
|
@@ -22,12 +23,14 @@ function dbToShimRow(row) {
|
|
|
22
23
|
? metadata.verify.trim()
|
|
23
24
|
: null;
|
|
24
25
|
const claimed = row.claimed_by || metadata.claimed || null;
|
|
26
|
+
const tags = [...new Set([row.tag, ...(Array.isArray(metadata.todo_tags) ? metadata.todo_tags : []),
|
|
27
|
+
...(Array.isArray(metadata.tags) ? metadata.tags : [])].filter(Boolean))];
|
|
25
28
|
// Map DB row → the shape the existing consumers expect from parseTodo().
|
|
26
29
|
return {
|
|
27
30
|
id: row.id,
|
|
28
31
|
title: row.title,
|
|
29
|
-
tag: row.tag || null,
|
|
30
|
-
tags
|
|
32
|
+
tag: tags.includes('endgame') ? 'endgame' : (row.tag || tags[0] || null),
|
|
33
|
+
tags,
|
|
31
34
|
claimed,
|
|
32
35
|
stage: row.status === 'claimed' ? 'in_progress' : (metadata.stage || null),
|
|
33
36
|
verify,
|
|
@@ -38,25 +41,29 @@ function dbToShimRow(row) {
|
|
|
38
41
|
function dbBuckets(workspaceRoot) {
|
|
39
42
|
const taskDb = require('./task-db');
|
|
40
43
|
const db = taskDb.open();
|
|
41
|
-
const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
|
|
44
|
+
const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot, limit: 500 }));
|
|
42
45
|
// Need raw source_key for merge dedup, plus the shim shape for callers.
|
|
43
46
|
const backlog = [];
|
|
44
47
|
const inProgress = [];
|
|
45
48
|
const review = [];
|
|
46
49
|
const completed = [];
|
|
47
50
|
const sourceKeys = new Set();
|
|
51
|
+
const sourceRefs = new Set();
|
|
48
52
|
// Query directly so the shim can dedup against markdown by the strong key
|
|
49
53
|
// even if future list filters hide rows.
|
|
50
54
|
const stmt = db.prepare('SELECT id, source_key FROM tasks WHERE workspace_root = ? AND source_key IS NOT NULL');
|
|
51
55
|
for (const r of stmt.all(workspaceRoot)) sourceKeys.add(r.source_key);
|
|
52
56
|
for (const r of rows) {
|
|
57
|
+
for (const ref of [r.id, r.display_id, r.legacy_ref, r.metadata?.todo_id]) {
|
|
58
|
+
if (ref) sourceRefs.add(String(ref).toLowerCase());
|
|
59
|
+
}
|
|
53
60
|
const shaped = dbToShimRow(r);
|
|
54
61
|
if (r.status === 'open') backlog.push(shaped);
|
|
55
62
|
else if (r.status === 'claimed') inProgress.push(shaped);
|
|
56
63
|
else if (r.status === 'review') review.push(shaped);
|
|
57
64
|
else if (r.status === 'done' || r.status === 'failed') completed.push(shaped);
|
|
58
65
|
}
|
|
59
|
-
return { backlog, inProgress, review, completed, sourceKeys };
|
|
66
|
+
return { backlog, inProgress, review, completed, sourceKeys, sourceRefs };
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function mergeBuckets(dbBuck, mdBuck, todoPath) {
|
|
@@ -71,6 +78,7 @@ function mergeBuckets(dbBuck, mdBuck, todoPath) {
|
|
|
71
78
|
for (const k of ['backlog', 'inProgress', 'review', 'completed']) {
|
|
72
79
|
for (const r of dbBuck[k]) { out[k].push(r); seenTitles.add(norm(r.title)); }
|
|
73
80
|
for (const r of mdBuck[k]) {
|
|
81
|
+
if (r.id && dbBuck.sourceRefs?.has(String(r.id).toLowerCase())) continue;
|
|
74
82
|
const sk = todoPath ? taskDb.sourceKey(todoPath, r.title) : null;
|
|
75
83
|
if (sk && dbBuck.sourceKeys && dbBuck.sourceKeys.has(sk)) continue;
|
|
76
84
|
if (seenTitles.has(norm(r.title))) continue;
|
|
@@ -81,14 +89,19 @@ function mergeBuckets(dbBuck, mdBuck, todoPath) {
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
function parseTodo(todoPath) {
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
const mdBuck = fallback.parseTodoFile(todoPath);
|
|
93
|
+
const compact = Object.values(mdBuck).some(rows => rows.some(row => Object.hasOwn(row, 'verify_preview')));
|
|
94
|
+
// Compact generated boards omit full commands. Resolve them from existing
|
|
95
|
+
// durable state even without the legacy opt-in, without minting a database.
|
|
96
|
+
if (!TASK_DB_ENABLED && !compact) return mdBuck;
|
|
86
97
|
|
|
87
98
|
// DB-first merged view. Workspace scope = directory containing todoPath
|
|
88
99
|
// (or its parent), so the DB stays per-repo.
|
|
89
100
|
const path = require('path');
|
|
90
101
|
const fs = require('fs');
|
|
91
|
-
|
|
102
|
+
let taskDb;
|
|
103
|
+
try { taskDb = require('./task-db'); } catch { return mdBuck; }
|
|
104
|
+
if (!TASK_DB_ENABLED && !fs.existsSync(taskDb.getDbPath())) return mdBuck;
|
|
92
105
|
let workspaceRoot;
|
|
93
106
|
try {
|
|
94
107
|
const todoAbs = path.resolve(todoPath);
|
|
@@ -106,9 +119,8 @@ function parseTodo(todoPath) {
|
|
|
106
119
|
} catch (e) {
|
|
107
120
|
// If sqlite blew up (missing in node, perms), don't break the legacy path.
|
|
108
121
|
if (process.env.ATRIS_DEBUG) console.error('[todo shim] db read failed:', e.message);
|
|
109
|
-
return
|
|
122
|
+
return mdBuck;
|
|
110
123
|
}
|
|
111
|
-
const mdBuck = fallback.parseTodoFile(todoPath);
|
|
112
124
|
return mergeBuckets(dbBuck, mdBuck, todoPath);
|
|
113
125
|
}
|
|
114
126
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// atris mcp, a stdio Model Context Protocol server exposing the design API.
|
|
3
|
+
// Tools: design_extract, design_check, design_search.
|
|
4
|
+
// Auth resolves the same way as the CLI: ATRIS_API_KEY, then
|
|
5
|
+
// the logged-in atris token, then ~/.atris/design-api-key.
|
|
6
|
+
|
|
7
|
+
import { realpathSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import designApi from '../../lib/design-api.js';
|
|
13
|
+
|
|
14
|
+
const { resolveDesignKey, designRequest, pollDesignJob, billingOf } = designApi;
|
|
15
|
+
|
|
16
|
+
export const TOOLS = [
|
|
17
|
+
{
|
|
18
|
+
name: 'design_extract',
|
|
19
|
+
description: 'extract a site\'s design system (colors, typography, layout, voice) as json. costs 10 credits, 2 on a cache hit.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
url: { type: 'string', description: 'site url to extract, e.g. https://stripe.com' },
|
|
24
|
+
},
|
|
25
|
+
required: ['url'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'design_check',
|
|
30
|
+
description: 'score how closely a page follows a reference brand. costs 20 credits.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
source_url: { type: 'string', description: 'page being checked' },
|
|
35
|
+
reference_url: { type: 'string', description: 'brand url to check against' },
|
|
36
|
+
},
|
|
37
|
+
required: ['source_url', 'reference_url'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'design_search',
|
|
42
|
+
description: 'search brands already extracted by atris design. costs 1 credit.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
query: { type: 'string', description: 'plain words, e.g. "dark developer tools brand"' },
|
|
47
|
+
limit: { type: 'number', description: 'max results' },
|
|
48
|
+
},
|
|
49
|
+
required: ['query'],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function terminal(job) {
|
|
55
|
+
const s = String((job && job.status) || '').toLowerCase();
|
|
56
|
+
return s === 'completed' || s === 'failed' || s === 'error' || s === 'succeeded';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function withBilling(data) {
|
|
60
|
+
const bill = billingOf(data);
|
|
61
|
+
return {
|
|
62
|
+
...(data && typeof data === 'object' ? data : { result: data }),
|
|
63
|
+
credits_charged: bill.credits,
|
|
64
|
+
balance_remaining_usd: bill.balanceUsd,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ok(data) {
|
|
69
|
+
return { content: [{ type: 'text', text: JSON.stringify(withBilling(data), null, 2) }] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fail(message) {
|
|
73
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function callApi(pathname, options, key) {
|
|
77
|
+
const res = await designRequest(pathname, { ...options, key });
|
|
78
|
+
if (!res.ok) throw new Error(`http ${res.status}: ${res.error || 'request failed'}`);
|
|
79
|
+
if (!res.data || typeof res.data !== 'object') {
|
|
80
|
+
throw new Error('the design api returned an empty response');
|
|
81
|
+
}
|
|
82
|
+
return res.data;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function runJob(first, pollPath, key) {
|
|
86
|
+
if (!first || typeof first !== 'object') {
|
|
87
|
+
throw new Error('the design api returned an empty response');
|
|
88
|
+
}
|
|
89
|
+
let job = first;
|
|
90
|
+
if (!terminal(job) && !job.id) {
|
|
91
|
+
throw new Error('the design api returned no job id');
|
|
92
|
+
}
|
|
93
|
+
if (!terminal(job) && job.id) {
|
|
94
|
+
let failures = 0;
|
|
95
|
+
const polled = await pollDesignJob(async () => {
|
|
96
|
+
try {
|
|
97
|
+
const out = await callApi(pollPath, {}, key);
|
|
98
|
+
failures = 0;
|
|
99
|
+
return out;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
failures += 1;
|
|
102
|
+
if (failures >= 3) return { status: 'failed', id: job.id, error: (error && error.message) || String(error) };
|
|
103
|
+
return { status: 'polling' };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
if (polled.timedOut) {
|
|
107
|
+
throw new Error(`still running after 3 minutes. job id: ${job.id}, poll: ${pollPath}`);
|
|
108
|
+
}
|
|
109
|
+
job = polled.job || job;
|
|
110
|
+
}
|
|
111
|
+
if (job.status !== 'completed') {
|
|
112
|
+
throw new Error(`job ${job.id || 'unknown'} did not finish: ${job.error || job.status || 'unknown'} (poll: ${pollPath})`);
|
|
113
|
+
}
|
|
114
|
+
return job;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function handleTool(name, args = {}, key) {
|
|
118
|
+
if (name === 'design_extract') {
|
|
119
|
+
const url = String(args.url || '').trim();
|
|
120
|
+
if (!url) return fail('design_extract needs a url');
|
|
121
|
+
const first = await callApi('/design/extractions', { method: 'POST', body: { url } }, key);
|
|
122
|
+
return ok(await runJob(first, `/design/extractions/${first.id}`, key));
|
|
123
|
+
}
|
|
124
|
+
if (name === 'design_check') {
|
|
125
|
+
const source = String(args.source_url || '').trim();
|
|
126
|
+
const reference = String(args.reference_url || '').trim();
|
|
127
|
+
if (!source || !reference) return fail('design_check needs source_url and reference_url');
|
|
128
|
+
const first = await callApi('/design/adherence', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
body: { source_url: source, reference_url: reference },
|
|
131
|
+
}, key);
|
|
132
|
+
return ok(await runJob(first, `/design/adherence/${first.id}`, key));
|
|
133
|
+
}
|
|
134
|
+
if (name === 'design_search') {
|
|
135
|
+
const query = String(args.query || '').trim();
|
|
136
|
+
if (!query) return fail('design_search needs a query');
|
|
137
|
+
const body = { query };
|
|
138
|
+
if (Number.isInteger(args.limit) && args.limit > 0) body.limit = args.limit;
|
|
139
|
+
return ok(await callApi('/design/search', { method: 'POST', body }, key));
|
|
140
|
+
}
|
|
141
|
+
return fail(`unknown tool: ${name}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createServer() {
|
|
145
|
+
const server = new Server(
|
|
146
|
+
{ name: 'atris', version: '1.0.0' },
|
|
147
|
+
{ capabilities: { tools: {} } },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
151
|
+
|
|
152
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
153
|
+
const key = resolveDesignKey();
|
|
154
|
+
if (!key) {
|
|
155
|
+
return fail('no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)');
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return await handleTool(request.params.name, request.params.arguments || {}, key);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return fail(`design call failed: ${(error && error.message) || error}`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return server;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Auto-start only when run directly (`atris mcp`, `npx atris-mcp`,
|
|
168
|
+
// node index.mjs); realpathSync resolves the npm bin symlink.
|
|
169
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
170
|
+
if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
|
|
171
|
+
const server = createServer();
|
|
172
|
+
const transport = new StdioServerTransport();
|
|
173
|
+
await server.connect(transport);
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.58.
|
|
3
|
+
"version": "3.58.7",
|
|
4
4
|
"description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
|
|
5
5
|
"main": "bin/atris.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"atris": "bin/atris",
|
|
8
|
-
"ax": "ax"
|
|
8
|
+
"ax": "ax",
|
|
9
|
+
"atris-mcp": "mcp/atris-mcp/index.mjs"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"ax",
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
"scripts/det/",
|
|
20
21
|
"utils/",
|
|
21
22
|
"lib/",
|
|
23
|
+
"mcp/",
|
|
22
24
|
"templates/",
|
|
23
25
|
"README.md",
|
|
24
26
|
"AGENTS.md",
|
|
@@ -99,5 +101,8 @@
|
|
|
99
101
|
"bugs": {
|
|
100
102
|
"url": "https://github.com/atrislabs/atris/issues"
|
|
101
103
|
},
|
|
102
|
-
"homepage": "https://github.com/atrislabs/atris#readme"
|
|
104
|
+
"homepage": "https://github.com/atrislabs/atris#readme",
|
|
105
|
+
"dependencies": {
|
|
106
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
107
|
+
}
|
|
103
108
|
}
|
package/scripts/det/ytnotes
CHANGED
|
@@ -8,7 +8,7 @@ set -euo pipefail
|
|
|
8
8
|
USAGE="usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]"
|
|
9
9
|
|
|
10
10
|
case "${1:-}" in
|
|
11
|
-
*youtube.com*|*youtu.be*) ;;
|
|
11
|
+
*youtube.com*|*youtu.be*|*youtube-nocookie.com*) ;;
|
|
12
12
|
*) echo "$USAGE" >&2; exit 2 ;;
|
|
13
13
|
esac
|
|
14
14
|
|
|
@@ -20,24 +20,135 @@ WORK="${TMPDIR:-/tmp}/ytnotes"
|
|
|
20
20
|
mkdir -p "$WORK"
|
|
21
21
|
cd "$WORK"
|
|
22
22
|
|
|
23
|
+
# Printed metadata and a written English VTT are a hit even when yt-dlp exits 429.
|
|
24
|
+
# A leaked WARNING|ERROR print line is not the video id.
|
|
25
|
+
# Manual English is --write-subs. Auto English is often en-orig / en-US / en-GB.
|
|
26
|
+
# Teach parses leftover yt_<id>.clean.txt stamps as cues.
|
|
27
|
+
# Empty --print still keeps a VTT written in this run (id from the watch /
|
|
28
|
+
# shorts / embed / live / e / nocookie embed URL or the new filename). A copied
|
|
29
|
+
# #t= timestamp is stripped so leftover yt_<id> files still match. When VTT is
|
|
30
|
+
# gone, leftover yt_<id>.clean.txt from a prior notes run is still a keep.
|
|
31
|
+
# Another video's leftover clean.txt is not invent-keep.
|
|
32
|
+
existing=""
|
|
33
|
+
for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
|
|
34
|
+
[ -e "$name" ] || continue
|
|
35
|
+
existing="$existing $name"
|
|
36
|
+
done
|
|
37
|
+
|
|
23
38
|
META=$(yt-dlp --no-update --quiet --no-warnings --skip-download \
|
|
24
|
-
--write-auto-subs --sub-format vtt --sub-langs en \
|
|
39
|
+
--write-subs --write-auto-subs --sub-format vtt --sub-langs "en,en-orig,en-US,en-GB" \
|
|
25
40
|
--no-simulate \
|
|
26
41
|
-o "yt_%(id)s" \
|
|
27
42
|
--print "%(id)s|%(title)s|%(channel)s|%(duration_string)s" \
|
|
28
|
-
"$URL" 2>/dev/null)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
"$URL" 2>/dev/null) || true
|
|
44
|
+
# Skip WARNING/ERROR/INFO print leaks and NA/None sentinels. Keep the last
|
|
45
|
+
# id|title line so notes stay on yt_<id>.md and keep can find them after a
|
|
46
|
+
# later error. Same sentinel bar as watch/search looksLikeFlatVideoId.
|
|
47
|
+
ID=""
|
|
48
|
+
INFO=""
|
|
49
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
50
|
+
trimmed="${line#"${line%%[![:space:]]*}"}"
|
|
51
|
+
case "$trimmed" in
|
|
52
|
+
WARNING*|ERROR*|INFO*|warning*|error*|info*) continue ;;
|
|
53
|
+
esac
|
|
54
|
+
case "$trimmed" in
|
|
55
|
+
*'|'*)
|
|
56
|
+
cand="${trimmed%%|*}"
|
|
57
|
+
folded=$(printf '%s' "$cand" | tr '[:upper:]' '[:lower:]')
|
|
58
|
+
case "$folded" in
|
|
59
|
+
''|na|none) continue ;;
|
|
60
|
+
esac
|
|
61
|
+
case "$cand" in
|
|
62
|
+
*[!A-Za-z0-9_-]*) continue ;;
|
|
63
|
+
esac
|
|
64
|
+
ID="$cand"
|
|
65
|
+
INFO="${trimmed#*|}"
|
|
66
|
+
;;
|
|
67
|
+
esac
|
|
68
|
+
done <<EOF
|
|
69
|
+
$META
|
|
70
|
+
EOF
|
|
71
|
+
if [ -z "$ID" ]; then
|
|
72
|
+
case "$URL" in
|
|
73
|
+
*'?v='*|*'&v='*)
|
|
74
|
+
ID="${URL#*'?v='}"
|
|
75
|
+
if [ "$ID" = "$URL" ]; then
|
|
76
|
+
ID="${URL#*'&v='}"
|
|
77
|
+
fi
|
|
78
|
+
ID="${ID%%&*}"
|
|
79
|
+
ID="${ID%%\?*}"
|
|
80
|
+
;;
|
|
81
|
+
*'youtu.be/'*)
|
|
82
|
+
ID="${URL#*'youtu.be/'}"
|
|
83
|
+
ID="${ID%%\?*}"
|
|
84
|
+
ID="${ID%%&*}"
|
|
85
|
+
ID="${ID%%/*}"
|
|
86
|
+
;;
|
|
87
|
+
*'/shorts/'*)
|
|
88
|
+
ID="${URL#*'/shorts/'}"
|
|
89
|
+
ID="${ID%%\?*}"
|
|
90
|
+
ID="${ID%%&*}"
|
|
91
|
+
ID="${ID%%/*}"
|
|
92
|
+
;;
|
|
93
|
+
*'/embed/'*)
|
|
94
|
+
ID="${URL#*'/embed/'}"
|
|
95
|
+
ID="${ID%%\?*}"
|
|
96
|
+
ID="${ID%%&*}"
|
|
97
|
+
ID="${ID%%/*}"
|
|
98
|
+
;;
|
|
99
|
+
*'/e/'*)
|
|
100
|
+
ID="${URL#*'/e/'}"
|
|
101
|
+
ID="${ID%%\?*}"
|
|
102
|
+
ID="${ID%%&*}"
|
|
103
|
+
ID="${ID%%/*}"
|
|
104
|
+
;;
|
|
105
|
+
*'/live/'*)
|
|
106
|
+
ID="${URL#*'/live/'}"
|
|
107
|
+
ID="${ID%%\?*}"
|
|
108
|
+
ID="${ID%%&*}"
|
|
109
|
+
ID="${ID%%/*}"
|
|
110
|
+
;;
|
|
111
|
+
esac
|
|
112
|
+
ID="${ID%%#*}"
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
VTT=""
|
|
116
|
+
if [ -n "$ID" ]; then
|
|
117
|
+
for name in "yt_${ID}.en.vtt" "yt_${ID}.en-orig.vtt" "yt_${ID}.en-US.vtt" "yt_${ID}.en-GB.vtt"; do
|
|
118
|
+
if [ -s "$name" ]; then
|
|
119
|
+
VTT="$name"
|
|
120
|
+
break
|
|
121
|
+
fi
|
|
122
|
+
done
|
|
123
|
+
fi
|
|
124
|
+
if [ -z "$VTT" ]; then
|
|
125
|
+
for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
|
|
126
|
+
[ -s "$name" ] || continue
|
|
127
|
+
case " $existing " in
|
|
128
|
+
*" $name "*) continue ;;
|
|
129
|
+
esac
|
|
130
|
+
VTT="$name"
|
|
131
|
+
if [ -z "$ID" ]; then
|
|
132
|
+
ID="${name#yt_}"
|
|
133
|
+
ID="${ID%%.*}"
|
|
134
|
+
fi
|
|
135
|
+
break
|
|
136
|
+
done
|
|
137
|
+
fi
|
|
138
|
+
if [ -z "$VTT" ]; then
|
|
139
|
+
if [ -n "$ID" ] && [ -s "yt_${ID}.clean.txt" ]; then
|
|
140
|
+
: # leftover cleaned transcript from a prior notes run
|
|
141
|
+
else
|
|
142
|
+
echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
|
|
143
|
+
exit 2
|
|
144
|
+
fi
|
|
36
145
|
fi
|
|
37
146
|
|
|
38
147
|
# Strip VTT cruft, drop only nearby rolling-caption duplicates, and keep one
|
|
39
148
|
# time anchor every 30 seconds. Raw auto-captions are mostly timestamp spam:
|
|
40
149
|
# the 21-minute canary is 210 KB raw, 95 KB with the old cleaner, and 22 KB here.
|
|
150
|
+
# Leftover clean.txt is already cleaned; do not overwrite it with an empty VTT pass.
|
|
151
|
+
if [ -n "$VTT" ]; then
|
|
41
152
|
sed -E 's/<[^>]*>//g; s/>/>/g; s/&/\&/g; s/"/"/g' "$VTT" | awk '
|
|
42
153
|
function seconds(ts, parts) {
|
|
43
154
|
split(ts, parts, ":")
|
|
@@ -69,6 +180,7 @@ function seconds(ts, parts) {
|
|
|
69
180
|
for (i = 5; i > 0; i--) recent[i] = recent[i - 1]
|
|
70
181
|
recent[0] = line
|
|
71
182
|
}' > "yt_${ID}.clean.txt"
|
|
183
|
+
fi
|
|
72
184
|
|
|
73
185
|
TRANSCRIPT=$(<"yt_${ID}.clean.txt")
|
|
74
186
|
|