mcp-agents-memory 0.9.24 → 0.9.26
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 +437 -174
- package/build/migrations/030_project_tag_new_suggestions.js +26013 -0
- package/package.json +3 -2
package/build/index.js
CHANGED
|
@@ -56411,6 +56411,294 @@ 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 f.tag, f.cnt
|
|
56555
|
+
FROM (SELECT unnest(d_tag) AS tag, COUNT(*)::int AS cnt
|
|
56556
|
+
FROM memory
|
|
56557
|
+
WHERE user_id = $1
|
|
56558
|
+
AND tag_processed = TRUE
|
|
56559
|
+
AND is_active = TRUE
|
|
56560
|
+
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
|
|
56561
|
+
GROUP BY tag
|
|
56562
|
+
HAVING COUNT(*) >= 2) f
|
|
56563
|
+
WHERE NOT EXISTS (
|
|
56564
|
+
SELECT 1 FROM project_tag_new_suggestions s
|
|
56565
|
+
WHERE s.user_id = $1 AND s.status = 'rejected' AND s.name = lower(btrim(f.tag)))
|
|
56566
|
+
ORDER BY f.cnt DESC
|
|
56567
|
+
LIMIT 50`,
|
|
56568
|
+
[userId, String(windowDays)]
|
|
56569
|
+
);
|
|
56570
|
+
const candidates = freqResult.rows.map((r) => ({ tag: normalizeTag(r.tag), cnt: Number(r.cnt) })).filter((t) => t.tag.length > 0 && t.cnt >= minCount);
|
|
56571
|
+
if (candidates.length === 0) return summary;
|
|
56572
|
+
const names = candidates.map((c) => c.tag);
|
|
56573
|
+
const existing = new Map((await db.query(
|
|
56574
|
+
`SELECT name, id FROM project_tags WHERE name = ANY($1::text[])`,
|
|
56575
|
+
[names]
|
|
56576
|
+
)).rows.map((r) => [String(r.name), Number(r.id)]));
|
|
56577
|
+
const prior = new Map((await db.query(
|
|
56578
|
+
`SELECT name, id, status FROM project_tag_new_suggestions WHERE user_id = $1 AND name = ANY($2::text[])`,
|
|
56579
|
+
[userId, names]
|
|
56580
|
+
)).rows.map((r) => [String(r.name), { id: Number(r.id), status: String(r.status) }]));
|
|
56581
|
+
for (const { tag, cnt } of candidates) {
|
|
56582
|
+
const row = prior.get(tag);
|
|
56583
|
+
if (row?.status === "rejected") {
|
|
56584
|
+
summary.blocked++;
|
|
56585
|
+
continue;
|
|
56586
|
+
}
|
|
56587
|
+
const pTagId = existing.get(tag);
|
|
56588
|
+
if (pTagId != null) {
|
|
56589
|
+
if (dryRun) continue;
|
|
56590
|
+
const n = await retrotag(db, userId, pTagId, tag);
|
|
56591
|
+
if (n > 0) {
|
|
56592
|
+
summary.retrotagged += n;
|
|
56593
|
+
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] retrotagged ${n} rows with "${tag}"`);
|
|
56594
|
+
}
|
|
56595
|
+
continue;
|
|
56596
|
+
}
|
|
56597
|
+
if (row) {
|
|
56598
|
+
if (row.status !== "pending") {
|
|
56599
|
+
summary.blocked++;
|
|
56600
|
+
continue;
|
|
56601
|
+
}
|
|
56602
|
+
if (dryRun) continue;
|
|
56603
|
+
const touched = await db.query(
|
|
56604
|
+
`UPDATE project_tag_new_suggestions
|
|
56605
|
+
SET uses = $2, last_seen_at = NOW(), updated_at = NOW()
|
|
56606
|
+
WHERE id = $1 AND status = 'pending'
|
|
56607
|
+
RETURNING id`,
|
|
56608
|
+
[row.id, cnt]
|
|
56609
|
+
);
|
|
56610
|
+
if (touched.rows.length > 0) summary.refreshed++;
|
|
56611
|
+
continue;
|
|
56612
|
+
}
|
|
56613
|
+
const verdict = await classifyCandidate(userId, tag, cnt, windowDays);
|
|
56614
|
+
if (!verdict) {
|
|
56615
|
+
summary.deferred++;
|
|
56616
|
+
continue;
|
|
56617
|
+
}
|
|
56618
|
+
const preview = { name: tag, uses: cnt, ...verdict };
|
|
56619
|
+
if (!dryRun) {
|
|
56620
|
+
const spec = ROLE_REGISTRY.clusterer;
|
|
56621
|
+
const inserted = await db.query(
|
|
56622
|
+
`INSERT INTO project_tag_new_suggestions
|
|
56623
|
+
(user_id, name, uses, recommendation, rationale, model_provider, model_name)
|
|
56624
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
56625
|
+
ON CONFLICT (user_id, name) DO NOTHING
|
|
56626
|
+
RETURNING id`,
|
|
56627
|
+
[userId, preview.name, preview.uses, preview.recommendation, preview.rationale, spec.provider, spec.model_name]
|
|
56628
|
+
);
|
|
56629
|
+
if (inserted.rows.length === 0) continue;
|
|
56630
|
+
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] suggested "${preview.name}" (uses=${preview.uses}, recommendation=${preview.recommendation})`);
|
|
56631
|
+
}
|
|
56632
|
+
summary.suggested.push(preview);
|
|
56633
|
+
}
|
|
56634
|
+
return summary;
|
|
56635
|
+
}
|
|
56636
|
+
async function confirmNewTagSuggestion(userId, suggestionId, opts) {
|
|
56637
|
+
const client2 = await db.getClient();
|
|
56638
|
+
try {
|
|
56639
|
+
await client2.query("BEGIN");
|
|
56640
|
+
const row = await client2.query(
|
|
56641
|
+
`SELECT id, name, status FROM project_tag_new_suggestions
|
|
56642
|
+
WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
56643
|
+
[suggestionId, userId]
|
|
56644
|
+
);
|
|
56645
|
+
if (row.rows.length === 0) {
|
|
56646
|
+
await client2.query("ROLLBACK");
|
|
56647
|
+
return { suggestion_id: suggestionId, name: "", status: "missing", error: "suggestion_id not found for current user" };
|
|
56648
|
+
}
|
|
56649
|
+
const { name, status } = row.rows[0];
|
|
56650
|
+
if (status !== "pending") {
|
|
56651
|
+
await client2.query("ROLLBACK");
|
|
56652
|
+
return { suggestion_id: suggestionId, name, status, error: "suggestion is not pending" };
|
|
56653
|
+
}
|
|
56654
|
+
const inserted = await client2.query(
|
|
56655
|
+
`INSERT INTO project_tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id`,
|
|
56656
|
+
[name]
|
|
56657
|
+
);
|
|
56658
|
+
const created = inserted.rows.length > 0;
|
|
56659
|
+
const pTagId = created ? Number(inserted.rows[0].id) : Number((await client2.query(`SELECT id FROM project_tags WHERE name = $1`, [name])).rows[0].id);
|
|
56660
|
+
const n = await retrotag(client2, userId, pTagId, name);
|
|
56661
|
+
await client2.query(
|
|
56662
|
+
`UPDATE project_tag_new_suggestions
|
|
56663
|
+
SET status = 'confirmed', decided_by = $2, decision_reason = $3, decided_at = NOW(),
|
|
56664
|
+
project_tag_id = $4, retrotagged = $5, updated_at = NOW()
|
|
56665
|
+
WHERE id = $1`,
|
|
56666
|
+
[suggestionId, opts.decidedBy, opts.reason ?? null, pTagId, n]
|
|
56667
|
+
);
|
|
56668
|
+
await client2.query("COMMIT");
|
|
56669
|
+
invalidateCandidateCache();
|
|
56670
|
+
return { suggestion_id: suggestionId, name, status: "confirmed", project_tag_id: pTagId, created, retrotagged: n };
|
|
56671
|
+
} catch (err3) {
|
|
56672
|
+
try {
|
|
56673
|
+
await client2.query("ROLLBACK");
|
|
56674
|
+
} catch {
|
|
56675
|
+
}
|
|
56676
|
+
throw err3;
|
|
56677
|
+
} finally {
|
|
56678
|
+
client2.release();
|
|
56679
|
+
}
|
|
56680
|
+
}
|
|
56681
|
+
async function rejectNewTagSuggestion(userId, suggestionId, opts) {
|
|
56682
|
+
const result = await db.query(
|
|
56683
|
+
`UPDATE project_tag_new_suggestions
|
|
56684
|
+
SET status = 'rejected', decided_by = $3, decision_reason = $4, decided_at = NOW(), updated_at = NOW()
|
|
56685
|
+
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
|
56686
|
+
RETURNING id, name, status`,
|
|
56687
|
+
[suggestionId, userId, opts.decidedBy, opts.reason ?? null]
|
|
56688
|
+
);
|
|
56689
|
+
if (result.rows.length > 0) {
|
|
56690
|
+
return { suggestion_id: suggestionId, name: String(result.rows[0].name), status: "rejected" };
|
|
56691
|
+
}
|
|
56692
|
+
const row = await db.query(
|
|
56693
|
+
`SELECT name, status FROM project_tag_new_suggestions WHERE id = $1 AND user_id = $2`,
|
|
56694
|
+
[suggestionId, userId]
|
|
56695
|
+
);
|
|
56696
|
+
if (row.rows.length === 0) {
|
|
56697
|
+
return { suggestion_id: suggestionId, name: "", status: "missing", error: "suggestion_id not found for current user" };
|
|
56698
|
+
}
|
|
56699
|
+
return { suggestion_id: suggestionId, name: String(row.rows[0].name), status: String(row.rows[0].status), error: "suggestion is not pending" };
|
|
56700
|
+
}
|
|
56701
|
+
|
|
56414
56702
|
// src/cold_path/tagger.ts
|
|
56415
56703
|
var GROK_FALLBACK_SPEC = { provider: "xai", model_name: "grok-4-1-fast-non-reasoning" };
|
|
56416
56704
|
var localFallbackEnabled = process.env.LOCAL_GROK_FALLBACK !== "false";
|
|
@@ -56571,7 +56859,9 @@ async function tagMessage(input) {
|
|
|
56571
56859
|
if (parsed.p_tag && typeof parsed.p_tag === "string") {
|
|
56572
56860
|
if (parsed.p_tag.startsWith("NEW:")) {
|
|
56573
56861
|
const newName = parsed.p_tag.slice(4).trim();
|
|
56574
|
-
if (newName) {
|
|
56862
|
+
if (newName && await isBlockedNewTagName(newName)) {
|
|
56863
|
+
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.`);
|
|
56864
|
+
} else if (newName) {
|
|
56575
56865
|
p_tag_id = await getOrCreateProjectTag(newName);
|
|
56576
56866
|
newly_created_p_tag_name = newName;
|
|
56577
56867
|
}
|
|
@@ -57640,7 +57930,7 @@ OUTPUT strict JSON only:
|
|
|
57640
57930
|
- If only a few memories are filed under the wrong tag, use "misfile_suspected".
|
|
57641
57931
|
- evidence_memory_ids and conflict_memory_ids must only use supplied memory ids.`;
|
|
57642
57932
|
var JUDGE_SYSTEM_PROMPT = getPrompt("alias_judge", GENERIC_ALIAS_JUDGE_PROMPT);
|
|
57643
|
-
function
|
|
57933
|
+
function envInt2(name, fallback) {
|
|
57644
57934
|
const raw = process.env[name];
|
|
57645
57935
|
if (!raw) return fallback;
|
|
57646
57936
|
const n = Number(raw);
|
|
@@ -57735,8 +58025,8 @@ function tagPayload(tag) {
|
|
|
57735
58025
|
};
|
|
57736
58026
|
}
|
|
57737
58027
|
function isNewOrLowFrequency(tag) {
|
|
57738
|
-
const lowMax =
|
|
57739
|
-
const newDays =
|
|
58028
|
+
const lowMax = envInt2("PROJECT_ALIAS_LOW_COUNT_MAX", 3);
|
|
58029
|
+
const newDays = envInt2("PROJECT_ALIAS_NEW_TAG_DAYS", 14);
|
|
57740
58030
|
const ageMs = Date.now() - tag.createdAt.getTime();
|
|
57741
58031
|
return tag.memoryCount <= lowMax || ageMs <= newDays * 24 * 60 * 60 * 1e3;
|
|
57742
58032
|
}
|
|
@@ -57805,7 +58095,7 @@ function messageMentionsTag(message, tag) {
|
|
|
57805
58095
|
return compactName.length >= 3 && compactMessage.includes(compactName);
|
|
57806
58096
|
}
|
|
57807
58097
|
async function listCanonicalTags(userId) {
|
|
57808
|
-
const maxTags =
|
|
58098
|
+
const maxTags = envInt2("PROJECT_ALIAS_MAX_TAGS", 120);
|
|
57809
58099
|
const result = await db.query(
|
|
57810
58100
|
`SELECT pt.id,
|
|
57811
58101
|
pt.name,
|
|
@@ -57907,7 +58197,7 @@ async function listRepresentativeMemories(userId, tagIds, perTag = 3) {
|
|
|
57907
58197
|
return out;
|
|
57908
58198
|
}
|
|
57909
58199
|
async function addExplicitStatementCandidates(userId, tags, tagsById, pairs) {
|
|
57910
|
-
const scanLimit =
|
|
58200
|
+
const scanLimit = envInt2("PROJECT_ALIAS_EXPLICIT_SCAN_LIMIT", 200);
|
|
57911
58201
|
const clauses = EXPLICIT_PATTERNS.map((_, i) => `message ILIKE $${i + 2}`).join(" OR ");
|
|
57912
58202
|
const limitParam = EXPLICIT_PATTERNS.length + 2;
|
|
57913
58203
|
const result = await db.query(
|
|
@@ -57992,10 +58282,10 @@ function addNameAndDtagCandidates(tags, tagsById, pairs) {
|
|
|
57992
58282
|
}
|
|
57993
58283
|
}
|
|
57994
58284
|
async function addVectorCandidates(userId, tags, tagsById, pairs) {
|
|
57995
|
-
const anchorLimit =
|
|
57996
|
-
const representativeLimit =
|
|
57997
|
-
const perMemoryLimit =
|
|
57998
|
-
const perTagLimit =
|
|
58285
|
+
const anchorLimit = envInt2("PROJECT_ALIAS_VECTOR_ANCHOR_LIMIT", 12);
|
|
58286
|
+
const representativeLimit = envInt2("PROJECT_ALIAS_VECTOR_REPRESENTATIVE_LIMIT", 5);
|
|
58287
|
+
const perMemoryLimit = envInt2("PROJECT_ALIAS_VECTOR_PER_MEMORY_LIMIT", 8);
|
|
58288
|
+
const perTagLimit = envInt2("PROJECT_ALIAS_VECTOR_TARGET_LIMIT", 3);
|
|
57999
58289
|
const maxDistance = envFloat("PROJECT_ALIAS_VECTOR_MAX_DISTANCE", 0.28);
|
|
58000
58290
|
const anchors = tags.slice().sort((a, b) => {
|
|
58001
58291
|
const aLow = isNewOrLowFrequency(a) ? 1 : 0;
|
|
@@ -58151,7 +58441,7 @@ async function judgePair(pair, tagsById, memoriesByTag) {
|
|
|
58151
58441
|
const allowedIds = collectAllowedMemoryIds(pair, sourceMemories, targetMemories);
|
|
58152
58442
|
const promptSignals = { ...pair.signals };
|
|
58153
58443
|
delete promptSignals.explicit_statement_previews;
|
|
58154
|
-
const explicitPromptCap =
|
|
58444
|
+
const explicitPromptCap = envInt2("PROJECT_ALIAS_EXPLICIT_PROMPT_CAP", 5);
|
|
58155
58445
|
const userPrompt = JSON.stringify(
|
|
58156
58446
|
{
|
|
58157
58447
|
task: "Judge whether Tag A and Tag B are the same project identity.",
|
|
@@ -58171,7 +58461,7 @@ async function judgePair(pair, tagsById, memoriesByTag) {
|
|
|
58171
58461
|
system: JUDGE_SYSTEM_PROMPT,
|
|
58172
58462
|
user: userPrompt,
|
|
58173
58463
|
...spec.provider === "local" ? { jsonSchema: JUDGE_SCHEMA, enableThinking: false } : { responseFormat: "json" },
|
|
58174
|
-
maxTokens:
|
|
58464
|
+
maxTokens: envInt2("PROJECT_ALIAS_JUDGE_MAX_TOKENS", 8192)
|
|
58175
58465
|
});
|
|
58176
58466
|
if (!raw) throw new Error("project_alias_judge returned empty content");
|
|
58177
58467
|
return normalizeJudgment(raw, allowedIds);
|
|
@@ -58188,7 +58478,7 @@ async function areCanonicalRoots(tagIds) {
|
|
|
58188
58478
|
return result.rows.every((row) => row.alias_of === null);
|
|
58189
58479
|
}
|
|
58190
58480
|
async function hasRecentRejectedPair(userId, sourceId, targetId) {
|
|
58191
|
-
const rejectWindowDays =
|
|
58481
|
+
const rejectWindowDays = envInt2("PROJECT_ALIAS_REJECT_WINDOW_DAYS", 30);
|
|
58192
58482
|
const result = await db.query(
|
|
58193
58483
|
`SELECT 1
|
|
58194
58484
|
FROM project_tag_alias_suggestions
|
|
@@ -58386,7 +58676,7 @@ function qualifiesForPending(judgment, minConfidence) {
|
|
|
58386
58676
|
}
|
|
58387
58677
|
async function runProjectAliasPromoter(opts = {}) {
|
|
58388
58678
|
const userId = opts.userId ?? await getDefaultUserId();
|
|
58389
|
-
const candidateLimit = opts.limit ??
|
|
58679
|
+
const candidateLimit = opts.limit ?? envInt2("PROJECT_ALIAS_CANDIDATE_LIMIT", 12);
|
|
58390
58680
|
const pendingMin = envFloat("PROJECT_ALIAS_PENDING_MIN", 0.85);
|
|
58391
58681
|
const summary = {
|
|
58392
58682
|
userId,
|
|
@@ -58478,7 +58768,7 @@ async function maybeRunProjectAliasPromoter() {
|
|
|
58478
58768
|
if (process.env.PROJECT_ALIAS_PROMOTER_ENABLED !== "true") return;
|
|
58479
58769
|
if (projectAliasPromoterRunning) return;
|
|
58480
58770
|
projectAliasPromoterRunning = true;
|
|
58481
|
-
const intervalHours =
|
|
58771
|
+
const intervalHours = envInt2(
|
|
58482
58772
|
"PROJECT_ALIAS_PROMOTER_INTERVAL_HOURS",
|
|
58483
58773
|
PROJECT_ALIAS_PROMOTER_DEFAULT_INTERVAL_HOURS
|
|
58484
58774
|
);
|
|
@@ -58812,6 +59102,76 @@ async function rejectAlias(userId, suggestionId, reason) {
|
|
|
58812
59102
|
decided_at: row.decided_at
|
|
58813
59103
|
});
|
|
58814
59104
|
}
|
|
59105
|
+
async function listNewTags(userId, status, limit2) {
|
|
59106
|
+
const result = await db.query(
|
|
59107
|
+
`SELECT id, name, uses, recommendation, rationale, created_at, last_seen_at,
|
|
59108
|
+
decided_by, decision_reason, decided_at
|
|
59109
|
+
FROM project_tag_new_suggestions
|
|
59110
|
+
WHERE user_id = $1
|
|
59111
|
+
AND status = $2
|
|
59112
|
+
ORDER BY created_at DESC
|
|
59113
|
+
LIMIT $3`,
|
|
59114
|
+
[userId, status, limit2]
|
|
59115
|
+
);
|
|
59116
|
+
const suggestions = result.rows.map((row) => ({
|
|
59117
|
+
suggestion_id: Number(row.id),
|
|
59118
|
+
name: String(row.name),
|
|
59119
|
+
uses: Number(row.uses),
|
|
59120
|
+
recommendation: row.recommendation ?? null,
|
|
59121
|
+
rationale: String(row.rationale ?? ""),
|
|
59122
|
+
created_at: row.created_at,
|
|
59123
|
+
last_seen_at: row.last_seen_at,
|
|
59124
|
+
decided_by: row.decided_by ?? null,
|
|
59125
|
+
decision_reason: row.decision_reason ?? null,
|
|
59126
|
+
decided_at: row.decided_at ?? null
|
|
59127
|
+
}));
|
|
59128
|
+
return ok2({ action: "list_new_tags", status, limit: limit2, count: suggestions.length, suggestions });
|
|
59129
|
+
}
|
|
59130
|
+
function decisionResult(action, result) {
|
|
59131
|
+
if (result.error) return toolError(result.error, { action, ...result });
|
|
59132
|
+
return ok2({ action, ...result });
|
|
59133
|
+
}
|
|
59134
|
+
async function applyRecommendations(userId, ids, reason) {
|
|
59135
|
+
const rows = await db.query(
|
|
59136
|
+
`SELECT id, name, status, recommendation
|
|
59137
|
+
FROM project_tag_new_suggestions
|
|
59138
|
+
WHERE user_id = $1
|
|
59139
|
+
AND id = ANY($2::bigint[])`,
|
|
59140
|
+
[userId, ids]
|
|
59141
|
+
);
|
|
59142
|
+
const names = new Map(rows.rows.map((r) => [Number(r.id), String(r.name)]));
|
|
59143
|
+
const plan = planApplyRecommendations(
|
|
59144
|
+
ids,
|
|
59145
|
+
rows.rows.map((r) => ({
|
|
59146
|
+
id: Number(r.id),
|
|
59147
|
+
status: String(r.status),
|
|
59148
|
+
recommendation: parseRecommendation(r.recommendation)
|
|
59149
|
+
}))
|
|
59150
|
+
);
|
|
59151
|
+
const why = reason ? `apply_recommendations: ${reason}` : "apply_recommendations";
|
|
59152
|
+
const confirmed = [];
|
|
59153
|
+
const rejected = [];
|
|
59154
|
+
const failed = [];
|
|
59155
|
+
const decide = async (id, fn, done) => {
|
|
59156
|
+
try {
|
|
59157
|
+
const r = await fn(userId, id, { decidedBy: "user", reason: why });
|
|
59158
|
+
(r.error ? failed : done).push(r);
|
|
59159
|
+
} catch (err3) {
|
|
59160
|
+
failed.push({ suggestion_id: id, name: names.get(id) ?? "", status: "error", error: errorDetail(err3) });
|
|
59161
|
+
}
|
|
59162
|
+
};
|
|
59163
|
+
for (const id of plan.confirm) await decide(id, confirmNewTagSuggestion, confirmed);
|
|
59164
|
+
for (const id of plan.reject) await decide(id, rejectNewTagSuggestion, rejected);
|
|
59165
|
+
const label = (id) => ({ suggestion_id: id, name: names.get(id) ?? null });
|
|
59166
|
+
return ok2({
|
|
59167
|
+
action: "apply_recommendations",
|
|
59168
|
+
confirmed,
|
|
59169
|
+
rejected,
|
|
59170
|
+
kept_pending: plan.keep.map(label),
|
|
59171
|
+
skipped_not_pending: plan.skipped.map(label),
|
|
59172
|
+
failed
|
|
59173
|
+
});
|
|
59174
|
+
}
|
|
58815
59175
|
async function setAlias(userId, sourceTagName, targetTagName, reason) {
|
|
58816
59176
|
const sourceName = normalizeTagName(sourceTagName);
|
|
58817
59177
|
const targetName = normalizeTagName(targetTagName);
|
|
@@ -58932,25 +59292,36 @@ function registerManageProjectTags(server) {
|
|
|
58932
59292
|
server.registerTool(
|
|
58933
59293
|
"manage_project_tags",
|
|
58934
59294
|
{
|
|
58935
|
-
description: `Project tag
|
|
59295
|
+
description: `Project tag suggestion management tool (Stage 2 / DEVLOG \xA719).
|
|
58936
59296
|
|
|
58937
|
-
|
|
59297
|
+
Alias suggestions (two existing tags look like the same project):
|
|
58938
59298
|
- list_suggestions: list alias suggestions by status for the default user.
|
|
58939
59299
|
- confirm_alias: confirm a pending suggestion through applyAliasSuggestion().
|
|
58940
59300
|
- reject_alias: reject a pending suggestion.
|
|
58941
59301
|
- set_alias: manually set source_tag as an alias of target_tag through a pending suggestion + applyAliasSuggestion().
|
|
58942
|
-
- unset_alias: clear alias_of for one project tag
|
|
59302
|
+
- unset_alias: clear alias_of for one project tag.
|
|
59303
|
+
|
|
59304
|
+
New project tag suggestions (a frequent d_tag that is not a project tag yet; ids are separate from alias ids):
|
|
59305
|
+
- 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.
|
|
59306
|
+
- confirm_new_tag: create the project tag and retro-tag untagged memories carrying that exact d_tag.
|
|
59307
|
+
- reject_new_tag: reject it; a rejected name is never suggested again.
|
|
59308
|
+
- 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.`,
|
|
58943
59309
|
inputSchema: {
|
|
58944
59310
|
action: external_exports.enum([
|
|
58945
59311
|
"list_suggestions",
|
|
58946
59312
|
"confirm_alias",
|
|
58947
59313
|
"reject_alias",
|
|
58948
59314
|
"set_alias",
|
|
58949
|
-
"unset_alias"
|
|
59315
|
+
"unset_alias",
|
|
59316
|
+
"list_new_tags",
|
|
59317
|
+
"confirm_new_tag",
|
|
59318
|
+
"reject_new_tag",
|
|
59319
|
+
"apply_recommendations"
|
|
58950
59320
|
]).describe("\uC791\uC5C5 \uC885\uB958"),
|
|
58951
|
-
status: statusSchema.optional().describe("list_suggestions status filter (default pending)"),
|
|
58952
|
-
limit: external_exports.number().int().min(1).max(MAX_LIMIT).optional().describe(`list_suggestions limit (default ${DEFAULT_LIMIT2})`),
|
|
58953
|
-
suggestion_id: external_exports.number().int().positive().optional().describe("confirm_alias/reject_alias \uB300\uC0C1 suggestion id"),
|
|
59321
|
+
status: statusSchema.optional().describe("list_suggestions / list_new_tags status filter (default pending)"),
|
|
59322
|
+
limit: external_exports.number().int().min(1).max(MAX_LIMIT).optional().describe(`list_suggestions / list_new_tags limit (default ${DEFAULT_LIMIT2})`),
|
|
59323
|
+
suggestion_id: external_exports.number().int().positive().optional().describe("confirm_alias/reject_alias/confirm_new_tag/reject_new_tag \uB300\uC0C1 suggestion id"),
|
|
59324
|
+
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"),
|
|
58954
59325
|
reason: external_exports.string().optional().describe("confirm/reject/set decision reason"),
|
|
58955
59326
|
source_tag: external_exports.string().optional().describe("set_alias source project_tags.name"),
|
|
58956
59327
|
target_tag: external_exports.string().optional().describe("set_alias target project_tags.name"),
|
|
@@ -58985,6 +59356,22 @@ Actions:
|
|
|
58985
59356
|
if (args.action === "unset_alias") {
|
|
58986
59357
|
return unsetAlias(userId, args.tag);
|
|
58987
59358
|
}
|
|
59359
|
+
if (args.action === "list_new_tags") {
|
|
59360
|
+
return listNewTags(userId, args.status ?? "pending", args.limit ?? DEFAULT_LIMIT2);
|
|
59361
|
+
}
|
|
59362
|
+
if (args.action === "confirm_new_tag" || args.action === "reject_new_tag") {
|
|
59363
|
+
if (args.suggestion_id == null) {
|
|
59364
|
+
return toolError(`suggestion_id is required for ${args.action}`);
|
|
59365
|
+
}
|
|
59366
|
+
const decide = args.action === "confirm_new_tag" ? confirmNewTagSuggestion : rejectNewTagSuggestion;
|
|
59367
|
+
return decisionResult(args.action, await decide(userId, args.suggestion_id, { decidedBy: "user", reason: args.reason }));
|
|
59368
|
+
}
|
|
59369
|
+
if (args.action === "apply_recommendations") {
|
|
59370
|
+
if (!args.suggestion_ids?.length) {
|
|
59371
|
+
return toolError("suggestion_ids is required for apply_recommendations");
|
|
59372
|
+
}
|
|
59373
|
+
return applyRecommendations(userId, args.suggestion_ids, args.reason);
|
|
59374
|
+
}
|
|
58988
59375
|
return toolError("unsupported action", { action: args.action });
|
|
58989
59376
|
} catch (err3) {
|
|
58990
59377
|
return toolError("manage_project_tags failed", { detail: errorDetail(err3) });
|
|
@@ -59005,6 +59392,7 @@ var PREVIEW_COMPACT = 100;
|
|
|
59005
59392
|
var PREVIEW_RECENT = 300;
|
|
59006
59393
|
var ACTIVE_PTAG_LIMIT = 5;
|
|
59007
59394
|
var PENDING_ALIAS_SUGGESTION_LIMIT = 2;
|
|
59395
|
+
var PENDING_NEW_TAG_LIMIT = 2;
|
|
59008
59396
|
var PINNED_LIMIT = 10;
|
|
59009
59397
|
var INJECT_PINNED_LIMIT = 5;
|
|
59010
59398
|
var BRIEF_MAX_CHARS = Number(process.env.BRIEF_MAX_CHARS ?? 8e3);
|
|
@@ -59101,11 +59489,32 @@ async function collectBrief(opts = {}) {
|
|
|
59101
59489
|
LIMIT $2`,
|
|
59102
59490
|
[userId, PENDING_ALIAS_SUGGESTION_LIMIT]
|
|
59103
59491
|
);
|
|
59492
|
+
let newTags = [];
|
|
59493
|
+
try {
|
|
59494
|
+
const r = await db.query(
|
|
59495
|
+
`SELECT id, name, uses, recommendation
|
|
59496
|
+
FROM project_tag_new_suggestions
|
|
59497
|
+
WHERE user_id = $1 AND status = 'pending'
|
|
59498
|
+
AND recommendation IS DISTINCT FROM 'generic'
|
|
59499
|
+
ORDER BY (recommendation = 'project') DESC NULLS LAST, uses DESC
|
|
59500
|
+
LIMIT $2`,
|
|
59501
|
+
[userId, PENDING_NEW_TAG_LIMIT]
|
|
59502
|
+
);
|
|
59503
|
+
newTags = r.rows;
|
|
59504
|
+
} catch {
|
|
59505
|
+
newTags = [];
|
|
59506
|
+
}
|
|
59104
59507
|
return {
|
|
59105
59508
|
user_name: user.user_name,
|
|
59106
59509
|
core_profile: user.core_profile,
|
|
59107
59510
|
sub_profile: user.sub_profile,
|
|
59108
59511
|
pinned_memories: pinnedMsgs.rows.map(rowToMsg),
|
|
59512
|
+
pending_new_tags: newTags.map((r) => ({
|
|
59513
|
+
id: Number(r.id),
|
|
59514
|
+
name: String(r.name),
|
|
59515
|
+
uses: Number(r.uses),
|
|
59516
|
+
recommendation: r.recommendation ?? null
|
|
59517
|
+
})),
|
|
59109
59518
|
pending_alias_suggestions: aliasSugg.rows.map((r) => ({
|
|
59110
59519
|
id: Number(r.id),
|
|
59111
59520
|
source: String(r.source),
|
|
@@ -59167,11 +59576,14 @@ function formatBriefFull(brief) {
|
|
|
59167
59576
|
}
|
|
59168
59577
|
gLines.push("");
|
|
59169
59578
|
}
|
|
59170
|
-
if (brief.pending_alias_suggestions.length > 0) {
|
|
59579
|
+
if (brief.pending_alias_suggestions.length > 0 || brief.pending_new_tags.length > 0) {
|
|
59171
59580
|
gLines.push("## Project Tag Suggestions (\uC0AC\uC6A9\uC790 \uD655\uC778 \uD544\uC694)");
|
|
59172
59581
|
for (const s of brief.pending_alias_suggestions) {
|
|
59173
59582
|
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\`.`);
|
|
59174
59583
|
}
|
|
59584
|
+
for (const t of brief.pending_new_tags) {
|
|
59585
|
+
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\`.`);
|
|
59586
|
+
}
|
|
59175
59587
|
gLines.push("");
|
|
59176
59588
|
}
|
|
59177
59589
|
const guaranteed = gLines.join("\n");
|
|
@@ -60820,155 +61232,6 @@ function registerTools(server) {
|
|
|
60820
61232
|
init_db();
|
|
60821
61233
|
init_buzz_envelope();
|
|
60822
61234
|
|
|
60823
|
-
// src/cold_path/dtag_promoter.ts
|
|
60824
|
-
init_db();
|
|
60825
|
-
init_users();
|
|
60826
|
-
function envInt2(name, fallback) {
|
|
60827
|
-
const raw = process.env[name];
|
|
60828
|
-
if (!raw) return fallback;
|
|
60829
|
-
const n = Number(raw);
|
|
60830
|
-
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
|
60831
|
-
}
|
|
60832
|
-
var CLUSTER_SYSTEM = `You are a keyword clustering assistant for a personal memory system.
|
|
60833
|
-
|
|
60834
|
-
Given a list of d_tags (short hyphenated keywords) with their occurrence counts,
|
|
60835
|
-
group semantically similar tags that refer to the same project or topic.
|
|
60836
|
-
|
|
60837
|
-
OUTPUT strict JSON object (NOT a bare array):
|
|
60838
|
-
{ "clusters": [
|
|
60839
|
-
{ "canonical": "<best-slug>", "members": ["<tag1>", "<tag2>", ...] }
|
|
60840
|
-
] }
|
|
60841
|
-
|
|
60842
|
-
Rules:
|
|
60843
|
-
- canonical must be one of the input tags (pick the most descriptive one) or a clean slug if none fit
|
|
60844
|
-
- canonical must be lowercase, hyphenated (e.g. "yt-signal-finder")
|
|
60845
|
-
- Only group tags that clearly refer to the same project/topic
|
|
60846
|
-
- Tags with no similar counterparts become their own single-member cluster
|
|
60847
|
-
- Do NOT merge unrelated topics just because they share one word`;
|
|
60848
|
-
var CLUSTER_SCHEMA = {
|
|
60849
|
-
type: "object",
|
|
60850
|
-
properties: {
|
|
60851
|
-
clusters: {
|
|
60852
|
-
type: "array",
|
|
60853
|
-
items: {
|
|
60854
|
-
type: "object",
|
|
60855
|
-
properties: {
|
|
60856
|
-
canonical: { type: "string" },
|
|
60857
|
-
members: { type: "array", items: { type: "string" } }
|
|
60858
|
-
},
|
|
60859
|
-
required: ["canonical", "members"],
|
|
60860
|
-
additionalProperties: false
|
|
60861
|
-
}
|
|
60862
|
-
}
|
|
60863
|
-
},
|
|
60864
|
-
required: ["clusters"],
|
|
60865
|
-
additionalProperties: false
|
|
60866
|
-
};
|
|
60867
|
-
async function clusterDTags(tags) {
|
|
60868
|
-
if (tags.length === 0) return [];
|
|
60869
|
-
const tagList = tags.map((t) => `${t.tag} (${t.cnt}x)`).join(", ");
|
|
60870
|
-
const userPrompt = `Cluster these d_tags by project/topic:
|
|
60871
|
-
${tagList}`;
|
|
60872
|
-
let raw;
|
|
60873
|
-
try {
|
|
60874
|
-
raw = await callRole("clusterer", {
|
|
60875
|
-
system: CLUSTER_SYSTEM,
|
|
60876
|
-
user: userPrompt,
|
|
60877
|
-
jsonSchema: CLUSTER_SCHEMA,
|
|
60878
|
-
enableThinking: false,
|
|
60879
|
-
maxTokens: 4096
|
|
60880
|
-
// was 512 — 50개 태그 클러스터링 출력이 잘려 invalid JSON 폴백됨 (ctx 8192 내 여유)
|
|
60881
|
-
});
|
|
60882
|
-
} catch (err3) {
|
|
60883
|
-
console.error("\u26A0\uFE0F [DTagPromoter] clusterer call failed, falling back to no clustering:", err3);
|
|
60884
|
-
return tags.map((t) => ({ canonical: t.tag, members: [t.tag], total: t.cnt }));
|
|
60885
|
-
}
|
|
60886
|
-
let parsed;
|
|
60887
|
-
try {
|
|
60888
|
-
const obj = JSON.parse(raw);
|
|
60889
|
-
parsed = obj?.clusters;
|
|
60890
|
-
if (!Array.isArray(parsed)) throw new Error("no clusters array");
|
|
60891
|
-
} catch {
|
|
60892
|
-
console.error("\u26A0\uFE0F [DTagPromoter] clusterer returned invalid JSON, falling back:", raw.slice(0, 200));
|
|
60893
|
-
return tags.map((t) => ({ canonical: t.tag, members: [t.tag], total: t.cnt }));
|
|
60894
|
-
}
|
|
60895
|
-
const freqMap = new Map(tags.map((t) => [t.tag, t.cnt]));
|
|
60896
|
-
return parsed.map((c) => {
|
|
60897
|
-
const canonical = String(c.canonical || "").toLowerCase().trim().replace(/\s+/g, "-");
|
|
60898
|
-
const members = Array.isArray(c.members) ? c.members.map((m) => String(m).toLowerCase().trim()).filter(Boolean) : [canonical];
|
|
60899
|
-
const total = members.reduce((sum, m) => sum + (freqMap.get(m) ?? 0), 0);
|
|
60900
|
-
return { canonical, members, total };
|
|
60901
|
-
});
|
|
60902
|
-
}
|
|
60903
|
-
async function runDtagPromotion() {
|
|
60904
|
-
const minCount = envInt2("DTAG_PROMOTE_MIN_COUNT", 10);
|
|
60905
|
-
const windowDays = envInt2("DTAG_PROMOTE_WINDOW_DAYS", 30);
|
|
60906
|
-
const userId = await getDefaultUserId();
|
|
60907
|
-
const freqResult = await db.query(
|
|
60908
|
-
`SELECT unnest(d_tag) AS tag, COUNT(*)::int AS cnt
|
|
60909
|
-
FROM memory
|
|
60910
|
-
WHERE user_id = $1
|
|
60911
|
-
AND tag_processed = TRUE
|
|
60912
|
-
AND is_active = TRUE
|
|
60913
|
-
AND created_at >= NOW() - ($2 || ' days')::INTERVAL
|
|
60914
|
-
GROUP BY tag
|
|
60915
|
-
HAVING COUNT(*) >= 2
|
|
60916
|
-
ORDER BY cnt DESC
|
|
60917
|
-
LIMIT 50`,
|
|
60918
|
-
[userId, String(windowDays)]
|
|
60919
|
-
);
|
|
60920
|
-
if (freqResult.rows.length === 0) return { promoted: [], retrotagged: 0 };
|
|
60921
|
-
const tags = freqResult.rows.map((r) => ({
|
|
60922
|
-
tag: String(r.tag).toLowerCase().trim(),
|
|
60923
|
-
cnt: Number(r.cnt)
|
|
60924
|
-
})).filter((t) => t.tag.length > 0);
|
|
60925
|
-
const clusters = await clusterDTags(tags);
|
|
60926
|
-
const toPromote = clusters.filter((c) => c.total >= minCount);
|
|
60927
|
-
if (toPromote.length === 0) return { promoted: [], retrotagged: 0 };
|
|
60928
|
-
const summary = { promoted: [], retrotagged: 0 };
|
|
60929
|
-
for (const cluster of toPromote) {
|
|
60930
|
-
if (!cluster.canonical) continue;
|
|
60931
|
-
const upsert = await db.query(
|
|
60932
|
-
`INSERT INTO project_tags (name)
|
|
60933
|
-
VALUES ($1)
|
|
60934
|
-
ON CONFLICT (name) DO NOTHING
|
|
60935
|
-
RETURNING id`,
|
|
60936
|
-
[cluster.canonical]
|
|
60937
|
-
);
|
|
60938
|
-
let pTagId;
|
|
60939
|
-
if (upsert.rows.length > 0) {
|
|
60940
|
-
pTagId = Number(upsert.rows[0].id);
|
|
60941
|
-
summary.promoted.push(cluster.canonical);
|
|
60942
|
-
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] promoted "${cluster.canonical}" (total=${cluster.total}, members=${cluster.members.join(", ")})`);
|
|
60943
|
-
invalidateCandidateCache();
|
|
60944
|
-
} else {
|
|
60945
|
-
const existing = await db.query(
|
|
60946
|
-
`SELECT id FROM project_tags WHERE name = $1 LIMIT 1`,
|
|
60947
|
-
[cluster.canonical]
|
|
60948
|
-
);
|
|
60949
|
-
if (existing.rows.length === 0) continue;
|
|
60950
|
-
pTagId = Number(existing.rows[0].id);
|
|
60951
|
-
}
|
|
60952
|
-
for (const memberTag of cluster.members) {
|
|
60953
|
-
const updated = await db.query(
|
|
60954
|
-
`UPDATE memory
|
|
60955
|
-
SET p_tag_id = $1, updated_at = NOW()
|
|
60956
|
-
WHERE user_id = $2
|
|
60957
|
-
AND tag_processed = TRUE
|
|
60958
|
-
AND p_tag_id IS NULL
|
|
60959
|
-
AND $3 = ANY(d_tag)
|
|
60960
|
-
RETURNING id`,
|
|
60961
|
-
[pTagId, userId, memberTag]
|
|
60962
|
-
);
|
|
60963
|
-
summary.retrotagged += updated.rows.length;
|
|
60964
|
-
}
|
|
60965
|
-
if (summary.retrotagged > 0) {
|
|
60966
|
-
console.error(`\u{1F3F7}\uFE0F [DTagPromoter] retrotagged ${summary.retrotagged} rows with "${cluster.canonical}"`);
|
|
60967
|
-
}
|
|
60968
|
-
}
|
|
60969
|
-
return summary;
|
|
60970
|
-
}
|
|
60971
|
-
|
|
60972
61235
|
// src/librarian.ts
|
|
60973
61236
|
init_db();
|
|
60974
61237
|
init_users();
|
|
@@ -61481,8 +61744,8 @@ async function maybeRunPromotion() {
|
|
|
61481
61744
|
if (tickCount % DTAG_PROMOTE_EVERY_N_TICKS !== 0) return;
|
|
61482
61745
|
try {
|
|
61483
61746
|
const result = await runDtagPromotion();
|
|
61484
|
-
if (result.
|
|
61485
|
-
console.error(`\u{1F3F7}\uFE0F [DTagPromoter]
|
|
61747
|
+
if (result.suggested.length > 0 || result.retrotagged > 0 || result.superseded > 0 || result.deferred > 0) {
|
|
61748
|
+
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`);
|
|
61486
61749
|
}
|
|
61487
61750
|
} catch (err3) {
|
|
61488
61751
|
console.error("\u26A0\uFE0F [DTagPromoter] promotion error (non-blocking):", err3);
|