@fyso/awareness-framework 0.3.0 → 0.3.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/docs/cli.md CHANGED
@@ -118,7 +118,7 @@ awareness init --wrappers \
118
118
 
119
119
  ### `status`
120
120
 
121
- Shows the current focus and warnings.
121
+ Shows the current focus and warnings. Warnings are printed but do not make the command fail; use `awareness check --strict` when automation should fail on warnings.
122
122
 
123
123
  ```bash
124
124
  awareness status
@@ -153,9 +153,12 @@ awareness focus \
153
153
  --summary "Agent awareness framework" \
154
154
  --repo fyso-dev/awareness-framework \
155
155
  --branch codex/cli-and-personality \
156
+ --state in-progress \
156
157
  --next "Run tests and open a PR"
157
158
  ```
158
159
 
160
+ Valid states are `started`, `in-progress`, `paused`, `blocked`, `waiting`, `done`, `in-review`, and `ready`. Underscore aliases such as `in_progress` and `in_review` are accepted and normalized.
161
+
159
162
  ### `log`
160
163
 
161
164
  Appends a concrete progress entry to the daily worklog.
@@ -170,7 +173,7 @@ awareness log \
170
173
 
171
174
  ### `handoff`
172
175
 
173
- Prints a handoff snapshot from the awareness board.
176
+ Prints a handoff snapshot from the awareness board. Like `status`, warnings are informational and return exit code 0.
174
177
 
175
178
  ```bash
176
179
  awareness handoff
@@ -202,6 +205,8 @@ awareness memory promote --kind preference --text "Surface memory candidates pro
202
205
 
203
206
  `memory review` scans promotion candidates and suggests repeated candidates as `pattern` promotions once they appear at least twice by default.
204
207
 
208
+ `memory candidates` lists active promotion candidates only. Text that has been pruned or revised remains in the Markdown history but is hidden from active candidates, excluded from suggestions, and rejected by `memory promote`.
209
+
205
210
  Valid promotion kinds are `preference`, `pattern`, `project`, and `review`.
206
211
 
207
212
  ### Local memory operations
@@ -216,9 +221,9 @@ awareness improve
216
221
  ```
217
222
 
218
223
  `remember` records a promotion candidate and appends `memory.candidate.created` to `memory/events.jsonl`.
219
- `recall` performs deterministic local text search across memory, memory events, worklogs, and evaluations.
220
- `forget` records a prune/revision entry and appends `memory.pruned`; it does not destructively delete historical evidence.
221
- `improve` runs the evaluation/review loop and appends `evaluation.created` and `pattern.suggested` events when applicable.
224
+ `recall` performs deterministic local text search across memory, memory events, worklogs, and evaluations. Matching is case- and accent-insensitive, includes simple singular/plural normalization, and includes a small English/Spanish alias set for memory/user terms.
225
+ `forget` records a prune/revision entry and appends `memory.pruned`; it does not destructively delete historical evidence, but pruned text is inactive for candidate review and promotion.
226
+ `improve` runs the evaluation/review loop and appends `evaluation.created` and `pattern.suggested` events when applicable. Auto-generated evaluation candidates are deduplicated by text across days so recurring diagnostics do not flood human-curated candidates.
222
227
 
223
228
  ### `hook run`
224
229
 
@@ -115,7 +115,7 @@ Each evaluation should end with one of these outcomes:
115
115
  - Propose framework PR.
116
116
  - Ask user for confirmation.
117
117
 
118
- Daily evaluation is active by default: when it writes an evaluation note, the CLI also records promotion candidates under `memory/long-term.md`. Candidates are intentionally reviewable. Use `awareness memory review` to surface repeated candidates as suggested `pattern` promotions, then promote them with `awareness memory promote` only after they are user-confirmed, repeated, or operationally important.
118
+ Daily evaluation is active by default: when it writes an evaluation note, the CLI also records promotion candidates under `memory/long-term.md`. Auto-generated candidates are deduplicated by text across days so repeated diagnostics do not crowd out human-curated observations. Candidates are intentionally reviewable. Use `awareness memory review` to surface repeated candidates as suggested `pattern` promotions, then promote them with `awareness memory promote` only after they are user-confirmed, repeated, operationally important, and not pruned or revised.
119
119
 
