@gethmy/mcp 2.2.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +780 -352
- package/dist/index.js +744 -351
- package/dist/lib/active-learning.js +73 -129
- package/dist/lib/consolidation.js +71 -11
- package/dist/lib/context-assembly.js +287 -30
- package/dist/lib/memory-cleanup.js +426 -0
- package/dist/lib/prompt-builder.js +5 -1
- package/dist/lib/server.js +63 -0
- package/dist/lib/skills.js +25 -1
- package/dist/lib/tui/setup.js +11 -0
- package/package.json +1 -1
- package/src/active-learning.ts +83 -145
- package/src/consolidation.ts +81 -12
- package/src/context-assembly.ts +342 -30
- package/src/memory-cleanup.ts +616 -0
- package/src/prompt-builder.ts +13 -1
- package/src/server.ts +74 -0
- package/src/skills.ts +25 -1
- package/src/tui/setup.ts +11 -0
package/src/active-learning.ts
CHANGED
|
@@ -111,15 +111,15 @@ export interface MidSessionContext {
|
|
|
111
111
|
* Called from harmony_update_agent_progress.
|
|
112
112
|
*/
|
|
113
113
|
export async function extractMidSessionLearnings(
|
|
114
|
-
|
|
114
|
+
_client: HarmonyApiClient,
|
|
115
115
|
ctx: MidSessionContext,
|
|
116
116
|
): Promise<{ count: number; entityIds: string[] }> {
|
|
117
117
|
const workspaceId = getActiveWorkspaceId();
|
|
118
118
|
if (!workspaceId) return { count: 0, entityIds: [] };
|
|
119
119
|
|
|
120
|
-
const
|
|
120
|
+
const _projectId = getActiveProjectId() || undefined;
|
|
121
121
|
const now = Date.now();
|
|
122
|
-
const
|
|
122
|
+
const _entityIds: string[] = [];
|
|
123
123
|
|
|
124
124
|
const history = sessionTaskHistory.get(ctx.cardId);
|
|
125
125
|
|
|
@@ -164,83 +164,31 @@ export async function extractMidSessionLearnings(
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Rule 1: Status transitions to "blocked" → create
|
|
167
|
+
// Rule 1: Status transitions to "blocked" → track but DON'T create mid-session entities.
|
|
168
|
+
// Blockers are captured at session end with full context — mid-session entities are
|
|
169
|
+
// low-confidence duplicates that add noise to the knowledge graph.
|
|
168
170
|
if (ctx.status === "blocked" && ctx.blockers?.length) {
|
|
169
|
-
for (const blocker of ctx.blockers) {
|
|
170
|
-
try {
|
|
171
|
-
const result = await client.createMemoryEntity({
|
|
172
|
-
workspace_id: workspaceId,
|
|
173
|
-
project_id: projectId,
|
|
174
|
-
type: "error",
|
|
175
|
-
scope: "project",
|
|
176
|
-
memory_tier: "draft",
|
|
177
|
-
title: `Blocker (mid-session): ${blocker.slice(0, 100)}`,
|
|
178
|
-
content: `Encountered while working on "${ctx.cardTitle}":\n\n${blocker}\n\nAgent: ${ctx.agentName}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
|
|
179
|
-
confidence: 0.5,
|
|
180
|
-
tags: ["auto-extracted", "blocker", "mid-session"],
|
|
181
|
-
metadata: {
|
|
182
|
-
source: "mid_session",
|
|
183
|
-
card_id: ctx.cardId,
|
|
184
|
-
},
|
|
185
|
-
agent_identifier: ctx.agentIdentifier,
|
|
186
|
-
});
|
|
187
|
-
const entity = result.entity as { id: string };
|
|
188
|
-
if (entity?.id) entityIds.push(entity.id);
|
|
189
|
-
} catch {
|
|
190
|
-
// Non-fatal
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
171
|
sessionTaskHistory.set(ctx.cardId, {
|
|
194
172
|
lastTask: ctx.currentTask || "",
|
|
195
173
|
lastExtractionAt: now,
|
|
196
174
|
steps: history?.steps || [],
|
|
197
175
|
});
|
|
198
|
-
return { count:
|
|
176
|
+
return { count: 0, entityIds: [] };
|
|
199
177
|
}
|
|
200
178
|
|
|
201
|
-
// Rule 2: Task
|
|
179
|
+
// Rule 2: Task transitions are tracked in step history (above) but no longer
|
|
180
|
+
// create separate context entities. The step history feeds procedure extraction
|
|
181
|
+
// at session end, which is more valuable than individual transition snapshots.
|
|
202
182
|
if (ctx.currentTask) {
|
|
203
|
-
const previousTask = history?.lastTask || "";
|
|
204
|
-
const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
|
|
205
|
-
|
|
206
|
-
if (similarity < 0.6 && previousTask.length > 0) {
|
|
207
|
-
try {
|
|
208
|
-
const result = await client.createMemoryEntity({
|
|
209
|
-
workspace_id: workspaceId,
|
|
210
|
-
project_id: projectId,
|
|
211
|
-
type: "context",
|
|
212
|
-
scope: "project",
|
|
213
|
-
memory_tier: "draft",
|
|
214
|
-
title: `Task transition: ${ctx.cardTitle}`,
|
|
215
|
-
content: `Agent transitioned tasks on "${ctx.cardTitle}".\n\nPrevious: ${previousTask}\nCurrent: ${ctx.currentTask}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
|
|
216
|
-
confidence: 0.5,
|
|
217
|
-
tags: ["auto-extracted", "task-transition", "mid-session"],
|
|
218
|
-
metadata: {
|
|
219
|
-
source: "mid_session",
|
|
220
|
-
card_id: ctx.cardId,
|
|
221
|
-
previous_task: previousTask,
|
|
222
|
-
current_task: ctx.currentTask,
|
|
223
|
-
},
|
|
224
|
-
agent_identifier: ctx.agentIdentifier,
|
|
225
|
-
});
|
|
226
|
-
const entity = result.entity as { id: string };
|
|
227
|
-
if (entity?.id) entityIds.push(entity.id);
|
|
228
|
-
} catch {
|
|
229
|
-
// Non-fatal
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Update lastExtractionAt only when entities were created
|
|
234
183
|
const currentHistory = sessionTaskHistory.get(ctx.cardId);
|
|
235
184
|
sessionTaskHistory.set(ctx.cardId, {
|
|
236
185
|
lastTask: ctx.currentTask,
|
|
237
|
-
lastExtractionAt:
|
|
238
|
-
entityIds.length > 0 ? now : (currentHistory?.lastExtractionAt ?? 0),
|
|
186
|
+
lastExtractionAt: currentHistory?.lastExtractionAt ?? 0,
|
|
239
187
|
steps: currentHistory?.steps || [],
|
|
240
188
|
});
|
|
241
189
|
}
|
|
242
190
|
|
|
243
|
-
return { count:
|
|
191
|
+
return { count: 0, entityIds: [] };
|
|
244
192
|
}
|
|
245
193
|
|
|
246
194
|
/**
|
|
@@ -589,51 +537,71 @@ export async function extractLearnings(
|
|
|
589
537
|
? `\nRelated: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}`
|
|
590
538
|
: "";
|
|
591
539
|
|
|
592
|
-
// Rule 1: Session had blockers → create error
|
|
540
|
+
// Rule 1: Session had blockers → create error entity (only for substantial blockers)
|
|
541
|
+
// Skip trivial blocker strings — only store if the blocker text contains
|
|
542
|
+
// enough detail to be useful to a future agent (>80 chars).
|
|
593
543
|
if (session.blockers && session.blockers.length > 0) {
|
|
594
544
|
for (const blocker of session.blockers) {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
545
|
+
if (blocker.length < 80) continue; // Skip trivial blockers like "stuck" or "waiting on API"
|
|
546
|
+
|
|
547
|
+
// Dedup: check if a similar error entity already exists
|
|
548
|
+
let isDuplicate = false;
|
|
549
|
+
try {
|
|
550
|
+
const similar = await findSimilarEntities(
|
|
551
|
+
client,
|
|
552
|
+
blocker.slice(0, 200),
|
|
553
|
+
blocker,
|
|
554
|
+
workspaceId,
|
|
555
|
+
{ projectId, limit: 3, minRrfScore: 0.05 },
|
|
556
|
+
);
|
|
557
|
+
isDuplicate = similar.some(
|
|
558
|
+
(e) => e.type === "error" && (e.rrf_score ?? 0) >= 0.06,
|
|
559
|
+
);
|
|
560
|
+
} catch {
|
|
561
|
+
/* non-fatal */
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!isDuplicate) {
|
|
565
|
+
learnings.push({
|
|
566
|
+
title: `Blocker: ${blocker.slice(0, 100)}`,
|
|
567
|
+
content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
|
|
568
|
+
type: "error",
|
|
569
|
+
tier: "episode",
|
|
570
|
+
confidence: 0.6,
|
|
571
|
+
tags: [
|
|
572
|
+
"auto-extracted",
|
|
573
|
+
"blocker",
|
|
574
|
+
...session.cardLabels.slice(0, 3),
|
|
575
|
+
],
|
|
576
|
+
metadata: {
|
|
577
|
+
source: "active_learning",
|
|
578
|
+
card_id: session.cardId,
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|
|
607
582
|
}
|
|
608
583
|
}
|
|
609
584
|
|
|
610
|
-
// Rule 2: Session
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
((session.cardSubtasks?.length ?? 0) > 0 &&
|
|
616
|
-
session.cardSubtasks?.some((s) => !s.done));
|
|
617
|
-
|
|
618
|
-
if (session.status === "completed" && hasMeaningfulContent) {
|
|
585
|
+
// Rule 2: Session paused with blockers → create lesson (paused only, not clean completions).
|
|
586
|
+
// Clean completions produce no reusable knowledge — the work is in the code/PR.
|
|
587
|
+
// Only create a lesson when the session was interrupted (paused with blockers),
|
|
588
|
+
// so a future agent can understand what was left unfinished and why.
|
|
589
|
+
if (session.status === "paused" && (session.blockers?.length ?? 0) > 0) {
|
|
619
590
|
const durationInfo = session.sessionDurationMs
|
|
620
591
|
? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
|
|
621
592
|
: "";
|
|
622
593
|
|
|
623
594
|
learnings.push({
|
|
624
|
-
title: `
|
|
595
|
+
title: `Paused: ${session.cardTitle}`,
|
|
625
596
|
content: [
|
|
626
|
-
`
|
|
627
|
-
session.currentTask ? `
|
|
597
|
+
`Paused work on "${session.cardTitle}".`,
|
|
598
|
+
session.currentTask ? `Last task: ${session.currentTask}` : "",
|
|
628
599
|
session.progressPercent !== undefined
|
|
629
600
|
? `Progress: ${session.progressPercent}%`
|
|
630
601
|
: "",
|
|
631
602
|
durationInfo,
|
|
632
|
-
session.cardLabels.length > 0
|
|
633
|
-
? `Labels: ${session.cardLabels.join(", ")}`
|
|
634
|
-
: "",
|
|
635
603
|
session.blockers?.length
|
|
636
|
-
? `Blockers
|
|
604
|
+
? `Blockers: ${session.blockers.join("; ")}`
|
|
637
605
|
: "",
|
|
638
606
|
`\nAgent: ${session.agentName}`,
|
|
639
607
|
wikiLinksLine,
|
|
@@ -641,11 +609,11 @@ export async function extractLearnings(
|
|
|
641
609
|
.filter(Boolean)
|
|
642
610
|
.join("\n"),
|
|
643
611
|
type: "lesson",
|
|
644
|
-
tier: "
|
|
645
|
-
confidence: 0.
|
|
612
|
+
tier: "draft",
|
|
613
|
+
confidence: 0.6,
|
|
646
614
|
tags: [
|
|
647
615
|
"auto-extracted",
|
|
648
|
-
"session-
|
|
616
|
+
"session-paused",
|
|
649
617
|
...session.cardLabels.slice(0, 3),
|
|
650
618
|
],
|
|
651
619
|
metadata: {
|
|
@@ -655,45 +623,30 @@ export async function extractLearnings(
|
|
|
655
623
|
});
|
|
656
624
|
}
|
|
657
625
|
|
|
658
|
-
// Rule 3:
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (hasBugLabel && session.status === "completed") {
|
|
663
|
-
learnings.push({
|
|
664
|
-
title: `Solution: ${session.cardTitle}`,
|
|
665
|
-
content: [
|
|
666
|
-
`Resolved bug: "${session.cardTitle}"`,
|
|
667
|
-
session.currentTask ? `\nApproach: ${session.currentTask}` : "",
|
|
668
|
-
`\nAgent: ${session.agentName}`,
|
|
669
|
-
wikiLinksLine,
|
|
670
|
-
]
|
|
671
|
-
.filter(Boolean)
|
|
672
|
-
.join("\n"),
|
|
673
|
-
type: "solution",
|
|
674
|
-
tier: "reference",
|
|
675
|
-
confidence: 0.8,
|
|
676
|
-
tags: ["auto-extracted", "bug-fix", ...session.cardLabels.slice(0, 3)],
|
|
677
|
-
metadata: {
|
|
678
|
-
source: "active_learning",
|
|
679
|
-
card_id: session.cardId,
|
|
680
|
-
auto_confidence: true,
|
|
681
|
-
},
|
|
682
|
-
});
|
|
683
|
-
}
|
|
626
|
+
// Rule 3: Bug solution — REMOVED.
|
|
627
|
+
// Storing "Resolved bug: {card title}" with no detail about the actual fix
|
|
628
|
+
// adds zero value. The real solution is in the code diff / PR. Agents should
|
|
629
|
+
// use `harmony_remember` to store non-obvious root cause details manually.
|
|
684
630
|
|
|
685
631
|
// Store learnings, tracking entity ID → learning for graph expansion
|
|
686
632
|
const entityIds: string[] = [];
|
|
687
633
|
|
|
688
634
|
// Rule 4: Successful session with tracked steps → create or reinforce procedure entity
|
|
635
|
+
// Thresholds raised: require 5+ distinct steps AND 10+ minute duration to avoid
|
|
636
|
+
// creating "procedures" from trivial tasks (e.g., a 2-step "investigate → fix" session).
|
|
689
637
|
const stepHistory = sessionTaskHistory.get(session.cardId);
|
|
690
|
-
const
|
|
638
|
+
const MIN_PROCEDURE_STEPS = 5;
|
|
639
|
+
const MIN_PROCEDURE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
|
640
|
+
const hasEnoughSteps =
|
|
641
|
+
stepHistory && stepHistory.steps.length >= MIN_PROCEDURE_STEPS;
|
|
642
|
+
const hasMinDuration =
|
|
643
|
+
(session.sessionDurationMs ?? 0) >= MIN_PROCEDURE_DURATION_MS;
|
|
691
644
|
const isSuccessful =
|
|
692
645
|
session.status === "completed" &&
|
|
693
646
|
(session.progressPercent === undefined || session.progressPercent >= 85) &&
|
|
694
647
|
!session.blockers?.length;
|
|
695
648
|
|
|
696
|
-
if (isSuccessful && hasEnoughSteps) {
|
|
649
|
+
if (isSuccessful && hasEnoughSteps && hasMinDuration) {
|
|
697
650
|
const procedureResult = await extractOrReinforceProcedure(
|
|
698
651
|
client,
|
|
699
652
|
session,
|
|
@@ -784,27 +737,12 @@ export async function extractLearnings(
|
|
|
784
737
|
);
|
|
785
738
|
}
|
|
786
739
|
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
workspaceId,
|
|
794
|
-
projectId,
|
|
795
|
-
).catch(() => {});
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Detect recurring causal patterns (error→solution chains across sessions)
|
|
799
|
-
if (createdPairs.length > 0) {
|
|
800
|
-
detectCausalPatterns(
|
|
801
|
-
client,
|
|
802
|
-
createdPairs,
|
|
803
|
-
session,
|
|
804
|
-
workspaceId,
|
|
805
|
-
projectId,
|
|
806
|
-
).catch(() => {});
|
|
807
|
-
}
|
|
740
|
+
// Pattern detection DISABLED — these create noise entities like
|
|
741
|
+
// "Pattern: recurring procedure (N instances)" that are just catalogs of
|
|
742
|
+
// entity titles, eating token budget with zero actionable content.
|
|
743
|
+
// The consolidation tool (harmony_consolidate_memories) serves a similar
|
|
744
|
+
// purpose and can be improved separately with LLM synthesis.
|
|
745
|
+
// See: https://github.com/getharmony/getharmony/issues/memory-quality
|
|
808
746
|
|
|
809
747
|
// Clean up mid-session tracking
|
|
810
748
|
clearMidSessionTracking(session.cardId);
|
package/src/consolidation.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface ConsolidationResult {
|
|
|
34
34
|
|
|
35
35
|
export interface ConsolidationOptions {
|
|
36
36
|
dryRun?: boolean;
|
|
37
|
-
minClusterSize?: number;
|
|
37
|
+
minClusterSize?: number; // Default: 3 (was 2 — raised to avoid premature merging)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -52,7 +52,7 @@ export async function consolidateMemories(
|
|
|
52
52
|
options?: ConsolidationOptions,
|
|
53
53
|
): Promise<ConsolidationResult> {
|
|
54
54
|
const dryRun = options?.dryRun !== false; // default true
|
|
55
|
-
const minClusterSize = options?.minClusterSize ?? 2
|
|
55
|
+
const minClusterSize = options?.minClusterSize ?? 3; // raised from 2 to reduce noise
|
|
56
56
|
|
|
57
57
|
const result: ConsolidationResult = {
|
|
58
58
|
consolidated: 0,
|
|
@@ -153,11 +153,10 @@ export async function consolidateMemories(
|
|
|
153
153
|
const mergedTitle = deriveClusterTitle(cluster, type);
|
|
154
154
|
const memberTitles = cluster.map((e) => e.title);
|
|
155
155
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
].join("\n");
|
|
156
|
+
// Synthesize content: extract unique knowledge from each member,
|
|
157
|
+
// not just a bullet list of titles. Each member's content is trimmed
|
|
158
|
+
// to its first meaningful paragraph (skipping headers and metadata).
|
|
159
|
+
const mergedContent = synthesizeClusterContent(cluster, type);
|
|
161
160
|
|
|
162
161
|
// Max confidence from cluster members
|
|
163
162
|
const maxConfidence = Math.max(...cluster.map((e) => e.confidence));
|
|
@@ -241,6 +240,76 @@ export async function consolidateMemories(
|
|
|
241
240
|
return result;
|
|
242
241
|
}
|
|
243
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Synthesize cluster content by extracting unique, actionable knowledge
|
|
245
|
+
* from each member entity. Skips boilerplate (headers, metadata, agent names)
|
|
246
|
+
* and deduplicates similar lines across members.
|
|
247
|
+
*/
|
|
248
|
+
function synthesizeClusterContent(
|
|
249
|
+
cluster: MemoryEntity[],
|
|
250
|
+
type: string,
|
|
251
|
+
): string {
|
|
252
|
+
// Lines to skip: headers, agent metadata, timestamps, progress percentages
|
|
253
|
+
const SKIP_PATTERNS = [
|
|
254
|
+
/^##\s/,
|
|
255
|
+
/^Agent:/,
|
|
256
|
+
/^Duration:/,
|
|
257
|
+
/^Labels:/,
|
|
258
|
+
/^Progress:/,
|
|
259
|
+
/^Session status:/,
|
|
260
|
+
/^Completed at/,
|
|
261
|
+
/^Final state:/,
|
|
262
|
+
/^Related:/,
|
|
263
|
+
/^When working on:/,
|
|
264
|
+
/^\d+\.\s+.+\(\d+%,\s*\+\d+%\)/, // procedure step with progress percentages
|
|
265
|
+
/^Last updated:/,
|
|
266
|
+
/^Recurring pattern:/,
|
|
267
|
+
/^Consolidated from/,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const seenLines = new Set<string>();
|
|
271
|
+
const knowledgeLines: string[] = [];
|
|
272
|
+
|
|
273
|
+
for (const entity of cluster) {
|
|
274
|
+
const lines = entity.content.split("\n").map((l) => l.trim());
|
|
275
|
+
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
if (!line || line.length < 20) continue;
|
|
278
|
+
if (SKIP_PATTERNS.some((p) => p.test(line))) continue;
|
|
279
|
+
|
|
280
|
+
// Normalize for dedup: lowercase, strip markdown formatting
|
|
281
|
+
const normalized = line
|
|
282
|
+
.toLowerCase()
|
|
283
|
+
.replace(/[*_`#[\]]/g, "")
|
|
284
|
+
.trim();
|
|
285
|
+
if (seenLines.has(normalized)) continue;
|
|
286
|
+
seenLines.add(normalized);
|
|
287
|
+
|
|
288
|
+
knowledgeLines.push(line);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (knowledgeLines.length === 0) {
|
|
293
|
+
// Fallback: if no knowledge was extractable, use a compact summary
|
|
294
|
+
return `${cluster.length} related ${type} entities consolidated. Original titles:\n${cluster.map((e) => `- ${e.title}`).join("\n")}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Cap at ~400 tokens worth of content (1600 chars)
|
|
298
|
+
const MAX_CHARS = 1600;
|
|
299
|
+
const result: string[] = [
|
|
300
|
+
`Consolidated knowledge from ${cluster.length} ${type} entities:\n`,
|
|
301
|
+
];
|
|
302
|
+
let charCount = result[0].length;
|
|
303
|
+
|
|
304
|
+
for (const line of knowledgeLines) {
|
|
305
|
+
if (charCount + line.length + 3 > MAX_CHARS) break;
|
|
306
|
+
result.push(`- ${line}`);
|
|
307
|
+
charCount += line.length + 3;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return result.join("\n");
|
|
311
|
+
}
|
|
312
|
+
|
|
244
313
|
/**
|
|
245
314
|
* Derive a cluster title from the most common meaningful words across member titles.
|
|
246
315
|
*/
|
|
@@ -303,12 +372,12 @@ function deriveClusterTitle(cluster: MemoryEntity[], type: string): string {
|
|
|
303
372
|
}
|
|
304
373
|
}
|
|
305
374
|
|
|
306
|
-
// Sort by frequency, take top
|
|
375
|
+
// Sort by frequency, take top 4 for more descriptive titles
|
|
307
376
|
const topWords = [...wordCounts.entries()]
|
|
308
377
|
.sort((a, b) => b[1] - a[1])
|
|
309
|
-
.slice(0,
|
|
310
|
-
.map(([word]) => word);
|
|
378
|
+
.slice(0, 4)
|
|
379
|
+
.map(([word]) => word[0].toUpperCase() + word.slice(1));
|
|
311
380
|
|
|
312
|
-
const suffix = topWords.length > 0 ? topWords.join("
|
|
313
|
-
return
|
|
381
|
+
const suffix = topWords.length > 0 ? topWords.join(" / ") : "Various";
|
|
382
|
+
return `${type[0].toUpperCase() + type.slice(1)}: ${suffix}`;
|
|
314
383
|
}
|