mcp-agents-memory 0.9.23 → 0.9.25
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/build/index.js +603 -189
- package/build/migrations/030_project_tag_new_suggestions.js +26013 -0
- package/package.json +4 -2
package/build/index.js
CHANGED
|
@@ -56411,6 +56411,290 @@ function getPrompt(name, fallback) {
|
|
|
56411
56411
|
return fallback;
|
|
56412
56412
|
}
|
|
56413
56413
|
|
|
56414
|
+
// src/cold_path/dtag_promoter.ts
|
|
56415
|
+
init_db();
|
|
56416
|
+
init_users();
|
|
56417
|
+
|
|
56418
|
+
// src/cold_path/dtag_suggest_gate.ts
|
|
56419
|
+
function normalizeTag(name) {
|
|
56420
|
+
return String(name ?? "").toLowerCase().trim();
|
|
56421
|
+
}
|
|
56422
|
+
function parseRecommendation(value) {
|
|
56423
|
+
return value === "project" || value === "generic" || value === "unsure" ? value : null;
|
|
56424
|
+
}
|
|
56425
|
+
function planApplyRecommendations(ids, rows) {
|
|
56426
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
56427
|
+
const plan = { confirm: [], reject: [], keep: [], skipped: [] };
|
|
56428
|
+
for (const id of [...new Set(ids)]) {
|
|
56429
|
+
const row = byId.get(id);
|
|
56430
|
+
if (!row || row.status !== "pending") plan.skipped.push(id);
|
|
56431
|
+
else if (row.recommendation === "project") plan.confirm.push(id);
|
|
56432
|
+
else if (row.recommendation === "generic") plan.reject.push(id);
|
|
56433
|
+
else plan.keep.push(id);
|
|
56434
|
+
}
|
|
56435
|
+
return plan;
|
|
56436
|
+
}
|
|
56437
|
+
|
|
56438
|
+
// src/cold_path/dtag_promoter.ts
|
|
56439
|
+
function envInt(name, fallback) {
|
|
56440
|
+
const raw = process.env[name];
|
|
56441
|
+
if (!raw) return fallback;
|
|
56442
|
+
const n = Number(raw);
|
|
56443
|
+
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
|
56444
|
+
}
|
|
56445
|
+
var CLASSIFY_SYSTEM = `You review keyword tags from a personal work-memory system and decide whether a tag should become a PROJECT tag.
|
|
56446
|
+
|
|
56447
|
+
A PROJECT tag names one specific thing the user's own work is organized around: their product, app, service, brand, company/client, YouTube channel, code repository, or named initiative.
|
|
56448
|
+
NOT a project: generic activities or topics (bug, fix, upload, login, review, approval, logging, error-handling, translation, policy), feature or UI words, and third-party tools/platforms/OSes the user merely uses (windows, ubuntu, photoshop, cloudflare, playwright, app-store) \u2014 unless the samples show the user is building something with that exact name.
|
|
56449
|
+
|
|
56450
|
+
Answer:
|
|
56451
|
+
- "project": clearly a specific named thing of the user's own work.
|
|
56452
|
+
- "generic": clearly a generic word or a third-party tool merely used.
|
|
56453
|
+
- "unsure": a specific name, but the samples do not make clear whether it is the user's own project or just a tool/platform they use.
|
|
56454
|
+
Never answer "generic" for a specific proper name (a brand, product, app or company name) \u2014 use "unsure" instead.
|
|
56455
|
+
reason: one short sentence (max 60 characters) in the same language as the samples.
|
|
56456
|
+
|
|
56457
|
+
OUTPUT strict JSON object: { "verdict": "project" | "generic" | "unsure", "reason": "<short sentence>" }`;
|
|
56458
|
+
var CLASSIFY_SCHEMA = {
|
|
56459
|
+
type: "object",
|
|
56460
|
+
properties: {
|
|
56461
|
+
verdict: { type: "string", enum: ["project", "generic", "unsure"] },
|
|
56462
|
+
reason: { type: "string" }
|
|
56463
|
+
},
|
|
56464
|
+
required: ["verdict", "reason"],
|
|
56465
|
+
additionalProperties: false
|
|
56466
|
+
};
|
|
56467
|
+
var CLASSIFY_SAMPLE_LIMIT = 3;
|
|
56468
|
+
var CLASSIFY_SAMPLE_CHARS = 160;
|
|
56469
|
+
async function classifyCandidate(userId, name, uses, windowDays) {
|
|
56470
|
+
try {
|
|
56471
|
+
const samples = await db.query(
|
|
56472
|
+
`SELECT left(regexp_replace(message, '\\s+', ' ', 'g'), $3) AS preview
|
|
56473
|
+
FROM memory
|
|
56474
|
+
WHERE user_id = $1
|
|
56475
|
+
AND d_tag @> ARRAY[$2]::text[]
|
|
56476
|
+
AND role = 'user'
|
|
56477
|
+
AND is_active = TRUE
|
|
56478
|
+
ORDER BY created_at DESC
|
|
56479
|
+
LIMIT $4`,
|
|
56480
|
+
[userId, name, CLASSIFY_SAMPLE_CHARS, CLASSIFY_SAMPLE_LIMIT]
|
|
56481
|
+
);
|
|
56482
|
+
const lines = samples.rows.map((r) => `- ${r.preview}`).join("\n") || "(none)";
|
|
56483
|
+
const raw = await callRole("clusterer", {
|
|
56484
|
+
system: CLASSIFY_SYSTEM,
|
|
56485
|
+
user: `tag: ${name}
|
|
56486
|
+
uses (${windowDays} days): ${uses}
|
|
56487
|
+
recent user messages with this tag:
|
|
56488
|
+
${lines}`,
|
|
56489
|
+
responseFormat: "json",
|
|
56490
|
+
// local 외 프로바이더용 (local은 jsonSchema가 우선)
|
|
56491
|
+
jsonSchema: CLASSIFY_SCHEMA,
|
|
56492
|
+
enableThinking: false,
|
|
56493
|
+
maxTokens: 200
|
|
56494
|
+
});
|
|
56495
|
+
const obj = JSON.parse(raw);
|
|
56496
|
+
const recommendation = parseRecommendation(obj?.verdict);
|
|
56497
|
+
if (!recommendation) throw new Error(`unexpected verdict: ${String(obj?.verdict).slice(0, 40)}`);
|
|
56498
|
+
return { recommendation, rationale: String(obj?.reason ?? "").slice(0, 300) };
|
|
56499
|
+
} catch (err3) {
|
|
56500
|
+
console.error(`\u26A0\uFE0F [DTagPromoter] classify "${name}" failed, will retry next run:`, err3);
|
|
56501
|
+
return null;
|
|
56502
|
+
}
|
|
56503
|
+
}
|
|
56504
|
+
async function supersedeExisting(userId) {
|
|
56505
|
+
const result = await db.query(
|
|
56506
|
+
`UPDATE project_tag_new_suggestions s
|
|
56507
|
+
SET status = 'superseded',
|
|
56508
|
+
decided_by = 'system',
|
|
56509
|
+
decision_reason = 'project tag already exists',
|
|
56510
|
+
decided_at = NOW(),
|
|
56511
|
+
project_tag_id = pt.id,
|
|
56512
|
+
updated_at = NOW()
|
|
56513
|
+
FROM project_tags pt
|
|
56514
|
+
WHERE s.user_id = $1
|
|
56515
|
+
AND s.status = 'pending'
|
|
56516
|
+
AND pt.name = s.name
|
|
56517
|
+
RETURNING s.id`,
|
|
56518
|
+
[userId]
|
|
56519
|
+
);
|
|
56520
|
+
return result.rows.length;
|
|
56521
|
+
}
|
|
56522
|
+
async function retrotag(client2, userId, pTagId, tag) {
|
|
56523
|
+
const updated = await client2.query(
|
|
56524
|
+
`UPDATE memory
|
|
56525
|
+
SET p_tag_id = $1, updated_at = NOW()
|
|
56526
|
+
WHERE user_id = $2
|
|
56527
|
+
AND tag_processed = TRUE
|
|
56528
|
+
AND p_tag_id IS NULL
|
|
56529
|
+
AND d_tag @> ARRAY[$3]::text[]
|
|
56530
|
+
RETURNING id`,
|
|
56531
|
+
[pTagId, userId, tag]
|
|
56532
|
+
);
|
|
56533
|
+
return updated.rows.length;
|
|
56534
|
+
}
|
|
56535
|
+
async function isBlockedNewTagName(name) {
|
|
56536
|
+
try {
|
|
56537
|
+
const r = await db.query(
|
|
56538
|
+
`SELECT 1 FROM project_tag_new_suggestions WHERE name = $1 AND status IN ('pending', 'rejected') LIMIT 1`,
|
|
56539
|
+
[normalizeTag(name)]
|
|
56540
|
+
);
|
|
56541
|
+
return r.rows.length > 0;
|
|
56542
|
+
} catch {
|
|
56543
|
+
return false;
|
|
56544
|
+
}
|
|
56545
|
+
}
|
|
56546
|
+
async function runDtagPromotion(opts = {}) {
|
|
56547
|
+
const dryRun = opts.dryRun === true;
|
|
56548
|
+
const minCount = envInt("DTAG_PROMOTE_MIN_COUNT", 10);
|
|
56549
|
+
const windowDays = envInt("DTAG_PROMOTE_WINDOW_DAYS", 30);
|
|
56550
|
+
const userId = await getDefaultUserId();
|
|
56551
|
+
const summary = { suggested: [], refreshed: 0, blocked: 0, deferred: 0, superseded: 0, retrotagged: 0 };
|
|
56552
|
+
if (!dryRun) summary.superseded = await supersedeExisting(userId);
|
|
56553
|
+
const freqResult = await db.query(
|
|
56554
|
+
`SELECT unnest(d_tag) AS tag, COUNT(*)::int AS cnt
|
|
56555
|
+
FROM memory
|
|
56556
|
+
WHERE user_id = $1
|
|
56557
|
+
AND tag_processed = TRUE
|
|
56558
|
+
AND is_active = TRUE
|
|
56559
|
+
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
|
|
56560
|
+
GROUP BY tag
|
|
56561
|
+
HAVING COUNT(*) >= 2
|
|
56562
|
+
ORDER BY cnt DESC
|
|
56563
|
+
LIMIT 50`,
|
|
56564
|
+
[userId, String(windowDays)]
|
|
56565
|
+
);
|
|
56566
|
+
const candidates = freqResult.rows.map((r) => ({ tag: normalizeTag(r.tag), cnt: Number(r.cnt) })).filter((t) => t.tag.length > 0 && t.cnt >= minCount);
|
|
56567
|
+
if (candidates.length === 0) return summary;
|
|
56568
|
+
const names = candidates.map((c) => c.tag);
|
|
56569
|
+
const existing = new Map((await db.query(
|
|
56570
|
+
`SELECT name, id FROM project_tags WHERE name = ANY($1::text[])`,
|
|
56571
|
+
[names]
|
|
56572
|
+
)).rows.map((r) => [String(r.name), Number(r.id)]));
|
|
56573
|
+
const prior = new Map((await db.query(
|
|
56574
|
+
`SELECT name, id, status FROM project_tag_new_suggestions WHERE user_id = $1 AND name = ANY($2::text[])`,
|
|
56575
|
+
[userId, names]
|
|
56576
|
+
)).rows.map((r) => [String(r.name), { id: Number(r.id), status: String(r.status) }]));
|
|
56577
|
+
for (const { tag, cnt } of candidates) {
|
|
56578
|
+
const row = prior.get(tag);
|
|
56579
|
+
if (row?.status === "rejected") {
|
|
56580
|
+
summary.blocked++;
|
|
56581
|
+
continue;
|
|
56582
|
+
}
|
|
56583
|
+
const pTagId = existing.get(tag);
|
|
56584
|
+
if (pTagId != null) {
|
|
56585
|
+
if (dryRun) continue;
|
|
56586
|
+
const n = await retrotag(db, userId, pTagId, tag);
|
|
56587
|
+
if (n > 0) {
|
|
56588
|
+
summary.retrotagged += n;
|
|
56589
|
+
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] retrotagged ${n} rows with "${tag}"`);
|
|
56590
|
+
}
|
|
56591
|
+
continue;
|
|
56592
|
+
}
|
|
56593
|
+
if (row) {
|
|
56594
|
+
if (row.status !== "pending") {
|
|
56595
|
+
summary.blocked++;
|
|
56596
|
+
continue;
|
|
56597
|
+
}
|
|
56598
|
+
if (dryRun) continue;
|
|
56599
|
+
const touched = await db.query(
|
|
56600
|
+
`UPDATE project_tag_new_suggestions
|
|
56601
|
+
SET uses = $2, last_seen_at = NOW(), updated_at = NOW()
|
|
56602
|
+
WHERE id = $1 AND status = 'pending'
|
|
56603
|
+
RETURNING id`,
|
|
56604
|
+
[row.id, cnt]
|
|
56605
|
+
);
|
|
56606
|
+
if (touched.rows.length > 0) summary.refreshed++;
|
|
56607
|
+
continue;
|
|
56608
|
+
}
|
|
56609
|
+
const verdict = await classifyCandidate(userId, tag, cnt, windowDays);
|
|
56610
|
+
if (!verdict) {
|
|
56611
|
+
summary.deferred++;
|
|
56612
|
+
continue;
|
|
56613
|
+
}
|
|
56614
|
+
const preview = { name: tag, uses: cnt, ...verdict };
|
|
56615
|
+
if (!dryRun) {
|
|
56616
|
+
const spec = ROLE_REGISTRY.clusterer;
|
|
56617
|
+
const inserted = await db.query(
|
|
56618
|
+
`INSERT INTO project_tag_new_suggestions
|
|
56619
|
+
(user_id, name, uses, recommendation, rationale, model_provider, model_name)
|
|
56620
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
56621
|
+
ON CONFLICT (user_id, name) DO NOTHING
|
|
56622
|
+
RETURNING id`,
|
|
56623
|
+
[userId, preview.name, preview.uses, preview.recommendation, preview.rationale, spec.provider, spec.model_name]
|
|
56624
|
+
);
|
|
56625
|
+
if (inserted.rows.length === 0) continue;
|
|
56626
|
+
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] suggested "${preview.name}" (uses=${preview.uses}, recommendation=${preview.recommendation})`);
|
|
56627
|
+
}
|
|
56628
|
+
summary.suggested.push(preview);
|
|
56629
|
+
}
|
|
56630
|
+
return summary;
|
|
56631
|
+
}
|
|
56632
|
+
async function confirmNewTagSuggestion(userId, suggestionId, opts) {
|
|
56633
|
+
const client2 = await db.getClient();
|
|
56634
|
+
try {
|
|
56635
|
+
await client2.query("BEGIN");
|
|
56636
|
+
const row = await client2.query(
|
|
56637
|
+
`SELECT id, name, status FROM project_tag_new_suggestions
|
|
56638
|
+
WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
56639
|
+
[suggestionId, userId]
|
|
56640
|
+
);
|
|
56641
|
+
if (row.rows.length === 0) {
|
|
56642
|
+
await client2.query("ROLLBACK");
|
|
56643
|
+
return { suggestion_id: suggestionId, name: "", status: "missing", error: "suggestion_id not found for current user" };
|
|
56644
|
+
}
|
|
56645
|
+
const { name, status } = row.rows[0];
|
|
56646
|
+
if (status !== "pending") {
|
|
56647
|
+
await client2.query("ROLLBACK");
|
|
56648
|
+
return { suggestion_id: suggestionId, name, status, error: "suggestion is not pending" };
|
|
56649
|
+
}
|
|
56650
|
+
const inserted = await client2.query(
|
|
56651
|
+
`INSERT INTO project_tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id`,
|
|
56652
|
+
[name]
|
|
56653
|
+
);
|
|
56654
|
+
const created = inserted.rows.length > 0;
|
|
56655
|
+
const pTagId = created ? Number(inserted.rows[0].id) : Number((await client2.query(`SELECT id FROM project_tags WHERE name = $1`, [name])).rows[0].id);
|
|
56656
|
+
const n = await retrotag(client2, userId, pTagId, name);
|
|
56657
|
+
await client2.query(
|
|
56658
|
+
`UPDATE project_tag_new_suggestions
|
|
56659
|
+
SET status = 'confirmed', decided_by = $2, decision_reason = $3, decided_at = NOW(),
|
|
56660
|
+
project_tag_id = $4, retrotagged = $5, updated_at = NOW()
|
|
56661
|
+
WHERE id = $1`,
|
|
56662
|
+
[suggestionId, opts.decidedBy, opts.reason ?? null, pTagId, n]
|
|
56663
|
+
);
|
|
56664
|
+
await client2.query("COMMIT");
|
|
56665
|
+
invalidateCandidateCache();
|
|
56666
|
+
return { suggestion_id: suggestionId, name, status: "confirmed", project_tag_id: pTagId, created, retrotagged: n };
|
|
56667
|
+
} catch (err3) {
|
|
56668
|
+
try {
|
|
56669
|
+
await client2.query("ROLLBACK");
|
|
56670
|
+
} catch {
|
|
56671
|
+
}
|
|
56672
|
+
throw err3;
|
|
56673
|
+
} finally {
|
|
56674
|
+
client2.release();
|
|
56675
|
+
}
|
|
56676
|
+
}
|
|
56677
|
+
async function rejectNewTagSuggestion(userId, suggestionId, opts) {
|
|
56678
|
+
const result = await db.query(
|
|
56679
|
+
`UPDATE project_tag_new_suggestions
|
|
56680
|
+
SET status = 'rejected', decided_by = $3, decision_reason = $4, decided_at = NOW(), updated_at = NOW()
|
|
56681
|
+
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
|
56682
|
+
RETURNING id, name, status`,
|
|
56683
|
+
[suggestionId, userId, opts.decidedBy, opts.reason ?? null]
|
|
56684
|
+
);
|
|
56685
|
+
if (result.rows.length > 0) {
|
|
56686
|
+
return { suggestion_id: suggestionId, name: String(result.rows[0].name), status: "rejected" };
|
|
56687
|
+
}
|
|
56688
|
+
const row = await db.query(
|
|
56689
|
+
`SELECT name, status FROM project_tag_new_suggestions WHERE id = $1 AND user_id = $2`,
|
|
56690
|
+
[suggestionId, userId]
|
|
56691
|
+
);
|
|
56692
|
+
if (row.rows.length === 0) {
|
|
56693
|
+
return { suggestion_id: suggestionId, name: "", status: "missing", error: "suggestion_id not found for current user" };
|
|
56694
|
+
}
|
|
56695
|
+
return { suggestion_id: suggestionId, name: String(row.rows[0].name), status: String(row.rows[0].status), error: "suggestion is not pending" };
|
|
56696
|
+
}
|
|
56697
|
+
|
|
56414
56698
|
// src/cold_path/tagger.ts
|
|
56415
56699
|
var GROK_FALLBACK_SPEC = { provider: "xai", model_name: "grok-4-1-fast-non-reasoning" };
|
|
56416
56700
|
var localFallbackEnabled = process.env.LOCAL_GROK_FALLBACK !== "false";
|
|
@@ -56571,7 +56855,9 @@ async function tagMessage(input) {
|
|
|
56571
56855
|
if (parsed.p_tag && typeof parsed.p_tag === "string") {
|
|
56572
56856
|
if (parsed.p_tag.startsWith("NEW:")) {
|
|
56573
56857
|
const newName = parsed.p_tag.slice(4).trim();
|
|
56574
|
-
if (newName) {
|
|
56858
|
+
if (newName && await isBlockedNewTagName(newName)) {
|
|
56859
|
+
console.error(`\u26A0\uFE0F [Tagger] "${newName}"\uC740(\uB294) \uC0C8 \uD0DC\uADF8 \uC81C\uC548\uC5D0\uC11C \uC0AC\uB78C\uC758 \uACB0\uC815\uC744 \uAE30\uB2E4\uB9AC\uAC70\uB098 \uBC18\uB824\uB41C \uC774\uB984\uC774\uB77C \uB9CC\uB4E4\uC9C0 \uC54A\uC74C. p_tag NULL.`);
|
|
56860
|
+
} else if (newName) {
|
|
56575
56861
|
p_tag_id = await getOrCreateProjectTag(newName);
|
|
56576
56862
|
newly_created_p_tag_name = newName;
|
|
56577
56863
|
}
|
|
@@ -57524,6 +57810,51 @@ init_users();
|
|
|
57524
57810
|
// src/cold_path/project_alias_promoter.ts
|
|
57525
57811
|
init_db();
|
|
57526
57812
|
init_users();
|
|
57813
|
+
|
|
57814
|
+
// src/cold_path/alias_reject_gate.ts
|
|
57815
|
+
function pairKey(a, b) {
|
|
57816
|
+
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
57817
|
+
}
|
|
57818
|
+
function groupPairHistory(rows) {
|
|
57819
|
+
const out = /* @__PURE__ */ new Map();
|
|
57820
|
+
for (const r of rows) {
|
|
57821
|
+
if (r.sourceId === r.targetId) continue;
|
|
57822
|
+
const k = pairKey(r.sourceId, r.targetId);
|
|
57823
|
+
const list = out.get(k);
|
|
57824
|
+
if (list) list.push(r);
|
|
57825
|
+
else out.set(k, [r]);
|
|
57826
|
+
}
|
|
57827
|
+
return out;
|
|
57828
|
+
}
|
|
57829
|
+
var isUserRejection = (r) => r.status === "rejected" && r.decidedBy === "user";
|
|
57830
|
+
function userRejectedDirection(rows, sourceId, targetId) {
|
|
57831
|
+
let hit = null;
|
|
57832
|
+
for (const r of rows ?? []) {
|
|
57833
|
+
if (!isUserRejection(r) || r.sourceId !== sourceId || r.targetId !== targetId) continue;
|
|
57834
|
+
if (!hit || r.decidedAt.getTime() > hit.decidedAt.getTime()) hit = r;
|
|
57835
|
+
}
|
|
57836
|
+
return hit ? hit.id : null;
|
|
57837
|
+
}
|
|
57838
|
+
function skipBeforeJudge(rows) {
|
|
57839
|
+
const all = rows ?? [];
|
|
57840
|
+
const rejections = all.filter(isUserRejection);
|
|
57841
|
+
if (rejections.length === 0) return false;
|
|
57842
|
+
const { sourceId: a, targetId: b } = rejections[0];
|
|
57843
|
+
if (userRejectedDirection(all, a, b) !== null && userRejectedDirection(all, b, a) !== null) return true;
|
|
57844
|
+
const lastRejection = Math.max(...rejections.map((r) => r.decidedAt.getTime()));
|
|
57845
|
+
return all.some((r) => !isUserRejection(r) && r.createdAt.getTime() > lastRejection);
|
|
57846
|
+
}
|
|
57847
|
+
function repeatsToSweep(rows) {
|
|
57848
|
+
const out = [];
|
|
57849
|
+
for (const r of rows ?? []) {
|
|
57850
|
+
if (r.status !== "pending" || r.manual) continue;
|
|
57851
|
+
const rejectedId = userRejectedDirection(rows, r.sourceId, r.targetId);
|
|
57852
|
+
if (rejectedId !== null) out.push({ pendingId: r.id, rejectedId });
|
|
57853
|
+
}
|
|
57854
|
+
return out;
|
|
57855
|
+
}
|
|
57856
|
+
|
|
57857
|
+
// src/cold_path/project_alias_promoter.ts
|
|
57527
57858
|
var QUALIFYING_RELATIONS = /* @__PURE__ */ new Set([
|
|
57528
57859
|
"rename",
|
|
57529
57860
|
"alias",
|
|
@@ -57595,7 +57926,7 @@ OUTPUT strict JSON only:
|
|
|
57595
57926
|
- If only a few memories are filed under the wrong tag, use "misfile_suspected".
|
|
57596
57927
|
- evidence_memory_ids and conflict_memory_ids must only use supplied memory ids.`;
|
|
57597
57928
|
var JUDGE_SYSTEM_PROMPT = getPrompt("alias_judge", GENERIC_ALIAS_JUDGE_PROMPT);
|
|
57598
|
-
function
|
|
57929
|
+
function envInt2(name, fallback) {
|
|
57599
57930
|
const raw = process.env[name];
|
|
57600
57931
|
if (!raw) return fallback;
|
|
57601
57932
|
const n = Number(raw);
|
|
@@ -57675,9 +58006,6 @@ function nameSimilarity(a, b) {
|
|
|
57675
58006
|
const dice = bigramDice(a, b);
|
|
57676
58007
|
return Math.max(tokenScore, dice);
|
|
57677
58008
|
}
|
|
57678
|
-
function pairKey(a, b) {
|
|
57679
|
-
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
57680
|
-
}
|
|
57681
58009
|
function uniqueSortedNumbers(values) {
|
|
57682
58010
|
return Array.from(new Set(values)).sort((a, b) => a - b);
|
|
57683
58011
|
}
|
|
@@ -57693,8 +58021,8 @@ function tagPayload(tag) {
|
|
|
57693
58021
|
};
|
|
57694
58022
|
}
|
|
57695
58023
|
function isNewOrLowFrequency(tag) {
|
|
57696
|
-
const lowMax =
|
|
57697
|
-
const newDays =
|
|
58024
|
+
const lowMax = envInt2("PROJECT_ALIAS_LOW_COUNT_MAX", 3);
|
|
58025
|
+
const newDays = envInt2("PROJECT_ALIAS_NEW_TAG_DAYS", 14);
|
|
57698
58026
|
const ageMs = Date.now() - tag.createdAt.getTime();
|
|
57699
58027
|
return tag.memoryCount <= lowMax || ageMs <= newDays * 24 * 60 * 60 * 1e3;
|
|
57700
58028
|
}
|
|
@@ -57763,7 +58091,7 @@ function messageMentionsTag(message, tag) {
|
|
|
57763
58091
|
return compactName.length >= 3 && compactMessage.includes(compactName);
|
|
57764
58092
|
}
|
|
57765
58093
|
async function listCanonicalTags(userId) {
|
|
57766
|
-
const maxTags =
|
|
58094
|
+
const maxTags = envInt2("PROJECT_ALIAS_MAX_TAGS", 120);
|
|
57767
58095
|
const result = await db.query(
|
|
57768
58096
|
`SELECT pt.id,
|
|
57769
58097
|
pt.name,
|
|
@@ -57865,7 +58193,7 @@ async function listRepresentativeMemories(userId, tagIds, perTag = 3) {
|
|
|
57865
58193
|
return out;
|
|
57866
58194
|
}
|
|
57867
58195
|
async function addExplicitStatementCandidates(userId, tags, tagsById, pairs) {
|
|
57868
|
-
const scanLimit =
|
|
58196
|
+
const scanLimit = envInt2("PROJECT_ALIAS_EXPLICIT_SCAN_LIMIT", 200);
|
|
57869
58197
|
const clauses = EXPLICIT_PATTERNS.map((_, i) => `message ILIKE $${i + 2}`).join(" OR ");
|
|
57870
58198
|
const limitParam = EXPLICIT_PATTERNS.length + 2;
|
|
57871
58199
|
const result = await db.query(
|
|
@@ -57950,10 +58278,10 @@ function addNameAndDtagCandidates(tags, tagsById, pairs) {
|
|
|
57950
58278
|
}
|
|
57951
58279
|
}
|
|
57952
58280
|
async function addVectorCandidates(userId, tags, tagsById, pairs) {
|
|
57953
|
-
const anchorLimit =
|
|
57954
|
-
const representativeLimit =
|
|
57955
|
-
const perMemoryLimit =
|
|
57956
|
-
const perTagLimit =
|
|
58281
|
+
const anchorLimit = envInt2("PROJECT_ALIAS_VECTOR_ANCHOR_LIMIT", 12);
|
|
58282
|
+
const representativeLimit = envInt2("PROJECT_ALIAS_VECTOR_REPRESENTATIVE_LIMIT", 5);
|
|
58283
|
+
const perMemoryLimit = envInt2("PROJECT_ALIAS_VECTOR_PER_MEMORY_LIMIT", 8);
|
|
58284
|
+
const perTagLimit = envInt2("PROJECT_ALIAS_VECTOR_TARGET_LIMIT", 3);
|
|
57957
58285
|
const maxDistance = envFloat("PROJECT_ALIAS_VECTOR_MAX_DISTANCE", 0.28);
|
|
57958
58286
|
const anchors = tags.slice().sort((a, b) => {
|
|
57959
58287
|
const aLow = isNewOrLowFrequency(a) ? 1 : 0;
|
|
@@ -58025,7 +58353,7 @@ async function addVectorCandidates(userId, tags, tagsById, pairs) {
|
|
|
58025
58353
|
}
|
|
58026
58354
|
}
|
|
58027
58355
|
}
|
|
58028
|
-
async function buildCandidatePairs(userId, tags, limit2) {
|
|
58356
|
+
async function buildCandidatePairs(userId, tags, limit2, exclude = () => false) {
|
|
58029
58357
|
const tagsById = new Map(tags.map((tag) => [tag.id, tag]));
|
|
58030
58358
|
const pairs = /* @__PURE__ */ new Map();
|
|
58031
58359
|
await addExplicitStatementCandidates(userId, tags, tagsById, pairs);
|
|
@@ -58040,7 +58368,7 @@ async function buildCandidatePairs(userId, tags, limit2) {
|
|
|
58040
58368
|
pair.signals.candidate_sources = Array.from(pair.sources);
|
|
58041
58369
|
}
|
|
58042
58370
|
}
|
|
58043
|
-
return Array.from(pairs.values()).sort((a, b) => {
|
|
58371
|
+
return Array.from(pairs.values()).filter((pair) => !exclude(pair)).sort((a, b) => {
|
|
58044
58372
|
const aExplicit = a.sources.has("explicit_user_statement") ? 1 : 0;
|
|
58045
58373
|
const bExplicit = b.sources.has("explicit_user_statement") ? 1 : 0;
|
|
58046
58374
|
if (aExplicit !== bExplicit) return bExplicit - aExplicit;
|
|
@@ -58109,7 +58437,7 @@ async function judgePair(pair, tagsById, memoriesByTag) {
|
|
|
58109
58437
|
const allowedIds = collectAllowedMemoryIds(pair, sourceMemories, targetMemories);
|
|
58110
58438
|
const promptSignals = { ...pair.signals };
|
|
58111
58439
|
delete promptSignals.explicit_statement_previews;
|
|
58112
|
-
const explicitPromptCap =
|
|
58440
|
+
const explicitPromptCap = envInt2("PROJECT_ALIAS_EXPLICIT_PROMPT_CAP", 5);
|
|
58113
58441
|
const userPrompt = JSON.stringify(
|
|
58114
58442
|
{
|
|
58115
58443
|
task: "Judge whether Tag A and Tag B are the same project identity.",
|
|
@@ -58129,7 +58457,7 @@ async function judgePair(pair, tagsById, memoriesByTag) {
|
|
|
58129
58457
|
system: JUDGE_SYSTEM_PROMPT,
|
|
58130
58458
|
user: userPrompt,
|
|
58131
58459
|
...spec.provider === "local" ? { jsonSchema: JUDGE_SCHEMA, enableThinking: false } : { responseFormat: "json" },
|
|
58132
|
-
maxTokens:
|
|
58460
|
+
maxTokens: envInt2("PROJECT_ALIAS_JUDGE_MAX_TOKENS", 8192)
|
|
58133
58461
|
});
|
|
58134
58462
|
if (!raw) throw new Error("project_alias_judge returned empty content");
|
|
58135
58463
|
return normalizeJudgment(raw, allowedIds);
|
|
@@ -58146,7 +58474,7 @@ async function areCanonicalRoots(tagIds) {
|
|
|
58146
58474
|
return result.rows.every((row) => row.alias_of === null);
|
|
58147
58475
|
}
|
|
58148
58476
|
async function hasRecentRejectedPair(userId, sourceId, targetId) {
|
|
58149
|
-
const rejectWindowDays =
|
|
58477
|
+
const rejectWindowDays = envInt2("PROJECT_ALIAS_REJECT_WINDOW_DAYS", 30);
|
|
58150
58478
|
const result = await db.query(
|
|
58151
58479
|
`SELECT 1
|
|
58152
58480
|
FROM project_tag_alias_suggestions
|
|
@@ -58170,10 +58498,8 @@ async function computeAutoApplyEligible(userId, sourceId, targetId, pair, judgme
|
|
|
58170
58498
|
if (await hasRecentRejectedPair(userId, sourceId, targetId)) return false;
|
|
58171
58499
|
return true;
|
|
58172
58500
|
}
|
|
58173
|
-
|
|
58174
|
-
|
|
58175
|
-
const candidateSources = Array.from(pair.sources);
|
|
58176
|
-
const signals = {
|
|
58501
|
+
function aliasSignals(pair, judgment, sourceId, targetId) {
|
|
58502
|
+
return {
|
|
58177
58503
|
...pair.signals,
|
|
58178
58504
|
llm: {
|
|
58179
58505
|
relation: judgment.relation,
|
|
@@ -58184,6 +58510,11 @@ async function upsertSuggestion(userId, sourceId, targetId, pair, judgment, auto
|
|
|
58184
58510
|
direction_reversed_from_candidate: sourceId !== pair.sourceId || targetId !== pair.targetId
|
|
58185
58511
|
}
|
|
58186
58512
|
};
|
|
58513
|
+
}
|
|
58514
|
+
async function upsertSuggestion(userId, sourceId, targetId, pair, judgment, autoApplyEligible) {
|
|
58515
|
+
const spec = ROLE_REGISTRY.project_alias_judge;
|
|
58516
|
+
const candidateSources = Array.from(pair.sources);
|
|
58517
|
+
const signals = aliasSignals(pair, judgment, sourceId, targetId);
|
|
58187
58518
|
const result = await db.query(
|
|
58188
58519
|
`INSERT INTO project_tag_alias_suggestions (
|
|
58189
58520
|
user_id,
|
|
@@ -58245,12 +58576,103 @@ async function upsertSuggestion(userId, sourceId, targetId, pair, judgment, auto
|
|
|
58245
58576
|
);
|
|
58246
58577
|
return Number(result.rows[0].id);
|
|
58247
58578
|
}
|
|
58579
|
+
async function loadPairHistory(userId, onlyPair) {
|
|
58580
|
+
const result = await db.query(
|
|
58581
|
+
`SELECT id, source_root, target_root, status, decided_by, decided, created_at, manual
|
|
58582
|
+
FROM (SELECT id,
|
|
58583
|
+
canonical_project_tag_id(source_tag_id) AS source_root,
|
|
58584
|
+
canonical_project_tag_id(target_tag_id) AS target_root,
|
|
58585
|
+
status,
|
|
58586
|
+
decided_by,
|
|
58587
|
+
COALESCE(decided_at, updated_at, created_at) AS decided,
|
|
58588
|
+
created_at,
|
|
58589
|
+
'manual_set_alias' = ANY(candidate_sources) AS manual
|
|
58590
|
+
FROM project_tag_alias_suggestions
|
|
58591
|
+
WHERE user_id = $1) h
|
|
58592
|
+
WHERE $2::bigint IS NULL
|
|
58593
|
+
OR (LEAST(source_root, target_root) = LEAST($2::bigint, $3::bigint)
|
|
58594
|
+
AND GREATEST(source_root, target_root) = GREATEST($2::bigint, $3::bigint))`,
|
|
58595
|
+
[userId, onlyPair?.[0] ?? null, onlyPair?.[1] ?? null]
|
|
58596
|
+
);
|
|
58597
|
+
return groupPairHistory(
|
|
58598
|
+
result.rows.filter((row) => row.source_root !== null && row.target_root !== null).map((row) => ({
|
|
58599
|
+
id: Number(row.id),
|
|
58600
|
+
sourceId: Number(row.source_root),
|
|
58601
|
+
targetId: Number(row.target_root),
|
|
58602
|
+
status: String(row.status),
|
|
58603
|
+
decidedBy: row.decided_by === null ? null : String(row.decided_by),
|
|
58604
|
+
decidedAt: toDate(row.decided),
|
|
58605
|
+
createdAt: toDate(row.created_at),
|
|
58606
|
+
manual: row.manual === true
|
|
58607
|
+
}))
|
|
58608
|
+
);
|
|
58609
|
+
}
|
|
58610
|
+
async function sweepRepeatedRejections(history) {
|
|
58611
|
+
let swept = 0;
|
|
58612
|
+
let errors = 0;
|
|
58613
|
+
for (const rows of history.values()) {
|
|
58614
|
+
for (const { pendingId, rejectedId } of repeatsToSweep(rows)) {
|
|
58615
|
+
try {
|
|
58616
|
+
const result = await db.query(
|
|
58617
|
+
`UPDATE project_tag_alias_suggestions
|
|
58618
|
+
SET status = 'rejected',
|
|
58619
|
+
decided_by = 'system',
|
|
58620
|
+
decision_reason = $2,
|
|
58621
|
+
signals = signals || jsonb_build_object('repeat_of_suggestion_id', $3::bigint),
|
|
58622
|
+
decided_at = NOW(),
|
|
58623
|
+
updated_at = NOW()
|
|
58624
|
+
WHERE id = $1
|
|
58625
|
+
AND status = 'pending'
|
|
58626
|
+
AND NOT ('manual_set_alias' = ANY(candidate_sources))`,
|
|
58627
|
+
[pendingId, `repeat of suggestion #${rejectedId} rejected by the user`, rejectedId]
|
|
58628
|
+
);
|
|
58629
|
+
swept += result.rowCount ?? 0;
|
|
58630
|
+
} catch (err3) {
|
|
58631
|
+
errors++;
|
|
58632
|
+
console.error(`[ProjectAliasPromoter] sweep of suggestion ${pendingId} failed:`, err3);
|
|
58633
|
+
}
|
|
58634
|
+
}
|
|
58635
|
+
}
|
|
58636
|
+
return { swept, errors };
|
|
58637
|
+
}
|
|
58638
|
+
async function recordRepeatOfRejected(userId, sourceId, targetId, pair, judgment, rejectedId) {
|
|
58639
|
+
const spec = ROLE_REGISTRY.project_alias_judge;
|
|
58640
|
+
await db.query(
|
|
58641
|
+
`INSERT INTO project_tag_alias_suggestions (
|
|
58642
|
+
user_id, source_tag_id, target_tag_id, relation, status, confidence,
|
|
58643
|
+
candidate_sources, evidence_memory_ids, conflict_memory_ids, signals,
|
|
58644
|
+
model_provider, model_name, rationale, auto_apply_eligible,
|
|
58645
|
+
decided_by, decision_reason, decided_at
|
|
58646
|
+
)
|
|
58647
|
+
VALUES (
|
|
58648
|
+
$1, $2, $3, $4, 'rejected', $5,
|
|
58649
|
+
$6::text[], $7::bigint[], $8::bigint[], $9::jsonb,
|
|
58650
|
+
$10, $11, $12, FALSE,
|
|
58651
|
+
'system', $13, NOW()
|
|
58652
|
+
)`,
|
|
58653
|
+
[
|
|
58654
|
+
userId,
|
|
58655
|
+
sourceId,
|
|
58656
|
+
targetId,
|
|
58657
|
+
judgment.relation,
|
|
58658
|
+
Number(judgment.confidence.toFixed(4)),
|
|
58659
|
+
Array.from(pair.sources),
|
|
58660
|
+
judgment.evidence_memory_ids,
|
|
58661
|
+
judgment.conflict_memory_ids,
|
|
58662
|
+
JSON.stringify({ ...aliasSignals(pair, judgment, sourceId, targetId), repeat_of_suggestion_id: rejectedId }),
|
|
58663
|
+
spec.provider,
|
|
58664
|
+
spec.model_name,
|
|
58665
|
+
truncate(judgment.rationale, 4e3),
|
|
58666
|
+
`repeat of suggestion #${rejectedId} rejected by the user`
|
|
58667
|
+
]
|
|
58668
|
+
);
|
|
58669
|
+
}
|
|
58248
58670
|
function qualifiesForPending(judgment, minConfidence) {
|
|
58249
58671
|
return QUALIFYING_RELATIONS.has(judgment.relation) && judgment.same_project && judgment.confidence >= minConfidence;
|
|
58250
58672
|
}
|
|
58251
58673
|
async function runProjectAliasPromoter(opts = {}) {
|
|
58252
58674
|
const userId = opts.userId ?? await getDefaultUserId();
|
|
58253
|
-
const candidateLimit = opts.limit ??
|
|
58675
|
+
const candidateLimit = opts.limit ?? envInt2("PROJECT_ALIAS_CANDIDATE_LIMIT", 12);
|
|
58254
58676
|
const pendingMin = envFloat("PROJECT_ALIAS_PENDING_MIN", 0.85);
|
|
58255
58677
|
const summary = {
|
|
58256
58678
|
userId,
|
|
@@ -58259,12 +58681,23 @@ async function runProjectAliasPromoter(opts = {}) {
|
|
|
58259
58681
|
inserted: 0,
|
|
58260
58682
|
autoApplied: 0,
|
|
58261
58683
|
skipped: 0,
|
|
58262
|
-
errors: 0
|
|
58684
|
+
errors: 0,
|
|
58685
|
+
blockedBeforeJudge: 0,
|
|
58686
|
+
blockedAfterJudge: 0,
|
|
58687
|
+
swept: 0
|
|
58263
58688
|
};
|
|
58689
|
+
const history = await loadPairHistory(userId);
|
|
58690
|
+
const sweep = await sweepRepeatedRejections(history);
|
|
58691
|
+
summary.swept = sweep.swept;
|
|
58692
|
+
summary.errors += sweep.errors;
|
|
58264
58693
|
const tags = await listCanonicalTags(userId);
|
|
58265
58694
|
if (tags.length < 2) return summary;
|
|
58266
58695
|
const tagsById = new Map(tags.map((tag) => [tag.id, tag]));
|
|
58267
|
-
const pairs = await buildCandidatePairs(userId, tags, candidateLimit)
|
|
58696
|
+
const pairs = await buildCandidatePairs(userId, tags, candidateLimit, (pair) => {
|
|
58697
|
+
const skip = skipBeforeJudge(history.get(pairKey(pair.sourceId, pair.targetId)));
|
|
58698
|
+
if (skip) summary.blockedBeforeJudge++;
|
|
58699
|
+
return skip;
|
|
58700
|
+
});
|
|
58268
58701
|
summary.candidates = pairs.length;
|
|
58269
58702
|
if (pairs.length === 0) return summary;
|
|
58270
58703
|
const tagIdsForPrompt = uniqueSortedNumbers(
|
|
@@ -58289,6 +58722,14 @@ async function runProjectAliasPromoter(opts = {}) {
|
|
|
58289
58722
|
summary.skipped++;
|
|
58290
58723
|
continue;
|
|
58291
58724
|
}
|
|
58725
|
+
const key = pairKey(sourceId, targetId);
|
|
58726
|
+
const live = await loadPairHistory(userId, [sourceId, targetId]);
|
|
58727
|
+
const rejectedId = userRejectedDirection(history.get(key), sourceId, targetId) ?? userRejectedDirection(live.get(key), sourceId, targetId);
|
|
58728
|
+
if (rejectedId !== null) {
|
|
58729
|
+
await recordRepeatOfRejected(userId, sourceId, targetId, pair, judgment, rejectedId);
|
|
58730
|
+
summary.blockedAfterJudge++;
|
|
58731
|
+
continue;
|
|
58732
|
+
}
|
|
58292
58733
|
const autoApplyEligible = await computeAutoApplyEligible(
|
|
58293
58734
|
userId,
|
|
58294
58735
|
sourceId,
|
|
@@ -58323,7 +58764,7 @@ async function maybeRunProjectAliasPromoter() {
|
|
|
58323
58764
|
if (process.env.PROJECT_ALIAS_PROMOTER_ENABLED !== "true") return;
|
|
58324
58765
|
if (projectAliasPromoterRunning) return;
|
|
58325
58766
|
projectAliasPromoterRunning = true;
|
|
58326
|
-
const intervalHours =
|
|
58767
|
+
const intervalHours = envInt2(
|
|
58327
58768
|
"PROJECT_ALIAS_PROMOTER_INTERVAL_HOURS",
|
|
58328
58769
|
PROJECT_ALIAS_PROMOTER_DEFAULT_INTERVAL_HOURS
|
|
58329
58770
|
);
|
|
@@ -58346,9 +58787,9 @@ async function maybeRunProjectAliasPromoter() {
|
|
|
58346
58787
|
[userId]
|
|
58347
58788
|
);
|
|
58348
58789
|
const summary = await runProjectAliasPromoter({ userId });
|
|
58349
|
-
if (summary.candidates > 0 || summary.inserted > 0 || summary.errors > 0) {
|
|
58790
|
+
if (summary.candidates > 0 || summary.inserted > 0 || summary.errors > 0 || summary.swept > 0 || summary.blockedBeforeJudge > 0) {
|
|
58350
58791
|
console.error(
|
|
58351
|
-
`\u{1F501} [ProjectAliasPromoter] done \u2014 candidates=${summary.candidates}, judged=${summary.judged}, inserted=${summary.inserted}, autoApplied=${summary.autoApplied}, skipped=${summary.skipped}, errors=${summary.errors}`
|
|
58792
|
+
`\u{1F501} [ProjectAliasPromoter] done \u2014 candidates=${summary.candidates}, judged=${summary.judged}, inserted=${summary.inserted}, autoApplied=${summary.autoApplied}, skipped=${summary.skipped}, errors=${summary.errors}, blockedBeforeJudge=${summary.blockedBeforeJudge}, blockedAfterJudge=${summary.blockedAfterJudge}, swept=${summary.swept}`
|
|
58352
58793
|
);
|
|
58353
58794
|
}
|
|
58354
58795
|
} catch (err3) {
|
|
@@ -58444,13 +58885,13 @@ async function applyAliasSuggestion(suggestionId, opts = {}) {
|
|
|
58444
58885
|
}
|
|
58445
58886
|
if (!Number.isInteger(targetRoot) || targetRoot === sourceId) {
|
|
58446
58887
|
const msg = "alias would point source to itself";
|
|
58447
|
-
await markSuggestionTerminal(client2, suggestionId, "rejected",
|
|
58888
|
+
await markSuggestionTerminal(client2, suggestionId, "rejected", "system", msg);
|
|
58448
58889
|
await client2.query("COMMIT");
|
|
58449
58890
|
return { suggestionId, applied: false, status: "rejected", reason: msg };
|
|
58450
58891
|
}
|
|
58451
58892
|
if (await targetChainContainsSource(client2, sourceId, targetId)) {
|
|
58452
58893
|
const msg = "alias would create a project_tag alias cycle";
|
|
58453
|
-
await markSuggestionTerminal(client2, suggestionId, "rejected",
|
|
58894
|
+
await markSuggestionTerminal(client2, suggestionId, "rejected", "system", msg);
|
|
58454
58895
|
await client2.query("COMMIT");
|
|
58455
58896
|
return { suggestionId, applied: false, status: "rejected", reason: msg };
|
|
58456
58897
|
}
|
|
@@ -58657,6 +59098,76 @@ async function rejectAlias(userId, suggestionId, reason) {
|
|
|
58657
59098
|
decided_at: row.decided_at
|
|
58658
59099
|
});
|
|
58659
59100
|
}
|
|
59101
|
+
async function listNewTags(userId, status, limit2) {
|
|
59102
|
+
const result = await db.query(
|
|
59103
|
+
`SELECT id, name, uses, recommendation, rationale, created_at, last_seen_at,
|
|
59104
|
+
decided_by, decision_reason, decided_at
|
|
59105
|
+
FROM project_tag_new_suggestions
|
|
59106
|
+
WHERE user_id = $1
|
|
59107
|
+
AND status = $2
|
|
59108
|
+
ORDER BY created_at DESC
|
|
59109
|
+
LIMIT $3`,
|
|
59110
|
+
[userId, status, limit2]
|
|
59111
|
+
);
|
|
59112
|
+
const suggestions = result.rows.map((row) => ({
|
|
59113
|
+
suggestion_id: Number(row.id),
|
|
59114
|
+
name: String(row.name),
|
|
59115
|
+
uses: Number(row.uses),
|
|
59116
|
+
recommendation: row.recommendation ?? null,
|
|
59117
|
+
rationale: String(row.rationale ?? ""),
|
|
59118
|
+
created_at: row.created_at,
|
|
59119
|
+
last_seen_at: row.last_seen_at,
|
|
59120
|
+
decided_by: row.decided_by ?? null,
|
|
59121
|
+
decision_reason: row.decision_reason ?? null,
|
|
59122
|
+
decided_at: row.decided_at ?? null
|
|
59123
|
+
}));
|
|
59124
|
+
return ok2({ action: "list_new_tags", status, limit: limit2, count: suggestions.length, suggestions });
|
|
59125
|
+
}
|
|
59126
|
+
function decisionResult(action, result) {
|
|
59127
|
+
if (result.error) return toolError(result.error, { action, ...result });
|
|
59128
|
+
return ok2({ action, ...result });
|
|
59129
|
+
}
|
|
59130
|
+
async function applyRecommendations(userId, ids, reason) {
|
|
59131
|
+
const rows = await db.query(
|
|
59132
|
+
`SELECT id, name, status, recommendation
|
|
59133
|
+
FROM project_tag_new_suggestions
|
|
59134
|
+
WHERE user_id = $1
|
|
59135
|
+
AND id = ANY($2::bigint[])`,
|
|
59136
|
+
[userId, ids]
|
|
59137
|
+
);
|
|
59138
|
+
const names = new Map(rows.rows.map((r) => [Number(r.id), String(r.name)]));
|
|
59139
|
+
const plan = planApplyRecommendations(
|
|
59140
|
+
ids,
|
|
59141
|
+
rows.rows.map((r) => ({
|
|
59142
|
+
id: Number(r.id),
|
|
59143
|
+
status: String(r.status),
|
|
59144
|
+
recommendation: parseRecommendation(r.recommendation)
|
|
59145
|
+
}))
|
|
59146
|
+
);
|
|
59147
|
+
const why = reason ? `apply_recommendations: ${reason}` : "apply_recommendations";
|
|
59148
|
+
const confirmed = [];
|
|
59149
|
+
const rejected = [];
|
|
59150
|
+
const failed = [];
|
|
59151
|
+
const decide = async (id, fn, done) => {
|
|
59152
|
+
try {
|
|
59153
|
+
const r = await fn(userId, id, { decidedBy: "user", reason: why });
|
|
59154
|
+
(r.error ? failed : done).push(r);
|
|
59155
|
+
} catch (err3) {
|
|
59156
|
+
failed.push({ suggestion_id: id, name: names.get(id) ?? "", status: "error", error: errorDetail(err3) });
|
|
59157
|
+
}
|
|
59158
|
+
};
|
|
59159
|
+
for (const id of plan.confirm) await decide(id, confirmNewTagSuggestion, confirmed);
|
|
59160
|
+
for (const id of plan.reject) await decide(id, rejectNewTagSuggestion, rejected);
|
|
59161
|
+
const label = (id) => ({ suggestion_id: id, name: names.get(id) ?? null });
|
|
59162
|
+
return ok2({
|
|
59163
|
+
action: "apply_recommendations",
|
|
59164
|
+
confirmed,
|
|
59165
|
+
rejected,
|
|
59166
|
+
kept_pending: plan.keep.map(label),
|
|
59167
|
+
skipped_not_pending: plan.skipped.map(label),
|
|
59168
|
+
failed
|
|
59169
|
+
});
|
|
59170
|
+
}
|
|
58660
59171
|
async function setAlias(userId, sourceTagName, targetTagName, reason) {
|
|
58661
59172
|
const sourceName = normalizeTagName(sourceTagName);
|
|
58662
59173
|
const targetName = normalizeTagName(targetTagName);
|
|
@@ -58777,25 +59288,36 @@ function registerManageProjectTags(server) {
|
|
|
58777
59288
|
server.registerTool(
|
|
58778
59289
|
"manage_project_tags",
|
|
58779
59290
|
{
|
|
58780
|
-
description: `Project tag
|
|
59291
|
+
description: `Project tag suggestion management tool (Stage 2 / DEVLOG \xA719).
|
|
58781
59292
|
|
|
58782
|
-
|
|
59293
|
+
Alias suggestions (two existing tags look like the same project):
|
|
58783
59294
|
- list_suggestions: list alias suggestions by status for the default user.
|
|
58784
59295
|
- confirm_alias: confirm a pending suggestion through applyAliasSuggestion().
|
|
58785
59296
|
- reject_alias: reject a pending suggestion.
|
|
58786
59297
|
- set_alias: manually set source_tag as an alias of target_tag through a pending suggestion + applyAliasSuggestion().
|
|
58787
|
-
- unset_alias: clear alias_of for one project tag
|
|
59298
|
+
- unset_alias: clear alias_of for one project tag.
|
|
59299
|
+
|
|
59300
|
+
New project tag suggestions (a frequent d_tag that is not a project tag yet; ids are separate from alias ids):
|
|
59301
|
+
- list_new_tags: list them by status (pending / confirmed / rejected / superseded). Each has a recommendation (project / generic / unsure) and uses = how often that d_tag was used in the window.
|
|
59302
|
+
- confirm_new_tag: create the project tag and retro-tag untagged memories carrying that exact d_tag.
|
|
59303
|
+
- reject_new_tag: reject it; a rejected name is never suggested again.
|
|
59304
|
+
- apply_recommendations: when the user accepts the recommendations ("\uCD94\uCC9C\uB300\uB85C"), pass the suggestion_ids that were shown to them. project \u2192 confirmed, generic \u2192 rejected, unsure / no recommendation \u2192 left pending for the user to decide.`,
|
|
58788
59305
|
inputSchema: {
|
|
58789
59306
|
action: external_exports.enum([
|
|
58790
59307
|
"list_suggestions",
|
|
58791
59308
|
"confirm_alias",
|
|
58792
59309
|
"reject_alias",
|
|
58793
59310
|
"set_alias",
|
|
58794
|
-
"unset_alias"
|
|
59311
|
+
"unset_alias",
|
|
59312
|
+
"list_new_tags",
|
|
59313
|
+
"confirm_new_tag",
|
|
59314
|
+
"reject_new_tag",
|
|
59315
|
+
"apply_recommendations"
|
|
58795
59316
|
]).describe("\uC791\uC5C5 \uC885\uB958"),
|
|
58796
|
-
status: statusSchema.optional().describe("list_suggestions status filter (default pending)"),
|
|
58797
|
-
limit: external_exports.number().int().min(1).max(MAX_LIMIT).optional().describe(`list_suggestions limit (default ${DEFAULT_LIMIT2})`),
|
|
58798
|
-
suggestion_id: external_exports.number().int().positive().optional().describe("confirm_alias/reject_alias \uB300\uC0C1 suggestion id"),
|
|
59317
|
+
status: statusSchema.optional().describe("list_suggestions / list_new_tags status filter (default pending)"),
|
|
59318
|
+
limit: external_exports.number().int().min(1).max(MAX_LIMIT).optional().describe(`list_suggestions / list_new_tags limit (default ${DEFAULT_LIMIT2})`),
|
|
59319
|
+
suggestion_id: external_exports.number().int().positive().optional().describe("confirm_alias/reject_alias/confirm_new_tag/reject_new_tag \uB300\uC0C1 suggestion id"),
|
|
59320
|
+
suggestion_ids: external_exports.array(external_exports.number().int().positive()).min(1).max(MAX_LIMIT).optional().describe("apply_recommendations: \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uBCF4\uC5EC \uC900 \uC0C8 \uD0DC\uADF8 \uC81C\uC548 id \uBAA9\uB85D"),
|
|
58799
59321
|
reason: external_exports.string().optional().describe("confirm/reject/set decision reason"),
|
|
58800
59322
|
source_tag: external_exports.string().optional().describe("set_alias source project_tags.name"),
|
|
58801
59323
|
target_tag: external_exports.string().optional().describe("set_alias target project_tags.name"),
|
|
@@ -58830,6 +59352,22 @@ Actions:
|
|
|
58830
59352
|
if (args.action === "unset_alias") {
|
|
58831
59353
|
return unsetAlias(userId, args.tag);
|
|
58832
59354
|
}
|
|
59355
|
+
if (args.action === "list_new_tags") {
|
|
59356
|
+
return listNewTags(userId, args.status ?? "pending", args.limit ?? DEFAULT_LIMIT2);
|
|
59357
|
+
}
|
|
59358
|
+
if (args.action === "confirm_new_tag" || args.action === "reject_new_tag") {
|
|
59359
|
+
if (args.suggestion_id == null) {
|
|
59360
|
+
return toolError(`suggestion_id is required for ${args.action}`);
|
|
59361
|
+
}
|
|
59362
|
+
const decide = args.action === "confirm_new_tag" ? confirmNewTagSuggestion : rejectNewTagSuggestion;
|
|
59363
|
+
return decisionResult(args.action, await decide(userId, args.suggestion_id, { decidedBy: "user", reason: args.reason }));
|
|
59364
|
+
}
|
|
59365
|
+
if (args.action === "apply_recommendations") {
|
|
59366
|
+
if (!args.suggestion_ids?.length) {
|
|
59367
|
+
return toolError("suggestion_ids is required for apply_recommendations");
|
|
59368
|
+
}
|
|
59369
|
+
return applyRecommendations(userId, args.suggestion_ids, args.reason);
|
|
59370
|
+
}
|
|
58833
59371
|
return toolError("unsupported action", { action: args.action });
|
|
58834
59372
|
} catch (err3) {
|
|
58835
59373
|
return toolError("manage_project_tags failed", { detail: errorDetail(err3) });
|
|
@@ -58850,6 +59388,7 @@ var PREVIEW_COMPACT = 100;
|
|
|
58850
59388
|
var PREVIEW_RECENT = 300;
|
|
58851
59389
|
var ACTIVE_PTAG_LIMIT = 5;
|
|
58852
59390
|
var PENDING_ALIAS_SUGGESTION_LIMIT = 2;
|
|
59391
|
+
var PENDING_NEW_TAG_LIMIT = 2;
|
|
58853
59392
|
var PINNED_LIMIT = 10;
|
|
58854
59393
|
var INJECT_PINNED_LIMIT = 5;
|
|
58855
59394
|
var BRIEF_MAX_CHARS = Number(process.env.BRIEF_MAX_CHARS ?? 8e3);
|
|
@@ -58946,11 +59485,32 @@ async function collectBrief(opts = {}) {
|
|
|
58946
59485
|
LIMIT $2`,
|
|
58947
59486
|
[userId, PENDING_ALIAS_SUGGESTION_LIMIT]
|
|
58948
59487
|
);
|
|
59488
|
+
let newTags = [];
|
|
59489
|
+
try {
|
|
59490
|
+
const r = await db.query(
|
|
59491
|
+
`SELECT id, name, uses, recommendation
|
|
59492
|
+
FROM project_tag_new_suggestions
|
|
59493
|
+
WHERE user_id = $1 AND status = 'pending'
|
|
59494
|
+
AND recommendation IS DISTINCT FROM 'generic'
|
|
59495
|
+
ORDER BY (recommendation = 'project') DESC NULLS LAST, uses DESC
|
|
59496
|
+
LIMIT $2`,
|
|
59497
|
+
[userId, PENDING_NEW_TAG_LIMIT]
|
|
59498
|
+
);
|
|
59499
|
+
newTags = r.rows;
|
|
59500
|
+
} catch {
|
|
59501
|
+
newTags = [];
|
|
59502
|
+
}
|
|
58949
59503
|
return {
|
|
58950
59504
|
user_name: user.user_name,
|
|
58951
59505
|
core_profile: user.core_profile,
|
|
58952
59506
|
sub_profile: user.sub_profile,
|
|
58953
59507
|
pinned_memories: pinnedMsgs.rows.map(rowToMsg),
|
|
59508
|
+
pending_new_tags: newTags.map((r) => ({
|
|
59509
|
+
id: Number(r.id),
|
|
59510
|
+
name: String(r.name),
|
|
59511
|
+
uses: Number(r.uses),
|
|
59512
|
+
recommendation: r.recommendation ?? null
|
|
59513
|
+
})),
|
|
58954
59514
|
pending_alias_suggestions: aliasSugg.rows.map((r) => ({
|
|
58955
59515
|
id: Number(r.id),
|
|
58956
59516
|
source: String(r.source),
|
|
@@ -59012,11 +59572,14 @@ function formatBriefFull(brief) {
|
|
|
59012
59572
|
}
|
|
59013
59573
|
gLines.push("");
|
|
59014
59574
|
}
|
|
59015
|
-
if (brief.pending_alias_suggestions.length > 0) {
|
|
59575
|
+
if (brief.pending_alias_suggestions.length > 0 || brief.pending_new_tags.length > 0) {
|
|
59016
59576
|
gLines.push("## Project Tag Suggestions (\uC0AC\uC6A9\uC790 \uD655\uC778 \uD544\uC694)");
|
|
59017
59577
|
for (const s of brief.pending_alias_suggestions) {
|
|
59018
59578
|
gLines.push(`- [${s.id}] \`${s.source}\` \u2192 \`${s.target}\` \uAC19\uC740 \uD504\uB85C\uC81D\uD2B8\uB85C \uBCF4\uC784 (${s.relation}, conf ${s.confidence}). \uB9DE\uC73C\uBA74 \`manage_project_tags({action:"confirm_alias",suggestion_id:${s.id}})\`, \uC544\uB2C8\uBA74 \`reject_alias\`.`);
|
|
59019
59579
|
}
|
|
59580
|
+
for (const t of brief.pending_new_tags) {
|
|
59581
|
+
gLines.push(`- [new ${t.id}] \`${t.name}\` \uC0C8 \uD504\uB85C\uC81D\uD2B8 \uD0DC\uADF8 \uD6C4\uBCF4 (${t.uses}\uD68C, \uCD94\uCC9C ${t.recommendation ?? "\uC5C6\uC74C"}). \uB9DE\uC73C\uBA74 \`manage_project_tags({action:"confirm_new_tag",suggestion_id:${t.id}})\`, \uC544\uB2C8\uBA74 \`reject_new_tag\`.`);
|
|
59582
|
+
}
|
|
59020
59583
|
gLines.push("");
|
|
59021
59584
|
}
|
|
59022
59585
|
const guaranteed = gLines.join("\n");
|
|
@@ -60665,155 +61228,6 @@ function registerTools(server) {
|
|
|
60665
61228
|
init_db();
|
|
60666
61229
|
init_buzz_envelope();
|
|
60667
61230
|
|
|
60668
|
-
// src/cold_path/dtag_promoter.ts
|
|
60669
|
-
init_db();
|
|
60670
|
-
init_users();
|
|
60671
|
-
function envInt2(name, fallback) {
|
|
60672
|
-
const raw = process.env[name];
|
|
60673
|
-
if (!raw) return fallback;
|
|
60674
|
-
const n = Number(raw);
|
|
60675
|
-
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
|
60676
|
-
}
|
|
60677
|
-
var CLUSTER_SYSTEM = `You are a keyword clustering assistant for a personal memory system.
|
|
60678
|
-
|
|
60679
|
-
Given a list of d_tags (short hyphenated keywords) with their occurrence counts,
|
|
60680
|
-
group semantically similar tags that refer to the same project or topic.
|
|
60681
|
-
|
|
60682
|
-
OUTPUT strict JSON object (NOT a bare array):
|
|
60683
|
-
{ "clusters": [
|
|
60684
|
-
{ "canonical": "<best-slug>", "members": ["<tag1>", "<tag2>", ...] }
|
|
60685
|
-
] }
|
|
60686
|
-
|
|
60687
|
-
Rules:
|
|
60688
|
-
- canonical must be one of the input tags (pick the most descriptive one) or a clean slug if none fit
|
|
60689
|
-
- canonical must be lowercase, hyphenated (e.g. "yt-signal-finder")
|
|
60690
|
-
- Only group tags that clearly refer to the same project/topic
|
|
60691
|
-
- Tags with no similar counterparts become their own single-member cluster
|
|
60692
|
-
- Do NOT merge unrelated topics just because they share one word`;
|
|
60693
|
-
var CLUSTER_SCHEMA = {
|
|
60694
|
-
type: "object",
|
|
60695
|
-
properties: {
|
|
60696
|
-
clusters: {
|
|
60697
|
-
type: "array",
|
|
60698
|
-
items: {
|
|
60699
|
-
type: "object",
|
|
60700
|
-
properties: {
|
|
60701
|
-
canonical: { type: "string" },
|
|
60702
|
-
members: { type: "array", items: { type: "string" } }
|
|
60703
|
-
},
|
|
60704
|
-
required: ["canonical", "members"],
|
|
60705
|
-
additionalProperties: false
|
|
60706
|
-
}
|
|
60707
|
-
}
|
|
60708
|
-
},
|
|
60709
|
-
required: ["clusters"],
|
|
60710
|
-
additionalProperties: false
|
|
60711
|
-
};
|
|
60712
|
-
async function clusterDTags(tags) {
|
|
60713
|
-
if (tags.length === 0) return [];
|
|
60714
|
-
const tagList = tags.map((t) => `${t.tag} (${t.cnt}x)`).join(", ");
|
|
60715
|
-
const userPrompt = `Cluster these d_tags by project/topic:
|
|
60716
|
-
${tagList}`;
|
|
60717
|
-
let raw;
|
|
60718
|
-
try {
|
|
60719
|
-
raw = await callRole("clusterer", {
|
|
60720
|
-
system: CLUSTER_SYSTEM,
|
|
60721
|
-
user: userPrompt,
|
|
60722
|
-
jsonSchema: CLUSTER_SCHEMA,
|
|
60723
|
-
enableThinking: false,
|
|
60724
|
-
maxTokens: 4096
|
|
60725
|
-
// was 512 — 50개 태그 클러스터링 출력이 잘려 invalid JSON 폴백됨 (ctx 8192 내 여유)
|
|
60726
|
-
});
|
|
60727
|
-
} catch (err3) {
|
|
60728
|
-
console.error("\u26A0\uFE0F [DTagPromoter] clusterer call failed, falling back to no clustering:", err3);
|
|
60729
|
-
return tags.map((t) => ({ canonical: t.tag, members: [t.tag], total: t.cnt }));
|
|
60730
|
-
}
|
|
60731
|
-
let parsed;
|
|
60732
|
-
try {
|
|
60733
|
-
const obj = JSON.parse(raw);
|
|
60734
|
-
parsed = obj?.clusters;
|
|
60735
|
-
if (!Array.isArray(parsed)) throw new Error("no clusters array");
|
|
60736
|
-
} catch {
|
|
60737
|
-
console.error("\u26A0\uFE0F [DTagPromoter] clusterer returned invalid JSON, falling back:", raw.slice(0, 200));
|
|
60738
|
-
return tags.map((t) => ({ canonical: t.tag, members: [t.tag], total: t.cnt }));
|
|
60739
|
-
}
|
|
60740
|
-
const freqMap = new Map(tags.map((t) => [t.tag, t.cnt]));
|
|
60741
|
-
return parsed.map((c) => {
|
|
60742
|
-
const canonical = String(c.canonical || "").toLowerCase().trim().replace(/\s+/g, "-");
|
|
60743
|
-
const members = Array.isArray(c.members) ? c.members.map((m) => String(m).toLowerCase().trim()).filter(Boolean) : [canonical];
|
|
60744
|
-
const total = members.reduce((sum, m) => sum + (freqMap.get(m) ?? 0), 0);
|
|
60745
|
-
return { canonical, members, total };
|
|
60746
|
-
});
|
|
60747
|
-
}
|
|
60748
|
-
async function runDtagPromotion() {
|
|
60749
|
-
const minCount = envInt2("DTAG_PROMOTE_MIN_COUNT", 10);
|
|
60750
|
-
const windowDays = envInt2("DTAG_PROMOTE_WINDOW_DAYS", 30);
|
|
60751
|
-
const userId = await getDefaultUserId();
|
|
60752
|
-
const freqResult = await db.query(
|
|
60753
|
-
`SELECT unnest(d_tag) AS tag, COUNT(*)::int AS cnt
|
|
60754
|
-
FROM memory
|
|
60755
|
-
WHERE user_id = $1
|
|
60756
|
-
AND tag_processed = TRUE
|
|
60757
|
-
AND is_active = TRUE
|
|
60758
|
-
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
|
|
60759
|
-
GROUP BY tag
|
|
60760
|
-
HAVING COUNT(*) >= 2
|
|
60761
|
-
ORDER BY cnt DESC
|
|
60762
|
-
LIMIT 50`,
|
|
60763
|
-
[userId, String(windowDays)]
|
|
60764
|
-
);
|
|
60765
|
-
if (freqResult.rows.length === 0) return { promoted: [], retrotagged: 0 };
|
|
60766
|
-
const tags = freqResult.rows.map((r) => ({
|
|
60767
|
-
tag: String(r.tag).toLowerCase().trim(),
|
|
60768
|
-
cnt: Number(r.cnt)
|
|
60769
|
-
})).filter((t) => t.tag.length > 0);
|
|
60770
|
-
const clusters = await clusterDTags(tags);
|
|
60771
|
-
const toPromote = clusters.filter((c) => c.total >= minCount);
|
|
60772
|
-
if (toPromote.length === 0) return { promoted: [], retrotagged: 0 };
|
|
60773
|
-
const summary = { promoted: [], retrotagged: 0 };
|
|
60774
|
-
for (const cluster of toPromote) {
|
|
60775
|
-
if (!cluster.canonical) continue;
|
|
60776
|
-
const upsert = await db.query(
|
|
60777
|
-
`INSERT INTO project_tags (name)
|
|
60778
|
-
VALUES ($1)
|
|
60779
|
-
ON CONFLICT (name) DO NOTHING
|
|
60780
|
-
RETURNING id`,
|
|
60781
|
-
[cluster.canonical]
|
|
60782
|
-
);
|
|
60783
|
-
let pTagId;
|
|
60784
|
-
if (upsert.rows.length > 0) {
|
|
60785
|
-
pTagId = Number(upsert.rows[0].id);
|
|
60786
|
-
summary.promoted.push(cluster.canonical);
|
|
60787
|
-
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] promoted "${cluster.canonical}" (total=${cluster.total}, members=${cluster.members.join(", ")})`);
|
|
60788
|
-
invalidateCandidateCache();
|
|
60789
|
-
} else {
|
|
60790
|
-
const existing = await db.query(
|
|
60791
|
-
`SELECT id FROM project_tags WHERE name = $1 LIMIT 1`,
|
|
60792
|
-
[cluster.canonical]
|
|
60793
|
-
);
|
|
60794
|
-
if (existing.rows.length === 0) continue;
|
|
60795
|
-
pTagId = Number(existing.rows[0].id);
|
|
60796
|
-
}
|
|
60797
|
-
for (const memberTag of cluster.members) {
|
|
60798
|
-
const updated = await db.query(
|
|
60799
|
-
`UPDATE memory
|
|
60800
|
-
SET p_tag_id = $1, updated_at = NOW()
|
|
60801
|
-
WHERE user_id = $2
|
|
60802
|
-
AND tag_processed = TRUE
|
|
60803
|
-
AND p_tag_id IS NULL
|
|
60804
|
-
AND $3 = ANY(d_tag)
|
|
60805
|
-
RETURNING id`,
|
|
60806
|
-
[pTagId, userId, memberTag]
|
|
60807
|
-
);
|
|
60808
|
-
summary.retrotagged += updated.rows.length;
|
|
60809
|
-
}
|
|
60810
|
-
if (summary.retrotagged > 0) {
|
|
60811
|
-
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] retrotagged ${summary.retrotagged} rows with "${cluster.canonical}"`);
|
|
60812
|
-
}
|
|
60813
|
-
}
|
|
60814
|
-
return summary;
|
|
60815
|
-
}
|
|
60816
|
-
|
|
60817
61231
|
// src/librarian.ts
|
|
60818
61232
|
init_db();
|
|
60819
61233
|
init_users();
|
|
@@ -61326,8 +61740,8 @@ async function maybeRunPromotion() {
|
|
|
61326
61740
|
if (tickCount % DTAG_PROMOTE_EVERY_N_TICKS !== 0) return;
|
|
61327
61741
|
try {
|
|
61328
61742
|
const result = await runDtagPromotion();
|
|
61329
|
-
if (result.
|
|
61330
|
-
console.error(`\u{1F3F7}\uFE0F [DTagPromoter]
|
|
61743
|
+
if (result.suggested.length > 0 || result.retrotagged > 0 || result.superseded > 0 || result.deferred > 0) {
|
|
61744
|
+
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] suggested=${result.suggested.length}, refreshed=${result.refreshed}, blocked=${result.blocked}, deferred=${result.deferred}, superseded=${result.superseded}, retrotagged=${result.retrotagged} rows`);
|
|
61331
61745
|
}
|
|
61332
61746
|
} catch (err3) {
|
|
61333
61747
|
console.error("\u26A0\uFE0F [DTagPromoter] promotion error (non-blocking):", err3);
|