120
120
  ## Example Outcomes
121
121
 
package/docs/lifecycle.md CHANGED
@@ -74,12 +74,16 @@ When switching tasks, the agent:
74
74
 
75
75
  Valid states:
76
76
 
77
+ - `started`
77
78
  - `in-progress`
78
79
  - `paused`
79
80
  - `blocked`
80
81
  - `waiting`
81
82
  - `done`
82
83
  - `in-review`
84
+ - `ready`
85
+
86
+ The CLI also accepts underscore aliases such as `in_progress` and normalizes them to hyphenated state names.
83
87
 
84
88
  ## 5. Handoff
85
89
 
package/docs/memory.md CHANGED
@@ -19,7 +19,7 @@ Do not load every layer into every prompt. Load the smallest layer that answers
19
19
  Awareness uses a small local operation vocabulary:
20
20
 
21
21
  - `remember`: capture an evidence-backed candidate.
22
- - `recall`: search local memory, events, worklogs, and evaluations.
22
+ - `recall`: search local memory, events, worklogs, and evaluations with deterministic normalized text matching.
23
23
  - `forget`: prune or revise stale memory without destructive deletion.
24
24
  - `improve`: run evaluation plus memory review to surface repeated candidates.
25
25
 
@@ -113,10 +113,13 @@ Information should move from short-term to long-term only when it earns promotio
113
113
 
114
114
  Hooks and scheduled maintenance may perform steps 1 and 2 by recording observations, warnings, or evaluation notes. They must not perform step 4 silently.
115
115
 
116
+ Pruned or revised text remains in the Markdown history for auditability, but it is inactive. It should not appear in active candidate listings, repeated-candidate suggestions, or promotion commands.
117
+
116
118
  ## Promotion Rules
117
119
 
118
120
  - Promote explicit user preferences immediately when they affect future collaboration.
119
121
  - Promote inferred preferences only after repeated evidence.
122
+ - Do not promote text that has been pruned or revised; record a new corrected candidate instead.
120
123
  - Promote framework changes only through version control.
121
124
  - Keep private memory private; do not copy private examples into public docs.
122
125
  - Prefer small, operational statements over long stories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyso/awareness-framework",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Methodology and helper CLI for agent awareness, initialization, daily worklogs, handoffs, and evaluation loops.",
5
5
  "type": "module",
6
6
  "author": "Fyso",
package/src/cli.js CHANGED
@@ -3,9 +3,21 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import {
7
+ activeMemoryCandidates,
8
+ isPrunedMemoryText,
9
+ memoryCandidateExists,
10
+ memoryCandidateTextExists,
11
+ repeatedMemoryCandidateSuggestions,
12
+ } from './memory-candidates.js';
13
+ import { normalizeSearchText, recallTermGroups } from './text.js';
6
14
 
7
15
  const VALID_STATES = new Set(['started', 'in-progress', 'paused', 'blocked', 'waiting', 'done', 'in-review', 'ready']);
8
16
  const DEFAULT_STATE = 'in-progress';
17
+ const STATE_ALIASES = {
18
+ in_progress: 'in-progress',
19
+ in_review: 'in-review',
20
+ };
9
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
22
  const repoRoot = path.resolve(__dirname, '..');
11
23
 
@@ -142,6 +154,9 @@ Scope options:
142
154
  --channel NAME Store state under <folder>/channels/<safe-name>.
143
155
  --user ID Select a user memory file for user commands.
144
156
 
