@jaimevalasek/aioson 1.9.3 → 1.17.2
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/CHANGELOG.md +237 -0
- package/README.md +44 -1
- package/package.json +1 -1
- package/src/cli.js +50 -1
- package/src/commands/chain-audit.js +156 -0
- package/src/commands/op-capture.js +146 -0
- package/src/commands/op-forget.js +54 -0
- package/src/commands/op-identity.js +145 -0
- package/src/commands/op-list.js +105 -0
- package/src/commands/op-migrate.js +158 -0
- package/src/commands/op-promote.js +66 -0
- package/src/commands/op-reinforce.js +73 -0
- package/src/commands/op-show.js +71 -0
- package/src/commands/op-stubs.js +67 -0
- package/src/commands/preflight.js +6 -2
- package/src/commands/runtime.js +178 -0
- package/src/commands/state-save.js +61 -0
- package/src/commands/sync-agents-preflight.js +117 -3
- package/src/commands/workflow-next.js +64 -0
- package/src/handoff-contract.js +25 -0
- package/src/i18n/messages/en.js +9 -0
- package/src/i18n/messages/es.js +9 -0
- package/src/i18n/messages/fr.js +9 -0
- package/src/i18n/messages/pt-BR.js +9 -0
- package/src/lib/agent-semantic-diff.js +199 -0
- package/src/neural-chain-agent-ingest.js +400 -0
- package/src/neural-chain-config.js +95 -0
- package/src/neural-chain-git-ingest.js +280 -0
- package/src/neural-chain-migration.js +61 -0
- package/src/neural-chain-noise-file.js +332 -0
- package/src/neural-chain-sanitize.js +0 -0
- package/src/neural-chain-telemetry.js +90 -0
- package/src/operator-memory/conflict.js +202 -0
- package/src/operator-memory/decay.js +157 -0
- package/src/operator-memory/decision.js +274 -0
- package/src/operator-memory/identity.js +109 -0
- package/src/operator-memory/index-md.js +170 -0
- package/src/operator-memory/loader.js +106 -0
- package/src/operator-memory/proposal.js +179 -0
- package/src/operator-memory/prune.js +81 -0
- package/src/operator-memory/slug.js +90 -0
- package/src/operator-memory/storage.js +121 -0
- package/src/preflight-engine.js +91 -1
- package/src/runtime-store.js +2 -0
- package/template/.aioson/agents/dev.md +1 -1
- package/template/.aioson/agents/deyvin.md +3 -3
- package/template/.aioson/agents/neo.md +23 -1
- package/template/.aioson/agents/product.md +1 -1
- package/template/.aioson/agents/setup.md +1 -1
- package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
- package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
- package/template/AGENTS.md +23 -0
- package/template/CLAUDE.md +23 -0
- package/template/agents/_shared/memory-capture-directive.md +115 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Neural Chain — shared chain_audit telemetry emit helper.
|
|
5
|
+
*
|
|
6
|
+
* Single emitter used by both the CLI command (`src/commands/chain-audit.js`)
|
|
7
|
+
* and the post-session hook (`src/neural-chain-agent-ingest.js`) so payload
|
|
8
|
+
* shape is identical across code paths (BR-NC-10).
|
|
9
|
+
*
|
|
10
|
+
* Spec payload schema (BR-NC-10), 8 required fields:
|
|
11
|
+
* feature_slug — string | null
|
|
12
|
+
* source_files — string[] (the files edited in this session, plural)
|
|
13
|
+
* impacts_found — number | null (null on query failure)
|
|
14
|
+
* auto_fixable_count — number (per BR-NC-02/03 classification)
|
|
15
|
+
* noise_file — string | null (path written, if any)
|
|
16
|
+
* tokens_used — number (V1 placeholder = 0; M2 hooks LLM-mediated path)
|
|
17
|
+
* duration_ms — number (audit query elapsed; 0 on no-op)
|
|
18
|
+
* error — string | null
|
|
19
|
+
*
|
|
20
|
+
* Emitters may attach extra context fields (e.g. `agent`, `autonomy_mode`,
|
|
21
|
+
* `chain_auto_threshold`, `ingest_stats`, `skipped_reason`) on top of the
|
|
22
|
+
* required schema — those are passed through verbatim.
|
|
23
|
+
*
|
|
24
|
+
* EC-NC-05 no-op event (empty artifact list) also populates `duration_ms = 0`
|
|
25
|
+
* and `error = null` so downstream aggregation never has to special-case the
|
|
26
|
+
* skip path. Hotfix v1.17.1 — bug-found-003 from @tester gap-fill audit.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const REQUIRED_FIELDS = Object.freeze([
|
|
30
|
+
'feature_slug',
|
|
31
|
+
'source_files',
|
|
32
|
+
'impacts_found',
|
|
33
|
+
'auto_fixable_count',
|
|
34
|
+
'noise_file',
|
|
35
|
+
'tokens_used',
|
|
36
|
+
'duration_ms',
|
|
37
|
+
'error'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function normalizeSourceFiles(value) {
|
|
41
|
+
if (Array.isArray(value)) return value.slice();
|
|
42
|
+
if (value === null || value === undefined) return [];
|
|
43
|
+
return [String(value)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildChainAuditPayload({
|
|
47
|
+
feature_slug = null,
|
|
48
|
+
source_files = [],
|
|
49
|
+
impacts_found = 0,
|
|
50
|
+
auto_fixable_count = 0,
|
|
51
|
+
noise_file = null,
|
|
52
|
+
tokens_used = 0,
|
|
53
|
+
duration_ms = 0,
|
|
54
|
+
error = null,
|
|
55
|
+
...extras
|
|
56
|
+
} = {}) {
|
|
57
|
+
return {
|
|
58
|
+
feature_slug,
|
|
59
|
+
source_files: normalizeSourceFiles(source_files),
|
|
60
|
+
impacts_found,
|
|
61
|
+
auto_fixable_count,
|
|
62
|
+
noise_file,
|
|
63
|
+
tokens_used,
|
|
64
|
+
duration_ms,
|
|
65
|
+
error,
|
|
66
|
+
...extras
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function emitChainAuditEvent(db, { agent = null, message = 'chain:audit', ...payloadOverrides } = {}) {
|
|
71
|
+
if (!db || typeof db.prepare !== 'function') return false;
|
|
72
|
+
const payload = buildChainAuditPayload(payloadOverrides);
|
|
73
|
+
try {
|
|
74
|
+
db.prepare(`
|
|
75
|
+
INSERT INTO execution_events (event_type, agent_name, message, payload_json, created_at)
|
|
76
|
+
VALUES ('chain_audit', ?, ?, ?, ?)
|
|
77
|
+
`).run(agent, message, JSON.stringify(payload), new Date().toISOString());
|
|
78
|
+
return true;
|
|
79
|
+
} catch (_) {
|
|
80
|
+
// BR-NC-10 best-effort: telemetry failure must never propagate to the caller.
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
buildChainAuditPayload,
|
|
87
|
+
emitChainAuditEvent,
|
|
88
|
+
normalizeSourceFiles,
|
|
89
|
+
REQUIRED_FIELDS
|
|
90
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — conflict policy + warning surface (Phase 4, v1.15.0).
|
|
5
|
+
*
|
|
6
|
+
* V1 binary policy (PMD-09): project rules in `.aioson/rules/` always win.
|
|
7
|
+
* Operator decisions are unchanged on conflict — only a stderr warning is
|
|
8
|
+
* emitted, debounced per (decision_slug, rule_path) pair via _conflict_state.json
|
|
9
|
+
* with a 60s window (AC-P4-03).
|
|
10
|
+
*
|
|
11
|
+
* Detection (V1 keyword-based — V2 will move to LLM-tagged):
|
|
12
|
+
* 1. Rule frontmatter must opt in: `conflicts_with_signal_types: [authorization, ...]`
|
|
13
|
+
* Rules without this field generate zero false positives (additive policy).
|
|
14
|
+
* 2. Operator decision's signal_type must intersect rule's `conflicts_with_signal_types`.
|
|
15
|
+
* 3. Keyword overlap ≥ 2 between rule body and decision body (case-insensitive,
|
|
16
|
+
* stopwords filtered). Configurable via env `AIOSON_OPERATOR_CONFLICT_KEYWORD_THRESHOLD`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { getStorageRoot } = require('./storage');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_KEYWORD_THRESHOLD = 2;
|
|
24
|
+
const DEFAULT_DEBOUNCE_MS = 60_000;
|
|
25
|
+
const CONFLICT_STATE_FILE = '_conflict_state.json';
|
|
26
|
+
|
|
27
|
+
const STOPWORDS = new Set([
|
|
28
|
+
'a', 'an', 'the', 'and', 'or', 'of', 'to', 'for', 'in', 'on', 'with', 'is', 'be',
|
|
29
|
+
'que', 'de', 'em', 'para', 'sem', 'com', 'um', 'uma', 'os', 'as', 'no', 'na'
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function tokenize(text) {
|
|
33
|
+
return String(text || '')
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.normalize('NFD')
|
|
36
|
+
.replace(/[̀-ͯ]/g, '')
|
|
37
|
+
.split(/[^a-z0-9]+/)
|
|
38
|
+
.filter((w) => w && !STOPWORDS.has(w) && w.length >= 3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function keywordOverlap(textA, textB) {
|
|
42
|
+
const a = new Set(tokenize(textA));
|
|
43
|
+
const b = new Set(tokenize(textB));
|
|
44
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
45
|
+
let overlap = 0;
|
|
46
|
+
for (const t of a) if (b.has(t)) overlap += 1;
|
|
47
|
+
return overlap;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRuleFrontmatter(content) {
|
|
51
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
const out = {};
|
|
54
|
+
let inList = null;
|
|
55
|
+
for (const rawLine of m[1].split('\n')) {
|
|
56
|
+
if (inList) {
|
|
57
|
+
const listItem = rawLine.match(/^\s+-\s+(.*)$/);
|
|
58
|
+
if (listItem) {
|
|
59
|
+
out[inList].push(listItem[1].trim().replace(/^['"]|['"]$/g, ''));
|
|
60
|
+
continue;
|
|
61
|
+
} else if (rawLine.trim() === '' || /^[a-z_]+:/.test(rawLine)) {
|
|
62
|
+
inList = null;
|
|
63
|
+
// fall through to parse this line as regular field
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const arrayLineMatch = rawLine.match(/^([a-z_]+):\s*\[(.*)\]$/);
|
|
67
|
+
if (arrayLineMatch) {
|
|
68
|
+
out[arrayLineMatch[1]] = arrayLineMatch[2].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const listStartMatch = rawLine.match(/^([a-z_]+):\s*$/);
|
|
72
|
+
if (listStartMatch) {
|
|
73
|
+
out[listStartMatch[1]] = [];
|
|
74
|
+
inList = listStartMatch[1];
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const fieldMatch = rawLine.match(/^([a-z_]+):\s*(.+)$/);
|
|
78
|
+
if (fieldMatch) {
|
|
79
|
+
let v = fieldMatch[2].trim();
|
|
80
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
81
|
+
out[fieldMatch[1]] = v;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readRule(rulePath) {
|
|
88
|
+
const content = fs.readFileSync(rulePath, 'utf8');
|
|
89
|
+
const fm = parseRuleFrontmatter(content);
|
|
90
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
91
|
+
return { frontmatter: fm || {}, body, path: rulePath };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function scanProjectRules(projectRoot) {
|
|
95
|
+
const rulesDir = path.join(projectRoot, '.aioson', 'rules');
|
|
96
|
+
if (!fs.existsSync(rulesDir)) return [];
|
|
97
|
+
const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md') && f !== 'README.md');
|
|
98
|
+
return files.map((f) => readRule(path.join(rulesDir, f)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect conflicts between loaded operator decisions and project rules.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<object>} decisions — decisions to check (e.g. matched by preflightLoad)
|
|
105
|
+
* @param {Array<object>} rules — output of scanProjectRules(projectRoot)
|
|
106
|
+
* @param {object} options — { threshold }
|
|
107
|
+
* @returns {Array<{decision_slug, rule_path, rule_basename, reason, severity, overlap}>}
|
|
108
|
+
*/
|
|
109
|
+
function detectConflicts(decisions, rules, options = {}) {
|
|
110
|
+
const threshold = options.threshold || DEFAULT_KEYWORD_THRESHOLD;
|
|
111
|
+
const conflicts = [];
|
|
112
|
+
|
|
113
|
+
for (const decision of decisions) {
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
const conflictSignals = rule.frontmatter.conflicts_with_signal_types;
|
|
116
|
+
if (!Array.isArray(conflictSignals) || conflictSignals.length === 0) continue;
|
|
117
|
+
if (!conflictSignals.includes(decision.signal_type)) continue;
|
|
118
|
+
const overlap = keywordOverlap(rule.body, decision.body || decision.proposal || '');
|
|
119
|
+
if (overlap >= threshold) {
|
|
120
|
+
conflicts.push({
|
|
121
|
+
decision_slug: decision.slug,
|
|
122
|
+
rule_path: rule.path,
|
|
123
|
+
rule_basename: path.basename(rule.path),
|
|
124
|
+
severity: 'warning',
|
|
125
|
+
overlap,
|
|
126
|
+
reason: `keyword overlap=${overlap} ≥ threshold=${threshold}, signal_type=${decision.signal_type} in rule.conflicts_with_signal_types`
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return conflicts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatConflictWarning(conflict) {
|
|
136
|
+
return `⚠ Operator memory '${conflict.decision_slug}' conflicts with project rule '${conflict.rule_basename}'. Project rule applies.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function conflictStatePath(identity) {
|
|
140
|
+
return path.join(getStorageRoot(identity), CONFLICT_STATE_FILE);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function loadConflictState(identity) {
|
|
144
|
+
const p = conflictStatePath(identity);
|
|
145
|
+
if (!fs.existsSync(p)) return {};
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
148
|
+
} catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function saveConflictState(identity, state) {
|
|
154
|
+
const p = conflictStatePath(identity);
|
|
155
|
+
const tmp = `${p}.tmp`;
|
|
156
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
157
|
+
fs.renameSync(tmp, p);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Filter conflicts to those whose debounce window has elapsed.
|
|
162
|
+
* Updates state file with new last_warned_at timestamps for emitted ones.
|
|
163
|
+
*
|
|
164
|
+
* @returns {Array} subset of conflicts that should be emitted right now
|
|
165
|
+
*/
|
|
166
|
+
function debounceConflicts(identity, conflicts, options = {}) {
|
|
167
|
+
if (conflicts.length === 0) return [];
|
|
168
|
+
const debounceMs = options.debounceMs || DEFAULT_DEBOUNCE_MS;
|
|
169
|
+
const state = loadConflictState(identity);
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const toEmit = [];
|
|
172
|
+
let stateChanged = false;
|
|
173
|
+
for (const c of conflicts) {
|
|
174
|
+
const key = `${c.decision_slug}::${c.rule_basename}`;
|
|
175
|
+
const last = state[key];
|
|
176
|
+
if (!last || (now - new Date(last).getTime()) >= debounceMs) {
|
|
177
|
+
toEmit.push(c);
|
|
178
|
+
state[key] = new Date(now).toISOString();
|
|
179
|
+
stateChanged = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (stateChanged) {
|
|
183
|
+
try { saveConflictState(identity, state); } catch { /* non-fatal */ }
|
|
184
|
+
}
|
|
185
|
+
return toEmit;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
detectConflicts,
|
|
190
|
+
debounceConflicts,
|
|
191
|
+
formatConflictWarning,
|
|
192
|
+
scanProjectRules,
|
|
193
|
+
readRule,
|
|
194
|
+
parseRuleFrontmatter,
|
|
195
|
+
keywordOverlap,
|
|
196
|
+
tokenize,
|
|
197
|
+
loadConflictState,
|
|
198
|
+
saveConflictState,
|
|
199
|
+
DEFAULT_KEYWORD_THRESHOLD,
|
|
200
|
+
DEFAULT_DEBOUNCE_MS,
|
|
201
|
+
CONFLICT_STATE_FILE
|
|
202
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* operator-memory — per-category TTL decay engine (Phase 5, v1.16.0).
|
|
5
|
+
*
|
|
6
|
+
* PMD-03: category half-life (identity=365d, autonomy=180d, tooling=90d, default=90d).
|
|
7
|
+
* Decay prompt fires when (now - last_reinforced) >= half_life, debounced 30d per slug
|
|
8
|
+
* via _decay_state.json.
|
|
9
|
+
*
|
|
10
|
+
* Per-category override via env: AIOSON_OPERATOR_DECAY_<CATEGORY>_DAYS (e.g.
|
|
11
|
+
* AIOSON_OPERATOR_DECAY_IDENTITY_DAYS=730 for 2-year identity persistence).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { getStorageRoot } = require('./storage');
|
|
17
|
+
const { readDecision } = require('./decision');
|
|
18
|
+
const { listDecisionSlugs } = require('./index-md');
|
|
19
|
+
|
|
20
|
+
const DECAY_STATE_FILE = '_decay_state.json';
|
|
21
|
+
|
|
22
|
+
const HALF_LIFE_DAYS_DEFAULT = {
|
|
23
|
+
identity: 365,
|
|
24
|
+
autonomy: 180,
|
|
25
|
+
tooling: 90,
|
|
26
|
+
default: 90
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const DEFAULT_PROMPT_DEBOUNCE_DAYS = 30;
|
|
31
|
+
|
|
32
|
+
function halfLifeForCategory(category) {
|
|
33
|
+
const envKey = `AIOSON_OPERATOR_DECAY_${String(category).toUpperCase()}_DAYS`;
|
|
34
|
+
const override = process.env[envKey];
|
|
35
|
+
if (override && !Number.isNaN(Number(override))) {
|
|
36
|
+
return Number(override);
|
|
37
|
+
}
|
|
38
|
+
return HALF_LIFE_DAYS_DEFAULT[category] ?? HALF_LIFE_DAYS_DEFAULT.default;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decayStatePath(identity) {
|
|
42
|
+
return path.join(getStorageRoot(identity), DECAY_STATE_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadDecayState(identity) {
|
|
46
|
+
const p = decayStatePath(identity);
|
|
47
|
+
if (!fs.existsSync(p)) return {};
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveDecayState(identity, state) {
|
|
56
|
+
const p = decayStatePath(identity);
|
|
57
|
+
const tmp = `${p}.tmp`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
59
|
+
fs.renameSync(tmp, p);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute days since last_reinforced for a decision.
|
|
64
|
+
*/
|
|
65
|
+
function daysSinceReinforced(decision, now = Date.now()) {
|
|
66
|
+
const reinforced = new Date(decision.last_reinforced || decision.promoted_at || 0).getTime();
|
|
67
|
+
if (!reinforced) return Infinity;
|
|
68
|
+
return Math.floor((now - reinforced) / DAY_MS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Identify stale decisions whose decay-prompt debounce window has elapsed.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Array<{slug, category, days_stale, half_life}>} candidates to surface
|
|
75
|
+
*/
|
|
76
|
+
function findStaleDecisions(identity, options = {}) {
|
|
77
|
+
const now = options.now || Date.now();
|
|
78
|
+
const debounceDays = options.debounceDays ?? DEFAULT_PROMPT_DEBOUNCE_DAYS;
|
|
79
|
+
const state = loadDecayState(identity);
|
|
80
|
+
const slugs = listDecisionSlugs(identity);
|
|
81
|
+
const stale = [];
|
|
82
|
+
|
|
83
|
+
for (const slug of slugs) {
|
|
84
|
+
const decision = readDecision(identity, slug);
|
|
85
|
+
if (!decision) continue;
|
|
86
|
+
const category = decision.category || 'default';
|
|
87
|
+
const halfLife = halfLifeForCategory(category);
|
|
88
|
+
const daysStale = daysSinceReinforced(decision, now);
|
|
89
|
+
if (daysStale < halfLife) continue;
|
|
90
|
+
|
|
91
|
+
const last = state[slug];
|
|
92
|
+
if (last) {
|
|
93
|
+
const daysSincePrompt = Math.floor((now - new Date(last).getTime()) / DAY_MS);
|
|
94
|
+
if (daysSincePrompt < debounceDays) continue;
|
|
95
|
+
}
|
|
96
|
+
stale.push({ slug, category, days_stale: daysStale, half_life: halfLife, title: decision.body?.slice(0, 80) || slug });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return stale;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Mark a stale decision's prompt as shown (updates debounce timestamp).
|
|
104
|
+
*/
|
|
105
|
+
function markDecayPromptShown(identity, slug) {
|
|
106
|
+
const state = loadDecayState(identity);
|
|
107
|
+
state[slug] = new Date().toISOString();
|
|
108
|
+
saveDecayState(identity, state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatDecayPrompt(stale) {
|
|
112
|
+
return `⏱ Memory '${stale.slug}' is ${stale.days_stale}d stale (${stale.category}, half-life=${stale.half_life}d). Still valid? aioson op:reinforce ${stale.slug} | op:forget ${stale.slug}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clean up history/ entries older than maxAgeDays (default 365). Phase 5
|
|
117
|
+
* cleanup runs alongside decay sweep — hard-delete old soft-deleted items.
|
|
118
|
+
*
|
|
119
|
+
* @returns {Array<string>} paths of files removed
|
|
120
|
+
*/
|
|
121
|
+
function cleanupHistory(identity, options = {}) {
|
|
122
|
+
const maxAgeDays = options.maxAgeDays || 365;
|
|
123
|
+
const now = options.now || Date.now();
|
|
124
|
+
const historyDir = path.join(getStorageRoot(identity), 'history');
|
|
125
|
+
if (!fs.existsSync(historyDir)) return [];
|
|
126
|
+
|
|
127
|
+
const removed = [];
|
|
128
|
+
for (const f of fs.readdirSync(historyDir)) {
|
|
129
|
+
if (!f.endsWith('.md')) continue;
|
|
130
|
+
const full = path.join(historyDir, f);
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.statSync(full);
|
|
133
|
+
const ageDays = Math.floor((now - stat.mtimeMs) / DAY_MS);
|
|
134
|
+
if (ageDays >= maxAgeDays) {
|
|
135
|
+
fs.unlinkSync(full);
|
|
136
|
+
removed.push(full);
|
|
137
|
+
}
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return removed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
findStaleDecisions,
|
|
146
|
+
markDecayPromptShown,
|
|
147
|
+
formatDecayPrompt,
|
|
148
|
+
cleanupHistory,
|
|
149
|
+
loadDecayState,
|
|
150
|
+
saveDecayState,
|
|
151
|
+
halfLifeForCategory,
|
|
152
|
+
daysSinceReinforced,
|
|
153
|
+
HALF_LIFE_DAYS_DEFAULT,
|
|
154
|
+
DEFAULT_PROMPT_DEBOUNCE_DAYS,
|
|
155
|
+
DECAY_STATE_FILE,
|
|
156
|
+
DAY_MS
|
|
157
|
+
};
|