@gethmy/mcp 2.4.7 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -20
- package/dist/cli.js +1857 -30209
- package/dist/index.js +1323 -26686
- package/dist/lib/api-client.js +122 -925
- package/package.json +4 -3
- package/src/api-client.ts +129 -96
- package/src/memory-floor.ts +264 -0
- package/src/memory-park.ts +252 -0
- package/src/memory-session.ts +61 -0
- package/src/prompt-builder.ts +93 -0
- package/src/server.ts +351 -1467
- package/dist/http.js +0 -1959
- package/dist/remote.js +0 -32328
- package/dist/server.js +0 -31967
- package/src/__tests__/active-learning.test.ts +0 -483
- package/src/__tests__/agent-performance-profiles.test.ts +0 -468
- package/src/__tests__/auto-session.test.ts +0 -912
- package/src/__tests__/context-assembly.test.ts +0 -506
- package/src/__tests__/graph-expansion.test.ts +0 -285
- package/src/__tests__/integration-memory-crud.test.ts +0 -948
- package/src/__tests__/integration-memory-system.test.ts +0 -321
- package/src/__tests__/lifecycle-maintenance.test.ts +0 -238
- package/src/__tests__/memory-audit.test.ts +0 -528
- package/src/__tests__/pattern-detection.test.ts +0 -438
- package/src/__tests__/prompt-builder.test.ts +0 -505
- package/src/__tests__/remote-routing.test.ts +0 -285
- package/src/active-learning.ts +0 -1165
- package/src/consolidation.ts +0 -383
- package/src/context-assembly.ts +0 -1175
- package/src/lifecycle-maintenance.ts +0 -120
- package/src/memory-audit.ts +0 -578
- package/src/memory-cleanup.ts +0 -902
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
27
|
"src",
|
|
28
|
+
"!src/__tests__",
|
|
28
29
|
"README.md"
|
|
29
30
|
],
|
|
30
31
|
"repository": {
|
|
@@ -54,12 +55,12 @@
|
|
|
54
55
|
"bun": ">=1.0.0"
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|
|
57
|
-
"build": "bun build src/index.ts src/cli.ts --outdir dist --target node && bun build src/api-client.ts src/config.ts --outdir dist/lib --root src --target node",
|
|
58
|
+
"build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
|
|
58
59
|
"build:bun": "bun build src/index.ts src/http.ts src/remote.ts src/cli.ts --outdir dist --target bun",
|
|
59
60
|
"serve:remote": "bun src/remote.ts",
|
|
60
61
|
"dev": "bun --watch src/index.ts",
|
|
61
62
|
"test": "bun run test:unit && bun run test:integration",
|
|
62
|
-
"test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts",
|
|
63
|
+
"test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
|
|
63
64
|
"test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
|
|
64
65
|
"typecheck": "tsc --noEmit",
|
|
65
66
|
"prepublishOnly": "bun run build"
|
package/src/api-client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getDisplayLinkType } from "@harmony/shared";
|
|
1
2
|
import { getApiKey, getApiUrl } from "./config.js";
|
|
2
3
|
|
|
3
4
|
export interface ApiResponse<T = unknown> {
|
|
@@ -642,34 +643,6 @@ export class HarmonyApiClient {
|
|
|
642
643
|
return this.request("GET", `/cards/${cardId}/agent-context${query}`);
|
|
643
644
|
}
|
|
644
645
|
|
|
645
|
-
// ============ AGENT PERFORMANCE PROFILES ============
|
|
646
|
-
|
|
647
|
-
async getAgentProfile(
|
|
648
|
-
workspaceId: string,
|
|
649
|
-
agentIdentifier: string,
|
|
650
|
-
): Promise<{ profile: unknown }> {
|
|
651
|
-
const params = new URLSearchParams({
|
|
652
|
-
workspace_id: workspaceId,
|
|
653
|
-
agent_identifier: agentIdentifier,
|
|
654
|
-
});
|
|
655
|
-
return this.request("GET", `/agent-profiles?${params.toString()}`);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
async listAgentProfiles(
|
|
659
|
-
workspaceId: string,
|
|
660
|
-
): Promise<{ profiles: unknown[] }> {
|
|
661
|
-
const params = new URLSearchParams({ workspace_id: workspaceId });
|
|
662
|
-
return this.request("GET", `/agent-profiles?${params.toString()}`);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
async refreshAgentProfiles(
|
|
666
|
-
workspaceId: string,
|
|
667
|
-
): Promise<{ refreshed: boolean }> {
|
|
668
|
-
return this.request("POST", "/agent-profiles/refresh", {
|
|
669
|
-
workspace_id: workspaceId,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
|
|
673
646
|
// ============ MEMORY OPERATIONS ============
|
|
674
647
|
|
|
675
648
|
async createMemoryEntity(data: {
|
|
@@ -682,6 +655,7 @@ export class HarmonyApiClient {
|
|
|
682
655
|
content: string;
|
|
683
656
|
metadata?: Record<string, unknown>;
|
|
684
657
|
confidence?: number;
|
|
658
|
+
importance?: number;
|
|
685
659
|
tags?: string[];
|
|
686
660
|
agent_identifier?: string;
|
|
687
661
|
}): Promise<{ entity: unknown; warnings?: string[] }> {
|
|
@@ -699,6 +673,7 @@ export class HarmonyApiClient {
|
|
|
699
673
|
q?: string;
|
|
700
674
|
limit?: number;
|
|
701
675
|
offset?: number;
|
|
676
|
+
include_superseded?: boolean;
|
|
702
677
|
}): Promise<{ entities: unknown[]; count: number }> {
|
|
703
678
|
const params = new URLSearchParams();
|
|
704
679
|
params.set("workspace_id", options.workspace_id);
|
|
@@ -714,6 +689,7 @@ export class HarmonyApiClient {
|
|
|
714
689
|
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
715
690
|
if (options.offset !== undefined)
|
|
716
691
|
params.set("offset", String(options.offset));
|
|
692
|
+
if (options.include_superseded) params.set("include_superseded", "true");
|
|
717
693
|
return this.request("GET", `/memory/entities?${params.toString()}`);
|
|
718
694
|
}
|
|
719
695
|
|
|
@@ -732,6 +708,10 @@ export class HarmonyApiClient {
|
|
|
732
708
|
scope?: string;
|
|
733
709
|
type?: string;
|
|
734
710
|
memory_tier?: string;
|
|
711
|
+
// AGP lifecycle fields. Backend may not yet whitelist these — extra keys
|
|
712
|
+
// are dropped server-side, leaving the call as a no-op for those fields.
|
|
713
|
+
superseded_by?: string | null;
|
|
714
|
+
version?: number;
|
|
735
715
|
},
|
|
736
716
|
): Promise<{ entity: unknown; warnings?: string[] }> {
|
|
737
717
|
return this.request("PUT", `/memory/entities/${entityId}`, updates);
|
|
@@ -1067,6 +1047,44 @@ export class HarmonyApiClient {
|
|
|
1067
1047
|
return this.request("POST", "/api-keys", { name });
|
|
1068
1048
|
}
|
|
1069
1049
|
|
|
1050
|
+
// ============ PROMPT HISTORY (AGP P2) ============
|
|
1051
|
+
|
|
1052
|
+
async recordPromptHistory(data: {
|
|
1053
|
+
cardId: string;
|
|
1054
|
+
generatedPrompt: string;
|
|
1055
|
+
variant: "analysis" | "draft" | "execute";
|
|
1056
|
+
contextIncluded?: Record<string, unknown>;
|
|
1057
|
+
sessionId?: string | null;
|
|
1058
|
+
contentHash?: string;
|
|
1059
|
+
templateVersion?: number;
|
|
1060
|
+
confidence?: number;
|
|
1061
|
+
templateId?: string | null;
|
|
1062
|
+
isPinned?: boolean;
|
|
1063
|
+
}): Promise<{ entry: unknown }> {
|
|
1064
|
+
return this.request("POST", "/prompt-history", data);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async recordPromptHistoryFeedback(
|
|
1068
|
+
sessionId: string,
|
|
1069
|
+
outcome: "success" | "blocker" | "neutral",
|
|
1070
|
+
): Promise<{ adjusted: number }> {
|
|
1071
|
+
return this.request("POST", "/prompt-history/feedback", {
|
|
1072
|
+
sessionId,
|
|
1073
|
+
outcome,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async getPromptHistoryCohort(contentHash: string): Promise<{
|
|
1078
|
+
cohort: Array<{
|
|
1079
|
+
status: string | null;
|
|
1080
|
+
progressPercent: number | null;
|
|
1081
|
+
hadBlockers: boolean;
|
|
1082
|
+
}>;
|
|
1083
|
+
}> {
|
|
1084
|
+
const params = new URLSearchParams({ content_hash: contentHash });
|
|
1085
|
+
return this.request("GET", `/prompt-history/cohort?${params.toString()}`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1070
1088
|
// ============ PROMPT GENERATION ============
|
|
1071
1089
|
|
|
1072
1090
|
/**
|
|
@@ -1087,6 +1105,8 @@ export class HarmonyApiClient {
|
|
|
1087
1105
|
includeLinks: boolean;
|
|
1088
1106
|
includeDescription: boolean;
|
|
1089
1107
|
}>;
|
|
1108
|
+
/** Optional active session ID to associate with the prompt snapshot. */
|
|
1109
|
+
sessionId?: string | null;
|
|
1090
1110
|
}): Promise<{
|
|
1091
1111
|
prompt: string;
|
|
1092
1112
|
variant: string;
|
|
@@ -1105,14 +1125,42 @@ export class HarmonyApiClient {
|
|
|
1105
1125
|
cardId: string;
|
|
1106
1126
|
shortId: number;
|
|
1107
1127
|
title: string;
|
|
1128
|
+
/** Local UUID identifying the persisted snapshot (AGP P2). */
|
|
1129
|
+
promptId: string;
|
|
1130
|
+
/** SHA-256 of the generated prompt body (AGP P2 cohort key). */
|
|
1131
|
+
contentHash: string;
|
|
1132
|
+
/** Template version that produced this prompt. */
|
|
1133
|
+
version: number;
|
|
1108
1134
|
}> {
|
|
1109
|
-
const {
|
|
1110
|
-
await loadPromptModules();
|
|
1135
|
+
const { generatePrompt } = await loadPromptModules();
|
|
1111
1136
|
|
|
1112
1137
|
// Fetch card data
|
|
1113
1138
|
const cardResult = await this.getCard(options.cardId);
|
|
1114
1139
|
const cardData = cardResult.card as CardPromptData;
|
|
1115
1140
|
|
|
1141
|
+
// Fetch card reference links so the prompt can render the "Related Cards"
|
|
1142
|
+
// section and the blocker-aware "Recommended Next Step" synthesis.
|
|
1143
|
+
// Best-effort — link fetch failures must not break prompt generation.
|
|
1144
|
+
try {
|
|
1145
|
+
const linksResult = await this.getCardLinks(options.cardId);
|
|
1146
|
+
const rawLinks =
|
|
1147
|
+
(linksResult.links as Array<{
|
|
1148
|
+
link_type: "relates_to" | "blocks" | "duplicates" | "is_part_of";
|
|
1149
|
+
direction: "outgoing" | "incoming";
|
|
1150
|
+
target_card: { short_id: number; title: string } | null;
|
|
1151
|
+
}>) || [];
|
|
1152
|
+
cardData.links = rawLinks
|
|
1153
|
+
.filter((l) => l.target_card)
|
|
1154
|
+
.map((l) => ({
|
|
1155
|
+
target_card: l.target_card as { short_id: number; title: string },
|
|
1156
|
+
direction: l.direction,
|
|
1157
|
+
display_type: getDisplayLinkType(l.link_type, l.direction),
|
|
1158
|
+
}));
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1161
|
+
console.debug(`[generateCardPrompt] getCardLinks failed: ${msg}`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1116
1164
|
// Try to get column info
|
|
1117
1165
|
let columnData: { name: string } | null = null;
|
|
1118
1166
|
const projectIdForBoard = options.projectId || cardData.project_id;
|
|
@@ -1132,67 +1180,37 @@ export class HarmonyApiClient {
|
|
|
1132
1180
|
|
|
1133
1181
|
const variant = options.variant || "execute";
|
|
1134
1182
|
|
|
1135
|
-
//
|
|
1136
|
-
|
|
1137
|
-
|
|
1183
|
+
// Phase 0 (memory architecture v2): full context assembly removed.
|
|
1184
|
+
// Use the basic memory search path so callers still get _some_ memory
|
|
1185
|
+
// hints. Phase 1 will reintroduce a session-scoped working memory layer.
|
|
1186
|
+
const assembledContextStr: string | undefined = undefined;
|
|
1187
|
+
const assemblyId: string | undefined = undefined;
|
|
1138
1188
|
let memories: MemoryItem[] | undefined;
|
|
1139
1189
|
|
|
1140
1190
|
try {
|
|
1141
1191
|
if (options.workspaceId && cardData.title) {
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
.
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
cacheManifest(assembled.manifest);
|
|
1192
|
+
const memoryResult = await this.searchMemoryEntities(
|
|
1193
|
+
options.workspaceId,
|
|
1194
|
+
cardData.title,
|
|
1195
|
+
{
|
|
1196
|
+
project_id: options.projectId,
|
|
1197
|
+
limit: 5,
|
|
1198
|
+
},
|
|
1199
|
+
);
|
|
1200
|
+
if (memoryResult.entities?.length > 0) {
|
|
1201
|
+
memories = (memoryResult.entities as MemoryItem[]).map((e) => ({
|
|
1202
|
+
id: e.id,
|
|
1203
|
+
type: e.type,
|
|
1204
|
+
title: e.title,
|
|
1205
|
+
content: e.content,
|
|
1206
|
+
confidence: e.confidence,
|
|
1207
|
+
tags: e.tags || [],
|
|
1208
|
+
}));
|
|
1160
1209
|
}
|
|
1161
1210
|
}
|
|
1162
1211
|
} catch (err) {
|
|
1163
|
-
// Context assembly failed, try legacy fallback
|
|
1164
1212
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1165
|
-
console.debug(`[generateCardPrompt]
|
|
1166
|
-
try {
|
|
1167
|
-
if (options.workspaceId && cardData.title) {
|
|
1168
|
-
const memoryResult = await this.searchMemoryEntities(
|
|
1169
|
-
options.workspaceId,
|
|
1170
|
-
cardData.title,
|
|
1171
|
-
{
|
|
1172
|
-
project_id: options.projectId,
|
|
1173
|
-
limit: 5,
|
|
1174
|
-
},
|
|
1175
|
-
);
|
|
1176
|
-
if (memoryResult.entities?.length > 0) {
|
|
1177
|
-
memories = (memoryResult.entities as MemoryItem[]).map((e) => ({
|
|
1178
|
-
id: e.id,
|
|
1179
|
-
type: e.type,
|
|
1180
|
-
title: e.title,
|
|
1181
|
-
content: e.content,
|
|
1182
|
-
confidence: e.confidence,
|
|
1183
|
-
tags: e.tags || [],
|
|
1184
|
-
}));
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
} catch (fallbackErr) {
|
|
1188
|
-
const fallbackMsg =
|
|
1189
|
-
fallbackErr instanceof Error
|
|
1190
|
-
? fallbackErr.message
|
|
1191
|
-
: String(fallbackErr);
|
|
1192
|
-
console.debug(
|
|
1193
|
-
`[generateCardPrompt] Memory fallback also failed: ${fallbackMsg}`,
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
1213
|
+
console.debug(`[generateCardPrompt] Memory search failed: ${msg}`);
|
|
1196
1214
|
}
|
|
1197
1215
|
|
|
1198
1216
|
const result = generatePrompt({
|
|
@@ -1206,6 +1224,30 @@ export class HarmonyApiClient {
|
|
|
1206
1224
|
assemblyId,
|
|
1207
1225
|
});
|
|
1208
1226
|
|
|
1227
|
+
// AGP P2: persist a session-linked snapshot. Best-effort — never fail
|
|
1228
|
+
// prompt generation just because logging didn't land.
|
|
1229
|
+
try {
|
|
1230
|
+
await this.recordPromptHistory({
|
|
1231
|
+
cardId: cardData.id,
|
|
1232
|
+
generatedPrompt: result.prompt,
|
|
1233
|
+
variant: variant as "analysis" | "draft" | "execute",
|
|
1234
|
+
contextIncluded: {
|
|
1235
|
+
assemblyId: result.assemblyId ?? null,
|
|
1236
|
+
tokenEstimate: result.tokenEstimate,
|
|
1237
|
+
contextSummary: result.contextSummary,
|
|
1238
|
+
},
|
|
1239
|
+
sessionId: options.sessionId ?? null,
|
|
1240
|
+
contentHash: result.contentHash,
|
|
1241
|
+
templateVersion: result.version,
|
|
1242
|
+
confidence: 0.5,
|
|
1243
|
+
});
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1246
|
+
console.debug(
|
|
1247
|
+
`[generateCardPrompt] prompt_history persistence failed: ${msg}`,
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1209
1251
|
return {
|
|
1210
1252
|
...result,
|
|
1211
1253
|
cardId: cardData.id,
|
|
@@ -1245,14 +1287,10 @@ interface MemoryItem {
|
|
|
1245
1287
|
tags: string[];
|
|
1246
1288
|
}
|
|
1247
1289
|
|
|
1248
|
-
// Cached dynamic
|
|
1290
|
+
// Cached dynamic import for prompt-builder.
|
|
1291
|
+
// Phase 0 (memory architecture v2): context-assembly module deleted; prompt
|
|
1292
|
+
// generation falls back to a basic memory search path until Phase 1.
|
|
1249
1293
|
let _promptModules: {
|
|
1250
|
-
assembleContext: Awaited<
|
|
1251
|
-
typeof import("./context-assembly.js")
|
|
1252
|
-
>["assembleContext"];
|
|
1253
|
-
cacheManifest: Awaited<
|
|
1254
|
-
typeof import("./context-assembly.js")
|
|
1255
|
-
>["cacheManifest"];
|
|
1256
1294
|
generatePrompt: Awaited<
|
|
1257
1295
|
typeof import("./prompt-builder.js")
|
|
1258
1296
|
>["generatePrompt"];
|
|
@@ -1260,13 +1298,8 @@ let _promptModules: {
|
|
|
1260
1298
|
|
|
1261
1299
|
async function loadPromptModules() {
|
|
1262
1300
|
if (!_promptModules) {
|
|
1263
|
-
const
|
|
1264
|
-
import("./context-assembly.js"),
|
|
1265
|
-
import("./prompt-builder.js"),
|
|
1266
|
-
]);
|
|
1301
|
+
const pb = await import("./prompt-builder.js");
|
|
1267
1302
|
_promptModules = {
|
|
1268
|
-
assembleContext: ca.assembleContext,
|
|
1269
|
-
cacheManifest: ca.cacheManifest,
|
|
1270
1303
|
generatePrompt: pb.generatePrompt,
|
|
1271
1304
|
};
|
|
1272
1305
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Utility Floor — write admission control.
|
|
3
|
+
*
|
|
4
|
+
* Implements §4.5.1 of docs/superpowers/plans/2026-05-07-memory-architecture-v2.md.
|
|
5
|
+
*
|
|
6
|
+
* The Floor is deterministic, regex + length based. No LLM calls.
|
|
7
|
+
* It runs at every write site (`harmony_remember` and any future Phase 3
|
|
8
|
+
* write-gate output). Rejected writes return a structured error explaining
|
|
9
|
+
* which rule fired so callers can adjust.
|
|
10
|
+
*
|
|
11
|
+
* Bypasses:
|
|
12
|
+
* - `source_trust='document'` rows skip the title-quality regex (legitimate
|
|
13
|
+
* long-form prose isn't tag-concat slop). Other rules still apply.
|
|
14
|
+
* - Session-scope memories (`scope` starts with `session:`) skip the Floor
|
|
15
|
+
* entirely — they're explicitly ephemeral.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const STOP_WORDS = new Set([
|
|
19
|
+
"a",
|
|
20
|
+
"an",
|
|
21
|
+
"and",
|
|
22
|
+
"are",
|
|
23
|
+
"as",
|
|
24
|
+
"at",
|
|
25
|
+
"be",
|
|
26
|
+
"by",
|
|
27
|
+
"for",
|
|
28
|
+
"from",
|
|
29
|
+
"has",
|
|
30
|
+
"have",
|
|
31
|
+
"he",
|
|
32
|
+
"in",
|
|
33
|
+
"is",
|
|
34
|
+
"it",
|
|
35
|
+
"its",
|
|
36
|
+
"of",
|
|
37
|
+
"on",
|
|
38
|
+
"that",
|
|
39
|
+
"the",
|
|
40
|
+
"this",
|
|
41
|
+
"to",
|
|
42
|
+
"was",
|
|
43
|
+
"were",
|
|
44
|
+
"will",
|
|
45
|
+
"with",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Rule registry — mirrors §4.5.1
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export type FloorRuleId =
|
|
53
|
+
| "tag-concat"
|
|
54
|
+
| "tag-concat-slash"
|
|
55
|
+
| "frequency-meta"
|
|
56
|
+
| "self-referential"
|
|
57
|
+
| "bare-type-prefix"
|
|
58
|
+
| "specificity-floor"
|
|
59
|
+
| "length-floor"
|
|
60
|
+
| "operational-data-ban";
|
|
61
|
+
|
|
62
|
+
export interface FloorRejection {
|
|
63
|
+
rule: FloorRuleId;
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface FloorInput {
|
|
68
|
+
title: string;
|
|
69
|
+
content: string;
|
|
70
|
+
type: string;
|
|
71
|
+
scope?: string;
|
|
72
|
+
source_trust?: string;
|
|
73
|
+
tags?: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Patterns
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
// "Consolidated procedure: procedure, add, user" — comma-separated tag concat
|
|
81
|
+
const TAG_CONCAT_COMMA =
|
|
82
|
+
/^(Pattern|Procedure|Lesson|Consolidated [a-z]+):\s+[a-z]+(?:,\s*[a-z]+){1,5}$/i;
|
|
83
|
+
|
|
84
|
+
// "Procedure: Procedure / Card / Mobile / Native" — slash-separated tag concat
|
|
85
|
+
const TAG_CONCAT_SLASH =
|
|
86
|
+
/^(Pattern|Procedure|Lesson):\s+[a-z]+(?:\s*\/\s*[a-z]+){2,5}$/i;
|
|
87
|
+
|
|
88
|
+
// "Pattern: recurring procedure (17 instances)"
|
|
89
|
+
const FREQUENCY_META = /^Pattern:\s+recurring\s+\w+\s+\(\d+\s+instances?\)$/i;
|
|
90
|
+
|
|
91
|
+
// "Type:" / "Memory:" with no real content after the colon
|
|
92
|
+
const BARE_TYPE_PREFIX = /^(Type|Memory):\s*$/i;
|
|
93
|
+
|
|
94
|
+
// Self-referential: mentions the memory implementation
|
|
95
|
+
const SELF_REFERENTIAL =
|
|
96
|
+
/\b(memory system|consolidation|harmony_(remember|recall|consolidate|audit|promote|forget|cleanup|backfill|prune)|context.assembly|context-assembly|AGP|active.learning|active-learning|knowledge_entities)\b/i;
|
|
97
|
+
|
|
98
|
+
// Implementation-detail leaking into a lesson body
|
|
99
|
+
const IMPL_DETAIL =
|
|
100
|
+
/^\s*(?:[A-Z][^.]*[.,]\s*)?\b(blocks?|prevents?|broken|in.memory only|fixme|todo)\b/i;
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Helpers
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function wordCount(s: string): number {
|
|
107
|
+
return s
|
|
108
|
+
.split(/\s+/)
|
|
109
|
+
.map((w) => w.replace(/[^\w]/g, "").toLowerCase())
|
|
110
|
+
.filter((w) => w.length > 0 && !STOP_WORDS.has(w)).length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasProperNounOrIdentifier(s: string): boolean {
|
|
114
|
+
// Capitalized word in the middle of the title (not just sentence start)
|
|
115
|
+
// OR snake_case / camelCase / PascalCase identifier OR backtick-fenced ident.
|
|
116
|
+
if (/`[^`]+`/.test(s)) return true;
|
|
117
|
+
if (/\b[A-Z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*/.test(s)) return true; // PascalCase / camelCase with caps
|
|
118
|
+
if (/\b[a-z]+(?:_[a-z]+){1,}/.test(s)) return true; // snake_case
|
|
119
|
+
// Capitalized non-first word
|
|
120
|
+
const tokens = s.split(/\s+/);
|
|
121
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
122
|
+
const t = tokens[i] ?? "";
|
|
123
|
+
if (/^[A-Z][a-z]+/.test(t)) return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Public API
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate a memory write against the Utility Floor. Returns null if the
|
|
134
|
+
* write passes; otherwise returns a structured rejection.
|
|
135
|
+
*
|
|
136
|
+
* Order is deterministic and matches §4.5.1 numbering:
|
|
137
|
+
* 1. Title quality gate (tag-concat, frequency-meta, bare-prefix)
|
|
138
|
+
* 2. Self-referential gate
|
|
139
|
+
* 3. Implementation-detail gate (lessons only)
|
|
140
|
+
* 4. Specificity floor
|
|
141
|
+
* 5. Length floor
|
|
142
|
+
* 6. Operational-data ban
|
|
143
|
+
*
|
|
144
|
+
* Bypasses:
|
|
145
|
+
* - `source_trust === 'document'` skips rule 1 (title checks).
|
|
146
|
+
* - `scope` starts with `session:` skips all rules — session memories are
|
|
147
|
+
* explicitly ephemeral.
|
|
148
|
+
*/
|
|
149
|
+
export function validateMemoryQuality(
|
|
150
|
+
input: FloorInput,
|
|
151
|
+
): FloorRejection | null {
|
|
152
|
+
const title = (input.title ?? "").trim();
|
|
153
|
+
const content = (input.content ?? "").trim();
|
|
154
|
+
const isDocSource = input.source_trust === "document";
|
|
155
|
+
const isSessionScope =
|
|
156
|
+
typeof input.scope === "string" && input.scope.startsWith("session:");
|
|
157
|
+
|
|
158
|
+
// Bypass: session-scoped memories are exempt entirely.
|
|
159
|
+
if (isSessionScope) return null;
|
|
160
|
+
|
|
161
|
+
// Rule 6: Operational-data ban (always applies; even before bypass below).
|
|
162
|
+
// Agent profiling was removed in #195; reject any attempt to revive the dump pattern.
|
|
163
|
+
if (input.type === "agent" && /^Agent Profile:\s*/i.test(title)) {
|
|
164
|
+
return {
|
|
165
|
+
rule: "operational-data-ban",
|
|
166
|
+
message:
|
|
167
|
+
"Agent Profile entries are not allowed in knowledge_entities (operational data, not knowledge).",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Rule 1: Title quality (skipped for doc-source imports).
|
|
172
|
+
if (!isDocSource) {
|
|
173
|
+
if (TAG_CONCAT_COMMA.test(title)) {
|
|
174
|
+
return {
|
|
175
|
+
rule: "tag-concat",
|
|
176
|
+
message:
|
|
177
|
+
"Title is a comma-separated tag concatenation. Provide a content-bearing title (a real sentence, file path, or symbol).",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (TAG_CONCAT_SLASH.test(title)) {
|
|
181
|
+
return {
|
|
182
|
+
rule: "tag-concat-slash",
|
|
183
|
+
message:
|
|
184
|
+
"Title is a slash-separated tag concatenation. Provide a content-bearing title.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (FREQUENCY_META.test(title)) {
|
|
188
|
+
return {
|
|
189
|
+
rule: "frequency-meta",
|
|
190
|
+
message:
|
|
191
|
+
'Frequency-meta titles ("Pattern: recurring X (N instances)") are not retrievable knowledge. Access counts live in knowledge_entity_audit_log, not in entities.',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (BARE_TYPE_PREFIX.test(title)) {
|
|
195
|
+
return {
|
|
196
|
+
rule: "bare-type-prefix",
|
|
197
|
+
message:
|
|
198
|
+
'Bare "Type:" / "Memory:" prefix without a content-bearing title. Use the type column; put substance in the title.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Rule 2: Self-referential gate (always — except when explicitly typed
|
|
204
|
+
// 'meta' which is excluded from default retrieval).
|
|
205
|
+
if (input.type !== "meta") {
|
|
206
|
+
if (SELF_REFERENTIAL.test(title) || SELF_REFERENTIAL.test(content)) {
|
|
207
|
+
return {
|
|
208
|
+
rule: "self-referential",
|
|
209
|
+
message:
|
|
210
|
+
"Memory references the memory implementation (operational noise). Operational notes belong in code comments or commit messages, not in retrievable memories.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Rule 3: Implementation-detail-as-lesson gate.
|
|
216
|
+
if (input.type === "lesson" && IMPL_DETAIL.test(content)) {
|
|
217
|
+
return {
|
|
218
|
+
rule: "impl-detail-lesson",
|
|
219
|
+
message:
|
|
220
|
+
"Lesson body reads as an implementation TODO/issue, not a learning. Convert to a positive takeaway or move to a code comment.",
|
|
221
|
+
} as FloorRejection;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Rule 4: Specificity floor (skipped for doc-source imports — they may
|
|
225
|
+
// legitimately have short heading-style titles).
|
|
226
|
+
if (!isDocSource) {
|
|
227
|
+
const titleWordCount = title
|
|
228
|
+
.split(/\s+/)
|
|
229
|
+
.filter((w) => w.length > 0).length;
|
|
230
|
+
if (titleWordCount < 4 && !hasProperNounOrIdentifier(title)) {
|
|
231
|
+
return {
|
|
232
|
+
rule: "specificity-floor",
|
|
233
|
+
message:
|
|
234
|
+
"Title is too generic — needs ≥4 words OR a proper noun / specific identifier (file path, symbol, etc).",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Rule 5: Length floor.
|
|
240
|
+
if (content.length < 40) {
|
|
241
|
+
return {
|
|
242
|
+
rule: "length-floor",
|
|
243
|
+
message: `Content is too short (${content.length} chars; minimum 40).`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (wordCount(content) < 8) {
|
|
247
|
+
return {
|
|
248
|
+
rule: "length-floor",
|
|
249
|
+
message:
|
|
250
|
+
"Content is too short after stop-word removal (minimum 8 substantive words).",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Test whether an entity's stored shape matches Floor-violating patterns.
|
|
259
|
+
* Used by retrieval-time retroactive filter (§4.5.2) and by the Phase 0
|
|
260
|
+
* cleanup script.
|
|
261
|
+
*/
|
|
262
|
+
export function classifyExisting(input: FloorInput): FloorRejection | null {
|
|
263
|
+
return validateMemoryQuality(input);
|
|
264
|
+
}
|