@sleep2agi/commhub-server 0.9.0-preview.137 → 0.9.0-preview.139
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/package.json +1 -1
- package/src/db.ts +1 -1
- package/src/human-inbox.ts +42 -0
- package/src/requirements-stats-http.test.ts +69 -0
- package/src/requirements-stats.ts +30 -2
- package/src/requirements.ts +2 -2
- package/src/tools.ts +14 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.139",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/db.ts
CHANGED
|
@@ -2228,7 +2228,7 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
|
|
|
2228
2228
|
// RFC-039 F3: queries break created_at ties with SQLite's implicit rowid
|
|
2229
2229
|
// (insertion order). PostgreSQL has none, so give these tables a real one.
|
|
2230
2230
|
if (db.dialect === "postgres") {
|
|
2231
|
-
for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
|
|
2231
|
+
for (const table of ["nodes", "user_inbox", "inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
|
|
2232
2232
|
}
|
|
2233
2233
|
|
|
2234
2234
|
// Board #625: common SQLite/PostgreSQL DDL. Adoption records authority, not processes.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
/** Metadata only: the body of an ordinary blocked task is never fetched by this scan. */
|
|
4
|
+
export function isAuthenticatedHumanInboxRow(row: { type: string; meta_json: unknown }): boolean {
|
|
5
|
+
if (row.type !== "task" && row.type !== "broadcast") return false;
|
|
6
|
+
let parsed: unknown;
|
|
7
|
+
try { parsed = typeof row.meta_json === "string" ? JSON.parse(row.meta_json) : null; }
|
|
8
|
+
catch { return false; }
|
|
9
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
10
|
+
const meta = parsed as Record<string, unknown>;
|
|
11
|
+
return meta.auth_origin === "user" && meta.source === "dashboard-chat"
|
|
12
|
+
&& typeof meta.client_request_id === "string" && /^dreq_[a-f0-9]{32}$/.test(meta.client_request_id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Caller supplies the already-authorized recipient/network WHERE clause. Both DB dialects
|
|
16
|
+
* support this scalar keyset; no JSON SQL extension, OFFSET, or arbitrary depth cutoff. */
|
|
17
|
+
export function selectHumanInboxIds(whereSql: string, params: unknown[], limit: number) {
|
|
18
|
+
const ids: string[] = [];
|
|
19
|
+
let cursor: { priority_rank: number; created_at: string; fifo_id: number } | undefined;
|
|
20
|
+
let examined = 0;
|
|
21
|
+
const rank = "CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END";
|
|
22
|
+
while (ids.length < limit) {
|
|
23
|
+
const pageParams = [...params];
|
|
24
|
+
let sql = `SELECT id, type, meta_json, created_at, rowid AS fifo_id, ${rank} AS priority_rank ${whereSql}`;
|
|
25
|
+
if (cursor) {
|
|
26
|
+
const n = pageParams.length + 1;
|
|
27
|
+
sql += ` AND (${rank}, created_at, rowid) > (?${n}, ?${n + 1}, ?${n + 2})`;
|
|
28
|
+
pageParams.push(cursor.priority_rank, cursor.created_at, cursor.fifo_id);
|
|
29
|
+
}
|
|
30
|
+
sql += ` ORDER BY ${rank}, created_at, rowid LIMIT 256`;
|
|
31
|
+
const page = db.all<{ id: string; type: string; meta_json: unknown; priority_rank: number; created_at: string; fifo_id: number }>(sql, ...pageParams);
|
|
32
|
+
if (!page.length) break;
|
|
33
|
+
for (const row of page) {
|
|
34
|
+
examined++;
|
|
35
|
+
if (isAuthenticatedHumanInboxRow(row)) ids.push(row.id);
|
|
36
|
+
if (ids.length === limit) break;
|
|
37
|
+
}
|
|
38
|
+
cursor = page[page.length - 1];
|
|
39
|
+
if (page.length < 256) break;
|
|
40
|
+
}
|
|
41
|
+
return { ids, examined };
|
|
42
|
+
}
|
|
@@ -286,3 +286,72 @@ describe("GET /api/requirements/stats", () => {
|
|
|
286
286
|
expect(r.body.requirement).toBeUndefined();
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
|
|
290
|
+
describe("tag_status current snapshot #1091", () => {
|
|
291
|
+
let project = "";
|
|
292
|
+
test("current unarchived four states, distinct memberships and separate null bucket", async () => {
|
|
293
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "tag-status" });
|
|
294
|
+
expect(p.status).toBe(201); project = p.body.project.id;
|
|
295
|
+
const seed = (name: string, extra: Record<string, unknown>) => card(admin.token, name, { project_id: project, ...extra });
|
|
296
|
+
await seed("tag-pool", { tags: ["a", "b", "a"] });
|
|
297
|
+
await seed("tag-done", { tags: ["a", "b"], column: "done" });
|
|
298
|
+
await seed("tag-doing", { tags: ["b"], column: "doing" });
|
|
299
|
+
await seed("tag-abandoned", { column: "abandoned" });
|
|
300
|
+
await seed("tag-literal", { tags: ["无标签"] });
|
|
301
|
+
const invalid = await seed("tag-invalid-legacy", { column: "done" });
|
|
302
|
+
db.run("UPDATE requirements SET tags_json='invalid legacy json' WHERE requirement_id=?1", [invalid.id]);
|
|
303
|
+
await seed("tag-visible", { tags: ["visible"], column: "doing", owner: { kind: "user", id: alice.id } });
|
|
304
|
+
await seed("tag-secret", { tags: ["secret"], column: "done" });
|
|
305
|
+
const archived = await seed("tag-archived", { tags: ["archived-only"], column: "doing" });
|
|
306
|
+
expect((await send(admin.token, "PATCH", `/api/requirements/${archived.id}?network_id=${NET}`, { archived: true })).status).toBe(200);
|
|
307
|
+
const r = await stats(admin.token, `&project_id=${project}`);
|
|
308
|
+
expect(r.status).toBe(200);
|
|
309
|
+
expect(r.body.tag_status).toMatchObject({ basis: "current", complete: true, includes_archived: false, project_filter: project, total: 8 });
|
|
310
|
+
const buckets = new Map(r.body.tag_status.by_tag.map((x: any) => [x.tag, x]));
|
|
311
|
+
expect(buckets.get("a")).toEqual({ tag: "a", pool: 1, doing: 0, done: 1, abandoned: 0, total: 2 });
|
|
312
|
+
expect(buckets.get("b")).toEqual({ tag: "b", pool: 1, doing: 1, done: 1, abandoned: 0, total: 3 });
|
|
313
|
+
expect(buckets.get(null)).toEqual({ tag: null, pool: 0, doing: 0, done: 1, abandoned: 1, total: 2 });
|
|
314
|
+
expect(buckets.get("无标签")).toMatchObject({ pool: 1, total: 1 });
|
|
315
|
+
expect(buckets.has("archived-only")).toBe(false);
|
|
316
|
+
expect(r.body.tag_status.by_tag[0].tag).toBe("b");
|
|
317
|
+
expect(r.body.tag_status.by_tag.reduce((sum: number, x: any) => sum + x.total, 0)).toBe(10);
|
|
318
|
+
});
|
|
319
|
+
test("snapshot ignores time range; completion totals still follow it", async () => {
|
|
320
|
+
const future = new Date(Date.now() + 86_400_000).toISOString();
|
|
321
|
+
const r = await stats(admin.token, `&project_id=${project}&from=${encodeURIComponent(future)}&to=${encodeURIComponent(new Date(Date.now()+2*86_400_000).toISOString())}`);
|
|
322
|
+
expect(r.body.totals.done).toBe(0); expect(r.body.tag_status.total).toBe(8);
|
|
323
|
+
expect(r.body.tag_status.by_tag.find((x: any) => x.tag === "a").done).toBe(1);
|
|
324
|
+
});
|
|
325
|
+
test("same visibility scope hides inaccessible tags and project results", async () => {
|
|
326
|
+
const r = await stats(alice.token, `&project_id=${project}`);
|
|
327
|
+
expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(1);
|
|
328
|
+
expect(r.body.tag_status.by_tag).toEqual([{ tag: "visible", pool: 0, doing: 1, done: 0, abandoned: 0, total: 1 }]);
|
|
329
|
+
expect(r.text).not.toContain('"secret"'); expect(r.text).not.toContain('"archived-only"');
|
|
330
|
+
const empty = await stats(alice.token, `&project_id=${PROJ}`);
|
|
331
|
+
expect(empty.body.tag_status.total).toBe(0); expect(empty.body.tag_status.by_tag).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
test("project none, unknown project and invalid project query", async () => {
|
|
334
|
+
const r = await stats(admin.token, "&project_id=none");
|
|
335
|
+
expect(r.body.tag_status.project_filter).toBe("none");
|
|
336
|
+
expect(r.body.tag_status.by_tag.every((x: any) => x.tag === null)).toBe(true);
|
|
337
|
+
expect((await stats(admin.token, "&project_id=proj_missing")).body.tag_status).toMatchObject({ total: 0, by_tag: [] });
|
|
338
|
+
expect((await stats(admin.token, `&project_id=${"x".repeat(201)}`)).body.error).toBe("invalid_project_id");
|
|
339
|
+
const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
340
|
+
expect(list.body.capabilities).toContain("tag_status_snapshot");
|
|
341
|
+
});
|
|
342
|
+
test("all visible rows counted beyond client 500 limit; another network excluded", async () => {
|
|
343
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "large-tag-status" });
|
|
344
|
+
expect(p.status).toBe(201); const large = p.body.project.id;
|
|
345
|
+
const other = `net_tag_foreign_${Date.now()}`;
|
|
346
|
+
db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [other,admin.id]);
|
|
347
|
+
db.transaction(() => {
|
|
348
|
+
for (let i=0;i<502;i++) db.run(`INSERT INTO requirements(requirement_id,network_id,title,column_name,tags_json,project_id,archived,created_at,updated_at)
|
|
349
|
+
VALUES(?1,?2,'bulk','pool','["bulk"]',?3,0,datetime('now'),datetime('now'))`, [`req_tag_bulk_${large}_${i}`,i===501?other:NET,large]);
|
|
350
|
+
});
|
|
351
|
+
const r = await stats(admin.token, `&project_id=${large}&recent=0`);
|
|
352
|
+
expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(501);
|
|
353
|
+
expect(r.body.tag_status.by_tag).toEqual([{ tag: "bulk", pool: 501, doing: 0, done: 0, abandoned: 0, total: 501 }]);
|
|
354
|
+
const denied = await send(alice.token,"GET",`/api/requirements/stats?network_id=${other}`);
|
|
355
|
+
expect([403,404]).toContain(denied.status);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 这里只按时间范围 / 时区分桶、按项目 / 完成者计数。放在 JS 里而不是 SQL:按 IANA 时区切「哪一天」在
|
|
3
3
|
// SQLite 和 PostgreSQL 上写法不同,而一个网络的卡片数是几百到几千,整表轻量列读一次就够。
|
|
4
4
|
import { isoInstant } from "./requirements-migrate.js";
|
|
5
|
+
import { storedTags } from "./requirement-tags.js";
|
|
5
6
|
|
|
6
7
|
export const STATS_DEFAULT_DAYS = 30;
|
|
7
8
|
export const STATS_MAX_DAYS = 371; // 53 周:够画一整年的贡献热力图
|
|
@@ -21,9 +22,10 @@ export type StatsRow = {
|
|
|
21
22
|
completed_at: string | null;
|
|
22
23
|
completed_at_approx: number | null;
|
|
23
24
|
completed_by_json: string | null;
|
|
25
|
+
tags_json?: string | null;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number };
|
|
28
|
+
export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number; projectId?: string | null };
|
|
27
29
|
type Ref = { kind: "user" | "node"; id: string };
|
|
28
30
|
|
|
29
31
|
/** 解析 from / to / tz / days。不合法 → 错误码(400)。to 缺省 = 现在;from 缺省 = 不设下限(全部)。 */
|
|
@@ -48,7 +50,10 @@ export function parseStatsQuery(q: URLSearchParams, nowMs: number): StatsQuery |
|
|
|
48
50
|
const recentRaw = q.get("recent");
|
|
49
51
|
const recent = recentRaw === null || recentRaw === "" ? STATS_DEFAULT_RECENT : Number(recentRaw);
|
|
50
52
|
if (!Number.isInteger(recent) || recent < 0 || recent > STATS_MAX_RECENT) return { error: "invalid_recent" };
|
|
51
|
-
|
|
53
|
+
const project = q.get("project_id");
|
|
54
|
+
if (project && (project.length > 200 || /[\u0000-\u001f\u007f]/u.test(project))) return { error: "invalid_project_id" };
|
|
55
|
+
return { fromMs: from, toMs, tz, days, recent,
|
|
56
|
+
...(project ? { projectId: project === "none" ? null : project } : {}) };
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function validTimeZone(tz: string): boolean {
|
|
@@ -86,6 +91,7 @@ const parseRef = (json: string | null): Ref | null => {
|
|
|
86
91
|
* 但归到 unattributed,不出现在 by_completer 里 —— 不给他留「那个节点做了几张」的探测口。
|
|
87
92
|
*/
|
|
88
93
|
export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId: string) => boolean) | null) {
|
|
94
|
+
if (q.projectId !== undefined) rows = rows.filter(row => row.project_id === q.projectId);
|
|
89
95
|
const inRange = (ms: number | null) => ms !== null && ms <= q.toMs && (q.fromMs === null || ms >= q.fromMs);
|
|
90
96
|
const days = dayRange(localDate(q.toMs, q.tz), q.days);
|
|
91
97
|
const firstDay = days[0];
|
|
@@ -97,8 +103,21 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
97
103
|
const byProject = new Map<string | null, number>();
|
|
98
104
|
const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
|
|
99
105
|
const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
|
|
106
|
+
const byTag = new Map<string | null, { tag: string | null; pool: number; doing: number; done: number; abandoned: number; total: number }>();
|
|
107
|
+
let snapshotTotal = 0;
|
|
100
108
|
|
|
101
109
|
for (const row of rows) {
|
|
110
|
+
if (!row.archived && ["pool", "doing", "done", "abandoned"].includes(row.column_name)) {
|
|
111
|
+
snapshotTotal++;
|
|
112
|
+
const tags: (string | null)[] = storedTags(row.tags_json ?? null);
|
|
113
|
+
if (!tags.length) tags.push(null);
|
|
114
|
+
for (const tag of tags) {
|
|
115
|
+
let bucket = byTag.get(tag);
|
|
116
|
+
if (!bucket) byTag.set(tag, bucket = { tag, pool: 0, doing: 0, done: 0, abandoned: 0, total: 0 });
|
|
117
|
+
bucket[row.column_name as "pool" | "doing" | "done" | "abandoned"]++;
|
|
118
|
+
bucket.total++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
102
121
|
// 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
|
|
103
122
|
if (!row.archived && row.column_name === "doing") doing++;
|
|
104
123
|
if (!row.archived && row.column_name === "pool") pool++;
|
|
@@ -133,6 +152,15 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
133
152
|
|
|
134
153
|
const spark = days.slice(-STATS_SPARK_DAYS);
|
|
135
154
|
return {
|
|
155
|
+
tag_status: {
|
|
156
|
+
basis: "current", complete: true, includes_archived: false,
|
|
157
|
+
project_filter: q.projectId === undefined ? null : q.projectId ?? "none",
|
|
158
|
+
total: snapshotTotal,
|
|
159
|
+
// Membership counts: a multi-tag card counts once in each distinct tag.
|
|
160
|
+
by_tag: [...byTag.values()].sort((a, b) =>
|
|
161
|
+
(b.pool + b.doing) - (a.pool + a.doing) || b.total - a.total ||
|
|
162
|
+
String(a.tag ?? "\uffff").localeCompare(String(b.tag ?? "\uffff"))),
|
|
163
|
+
},
|
|
136
164
|
range: { from: q.fromMs === null ? null : new Date(q.fromMs).toISOString(), to: new Date(q.toMs).toISOString(), tz: q.tz },
|
|
137
165
|
totals: {
|
|
138
166
|
done,
|
package/src/requirements.ts
CHANGED
|
@@ -770,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
770
770
|
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
771
771
|
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
772
772
|
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
773
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
773
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
774
774
|
|
|
775
775
|
// ── 子需求 ──
|
|
776
776
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -1584,7 +1584,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1584
1584
|
if ("error" in sq) return jsonError(sq.error, 400);
|
|
1585
1585
|
const statsParams: unknown[] = [networkId];
|
|
1586
1586
|
const statsSql = addTaskVisibilityScope(
|
|
1587
|
-
"SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
|
|
1587
|
+
"SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json, tags_json FROM requirements WHERE network_id = ?1",
|
|
1588
1588
|
statsParams, taskCaller(ctx),
|
|
1589
1589
|
);
|
|
1590
1590
|
const rows = db.all<StatsRow>(statsSql, ...statsParams);
|
package/src/tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectHumanInboxIds } from "./human-inbox.js";
|
|
1
2
|
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { markTaskActivity } from "./task-activity.js";
|
|
@@ -1427,13 +1428,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1427
1428
|
|
|
1428
1429
|
server.tool(
|
|
1429
1430
|
"get_inbox",
|
|
1430
|
-
"Read
|
|
1431
|
+
"Read inbox.",
|
|
1431
1432
|
{
|
|
1432
|
-
alias: z.string().min(1).max(200)
|
|
1433
|
+
alias: z.string().min(1).max(200),
|
|
1433
1434
|
limit: z.number().min(1).max(100).optional().default(10),
|
|
1434
1435
|
network_id: z.string().max(200).optional(),
|
|
1436
|
+
human_first: z.boolean().optional(),
|
|
1435
1437
|
},
|
|
1436
|
-
async ({ alias, limit, network_id: netId }) => {
|
|
1438
|
+
async ({ alias, limit, network_id: netId, human_first }) => {
|
|
1437
1439
|
const effectiveNetId = getNetworkId(netId);
|
|
1438
1440
|
const recipient = resolveInboxCaller(alias, { tokenId: callerTokenId, userId: enforceUserId, networkId: effectiveNetId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected });
|
|
1439
1441
|
if (!recipient.ok) return { content: [{ type: "text" as const, text: JSON.stringify(recipient) }] };
|
|
@@ -1445,13 +1447,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1445
1447
|
countSql = addReadScope(countSql, countParams, readScope);
|
|
1446
1448
|
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
1447
1449
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
1450
|
+
const scanParams: any[] = [alias];
|
|
1451
|
+
const scanWhere = addReadScope("FROM inbox WHERE session_name = ?1 AND acked = 0", scanParams, readScope);
|
|
1452
|
+
const human = human_first ? selectHumanInboxIds(scanWhere, scanParams, limit) : undefined;
|
|
1448
1453
|
const rowsParams: any[] = [alias];
|
|
1449
1454
|
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
|
|
1450
1455
|
CASE WHEN EXISTS(SELECT 1 FROM task_late_replies lr WHERE lr.inbox_id=inbox.id) THEN 1 ELSE 0 END AS late,
|
|
1451
1456
|
CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
|
|
1452
1457
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
1453
1458
|
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
1454
|
-
|
|
1459
|
+
if (human?.ids.length) {
|
|
1460
|
+
const placeholders = human.ids.map((id) => { rowsParams.push(id); return `?${rowsParams.length}`; });
|
|
1461
|
+
rowsSql += ` ORDER BY CASE WHEN id IN (${placeholders.join(",")}) THEN 0 ELSE 1 END,`;
|
|
1462
|
+
} else rowsSql += " ORDER BY";
|
|
1463
|
+
rowsSql += ` CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at, rowid
|
|
1455
1464
|
LIMIT ?${rowsParams.length + 1}`;
|
|
1456
1465
|
rowsParams.push(limit);
|
|
1457
1466
|
const rows = db.all(rowsSql, ...rowsParams).map((row: any) => {
|
|
@@ -1461,7 +1470,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1461
1470
|
});
|
|
1462
1471
|
|
|
1463
1472
|
return {
|
|
1464
|
-
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
1473
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows, ...(human ? { human_inbox: { version: 1, mode: "metadata-keyset", examined: human.examined } } : {}) }) }],
|
|
1465
1474
|
};
|
|
1466
1475
|
}
|
|
1467
1476
|
);
|