157
+ State values:
158
+ ${[...VALID_STATES].join(', ')}
159
+
145
160
  The CLI maintains private files under ~/.agents by default. It does not post to Jira, GitHub, or any external system.`);
146
161
  }
147
162
 
@@ -209,7 +224,7 @@ function statusCommand(ctx, opts) {
209
224
  for (const warning of warnings) {
210
225
  out(ctx, `- ${warning}`);
211
226
  }
212
- return warnings.length ? 1 : 0;
227
+ return 0;
213
228
  }
214
229
 
215
230
  function checkCommand(ctx, opts) {
@@ -330,7 +345,7 @@ function handoffCommand(ctx, opts) {
330
345
  }
331
346
  }
332
347
 
333
- return warnings.length ? 1 : 0;
348
+ return 0;
334
349
  }
335
350
 
336
351
  function evaluateCommand(ctx, opts) {
@@ -354,8 +369,9 @@ function evaluateCommand(ctx, opts) {
354
369
  ensureDir(path.dirname(evaluationPath));
355
370
  fs.writeFileSync(evaluationPath, content);
356
371
  const candidates = recordEvaluationMemoryCandidates(home, today);
372
+ const candidateSummary = candidates.length ? `${candidates.length} recorded` : 'none';
357
373
  out(ctx, `Evaluation written: ${evaluationPath}`);
358
- out(ctx, `Memory candidates: ${candidates.length ? `${candidates.length} recorded` : 'none'}`);
374
+ out(ctx, `Memory candidates: ${candidateSummary}`);
359
375
  return 0;
360
376
  }
361
377
 
@@ -382,8 +398,11 @@ function memoryCommand(ctx, subcommand, opts) {
382
398
 
383
399
  function memoryCandidatesCommand(ctx, home) {
384
400
  const content = fs.readFileSync(longTermMemoryPath(home), 'utf8');
401
+ const activeCandidates = activeMemoryCandidates(content)
402
+ .map((candidate) => candidate.line)
403
+ .join('\n');
385
404
  out(ctx, 'Promotion Candidates');
386
- out(ctx, extractSection(content, 'Promotion Candidates').trim() || '- None yet.');
405
+ out(ctx, activeCandidates || '- None yet.');
387
406
  return 0;
388
407
  }
389
408
 
@@ -427,6 +446,9 @@ function memoryPromoteCommand(ctx, home, opts) {
427
446
  const today = todayParts(ctx);
428
447
  const file = longTermMemoryPath(home);
429
448
  let content = fs.readFileSync(file, 'utf8');
449
+ if (isPrunedMemoryText(content, text)) {
450
+ throw new Error(`Cannot promote pruned or revised memory: ${text}`);
451
+ }
430
452
  content = replaceMetadata(content, 'Updated', formatTimestamp(today));
431
453
  content = appendToSection(content, section, `- ${today.date}: ${text} (evidence: ${evidence})\n`);
432
454
  fs.writeFileSync(file, content);
@@ -553,7 +575,8 @@ function improveCommand(ctx, opts) {
553
575
  }
554
576
 
555
577
  out(ctx, `Evaluation: ${evaluation.status} (${evaluation.file})`);
556
- out(ctx, `Memory candidates: ${evaluation.candidates ? evaluation.candidates.length : 'not changed'}`);
578
+ const candidateSummary = evaluation.candidates ? `${evaluation.candidates.length} (from evaluation diagnostics)` : 'not changed';
579
+ out(ctx, `Auto-generated candidates: ${candidateSummary}`);
557
580
  out(ctx, `Pattern suggestions: ${suggestions.length}`);
558
581
  for (const suggestion of suggestions) {
559
582
  out(ctx, `- ${suggestion.text} (${suggestion.count} observations)`);
@@ -767,7 +790,10 @@ function scheduleRunCommand(ctx, opts) {
767
790
  out(ctx, `Schedule run complete: ${cadence}`);
768
791
  out(ctx, `Runtime log: ${eventFile}`);
769
792
  if (evaluation) out(ctx, `Evaluation: ${evaluation.status} (${evaluation.file})`);
770
- if (evaluation?.candidates) out(ctx, `Memory candidates: ${evaluation.candidates.length ? `${evaluation.candidates.length} recorded` : 'none'}`);
793
+ if (evaluation?.candidates) {
794
+ const candidateSummary = evaluation.candidates.length ? `${evaluation.candidates.length} recorded` : 'none';
795
+ out(ctx, `Memory candidates: ${candidateSummary}`);
796
+ }
771
797
  out(ctx, warnings.length ? `Warnings: ${warnings.length}` : 'Warnings: none');
772
798
  return 0;
773
799
  }
@@ -806,7 +832,8 @@ function scheduleInstallCommand(ctx, opts) {
806
832
  if (opts.load) {
807
833
  const result = loadLaunchAgent(file, label);
808
834
  if (result.status !== 0) {
809
- throw new Error(`launchctl failed for ${file}: ${result.stderr || result.stdout || `exit ${result.status}`}`);
835
+ const failure = result.stderr || result.stdout || `exit ${result.status}`;
836
+ throw new Error(`launchctl failed for ${file}: ${failure}`);
810
837
  }
811
838
  loaded.push(file);
812
839
  }
@@ -1086,6 +1113,7 @@ function buildEvaluation(home, today) {
1086
1113
  const handoff = /- Next:\s+(?!The next concrete action)\S+/.test(extractSection(current, 'Current Focus')) ? 2 : current ? 1 : 0;
1087
1114
  const noise = current.split('\n').length <= 180 && !/YYYY-MM-DD|branch-name/.test(current) ? 2 : 1;
1088
1115
  const reporting = sectionHasMeaningfulContent(extractSection(current, 'End-of-Day Candidates')) ? 2 : entries.length ? 1 : 0;
1116
+ const warningsMarkdown = warnings.length ? warnings.map((warning) => `- ${warning}`).join('\n') : '- None.';
1089
1117
 
1090
1118
  return `# Awareness Evaluation - ${today.date}
