wendkeep 0.57.1 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.en.md +60 -14
- package/README.md +60 -14
- package/bin/wendkeep.mjs +16 -4
- package/hooks/brain-inject.mjs +185 -19
- package/hooks/change-core.mjs +42 -5
- package/hooks/lessons-core.mjs +8 -2
- package/hooks/memory-handoff.mjs +199 -0
- package/hooks/memory-schema.mjs +295 -0
- package/hooks/memory-store.mjs +660 -0
- package/hooks/obsidian-common.mjs +241 -6
- package/hooks/session-ensure.mjs +20 -0
- package/hooks/session-start.mjs +13 -4
- package/hooks/session-stop.mjs +134 -16
- package/hooks/vault-health.mjs +139 -1
- package/package.json +2 -2
- package/src/init.mjs +2 -0
- package/src/memory.mjs +210 -0
- package/src/taxonomy.mjs +3 -0
- package/src/validate-memory.mjs +115 -0
package/hooks/change-core.mjs
CHANGED
|
@@ -245,30 +245,67 @@ export function allChangesState(vaultBase) {
|
|
|
245
245
|
return { current, changes, pointerWarning, hash: tasksHashOf(fingerprint.join('\n')) };
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
function boundInjectionLine(line, maxLineChars) {
|
|
249
|
+
if (!Number.isFinite(maxLineChars) || line.length <= maxLineChars) return line;
|
|
250
|
+
const marker = ' … [linha resumida pelo budget]';
|
|
251
|
+
return `${line.slice(0, Math.max(0, maxLineChars - marker.length))}${marker}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function capInjectionBlock(lines, { tag, maxBytes, maxLineChars }) {
|
|
255
|
+
const bounded = lines.map((line) => boundInjectionLine(line, maxLineChars));
|
|
256
|
+
if (!Number.isFinite(maxBytes) || Buffer.byteLength(bounded.join('\n'), 'utf8') <= maxBytes) {
|
|
257
|
+
return bounded.join('\n');
|
|
258
|
+
}
|
|
259
|
+
const closing = tag ? `</${tag}>` : '';
|
|
260
|
+
const marker = '- … conteúdo restante omitido pelo budget de injeção.';
|
|
261
|
+
const body = tag ? bounded.slice(0, -1) : bounded;
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const line of body) {
|
|
264
|
+
const candidate = [...out, line, marker, closing].filter(Boolean).join('\n');
|
|
265
|
+
if (Buffer.byteLength(candidate, 'utf8') > maxBytes) break;
|
|
266
|
+
out.push(line);
|
|
267
|
+
}
|
|
268
|
+
// The tag itself and an explicit marker are the minimum valid degraded block.
|
|
269
|
+
if (!out.length && tag) out.push(`<${tag}>`);
|
|
270
|
+
out.push(marker);
|
|
271
|
+
if (closing) out.push(closing);
|
|
272
|
+
return out.join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function renderOpenChanges(state, {
|
|
276
|
+
tag = 'open_changes',
|
|
277
|
+
currentOnly = false,
|
|
278
|
+
maxBytes = Number.POSITIVE_INFINITY,
|
|
279
|
+
maxLineChars = 320,
|
|
280
|
+
} = {}) {
|
|
249
281
|
if (!state?.changes?.length && !state?.pointerWarning) return '';
|
|
250
282
|
const lines = [];
|
|
251
283
|
if (tag) lines.push(`<${tag}>`);
|
|
252
284
|
if (state.current) lines.push(`Change atual (comandos sem --change): ${state.current}.`);
|
|
253
285
|
else lines.push('Nenhuma change atual selecionada; comandos sem --change continuam recusados.');
|
|
254
286
|
if (state.pointerWarning) lines.push(`Aviso: ${state.pointerWarning}.`);
|
|
255
|
-
|
|
287
|
+
const visibleChanges = currentOnly
|
|
288
|
+
? (state.changes || []).filter((change) => change.current)
|
|
289
|
+
: (state.changes || []);
|
|
290
|
+
for (const change of visibleChanges) {
|
|
256
291
|
const label = change.current ? 'ATUAL' : 'ABERTA';
|
|
257
292
|
lines.push(`### ${label} — ${change.slug} (${change.openCount} aberta(s), ${change.doneCount} concluída(s))`);
|
|
258
293
|
if (change.warning) lines.push(`- Aviso: ${change.warning}.`);
|
|
259
294
|
else if (!change.openTasks.length) lines.push('- Nenhuma tarefa aberta.');
|
|
260
295
|
else for (const task of change.openTasks) lines.push(`- [ ] ${task.id} ${task.text}`);
|
|
261
296
|
}
|
|
297
|
+
const omittedChanges = (state.changes || []).length - visibleChanges.length;
|
|
298
|
+
if (omittedChanges > 0) lines.push(`- ${omittedChanges} change(s) não atual(is) omitida(s) pelo budget global.`);
|
|
262
299
|
if (state.current) lines.push('Para change atual: `wendkeep change done <id>`; antes de archive: `wendkeep verify`.');
|
|
263
300
|
lines.push('Qualquer agente pode assumir uma change: selecione-a com `wendkeep change use <slug>` ou use `--change <slug>` quando disponível.');
|
|
264
301
|
if (tag) lines.push(`</${tag}>`);
|
|
265
|
-
return lines
|
|
302
|
+
return capInjectionBlock(lines, { tag, maxBytes, maxLineChars });
|
|
266
303
|
}
|
|
267
304
|
|
|
268
305
|
// Mantém o nome exportado para consumidores internos existentes, mas agora injeta o backlog
|
|
269
306
|
// completo em vez de ocultar changes não selecionadas.
|
|
270
|
-
export function buildActiveChangeInjection(vaultBase) {
|
|
271
|
-
return renderOpenChanges(allChangesState(vaultBase));
|
|
307
|
+
export function buildActiveChangeInjection(vaultBase, options = {}) {
|
|
308
|
+
return renderOpenChanges(allChangesState(vaultBase), options);
|
|
272
309
|
}
|
|
273
310
|
|
|
274
311
|
export function activeChangeLink(vaultBase) {
|
package/hooks/lessons-core.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export function addLesson(vaultBase, { trigger, lesson, sourceChange = '', dateS
|
|
|
36
36
|
|
|
37
37
|
// Compact <lessons> block of the most recent lessons (filename-sorted desc; a date prefix
|
|
38
38
|
// makes that chronological). Budget-capped by `max`. '' when there are none.
|
|
39
|
-
export function buildLessonsInjection(vaultBase, { max = 5 } = {}) {
|
|
39
|
+
export function buildLessonsInjection(vaultBase, { max = 5, maxLineChars = 320 } = {}) {
|
|
40
40
|
const dir = join(vaultBase, '.brain', 'lessons');
|
|
41
41
|
let files;
|
|
42
42
|
try { files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort().reverse().slice(0, max); }
|
|
@@ -45,7 +45,13 @@ export function buildLessonsInjection(vaultBase, { max = 5 } = {}) {
|
|
|
45
45
|
for (const f of files) {
|
|
46
46
|
try {
|
|
47
47
|
const body = readFileSync(join(dir, f), 'utf8').replace(/^---[\s\S]*?---\n/, '').trim().split('\n')[0];
|
|
48
|
-
if (body)
|
|
48
|
+
if (body) {
|
|
49
|
+
const line = `- ${body}`;
|
|
50
|
+
const marker = ' … [linha resumida pelo budget]';
|
|
51
|
+
lines.push(line.length <= maxLineChars
|
|
52
|
+
? line
|
|
53
|
+
: `${line.slice(0, Math.max(0, maxLineChars - marker.length))}${marker}`);
|
|
54
|
+
}
|
|
49
55
|
} catch { /* skip */ }
|
|
50
56
|
}
|
|
51
57
|
return lines.length ? `<lessons>\nLições do projeto (de falhas anteriores):\n${lines.join('\n')}\n</lessons>` : '';
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { basename, join, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
6
|
+
|
|
7
|
+
function canonicalValue(value) {
|
|
8
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
9
|
+
if (!value || typeof value !== 'object') return value;
|
|
10
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function eventId(context, memoryKey, value) {
|
|
14
|
+
const digest = createHash('sha256')
|
|
15
|
+
.update(JSON.stringify([
|
|
16
|
+
context.projectId,
|
|
17
|
+
context.identity?.canonicalConversationId,
|
|
18
|
+
context.activation?.id,
|
|
19
|
+
context.turn?.id,
|
|
20
|
+
memoryKey,
|
|
21
|
+
canonicalValue(value),
|
|
22
|
+
]))
|
|
23
|
+
.digest('hex')
|
|
24
|
+
.slice(0, 24);
|
|
25
|
+
return `mem-${digest}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeEvent(context, { memoryKey, value, authority, evidence }) {
|
|
29
|
+
const cleanValue = typeof value === 'string' ? sanitizeMemoryText(value) : canonicalValue(value);
|
|
30
|
+
return {
|
|
31
|
+
v: 1,
|
|
32
|
+
event_id: eventId(context, memoryKey, cleanValue),
|
|
33
|
+
project_id: String(context.projectId || ''),
|
|
34
|
+
memory_key: memoryKey,
|
|
35
|
+
operation: 'assert',
|
|
36
|
+
value: cleanValue,
|
|
37
|
+
authority,
|
|
38
|
+
canonical_session_id: String(context.identity?.canonicalConversationId || ''),
|
|
39
|
+
activation_id: String(context.activation?.id || ''),
|
|
40
|
+
activation_epoch: Number(context.activation?.epoch || 0),
|
|
41
|
+
turn_sequence: Number(context.turn?.sequence || 0),
|
|
42
|
+
source_turn_id: String(context.turn?.id || ''),
|
|
43
|
+
observed_at: context.observedAt,
|
|
44
|
+
evidence: (evidence || []).filter(Boolean).map((item) => sanitizeMemoryText(item)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readJson(path) {
|
|
49
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function filesBelow(dir, accept, found = []) {
|
|
53
|
+
let entries = [];
|
|
54
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; }
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const path = join(dir, entry.name);
|
|
57
|
+
if (entry.isDirectory()) filesBelow(path, accept, found);
|
|
58
|
+
else if (accept(entry.name)) found.push(path);
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function vaultRel(vaultBase, path) {
|
|
64
|
+
return relative(vaultBase, path).replaceAll('\\', '/');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function nextActionFrom(summary) {
|
|
68
|
+
const match = String(summary || '').match(/(?:a\s+)?pr[oó]xima\s+(?:change\s+)?(?:ser[aá]|[ée]|:)\s+(?:a\s+)?([^.!?\n]+)/i);
|
|
69
|
+
if (!match) return null;
|
|
70
|
+
const text = sanitizeMemoryText(match[1].trim());
|
|
71
|
+
const id = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64);
|
|
73
|
+
return id && text ? { id, summary: text } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary = '', noteRel = '' } = {}) {
|
|
77
|
+
const evidence = {};
|
|
78
|
+
const slug = String(changeSlug || '').trim();
|
|
79
|
+
if (slug) {
|
|
80
|
+
const changeRoots = ['08-Mudanças', '08-Changes'];
|
|
81
|
+
let archivedDir = '';
|
|
82
|
+
for (const root of changeRoots) {
|
|
83
|
+
const archive = join(vaultBase, root, '_arquivo');
|
|
84
|
+
let names = [];
|
|
85
|
+
try { names = readdirSync(archive, { withFileTypes: true }); } catch { /* absent locale */ }
|
|
86
|
+
const match = names.find((entry) => entry.isDirectory() && (entry.name === slug || entry.name.endsWith(`-${slug}`)));
|
|
87
|
+
if (match) { archivedDir = join(archive, match.name); break; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const adrPattern = new RegExp(`^ADR-(\\d{4})-${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.md$`, 'i');
|
|
91
|
+
const adrPath = filesBelow(vaultBase, (name) => adrPattern.test(name))[0];
|
|
92
|
+
if (archivedDir && adrPath) {
|
|
93
|
+
const adr = (basename(adrPath).match(/^ADR-\d{4}/i) || [''])[0].toUpperCase();
|
|
94
|
+
evidence.change = { slug, status: 'archived', adr, path: vaultRel(vaultBase, adrPath) };
|
|
95
|
+
const verdictPath = join(archivedDir, 'verdict.json');
|
|
96
|
+
const verdict = readJson(verdictPath);
|
|
97
|
+
if (verdict && typeof verdict.ok === 'boolean' && Array.isArray(verdict.coverage)) {
|
|
98
|
+
evidence.verdict = {
|
|
99
|
+
ok: verdict.ok,
|
|
100
|
+
covered: verdict.coverage.filter((item) => item?.covered === true).length,
|
|
101
|
+
total: verdict.coverage.length,
|
|
102
|
+
path: vaultRel(vaultBase, verdictPath),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const sensorPath = join(archivedDir, 'evidencia.json');
|
|
106
|
+
const sensors = readJson(sensorPath);
|
|
107
|
+
if (Array.isArray(sensors) && sensors.length && sensors.every((item) => item?.status === 'green')) {
|
|
108
|
+
evidence.sensors = [...new Set(sensors.map((item) => String(item.id || '')).filter(Boolean))].sort();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nextAction = nextActionFrom(summary);
|
|
114
|
+
if (nextAction) evidence.nextAction = nextAction;
|
|
115
|
+
const commit = String(summary || '').match(/\b[0-9a-f]{40}\b/i)?.[0];
|
|
116
|
+
if (commit) {
|
|
117
|
+
evidence.git = {
|
|
118
|
+
commit: commit.toLowerCase(),
|
|
119
|
+
pushed: !/(?:nenhum|sem)\s+push/i.test(String(summary || '')),
|
|
120
|
+
verified: false,
|
|
121
|
+
path: noteRel,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return evidence;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function buildSessionMemoryEvents({
|
|
128
|
+
projectId,
|
|
129
|
+
identity,
|
|
130
|
+
activation,
|
|
131
|
+
turn,
|
|
132
|
+
noteRel,
|
|
133
|
+
observedAt,
|
|
134
|
+
summary,
|
|
135
|
+
evidence = {},
|
|
136
|
+
}) {
|
|
137
|
+
const context = { projectId, identity, activation, turn, observedAt };
|
|
138
|
+
const events = [makeEvent(context, {
|
|
139
|
+
memoryKey: 'handoff.latest',
|
|
140
|
+
value: sanitizeMemoryText(summary),
|
|
141
|
+
authority: 'reported',
|
|
142
|
+
evidence: [noteRel],
|
|
143
|
+
})];
|
|
144
|
+
|
|
145
|
+
if (evidence.change?.slug && evidence.change?.status && evidence.change?.adr) {
|
|
146
|
+
events.push(makeEvent(context, {
|
|
147
|
+
memoryKey: `change.${evidence.change.slug}.status`,
|
|
148
|
+
value: { status: evidence.change.status, adr: evidence.change.adr },
|
|
149
|
+
authority: 'verified',
|
|
150
|
+
evidence: [evidence.change.path || evidence.change.adr],
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (evidence.verdict?.path && typeof evidence.verdict.ok === 'boolean') {
|
|
155
|
+
events.push(makeEvent(context, {
|
|
156
|
+
memoryKey: 'quality.latest-verdict',
|
|
157
|
+
value: {
|
|
158
|
+
ok: evidence.verdict.ok,
|
|
159
|
+
covered: Number(evidence.verdict.covered || 0),
|
|
160
|
+
total: Number(evidence.verdict.total || 0),
|
|
161
|
+
},
|
|
162
|
+
authority: 'verified',
|
|
163
|
+
evidence: [evidence.verdict.path],
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Array.isArray(evidence.sensors) && evidence.sensors.length) {
|
|
168
|
+
events.push(makeEvent(context, {
|
|
169
|
+
memoryKey: 'quality.latest-sensors',
|
|
170
|
+
value: [...new Set(evidence.sensors.map(String))].sort(),
|
|
171
|
+
authority: 'verified',
|
|
172
|
+
evidence: evidence.sensors,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (evidence.git?.commit) {
|
|
177
|
+
events.push(makeEvent(context, {
|
|
178
|
+
memoryKey: 'git.local-head',
|
|
179
|
+
value: {
|
|
180
|
+
commit: evidence.git.commit,
|
|
181
|
+
pushed: Boolean(evidence.git.pushed),
|
|
182
|
+
push_status: evidence.git.pushed ? 'pushed' : 'nenhum push',
|
|
183
|
+
},
|
|
184
|
+
authority: evidence.git.verified === false ? 'reported' : 'verified',
|
|
185
|
+
evidence: [evidence.git.path || evidence.git.commit],
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (evidence.nextAction?.id && evidence.nextAction?.summary) {
|
|
190
|
+
events.push(makeEvent(context, {
|
|
191
|
+
memoryKey: `next.${evidence.nextAction.id}`,
|
|
192
|
+
value: sanitizeMemoryText(evidence.nextAction.summary),
|
|
193
|
+
authority: 'verified',
|
|
194
|
+
evidence: [noteRel],
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return events;
|
|
199
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const SHARED_LIMITS = Object.freeze({ lines: 48, bytes: 6144, lineChars: 320 });
|
|
4
|
+
|
|
5
|
+
export const SHARED_SECTIONS = Object.freeze([
|
|
6
|
+
'Objetivo Atual',
|
|
7
|
+
'Estado Entregue',
|
|
8
|
+
'Restrições Ativas',
|
|
9
|
+
'Decisões em Vigor',
|
|
10
|
+
'Próximas Ações',
|
|
11
|
+
'Bloqueios',
|
|
12
|
+
'Riscos Conhecidos',
|
|
13
|
+
'Último Handoff',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const AUTHORITIES = new Set(['verified', 'reported', 'candidate']);
|
|
17
|
+
const OPERATIONS = new Set(['assert', 'replace', 'add', 'remove']);
|
|
18
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
19
|
+
const HARNESS_BLOCK = /<(recommended_plugins|environment_context|apps_instructions|plugins_instructions|skills_instructions)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
20
|
+
const WINDOWS_LOCAL_PATH = /\b[A-Z]:\\(?:Users|Documents and Settings)\\[^\r\n"'<>|]*/gi;
|
|
21
|
+
const UNIX_TRANSCRIPT_PATH = /\/(?:home|Users|private|var|tmp)\/[^\r\n"'<>]*(?:rollout|transcript|sessions?)[^\r\n"'<>]*/gi;
|
|
22
|
+
const EMAIL = /\b[\w.%+-]+@(?!(?:[\w.-]+\.)?example\.(?:com|org|net)\b)[\w.-]+\.[A-Za-z]{2,}\b/gi;
|
|
23
|
+
|
|
24
|
+
function stringValue(value) {
|
|
25
|
+
if (typeof value === 'string') return value;
|
|
26
|
+
if (value === null || value === undefined) return '';
|
|
27
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pure, idempotent boundary sanitizer used before event persistence and again before
|
|
32
|
+
* projection/injection. It deliberately preserves example.com addresses for docs/tests.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeMemoryText(value) {
|
|
35
|
+
return stringValue(value)
|
|
36
|
+
.replace(HARNESS_BLOCK, '')
|
|
37
|
+
.replace(/<recommended_plugins\b[^>]*>[\s\S]*?<\/recommended_plugins>/gi, '')
|
|
38
|
+
.replace(/\b(PASSWORD|PASSWD|TOKEN|SECRET|API_KEY|ACCESS_KEY)\s*=\s*[^\s,;]+/gi, '$1=[REDACTED_SECRET]')
|
|
39
|
+
.replace(/(["']?(?:password|passwd|token|secret|api[_-]?key|access[_-]?key)["']?\s*:\s*["'])[^"'\r\n]+(["'])/gi, '$1[REDACTED_SECRET]$2')
|
|
40
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, '[REDACTED_SECRET]')
|
|
41
|
+
.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED_SECRET]')
|
|
42
|
+
.replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{32,}\b/g, '[REDACTED_SECRET]')
|
|
43
|
+
.replace(/\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b/g, '[REDACTED_SECRET]')
|
|
44
|
+
.replace(/\bwhsec_[A-Za-z0-9]{20,}\b/g, '[REDACTED_SECRET]')
|
|
45
|
+
.replace(WINDOWS_LOCAL_PATH, '[REDACTED_LOCAL_PATH]')
|
|
46
|
+
.replace(UNIX_TRANSCRIPT_PATH, '[REDACTED_LOCAL_PATH]')
|
|
47
|
+
.replace(/\btranscript(?:_path)?\s*[=:]\s*[^\s,;]+/gi, 'transcript_path=[REDACTED_LOCAL_PATH]')
|
|
48
|
+
.replace(EMAIL, '[REDACTED_EMAIL]');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function eventBelongsToVault(event, projectId) {
|
|
52
|
+
return typeof event?.project_id === 'string'
|
|
53
|
+
&& event.project_id.length > 0
|
|
54
|
+
&& typeof projectId === 'string'
|
|
55
|
+
&& projectId.length > 0
|
|
56
|
+
&& event.project_id === projectId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requiredString(event, field, errors) {
|
|
60
|
+
if (typeof event[field] !== 'string' || !event[field].trim()) errors.push(`${field} deve ser string não vazia.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sanitizedField(event, field, errors) {
|
|
64
|
+
if (!(field in event)) return;
|
|
65
|
+
const raw = stringValue(event[field]);
|
|
66
|
+
if (sanitizeMemoryText(raw) !== raw) errors.push(`${field} contém segredo, PII, path local ou payload de harness não sanitizado.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Validate one immutable ledger/outbox event without reading or writing the vault. */
|
|
70
|
+
export function validateMemoryEvent(event, { projectId } = {}) {
|
|
71
|
+
const errors = [];
|
|
72
|
+
const warnings = [];
|
|
73
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
74
|
+
return { ok: false, errors: ['Evento deve ser um objeto.'], warnings };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (event.v !== 1) errors.push('v deve ser 1.');
|
|
78
|
+
requiredString(event, 'event_id', errors);
|
|
79
|
+
requiredString(event, 'memory_key', errors);
|
|
80
|
+
requiredString(event, 'operation', errors);
|
|
81
|
+
requiredString(event, 'authority', errors);
|
|
82
|
+
requiredString(event, 'activation_id', errors);
|
|
83
|
+
requiredString(event, 'observed_at', errors);
|
|
84
|
+
|
|
85
|
+
if (event.operation && !OPERATIONS.has(event.operation)) {
|
|
86
|
+
errors.push(`operation inválida: ${event.operation}.`);
|
|
87
|
+
}
|
|
88
|
+
if (event.operation !== 'remove' && !Object.hasOwn(event, 'value')) {
|
|
89
|
+
errors.push('value é obrigatório para operation diferente de remove.');
|
|
90
|
+
}
|
|
91
|
+
if (event.authority && !AUTHORITIES.has(event.authority)) {
|
|
92
|
+
errors.push(`authority inválida: ${event.authority}.`);
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isInteger(event.turn_sequence) || event.turn_sequence < 0) {
|
|
95
|
+
errors.push('turn_sequence deve ser inteiro não negativo.');
|
|
96
|
+
}
|
|
97
|
+
if (typeof event.observed_at === 'string'
|
|
98
|
+
&& (!ISO_INSTANT.test(event.observed_at) || Number.isNaN(Date.parse(event.observed_at)))) {
|
|
99
|
+
errors.push('observed_at deve ser um instante ISO-8601 UTC.');
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(event.evidence) || event.evidence.some((item) => typeof item !== 'string')) {
|
|
102
|
+
errors.push('evidence deve ser um array de strings.');
|
|
103
|
+
}
|
|
104
|
+
if (event.project_id !== undefined && (typeof event.project_id !== 'string' || !event.project_id)) {
|
|
105
|
+
errors.push('project_id, quando presente, deve ser string não vazia.');
|
|
106
|
+
}
|
|
107
|
+
if (projectId !== undefined && !eventBelongsToVault(event, projectId)) {
|
|
108
|
+
errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
|
|
112
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sectionFor(memoryKey) {
|
|
116
|
+
const key = String(memoryKey || '').toLowerCase();
|
|
117
|
+
if (/^(objective|goal)\b/.test(key)) return 'Objetivo Atual';
|
|
118
|
+
if (/^(constraint|restriction)\b/.test(key)) return 'Restrições Ativas';
|
|
119
|
+
if (/^(decision|adr)\b/.test(key)) return 'Decisões em Vigor';
|
|
120
|
+
if (/^(next|action)\b/.test(key)) return 'Próximas Ações';
|
|
121
|
+
if (/^(block|blocker)\b/.test(key)) return 'Bloqueios';
|
|
122
|
+
if (/^risk\b/.test(key)) return 'Riscos Conhecidos';
|
|
123
|
+
if (/^handoff\b/.test(key)) return 'Último Handoff';
|
|
124
|
+
return 'Estado Entregue';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hashProjection(events) {
|
|
128
|
+
if (!events.length) {
|
|
129
|
+
// Must match memory-store.reduceMemoryEvents([]): an empty operational state is still
|
|
130
|
+
// represented by the reducer's canonical {state,tombstones} envelope, not by raw [].
|
|
131
|
+
return createHash('sha256').update(JSON.stringify({ state: {}, tombstones: {} })).digest('hex');
|
|
132
|
+
}
|
|
133
|
+
const canonical = events.map((event) => ({
|
|
134
|
+
event_id: event.event_id,
|
|
135
|
+
memory_key: event.memory_key,
|
|
136
|
+
operation: event.operation,
|
|
137
|
+
value: sanitizeMemoryText(event.value),
|
|
138
|
+
authority: event.authority,
|
|
139
|
+
observed_at: event.observed_at,
|
|
140
|
+
evidence: Array.isArray(event.evidence) ? event.evidence.map(sanitizeMemoryText) : [],
|
|
141
|
+
}));
|
|
142
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function eventLine(event) {
|
|
146
|
+
const value = event.operation === 'remove' ? '[removido]' : sanitizeMemoryText(event.value);
|
|
147
|
+
const evidence = Array.isArray(event.evidence) && event.evidence.length
|
|
148
|
+
? event.evidence.map(sanitizeMemoryText).join(', ')
|
|
149
|
+
: 'none';
|
|
150
|
+
const source = sanitizeMemoryText(event.source_turn_id || event.canonical_session_id || event.activation_id || 'unknown');
|
|
151
|
+
const line = `- [${sanitizeMemoryText(event.event_id)}] ${value} · authority:${sanitizeMemoryText(event.authority)} · source:${source} · as_of:${sanitizeMemoryText(event.observed_at)} · evidence:${evidence}`;
|
|
152
|
+
return line.length <= SHARED_LIMITS.lineChars
|
|
153
|
+
? line
|
|
154
|
+
: `${line.slice(0, SHARED_LIMITS.lineChars - 1).trimEnd()}…`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function defaultInstant(events) {
|
|
158
|
+
const instants = events
|
|
159
|
+
.map((event) => event.observed_at)
|
|
160
|
+
.filter((value) => typeof value === 'string' && !Number.isNaN(Date.parse(value)))
|
|
161
|
+
.sort();
|
|
162
|
+
return instants.at(-1) || new Date(0).toISOString();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Render the generated operational projection. Inputs are sanitized a second time. */
|
|
166
|
+
export function renderSharedMemory({
|
|
167
|
+
revision = 0,
|
|
168
|
+
eventCursor = 'none',
|
|
169
|
+
events = [],
|
|
170
|
+
stateHash,
|
|
171
|
+
updatedAt,
|
|
172
|
+
reviewAfter,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const safeEvents = Array.isArray(events) ? events : [];
|
|
175
|
+
const updated = updatedAt || defaultInstant(safeEvents);
|
|
176
|
+
const review = reviewAfter || new Date(Date.parse(updated) + (7 * 24 * 60 * 60 * 1000)).toISOString();
|
|
177
|
+
const grouped = new Map(SHARED_SECTIONS.map((section) => [section, []]));
|
|
178
|
+
for (const event of safeEvents) grouped.get(sectionFor(event.memory_key)).push(eventLine(event));
|
|
179
|
+
|
|
180
|
+
const lines = [
|
|
181
|
+
'---',
|
|
182
|
+
'schema_version: 2',
|
|
183
|
+
`revision: ${Number.isInteger(revision) ? revision : 0}`,
|
|
184
|
+
`event_cursor: ${sanitizeMemoryText(eventCursor || 'none')}`,
|
|
185
|
+
`state_hash: ${sanitizeMemoryText(stateHash || hashProjection(safeEvents))}`,
|
|
186
|
+
`updated_at: ${sanitizeMemoryText(updated)}`,
|
|
187
|
+
`review_after: ${sanitizeMemoryText(review)}`,
|
|
188
|
+
'---',
|
|
189
|
+
'',
|
|
190
|
+
'# SHARED_MEMORY — projeção operacional gerada',
|
|
191
|
+
'',
|
|
192
|
+
];
|
|
193
|
+
for (const section of SHARED_SECTIONS) {
|
|
194
|
+
lines.push(`## ${section}`, ...(grouped.get(section).length ? grouped.get(section) : ['- (vazio)']), '');
|
|
195
|
+
}
|
|
196
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseScalar(value) {
|
|
200
|
+
if (/^-?\d+$/.test(value)) return Number(value);
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Parse SHARED without throwing so hooks can surface a valid degraded context. */
|
|
205
|
+
export function parseSharedMemory(content) {
|
|
206
|
+
const text = String(content ?? '').replace(/\r\n/g, '\n');
|
|
207
|
+
const errors = [];
|
|
208
|
+
const metadata = {};
|
|
209
|
+
const sections = new Map();
|
|
210
|
+
const match = text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
211
|
+
if (!match) {
|
|
212
|
+
return { ok: false, errors: ['Frontmatter de SHARED ausente ou inválido.'], metadata, sections };
|
|
213
|
+
}
|
|
214
|
+
for (const line of match[1].split('\n')) {
|
|
215
|
+
const separator = line.indexOf(':');
|
|
216
|
+
if (separator <= 0) {
|
|
217
|
+
errors.push(`Linha inválida no frontmatter: ${line}`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const key = line.slice(0, separator).trim();
|
|
221
|
+
metadata[key] = parseScalar(line.slice(separator + 1).trim());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let current = null;
|
|
225
|
+
const seenOrder = [];
|
|
226
|
+
for (const line of text.slice(match[0].length).split('\n')) {
|
|
227
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
228
|
+
if (heading) {
|
|
229
|
+
current = heading[1];
|
|
230
|
+
if (sections.has(current)) errors.push(`Seção duplicada: ## ${current}`);
|
|
231
|
+
else {
|
|
232
|
+
sections.set(current, []);
|
|
233
|
+
seenOrder.push(current);
|
|
234
|
+
}
|
|
235
|
+
} else if (current && line.trim()) {
|
|
236
|
+
sections.get(current).push(line);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (seenOrder.length !== SHARED_SECTIONS.length
|
|
240
|
+
|| seenOrder.some((section, index) => section !== SHARED_SECTIONS[index])) {
|
|
241
|
+
errors.push(`Seções fixas ausentes, extras ou fora de ordem: esperado ${SHARED_SECTIONS.join(' | ')}.`);
|
|
242
|
+
}
|
|
243
|
+
return { ok: errors.length === 0, errors, metadata, sections };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function actualLineCount(text) {
|
|
247
|
+
const lines = text.split('\n');
|
|
248
|
+
return text.endsWith('\n') ? lines.length - 1 : lines.length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function validateSharedMemory(content, { eventIds } = {}) {
|
|
252
|
+
const text = String(content ?? '').replace(/\r\n/g, '\n');
|
|
253
|
+
const parsed = parseSharedMemory(text);
|
|
254
|
+
const errors = [...parsed.errors];
|
|
255
|
+
const warnings = [];
|
|
256
|
+
const lineCount = actualLineCount(text);
|
|
257
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
258
|
+
|
|
259
|
+
if (lineCount > SHARED_LIMITS.lines) errors.push(`SHARED tem ${lineCount} linhas; limite ${SHARED_LIMITS.lines}.`);
|
|
260
|
+
if (bytes > SHARED_LIMITS.bytes) errors.push(`SHARED tem ${bytes} bytes; limite ${SHARED_LIMITS.bytes}.`);
|
|
261
|
+
text.split('\n').forEach((line, index) => {
|
|
262
|
+
if (line.length > SHARED_LIMITS.lineChars) {
|
|
263
|
+
errors.push(`Linha ${index + 1} tem ${line.length} caracteres; limite ${SHARED_LIMITS.lineChars}.`);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
if (sanitizeMemoryText(text) !== text) errors.push('SHARED contém segredo, PII, path local ou payload de harness não sanitizado.');
|
|
267
|
+
|
|
268
|
+
const { metadata } = parsed;
|
|
269
|
+
if (metadata.schema_version !== 2) errors.push('schema_version deve ser 2.');
|
|
270
|
+
if (!Number.isInteger(metadata.revision) || metadata.revision < 0) errors.push('revision deve ser inteiro não negativo.');
|
|
271
|
+
for (const key of ['event_cursor', 'state_hash', 'updated_at', 'review_after']) {
|
|
272
|
+
if (typeof metadata[key] !== 'string' || !metadata[key]) errors.push(`${key} é obrigatório.`);
|
|
273
|
+
}
|
|
274
|
+
for (const key of ['updated_at', 'review_after']) {
|
|
275
|
+
if (typeof metadata[key] === 'string'
|
|
276
|
+
&& (!ISO_INSTANT.test(metadata[key]) || Number.isNaN(Date.parse(metadata[key])))) {
|
|
277
|
+
errors.push(`${key} deve ser um instante ISO-8601 UTC.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (eventIds instanceof Set) {
|
|
281
|
+
const cursor = metadata.event_cursor;
|
|
282
|
+
if (cursor !== 'none' && !eventIds.has(cursor)) errors.push(`event_cursor "${cursor}" não existe no ledger.`);
|
|
283
|
+
if (cursor === 'none' && eventIds.size > 0) errors.push('event_cursor none diverge de um ledger não vazio.');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
ok: errors.length === 0,
|
|
288
|
+
errors,
|
|
289
|
+
warnings,
|
|
290
|
+
lineCount,
|
|
291
|
+
bytes,
|
|
292
|
+
metadata,
|
|
293
|
+
sections: parsed.sections,
|
|
294
|
+
};
|
|
295
|
+
}
|