metame-cli 1.5.19 → 1.5.21
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/index.js +157 -80
- package/package.json +2 -2
- package/scripts/bin/bootstrap-worktree.sh +20 -0
- package/scripts/core/audit.js +190 -0
- package/scripts/core/handoff.js +780 -0
- package/scripts/core/handoff.test.js +1074 -0
- package/scripts/core/memory-model.js +183 -0
- package/scripts/core/memory-model.test.js +486 -0
- package/scripts/core/reactive-paths.js +44 -0
- package/scripts/core/reactive-paths.test.js +35 -0
- package/scripts/core/reactive-prompt.js +51 -0
- package/scripts/core/reactive-prompt.test.js +88 -0
- package/scripts/core/reactive-signal.js +40 -0
- package/scripts/core/reactive-signal.test.js +88 -0
- package/scripts/core/thread-chat-id.js +52 -0
- package/scripts/core/thread-chat-id.test.js +113 -0
- package/scripts/daemon-bridges.js +92 -38
- package/scripts/daemon-claude-engine.js +373 -444
- package/scripts/daemon-command-router.js +82 -8
- package/scripts/daemon-engine-runtime.js +7 -10
- package/scripts/daemon-reactive-lifecycle.js +100 -33
- package/scripts/daemon-session-commands.js +133 -43
- package/scripts/daemon-session-store.js +300 -82
- package/scripts/daemon-team-dispatch.js +16 -16
- package/scripts/daemon.js +21 -175
- package/scripts/deploy-manifest.js +90 -0
- package/scripts/docs/maintenance-manual.md +14 -11
- package/scripts/docs/pointer-map.md +13 -4
- package/scripts/feishu-adapter.js +31 -27
- package/scripts/hooks/intent-engine.js +6 -3
- package/scripts/hooks/intent-memory-recall.js +1 -0
- package/scripts/hooks/intent-perpetual.js +1 -1
- package/scripts/memory-extract.js +5 -97
- package/scripts/memory-gc.js +35 -90
- package/scripts/memory-migrate-v2.js +304 -0
- package/scripts/memory-nightly-reflect.js +40 -41
- package/scripts/memory.js +340 -859
- package/scripts/migrate-reactive-paths.js +122 -0
- package/scripts/signal-capture.js +4 -0
- package/scripts/sync-plugin.js +56 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const KIND_WEIGHTS = { convention: 1.0, insight: 0.8, profile: 0.7, episode: 0.4 };
|
|
4
|
+
const DEFAULT_BUDGET = { convention: 8, insight: 8, profile: 6, episode: 3 };
|
|
5
|
+
const RECENCY_HALF_LIFE_DAYS = 30;
|
|
6
|
+
const LN2 = Math.LN2;
|
|
7
|
+
const MS_PER_DAY = 86400000;
|
|
8
|
+
|
|
9
|
+
/** @param {{ project, scope, task, session, agent }} itemScope */
|
|
10
|
+
/** @param {{ project, scope, task, session, agent }} queryScope */
|
|
11
|
+
/** @returns {number} 0-1 */
|
|
12
|
+
function matchScope(itemScope, queryScope) {
|
|
13
|
+
if (!itemScope || !queryScope) return 0;
|
|
14
|
+
const ip = itemScope.project || '*';
|
|
15
|
+
const qp = queryScope.project || '*';
|
|
16
|
+
if (ip === '*' || qp === '*') return 0.3;
|
|
17
|
+
if (ip !== qp) return 0;
|
|
18
|
+
const sameScope = (itemScope.scope || '') === (queryScope.scope || '');
|
|
19
|
+
const sameAgent = (itemScope.agent || '') === (queryScope.agent || '');
|
|
20
|
+
if (sameScope && sameAgent) return 1.0;
|
|
21
|
+
return 0.6;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function recencyScore(createdAt, now) {
|
|
25
|
+
if (!createdAt) return 0;
|
|
26
|
+
const created = typeof createdAt === 'number' ? createdAt : new Date(createdAt).getTime();
|
|
27
|
+
if (Number.isNaN(created)) return 0;
|
|
28
|
+
const ageDays = Math.max(0, (now - created) / MS_PER_DAY);
|
|
29
|
+
return Math.exp(-LN2 * ageDays / RECENCY_HALF_LIFE_DAYS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {{ kind, state, confidence, created_at, project, scope, agent_key, fts_rank }} item */
|
|
33
|
+
/** @param {string} query */
|
|
34
|
+
/** @param {{ project, scope, task, session, agent }} scopeContext */
|
|
35
|
+
/** @returns {number} */
|
|
36
|
+
function scoreMemoryItem(item, query, scopeContext, now) {
|
|
37
|
+
const scopeMatch = matchScope(
|
|
38
|
+
{ project: item.project, scope: item.scope, agent: item.agent_key },
|
|
39
|
+
scopeContext,
|
|
40
|
+
);
|
|
41
|
+
const kindWeight = KIND_WEIGHTS[item.kind] || 0;
|
|
42
|
+
const textRelevance = (typeof item.fts_rank === 'number' && Number.isFinite(item.fts_rank))
|
|
43
|
+
? Math.max(0, Math.min(1, item.fts_rank))
|
|
44
|
+
: 0;
|
|
45
|
+
const confidence = (typeof item.confidence === 'number' && Number.isFinite(item.confidence))
|
|
46
|
+
? Math.max(0, Math.min(1, item.confidence))
|
|
47
|
+
: 0;
|
|
48
|
+
const recency = recencyScore(item.created_at, now || Date.now());
|
|
49
|
+
|
|
50
|
+
return scopeMatch * 4 + kindWeight * 3 + textRelevance * 2 + confidence * 1 + recency * 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rankMemoryItems(items, query, scopeContext, now) {
|
|
54
|
+
const scored = items.map(item => {
|
|
55
|
+
const score = scoreMemoryItem(item, query, scopeContext, now);
|
|
56
|
+
return Object.assign({}, item, { score });
|
|
57
|
+
});
|
|
58
|
+
scored.sort((a, b) => b.score - a.score);
|
|
59
|
+
return scored;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {Array} rankedItems */
|
|
63
|
+
/** @param {{ convention?: number, insight?: number, profile?: number, episode?: number }} [budgetConfig] */
|
|
64
|
+
/** @returns {{ convention: Array, insight: Array, profile: Array, episode: Array }} */
|
|
65
|
+
function allocateBudget(rankedItems, budgetConfig) {
|
|
66
|
+
const limits = Object.assign({}, DEFAULT_BUDGET, budgetConfig);
|
|
67
|
+
const result = { convention: [], insight: [], profile: [], episode: [] };
|
|
68
|
+
for (const item of rankedItems) {
|
|
69
|
+
if (item.state !== 'active') continue;
|
|
70
|
+
const bucket = result[item.kind];
|
|
71
|
+
if (!bucket) continue;
|
|
72
|
+
const limit = limits[item.kind] || 0;
|
|
73
|
+
if (bucket.length < limit) bucket.push(item);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseTags(tags) {
|
|
79
|
+
if (Array.isArray(tags)) return tags;
|
|
80
|
+
if (typeof tags === 'string') {
|
|
81
|
+
try { const parsed = JSON.parse(tags); return Array.isArray(parsed) ? parsed : []; } catch { return []; }
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isProtected(item) {
|
|
87
|
+
if (item.source_type === 'manual') return true;
|
|
88
|
+
if (parseTags(item.tags).includes('protected')) return true;
|
|
89
|
+
if (item.kind === 'profile' && typeof item.confidence === 'number' && item.confidence >= 0.9) return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function contentEqual(a, b) {
|
|
94
|
+
return a.content === b.content && a.title === b.title;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sameTitle(a, b) {
|
|
98
|
+
return !!(a.title && b.title && a.title === b.title);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {object} candidate */
|
|
102
|
+
/** @param {Array} existingItems */
|
|
103
|
+
/** @returns {{ action: string, targetId?: * }} */
|
|
104
|
+
function judgeMerge(candidate, existingItems) {
|
|
105
|
+
for (const existing of existingItems) {
|
|
106
|
+
if (contentEqual(candidate, existing)) return { action: 'noop' };
|
|
107
|
+
}
|
|
108
|
+
for (const existing of existingItems) {
|
|
109
|
+
if (sameTitle(candidate, existing)) {
|
|
110
|
+
if (isProtected(existing)) return { action: 'reject' };
|
|
111
|
+
return { action: 'supersede', targetId: existing.id };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { action: 'promote' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function truncate(str, max) {
|
|
118
|
+
if (!str || str.length <= max) return str || '';
|
|
119
|
+
return str.slice(0, max - 3) + '...';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {{ convention: Array, insight: Array, profile: Array, episode: Array }} allocated */
|
|
123
|
+
/** @returns {{ conventions: string, insights: string, profile: string, episodes: string }} */
|
|
124
|
+
function assemblePromptBlocks(allocated) {
|
|
125
|
+
function formatBucket(items) {
|
|
126
|
+
return items.map(item => {
|
|
127
|
+
const title = item.title || '';
|
|
128
|
+
const content = truncate(item.content || '', 200);
|
|
129
|
+
return title ? `- [${title}]: ${content}` : `- ${content}`;
|
|
130
|
+
}).join('\n');
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
conventions: formatBucket(allocated.convention || []),
|
|
134
|
+
insights: formatBucket(allocated.insight || []),
|
|
135
|
+
profile: formatBucket(allocated.profile || []),
|
|
136
|
+
episodes: formatBucket(allocated.episode || []),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {object} item */
|
|
141
|
+
/** @returns {boolean} */
|
|
142
|
+
function shouldPromote(item) {
|
|
143
|
+
if (typeof item.search_count !== 'number' || item.search_count < 3) return false;
|
|
144
|
+
if (!item.last_searched_at) return false;
|
|
145
|
+
const searched = new Date(item.last_searched_at).getTime();
|
|
146
|
+
if (Number.isNaN(searched)) return false;
|
|
147
|
+
const sevenDaysAgo = Date.now() - 7 * MS_PER_DAY;
|
|
148
|
+
return searched >= sevenDaysAgo;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {object} item */
|
|
152
|
+
/** @returns {boolean} */
|
|
153
|
+
function shouldArchive(item) {
|
|
154
|
+
if (isProtected(item)) return false;
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const created = new Date(item.created_at).getTime();
|
|
157
|
+
if (Number.isNaN(created)) return false;
|
|
158
|
+
const ageDays = (now - created) / MS_PER_DAY;
|
|
159
|
+
const neverSearched = !item.search_count || item.search_count === 0;
|
|
160
|
+
const lastSearched = item.last_searched_at ? new Date(item.last_searched_at).getTime() : 0;
|
|
161
|
+
const notSearchedRecently = !lastSearched || (now - lastSearched) / MS_PER_DAY > 30;
|
|
162
|
+
|
|
163
|
+
if (item.state === 'candidate' && ageDays >= 30 && neverSearched) return true;
|
|
164
|
+
if (item.state === 'active' && ageDays >= 90
|
|
165
|
+
&& typeof item.confidence === 'number' && item.confidence < 0.6
|
|
166
|
+
&& notSearchedRecently) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
KIND_WEIGHTS,
|
|
172
|
+
DEFAULT_BUDGET,
|
|
173
|
+
RECENCY_HALF_LIFE_DAYS,
|
|
174
|
+
matchScope,
|
|
175
|
+
scoreMemoryItem,
|
|
176
|
+
rankMemoryItems,
|
|
177
|
+
allocateBudget,
|
|
178
|
+
judgeMerge,
|
|
179
|
+
assemblePromptBlocks,
|
|
180
|
+
shouldPromote,
|
|
181
|
+
shouldArchive,
|
|
182
|
+
_internal: { isProtected, recencyScore, truncate },
|
|
183
|
+
};
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
matchScope,
|
|
8
|
+
scoreMemoryItem,
|
|
9
|
+
rankMemoryItems,
|
|
10
|
+
allocateBudget,
|
|
11
|
+
judgeMerge,
|
|
12
|
+
assemblePromptBlocks,
|
|
13
|
+
shouldPromote,
|
|
14
|
+
shouldArchive,
|
|
15
|
+
KIND_WEIGHTS,
|
|
16
|
+
DEFAULT_BUDGET,
|
|
17
|
+
RECENCY_HALF_LIFE_DAYS,
|
|
18
|
+
} = require('./memory-model');
|
|
19
|
+
|
|
20
|
+
function makeItem(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
id: 'mem_test',
|
|
23
|
+
kind: 'insight',
|
|
24
|
+
state: 'active',
|
|
25
|
+
title: 'Test item',
|
|
26
|
+
content: 'Test content',
|
|
27
|
+
confidence: 0.7,
|
|
28
|
+
project: 'metame',
|
|
29
|
+
scope: null,
|
|
30
|
+
agent_key: null,
|
|
31
|
+
task_key: null,
|
|
32
|
+
session_id: null,
|
|
33
|
+
source_type: 'extract',
|
|
34
|
+
tags: '[]',
|
|
35
|
+
fts_rank: 0.5,
|
|
36
|
+
search_count: 0,
|
|
37
|
+
last_searched_at: null,
|
|
38
|
+
created_at: new Date().toISOString(),
|
|
39
|
+
updated_at: new Date().toISOString(),
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function daysAgo(n) {
|
|
45
|
+
const d = new Date();
|
|
46
|
+
d.setDate(d.getDate() - n);
|
|
47
|
+
return d.toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// matchScope
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe('matchScope', () => {
|
|
55
|
+
it('exact match (same project + scope + agent) → 1.0', () => {
|
|
56
|
+
const itemScope = { project: 'metame', scope: 'core', agent: 'jarvis' };
|
|
57
|
+
const queryScope = { project: 'metame', scope: 'core', agent: 'jarvis' };
|
|
58
|
+
assert.strictEqual(matchScope(itemScope, queryScope), 1.0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('same project, different scope → 0.6', () => {
|
|
62
|
+
const itemScope = { project: 'metame', scope: 'daemon', agent: null };
|
|
63
|
+
const queryScope = { project: 'metame', scope: 'core', agent: null };
|
|
64
|
+
assert.strictEqual(matchScope(itemScope, queryScope), 0.6);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('wildcard project * → 0.3', () => {
|
|
68
|
+
const itemScope = { project: '*', scope: null, agent: null };
|
|
69
|
+
const queryScope = { project: 'metame', scope: 'core', agent: null };
|
|
70
|
+
assert.strictEqual(matchScope(itemScope, queryScope), 0.3);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('no match at all → 0', () => {
|
|
74
|
+
const itemScope = { project: 'other', scope: 'x', agent: 'a' };
|
|
75
|
+
const queryScope = { project: 'metame', scope: 'core', agent: 'jarvis' };
|
|
76
|
+
assert.strictEqual(matchScope(itemScope, queryScope), 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles missing/null fields gracefully', () => {
|
|
80
|
+
// null/undefined → 0
|
|
81
|
+
assert.strictEqual(matchScope(null, null), 0);
|
|
82
|
+
assert.strictEqual(matchScope(null, { project: 'metame' }), 0);
|
|
83
|
+
assert.strictEqual(matchScope({ project: 'metame' }, null), 0);
|
|
84
|
+
// both empty → both default to '*' → 0.3 (wildcard rule)
|
|
85
|
+
assert.strictEqual(matchScope({}, {}), 0.3);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// scoreMemoryItem
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
describe('scoreMemoryItem', () => {
|
|
94
|
+
const scopeCtx = { project: 'metame', scope: 'core', agent: 'jarvis' };
|
|
95
|
+
|
|
96
|
+
it('convention item with high scope match scores highest', () => {
|
|
97
|
+
const item = makeItem({
|
|
98
|
+
kind: 'convention',
|
|
99
|
+
project: 'metame',
|
|
100
|
+
scope: 'core',
|
|
101
|
+
agent_key: 'jarvis',
|
|
102
|
+
confidence: 1.0,
|
|
103
|
+
fts_rank: 1.0,
|
|
104
|
+
});
|
|
105
|
+
const query = { text: 'test', scope: scopeCtx };
|
|
106
|
+
const score = scoreMemoryItem(item, query, scopeCtx);
|
|
107
|
+
assert.ok(score > 0.5, `expected high score, got ${score}`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('episode item with low scope match scores lower than convention', () => {
|
|
111
|
+
const convention = makeItem({
|
|
112
|
+
kind: 'convention',
|
|
113
|
+
project: 'metame',
|
|
114
|
+
scope: 'core',
|
|
115
|
+
agent_key: 'jarvis',
|
|
116
|
+
confidence: 1.0,
|
|
117
|
+
fts_rank: 1.0,
|
|
118
|
+
});
|
|
119
|
+
const episode = makeItem({
|
|
120
|
+
kind: 'episode',
|
|
121
|
+
project: 'other',
|
|
122
|
+
confidence: 0.3,
|
|
123
|
+
fts_rank: 0.1,
|
|
124
|
+
});
|
|
125
|
+
const convScore = scoreMemoryItem(convention, 'test', scopeCtx);
|
|
126
|
+
const epiScore = scoreMemoryItem(episode, 'test', scopeCtx);
|
|
127
|
+
assert.ok(convScore > epiScore, `convention ${convScore} should beat episode ${epiScore}`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('recency decay: item from today vs 60 days ago', () => {
|
|
131
|
+
const recent = makeItem({ created_at: new Date().toISOString() });
|
|
132
|
+
const old = makeItem({ created_at: daysAgo(60) });
|
|
133
|
+
const query = { text: 'test', scope: scopeCtx };
|
|
134
|
+
const recentScore = scoreMemoryItem(recent, query, scopeCtx);
|
|
135
|
+
const oldScore = scoreMemoryItem(old, query, scopeCtx);
|
|
136
|
+
assert.ok(recentScore > oldScore, `recent ${recentScore} should beat old ${oldScore}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('missing fts_rank defaults to 0', () => {
|
|
140
|
+
const item = makeItem({ fts_rank: undefined });
|
|
141
|
+
const query = { text: 'test', scope: scopeCtx };
|
|
142
|
+
const score = scoreMemoryItem(item, query, scopeCtx);
|
|
143
|
+
assert.strictEqual(typeof score, 'number');
|
|
144
|
+
assert.ok(!Number.isNaN(score));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('confidence 0 vs 1 difference', () => {
|
|
148
|
+
const low = makeItem({ confidence: 0 });
|
|
149
|
+
const high = makeItem({ confidence: 1 });
|
|
150
|
+
const query = { text: 'test', scope: scopeCtx };
|
|
151
|
+
const lowScore = scoreMemoryItem(low, query, scopeCtx);
|
|
152
|
+
const highScore = scoreMemoryItem(high, query, scopeCtx);
|
|
153
|
+
assert.ok(highScore > lowScore, `high conf ${highScore} should beat low ${lowScore}`);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// rankMemoryItems
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe('rankMemoryItems', () => {
|
|
162
|
+
const scopeCtx = { project: 'metame', scope: 'core', agent: 'jarvis' };
|
|
163
|
+
const query = { text: 'test', scope: scopeCtx };
|
|
164
|
+
|
|
165
|
+
it('returns items sorted by score descending', () => {
|
|
166
|
+
const items = [
|
|
167
|
+
makeItem({ id: 'low', confidence: 0.1, fts_rank: 0.1 }),
|
|
168
|
+
makeItem({ id: 'high', confidence: 1.0, fts_rank: 1.0, kind: 'convention', project: 'metame', scope: 'core', agent_key: 'jarvis' }),
|
|
169
|
+
];
|
|
170
|
+
const ranked = rankMemoryItems(items, query, scopeCtx);
|
|
171
|
+
assert.strictEqual(ranked[0].id, 'high');
|
|
172
|
+
assert.strictEqual(ranked[1].id, 'low');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('each item gets .score property added', () => {
|
|
176
|
+
const items = [makeItem()];
|
|
177
|
+
const ranked = rankMemoryItems(items, query, scopeCtx);
|
|
178
|
+
assert.strictEqual(typeof ranked[0].score, 'number');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('empty array returns empty array', () => {
|
|
182
|
+
const ranked = rankMemoryItems([], query, scopeCtx);
|
|
183
|
+
assert.deepStrictEqual(ranked, []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('all same score: stable order', () => {
|
|
187
|
+
const items = [
|
|
188
|
+
makeItem({ id: 'a' }),
|
|
189
|
+
makeItem({ id: 'b' }),
|
|
190
|
+
makeItem({ id: 'c' }),
|
|
191
|
+
];
|
|
192
|
+
const ranked = rankMemoryItems(items, query, scopeCtx);
|
|
193
|
+
// Same score → original insertion order preserved
|
|
194
|
+
const ids = ranked.map((r) => r.id);
|
|
195
|
+
assert.deepStrictEqual(ids, ['a', 'b', 'c']);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// allocateBudget
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('allocateBudget', () => {
|
|
204
|
+
it('respects per-kind limits (e.g., 8 conventions max)', () => {
|
|
205
|
+
const items = Array.from({ length: 20 }, (_, i) =>
|
|
206
|
+
makeItem({ id: `c${i}`, kind: 'convention', state: 'active', score: 1 - i * 0.01 })
|
|
207
|
+
);
|
|
208
|
+
const result = allocateBudget(items, DEFAULT_BUDGET);
|
|
209
|
+
assert.ok(result.convention.length <= (DEFAULT_BUDGET.convention || 8));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('only includes state=active items', () => {
|
|
213
|
+
const items = [
|
|
214
|
+
makeItem({ id: 'a1', state: 'active', kind: 'insight', score: 0.9 }),
|
|
215
|
+
makeItem({ id: 'a2', state: 'candidate', kind: 'insight', score: 0.95 }),
|
|
216
|
+
];
|
|
217
|
+
const result = allocateBudget(items, DEFAULT_BUDGET);
|
|
218
|
+
const allIds = [...result.convention, ...result.insight, ...result.profile, ...result.episode].map((i) => i.id);
|
|
219
|
+
assert.ok(!allIds.includes('a2'), 'candidate item should be filtered out');
|
|
220
|
+
assert.ok(allIds.includes('a1'), 'active item should be included');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('filters out candidate/archived items', () => {
|
|
224
|
+
const items = [
|
|
225
|
+
makeItem({ id: 'cand', state: 'candidate', kind: 'insight', score: 0.9 }),
|
|
226
|
+
makeItem({ id: 'arch', state: 'archived', kind: 'insight', score: 0.8 }),
|
|
227
|
+
makeItem({ id: 'act', state: 'active', kind: 'insight', score: 0.7 }),
|
|
228
|
+
];
|
|
229
|
+
const result = allocateBudget(items, DEFAULT_BUDGET);
|
|
230
|
+
const allIds = [...result.convention, ...result.insight, ...result.profile, ...result.episode].map((i) => i.id);
|
|
231
|
+
assert.ok(!allIds.includes('cand'));
|
|
232
|
+
assert.ok(!allIds.includes('arch'));
|
|
233
|
+
assert.ok(allIds.includes('act'));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('empty input returns empty buckets', () => {
|
|
237
|
+
const result = allocateBudget([], DEFAULT_BUDGET);
|
|
238
|
+
assert.deepStrictEqual(result.convention, []);
|
|
239
|
+
assert.deepStrictEqual(result.insight, []);
|
|
240
|
+
assert.deepStrictEqual(result.profile, []);
|
|
241
|
+
assert.deepStrictEqual(result.episode, []);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('budget overflow: 20 conventions truncated to limit', () => {
|
|
245
|
+
const items = Array.from({ length: 20 }, (_, i) =>
|
|
246
|
+
makeItem({ id: `c${i}`, kind: 'convention', state: 'active', score: 1 - i * 0.01 })
|
|
247
|
+
);
|
|
248
|
+
const budget = { convention: 5, insight: 5, profile: 3, episode: 3 };
|
|
249
|
+
const result = allocateBudget(items, budget);
|
|
250
|
+
assert.strictEqual(result.convention.length, 5);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('custom budget config overrides defaults', () => {
|
|
254
|
+
const items = [
|
|
255
|
+
makeItem({ id: 'i1', kind: 'insight', state: 'active', score: 0.9 }),
|
|
256
|
+
makeItem({ id: 'i2', kind: 'insight', state: 'active', score: 0.8 }),
|
|
257
|
+
makeItem({ id: 'i3', kind: 'insight', state: 'active', score: 0.7 }),
|
|
258
|
+
];
|
|
259
|
+
const budget = { convention: 8, insight: 1, profile: 3, episode: 3 };
|
|
260
|
+
const result = allocateBudget(items, budget);
|
|
261
|
+
assert.strictEqual(result.insight.length, 1);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// judgeMerge
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe('judgeMerge', () => {
|
|
270
|
+
it('exact content match → noop', () => {
|
|
271
|
+
const candidate = makeItem({ title: 'Rule A', content: 'Do X always' });
|
|
272
|
+
const existing = [makeItem({ id: 'e1', title: 'Rule A', content: 'Do X always' })];
|
|
273
|
+
const result = judgeMerge(candidate, existing);
|
|
274
|
+
assert.strictEqual(result.action, 'noop');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('same title, different content → supersede with targetId', () => {
|
|
278
|
+
const candidate = makeItem({ title: 'Rule A', content: 'Do X v2' });
|
|
279
|
+
const existing = [makeItem({ id: 'e1', title: 'Rule A', content: 'Do X v1' })];
|
|
280
|
+
const result = judgeMerge(candidate, existing);
|
|
281
|
+
assert.strictEqual(result.action, 'supersede');
|
|
282
|
+
assert.strictEqual(result.targetId, 'e1');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('genuinely new → promote', () => {
|
|
286
|
+
const candidate = makeItem({ title: 'Brand new rule', content: 'Something unique' });
|
|
287
|
+
const existing = [makeItem({ id: 'e1', title: 'Old rule', content: 'Old content' })];
|
|
288
|
+
const result = judgeMerge(candidate, existing);
|
|
289
|
+
assert.strictEqual(result.action, 'promote');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('protected item (source_type=manual) → reject', () => {
|
|
293
|
+
const candidate = makeItem({ title: 'Rule A', content: 'Do X v2' });
|
|
294
|
+
const existing = [makeItem({ id: 'e1', title: 'Rule A', content: 'Do X v1', source_type: 'manual' })];
|
|
295
|
+
const result = judgeMerge(candidate, existing);
|
|
296
|
+
assert.strictEqual(result.action, 'reject');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('protected item (tags=[protected]) → reject', () => {
|
|
300
|
+
const candidate = makeItem({ title: 'Rule A', content: 'Do X v2' });
|
|
301
|
+
const existing = [makeItem({ id: 'e1', title: 'Rule A', content: 'Do X v1', tags: '["protected"]' })];
|
|
302
|
+
const result = judgeMerge(candidate, existing);
|
|
303
|
+
assert.strictEqual(result.action, 'reject');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('protected item (kind=profile, confidence=0.95) → reject', () => {
|
|
307
|
+
const candidate = makeItem({ title: 'User pref', content: 'Updated pref' });
|
|
308
|
+
const existing = [makeItem({ id: 'e1', title: 'User pref', content: 'Original pref', kind: 'profile', confidence: 0.95 })];
|
|
309
|
+
const result = judgeMerge(candidate, existing);
|
|
310
|
+
assert.strictEqual(result.action, 'reject');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// assemblePromptBlocks
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('assemblePromptBlocks', () => {
|
|
319
|
+
it('formats items as "- [title]: content"', () => {
|
|
320
|
+
const allocated = {
|
|
321
|
+
convention: [makeItem({ title: 'Rule 1', content: 'Always do X' })],
|
|
322
|
+
insight: [],
|
|
323
|
+
profile: [],
|
|
324
|
+
episode: [],
|
|
325
|
+
};
|
|
326
|
+
const blocks = assemblePromptBlocks(allocated);
|
|
327
|
+
assert.ok(blocks.conventions.includes('- [Rule 1]: Always do X'));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('long content truncated to 200 chars', () => {
|
|
331
|
+
const longContent = 'A'.repeat(300);
|
|
332
|
+
const allocated = {
|
|
333
|
+
convention: [makeItem({ title: 'Long', content: longContent })],
|
|
334
|
+
insight: [],
|
|
335
|
+
profile: [],
|
|
336
|
+
episode: [],
|
|
337
|
+
};
|
|
338
|
+
const blocks = assemblePromptBlocks(allocated);
|
|
339
|
+
// Truncated content should be <= 200 chars (plus possible ellipsis)
|
|
340
|
+
const line = blocks.conventions.split('\n').find((l) => l.includes('[Long]'));
|
|
341
|
+
assert.ok(line, 'should contain the item');
|
|
342
|
+
assert.ok(line.length < 250, `line too long: ${line.length}`);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('empty buckets produce empty strings', () => {
|
|
346
|
+
const allocated = { convention: [], insight: [], profile: [], episode: [] };
|
|
347
|
+
const blocks = assemblePromptBlocks(allocated);
|
|
348
|
+
assert.strictEqual(blocks.conventions, '');
|
|
349
|
+
assert.strictEqual(blocks.insights, '');
|
|
350
|
+
assert.strictEqual(blocks.profile, '');
|
|
351
|
+
assert.strictEqual(blocks.episodes, '');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('missing title handled gracefully', () => {
|
|
355
|
+
const allocated = {
|
|
356
|
+
convention: [makeItem({ title: null, content: 'No title content' })],
|
|
357
|
+
insight: [],
|
|
358
|
+
profile: [],
|
|
359
|
+
episode: [],
|
|
360
|
+
};
|
|
361
|
+
const blocks = assemblePromptBlocks(allocated);
|
|
362
|
+
assert.ok(blocks.conventions.includes('No title content'));
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// shouldPromote
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
describe('shouldPromote', () => {
|
|
371
|
+
it('search_count=3, last_searched_at=today → true', () => {
|
|
372
|
+
const item = makeItem({ search_count: 3, last_searched_at: new Date().toISOString(), state: 'candidate' });
|
|
373
|
+
assert.strictEqual(shouldPromote(item), true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('search_count=2 → false', () => {
|
|
377
|
+
const item = makeItem({ search_count: 2, last_searched_at: new Date().toISOString(), state: 'candidate' });
|
|
378
|
+
assert.strictEqual(shouldPromote(item), false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('search_count=5, last_searched_at=8 days ago → false', () => {
|
|
382
|
+
const item = makeItem({ search_count: 5, last_searched_at: daysAgo(8), state: 'candidate' });
|
|
383
|
+
assert.strictEqual(shouldPromote(item), false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('search_count=0 → false', () => {
|
|
387
|
+
const item = makeItem({ search_count: 0, state: 'candidate' });
|
|
388
|
+
assert.strictEqual(shouldPromote(item), false);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// shouldArchive
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
describe('shouldArchive', () => {
|
|
397
|
+
it('candidate, 31 days old, never searched → true', () => {
|
|
398
|
+
const item = makeItem({ state: 'candidate', created_at: daysAgo(31), search_count: 0 });
|
|
399
|
+
assert.strictEqual(shouldArchive(item), true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('candidate, 29 days old → false', () => {
|
|
403
|
+
const item = makeItem({ state: 'candidate', created_at: daysAgo(29), search_count: 0 });
|
|
404
|
+
assert.strictEqual(shouldArchive(item), false);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('active, 91 days old, confidence=0.5, not searched 31 days → true', () => {
|
|
408
|
+
const item = makeItem({
|
|
409
|
+
state: 'active',
|
|
410
|
+
created_at: daysAgo(91),
|
|
411
|
+
confidence: 0.5,
|
|
412
|
+
last_searched_at: daysAgo(31),
|
|
413
|
+
search_count: 1,
|
|
414
|
+
});
|
|
415
|
+
assert.strictEqual(shouldArchive(item), true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('active, 91 days old, confidence=0.7 → false (confidence too high)', () => {
|
|
419
|
+
const item = makeItem({
|
|
420
|
+
state: 'active',
|
|
421
|
+
created_at: daysAgo(91),
|
|
422
|
+
confidence: 0.7,
|
|
423
|
+
last_searched_at: daysAgo(31),
|
|
424
|
+
search_count: 1,
|
|
425
|
+
});
|
|
426
|
+
assert.strictEqual(shouldArchive(item), false);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('source_type=manual → NEVER archive', () => {
|
|
430
|
+
const item = makeItem({
|
|
431
|
+
state: 'candidate',
|
|
432
|
+
created_at: daysAgo(100),
|
|
433
|
+
source_type: 'manual',
|
|
434
|
+
search_count: 0,
|
|
435
|
+
});
|
|
436
|
+
assert.strictEqual(shouldArchive(item), false);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('tags=[protected] → NEVER archive', () => {
|
|
440
|
+
const item = makeItem({
|
|
441
|
+
state: 'candidate',
|
|
442
|
+
created_at: daysAgo(100),
|
|
443
|
+
tags: '["protected"]',
|
|
444
|
+
search_count: 0,
|
|
445
|
+
});
|
|
446
|
+
assert.strictEqual(shouldArchive(item), false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('kind=convention + source_type=manual → NEVER archive', () => {
|
|
450
|
+
const item = makeItem({
|
|
451
|
+
kind: 'convention',
|
|
452
|
+
state: 'active',
|
|
453
|
+
created_at: daysAgo(200),
|
|
454
|
+
confidence: 0.3,
|
|
455
|
+
source_type: 'manual',
|
|
456
|
+
last_searched_at: daysAgo(60),
|
|
457
|
+
search_count: 0,
|
|
458
|
+
});
|
|
459
|
+
assert.strictEqual(shouldArchive(item), false);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Constants sanity checks
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
describe('constants', () => {
|
|
468
|
+
it('KIND_WEIGHTS is an object with expected keys', () => {
|
|
469
|
+
assert.ok(typeof KIND_WEIGHTS === 'object');
|
|
470
|
+
assert.ok('convention' in KIND_WEIGHTS);
|
|
471
|
+
assert.ok('insight' in KIND_WEIGHTS);
|
|
472
|
+
assert.ok('profile' in KIND_WEIGHTS);
|
|
473
|
+
assert.ok('episode' in KIND_WEIGHTS);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('DEFAULT_BUDGET has per-kind limits', () => {
|
|
477
|
+
assert.ok(typeof DEFAULT_BUDGET === 'object');
|
|
478
|
+
assert.ok(typeof DEFAULT_BUDGET.convention === 'number');
|
|
479
|
+
assert.ok(typeof DEFAULT_BUDGET.insight === 'number');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('RECENCY_HALF_LIFE_DAYS is a positive number', () => {
|
|
483
|
+
assert.ok(typeof RECENCY_HALF_LIFE_DAYS === 'number');
|
|
484
|
+
assert.ok(RECENCY_HALF_LIFE_DAYS > 0);
|
|
485
|
+
});
|
|
486
|
+
});
|