1091
1119
 
@@ -1101,7 +1129,7 @@ function buildEvaluation(home, today) {
1101
1129
 
1102
1130
  ## Warnings
1103
1131
 
1104
- ${warnings.length ? warnings.map((warning) => `- ${warning}`).join('\n') : '- None.'}
1132
+ ${warningsMarkdown}
1105
1133
 
1106
1134
  ## Proposed Changes
1107
1135
 
@@ -1112,7 +1140,9 @@ ${warnings.length ? warnings.map((warning) => `- ${warning}`).join('\n') : '- No
1112
1140
 
1113
1141
  function recordEvaluationMemoryCandidates(home, today) {
1114
1142
  const candidates = buildEvaluationMemoryCandidates(home, today);
1115
- return candidates.filter((candidate) => appendMemoryCandidate(home, today, candidate.text, candidate.evidence, 'evaluation'));
1143
+ return candidates.filter((candidate) => appendMemoryCandidate(home, today, candidate.text, candidate.evidence, 'evaluation', {
1144
+ dedupeByText: true,
1145
+ }));
1116
1146
  }
1117
1147
 
1118
1148
  function buildEvaluationMemoryCandidates(home, today) {
@@ -1159,10 +1189,12 @@ function buildEvaluationMemoryCandidates(home, today) {
1159
1189
  return candidates;
1160
1190
  }
1161
1191
 
1162
- function appendMemoryCandidate(home, today, text, evidence, source = 'memory.note') {
1192
+ function appendMemoryCandidate(home, today, text, evidence, source = 'memory.note', opts = {}) {
1163
1193
  const file = longTermMemoryPath(home);
1164
1194
  let content = fs.readFileSync(file, 'utf8');
1165
1195
  if (memoryCandidateExists(content, text, evidence)) return false;
1196
+ if (isPrunedMemoryText(content, text)) return false;
1197
+ if (opts.dedupeByText && memoryCandidateTextExists(content, text)) return false;
1166
1198
 
1167
1199
  content = replaceMetadata(content, 'Updated', formatTimestamp(today));
1168
1200
  content = appendToSection(content, 'Promotion Candidates', `- ${today.date}: ${text} (evidence: ${evidence})\n`);
@@ -1176,58 +1208,6 @@ function appendMemoryCandidate(home, today, text, evidence, source = 'memory.not
1176
1208
  return true;
1177
1209
  }
1178
1210
 
1179
- function memoryCandidateExists(content, text, evidence) {
1180
- const candidates = extractSection(content, 'Promotion Candidates');
1181
- return candidates.split('\n').some((line) => line.includes(`: ${text} (evidence: ${evidence})`));
1182
- }
1183
-
1184
- function repeatedMemoryCandidateSuggestions(content, minCount) {
1185
- const grouped = new Map();
1186
- const prunedTexts = prunedMemoryCandidateTexts(content);
1187
- for (const candidate of parseMemoryCandidates(content)) {
1188
- const key = normalizeMemoryCandidateText(candidate.text);
1189
- if (prunedTexts.has(key)) continue;
1190
- const group = grouped.get(key) || { text: candidate.text, count: 0, evidence: [] };
1191
- group.count += 1;
1192
- group.evidence.push(candidate.evidence);
1193
- grouped.set(key, group);
1194
- }
1195
-
1196
- return [...grouped.values()]
1197
- .filter((group) => group.count >= minCount)
1198
- .map((group) => ({
1199
- text: group.text,
1200
- count: group.count,
1201
- evidence: [...new Set(group.evidence)].join('; '),
1202
- }))
1203
- .sort((left, right) => right.count - left.count || left.text.localeCompare(right.text));
1204
- }
1205
-
1206
- function parseMemoryCandidates(content) {
1207
- return extractSection(content, 'Promotion Candidates')
1208
- .split('\n')
1209
- .map((line) => line.trim())
1210
- .map((line) => line.match(/^- \d{4}-\d{2}-\d{2}: (.+) \(evidence: (.+)\)$/))
1211
- .filter(Boolean)
1212
- .map((match) => ({
1213
- text: match[1],
1214
- evidence: match[2],
1215
- }));
1216
- }
1217
-
1218
- function prunedMemoryCandidateTexts(content) {
1219
- return new Set(extractSection(content, 'Pruned Or Revised')
1220
- .split('\n')
1221
- .map((line) => line.trim())
1222
- .map((line) => line.match(/^- \d{4}-\d{2}-\d{2}: (.+) \(reason: .+; evidence: .+\)$/))
1223
- .filter(Boolean)
1224
- .map((match) => normalizeMemoryCandidateText(match[1])));
1225
- }
1226
-
1227
- function normalizeMemoryCandidateText(text) {
1228
- return text.toLowerCase().replace(/\s+/g, ' ').trim();
1229
- }
1230
-
1231
1211
  function shellQuoteText(text) {
1232
1212
  return text.replace(/["\\$`]/g, '\\$&');
1233
1213
  }
@@ -1269,7 +1249,7 @@ function parseWorklogEntries(worklog) {
1269
1249
  const headingText = heading[0];
1270
1250
  const headingParts = headingText.replace(/^#{2,3} \d{2}:\d{2} - /, '').split(' - ');
1271
1251
  const headingTask = headingParts.length > 1 && isTaskId(headingParts[0]) ? headingParts[0] : null;
1272
- const jiraTask = block.match(/^- Jira:\s+(.+)$/m)?.[1]?.trim();
1252
+ const jiraTask = metadataValue(block, 'Jira');
1273
1253
  return {
1274
1254
  block,
1275
1255
  task: headingTask || jiraTask || null,
@@ -1340,12 +1320,13 @@ function replaceSection(content, section, body) {
1340
1320
  function appendToSection(content, section, addition) {
1341
1321
  const current = extractSection(content, section);
1342
1322
  const cleaned = current.replace(/^- None yet\.\n?/m, '').trimEnd();
1343
- return replaceSection(content, section, `${cleaned ? `${cleaned}\n\n` : ''}${addition.trimEnd()}\n`);
1323
+ const prefix = cleaned ? `${cleaned}\n\n` : '';
1324
+ return replaceSection(content, section, `${prefix}${addition.trimEnd()}\n`);
1344
1325
  }
1345
1326
 
1346
1327
  function extractSection(content, section) {
1347
1328
  const pattern = new RegExp(`(?:^|\\n)## ${escapeRegExp(section)}\\n\\n?([\\s\\S]*?)(?=\\n## |$)`);
1348
- const match = content.match(pattern);
1329
+ const match = pattern.exec(content);
1349
1330
  return match ? match[1] : '';
1350
1331
  }
1351
1332
 
@@ -1357,12 +1338,18 @@ function replaceMetadata(content, key, value) {
1357
1338
  return content.replace(/^# .+$/m, (heading) => `${heading}\n\n- ${key}: ${value}`);
1358
1339
  }
1359
1340
 
1341
+ function metadataValue(content, key) {
1342
+ const prefix = `- ${key}:`;
1343
+ const line = content.split('\n').find((candidate) => candidate.startsWith(prefix));
1344
+ return line?.slice(prefix.length).trim() || null;
1345
+ }
1346
+
1360
1347
  function currentContext(home) {
1361
1348
  const file = awarenessPath(home);
1362
1349
  if (!fs.existsSync(file)) return 'Not specified';
1363
1350
  const focus = extractSection(fs.readFileSync(file, 'utf8'), 'Current Focus');
1364
- const repo = focus.match(/^- Repository:\s+(.+)$/m)?.[1] || 'Not specified';
1365
- const branch = focus.match(/^- Branch:\s+(.+)$/m)?.[1] || 'Not specified';
1351
+ const repo = metadataValue(focus, 'Repository') || 'Not specified';
1352
+ const branch = metadataValue(focus, 'Branch') || 'Not specified';
1366
1353
  return `${repo} / ${branch}`;
1367
1354
  }
1368
1355
 
@@ -1540,10 +1527,11 @@ function initialUserMemory(user, timestamp) {
1540
1527
  }
1541
1528
 
1542
1529
  function normalizeState(state) {
1543
- if (!VALID_STATES.has(state)) {
1530
+ const normalized = STATE_ALIASES[state] || String(state).replaceAll('_', '-');
1531
+ if (!VALID_STATES.has(normalized)) {
1544
1532
  throw new Error(`Invalid state: ${state}. Valid states: ${[...VALID_STATES].join(', ')}`);
1545
1533
  }
1546
- return state;
1534
+ return normalized;
1547
1535
  }
1548
1536
 
1549
1537
  function expandTargets(value, allowed) {
@@ -1560,11 +1548,15 @@ function expandTargets(value, allowed) {
1560
1548
 
1561
1549
  function required(opts, key) {
1562
1550
  if (!opts[key] || opts[key] === true) {
1563
- throw new Error(`Missing required option: --${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`);
1551
+ throw new Error(`Missing required option: --${optionName(key)}`);
1564
1552
  }
1565
1553
  return opts[key];
1566
1554
  }
1567
1555
 
1556
+ function optionName(key) {
1557
+ return key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
1558
+ }
1559
+
1568
1560
  function agentsHome(ctx, opts) {
1569
1561
  const raw = opts.home
1570
1562
  || opts.agentFolder
@@ -1704,14 +1696,14 @@ function markdownFilesRecursive(dir) {
1704
1696
  }
1705
1697
 
1706
1698
  function recallMatches(home, query, limit) {
1707
- const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
1699
+ const termGroups = recallTermGroups(query);
1708
1700
  const results = [];
1709
1701
  for (const file of collectRecallSources(home)) {
1710
1702
  const content = fs.readFileSync(file, 'utf8');
1711
1703
  const lines = content.split('\n');
1712
1704
  lines.forEach((line, index) => {
1713
- const haystack = line.toLowerCase();
1714
- const score = terms.filter((term) => haystack.includes(term)).length;
1705
+ const haystack = normalizeSearchText(line);
1706
+ const score = termGroups.filter((terms) => terms.some((term) => haystack.includes(term))).length;
1715
1707
  if (score > 0) {
1716
1708
  results.push({
1717
1709
  file,
@@ -0,0 +1,82 @@
1
+ import { normalizeSearchText } from './text.js';
2
+
3
+ export function activeMemoryCandidates(content) {
4
+ const prunedTexts = prunedMemoryCandidateTexts(content);
5
+ return parseMemoryCandidates(content).filter((candidate) => !prunedTexts.has(normalizeMemoryCandidateText(candidate.text)));
6
+ }
7
+
8
+ export function isPrunedMemoryText(content, text) {
9
+ return prunedMemoryCandidateTexts(content).has(normalizeMemoryCandidateText(text));
10
+ }
11
+
12
+ export function memoryCandidateExists(content, text, evidence) {
13
+ const candidates = extractMarkdownSection(content, 'Promotion Candidates');
14
+ return candidates.split('\n').some((line) => line.includes(`: ${text} (evidence: ${evidence})`));
15
+ }
16
+
17
+ export function memoryCandidateTextExists(content, text) {
18
+ const key = normalizeMemoryCandidateText(text);
19
+ return parseMemoryCandidates(content).some((candidate) => normalizeMemoryCandidateText(candidate.text) === key);
20
+ }
21
+
22
+ export function repeatedMemoryCandidateSuggestions(content, minCount) {
23
+ const grouped = new Map();
24
+ for (const candidate of activeMemoryCandidates(content)) {
25
+ const key = normalizeMemoryCandidateText(candidate.text);
26
+ const group = grouped.get(key) || { text: candidate.text, count: 0, evidence: [] };
27
+ group.count += 1;
28
+ group.evidence.push(candidate.evidence);
29
+ grouped.set(key, group);
30
+ }
31
+
32
+ return [...grouped.values()]
33
+ .filter((group) => group.count >= minCount)
34
+ .map((group) => ({
35
+ text: group.text,
36
+ count: group.count,
37
+ evidence: [...new Set(group.evidence)].join('; '),
38
+ }))
39
+ .sort((left, right) => right.count - left.count || left.text.localeCompare(right.text));
40
+ }
41
+
42
+ function parseMemoryCandidates(content) {
43
+ const candidates = [];
44
+ const candidatePattern = /^- \d{4}-\d{2}-\d{2}: (.+) \(evidence: (.+)\)$/;
45
+ for (const rawLine of extractMarkdownSection(content, 'Promotion Candidates').split('\n')) {
46
+ const match = candidatePattern.exec(rawLine.trim());
47
+ if (!match) continue;
48
+ candidates.push({
49
+ line: match[0],
50
+ text: match[1],
51
+ evidence: match[2],
52
+ });
53
+ }
54
+ return candidates;
55
+ }
56
+
57
+ function prunedMemoryCandidateTexts(content) {
58
+ const pruned = new Set();
59
+ const prunedPattern = /^- \d{4}-\d{2}-\d{2}: (.+) \(reason: .+; evidence: .+\)$/;
60
+ for (const rawLine of extractMarkdownSection(content, 'Pruned Or Revised').split('\n')) {
61
+ const match = prunedPattern.exec(rawLine.trim());
62
+ if (match) pruned.add(normalizeMemoryCandidateText(match[1]));
63
+ }
64
+ return pruned;
65
+ }
66
+
67
+ function normalizeMemoryCandidateText(text) {
68
+ return normalizeSearchText(text);
69
+ }
70
+
71
+ function extractMarkdownSection(content, section) {
72
+ const lines = content.split('\n');
73
+ const start = lines.findIndex((line) => line.trimEnd() === `## ${section}`);
74
+ if (start === -1) return '';
75
+
76
+ const body = [];
77
+ for (const line of lines.slice(start + 1)) {
78
+ if (line.startsWith('## ')) break;
79
+ body.push(line);
80
+ }
81
+ return body.join('\n').replace(/^\n/, '');
82
+ }
package/src/text.js ADDED
@@ -0,0 +1,35 @@
1
+ const RECALL_ALIASES = {
2
+ memoria: ['memory'],
3
+ memorias: ['memory'],
4
+ memory: ['memoria', 'memorias'],
5
+ user: ['usuario', 'usuarios'],
6
+ users: ['usuario', 'usuarios'],
7
+ usuario: ['user', 'users'],
8
+ usuarios: ['user', 'users'],
9
+ };
10
+
11
+ export function normalizeSearchText(text) {
12
+ return String(text)
13
+ .normalize('NFD')
14
+ .replace(/[\u0300-\u036f]/g, '')
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9._-]+/g, ' ')
17
+ .replace(/\s+/g, ' ')
18
+ .trim();
19
+ }
20
+
21
+ export function recallTermGroups(query) {
22
+ return normalizeSearchText(query)
23
+ .split(/\s+/)
24
+ .filter(Boolean)
25
+ .map((term) => new Set([term, ...recallTokenVariants(term), ...(RECALL_ALIASES[term] || [])]))
26
+ .map((terms) => [...terms].filter(Boolean))
27
+ .filter((terms, index, groups) => groups.findIndex((group) => group[0] === terms[0]) === index);
28
+ }
29
+
30
+ function recallTokenVariants(term) {
31
+ const variants = [];
32
+ if (term.endsWith('es') && term.length > 4) variants.push(term.slice(0, -2));
33
+ if (term.endsWith('s') && term.length > 3) variants.push(term.slice(0, -1));
34
+ return variants;
35
+ }