ihow-memory 0.1.0-alpha.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/LICENSE +202 -0
- package/NOTICE +15 -0
- package/README.md +250 -0
- package/TRADEMARK.md +24 -0
- package/bin/ihow-memory.mjs +53 -0
- package/dist/cli.js +1084 -0
- package/dist/core.js +85 -0
- package/dist/engine/fts.js +210 -0
- package/dist/engine/manifest.js +45 -0
- package/dist/engine/retrieval.js +324 -0
- package/dist/governance.js +369 -0
- package/dist/http/console.js +287 -0
- package/dist/mcp/server.js +235 -0
- package/dist/store/events.js +17 -0
- package/dist/store/files.js +61 -0
- package/dist/store/lock.js +35 -0
- package/dist/telemetry.js +98 -0
- package/dist/types.js +3 -0
- package/dist/workspace.js +151 -0
- package/package.json +62 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { absoluteFromMemoryPath, isMcpSandboxPath, relativeToMemory, relativeToSpace } from './workspace.js';
|
|
5
|
+
import { appendEvent } from './store/events.js';
|
|
6
|
+
import { atomicWriteFile, nowCompact, readMemoryFile, safeFileSlug } from './store/files.js';
|
|
7
|
+
import { withWorkspaceLock } from './store/lock.js';
|
|
8
|
+
export const DEFAULT_PROTECTED_PATTERNS = [
|
|
9
|
+
'SOUL.md',
|
|
10
|
+
'USER.md',
|
|
11
|
+
'IDENTITY.md',
|
|
12
|
+
'MEMORY.md',
|
|
13
|
+
'AGENTS.md',
|
|
14
|
+
'memory/SOUL.md',
|
|
15
|
+
'memory/USER.md',
|
|
16
|
+
'memory/IDENTITY.md',
|
|
17
|
+
'memory/MEMORY.md',
|
|
18
|
+
'current.md'
|
|
19
|
+
];
|
|
20
|
+
const SECRET_LIKE_PATTERNS = [
|
|
21
|
+
/\b(api[_-]?key|secret|token|password|passwd|cookie|authorization|bearer|refresh[_-]?token)\b\s*[:=]/i,
|
|
22
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/i,
|
|
23
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/i,
|
|
24
|
+
/\bghp_[A-Za-z0-9_]{16,}\b/i,
|
|
25
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
26
|
+
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
|
27
|
+
/(?:账号|账户|邮箱)\s*[::=]\s*\S+/i
|
|
28
|
+
];
|
|
29
|
+
function candidateText(payload) {
|
|
30
|
+
const text = payload.text ?? payload.content;
|
|
31
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
32
|
+
throw new Error('candidate_text_required');
|
|
33
|
+
}
|
|
34
|
+
assertNoSecretLikeContent(text);
|
|
35
|
+
return text.trim();
|
|
36
|
+
}
|
|
37
|
+
function assertNoSecretLikeContent(text) {
|
|
38
|
+
if (SECRET_LIKE_PATTERNS.some((pattern)=>pattern.test(text))) {
|
|
39
|
+
throw new Error('candidate_contains_secret_like_content');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function assertNoSecretLikeDurableCandidate(content) {
|
|
43
|
+
if (SECRET_LIKE_PATTERNS.some((pattern)=>pattern.test(content))) {
|
|
44
|
+
throw new Error('redact_check_failed_candidate_contains_secret_like_content');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function frontMatter(data) {
|
|
48
|
+
const lines = [
|
|
49
|
+
'---'
|
|
50
|
+
];
|
|
51
|
+
for (const [key, value] of Object.entries(data)){
|
|
52
|
+
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
53
|
+
}
|
|
54
|
+
lines.push('---');
|
|
55
|
+
return `${lines.join('\n')}\n`;
|
|
56
|
+
}
|
|
57
|
+
function markdownCandidate(candidateId, payload) {
|
|
58
|
+
const title = payload.title || `Candidate ${candidateId}`;
|
|
59
|
+
const sourceAgent = payload.sourceAgent || payload.source || 'unknown';
|
|
60
|
+
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {};
|
|
61
|
+
return `${frontMatter({
|
|
62
|
+
type: 'memory_candidate',
|
|
63
|
+
candidate_id: candidateId,
|
|
64
|
+
status: 'candidate',
|
|
65
|
+
source_agent: sourceAgent,
|
|
66
|
+
created_at: new Date().toISOString(),
|
|
67
|
+
...metadata
|
|
68
|
+
})}\n# ${title}\n\n${candidateText(payload)}\n`;
|
|
69
|
+
}
|
|
70
|
+
export function isProtectedPath(ref) {
|
|
71
|
+
const normalized = ref.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
72
|
+
return DEFAULT_PROTECTED_PATTERNS.some((pattern)=>normalized === pattern || normalized.endsWith(`/${pattern}`));
|
|
73
|
+
}
|
|
74
|
+
function normalizeRef(ref) {
|
|
75
|
+
return ref.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
76
|
+
}
|
|
77
|
+
function stripMemoryPrefix(ref) {
|
|
78
|
+
const normalized = normalizeRef(ref);
|
|
79
|
+
return normalized.startsWith('memory/') ? normalized.slice('memory/'.length) : normalized;
|
|
80
|
+
}
|
|
81
|
+
function resolveTargetPath(workspace, candidateId, target = {}) {
|
|
82
|
+
if (workspace.mode === 'existing-memory-root') {
|
|
83
|
+
const title = safeFileSlug(target.title || candidateId, candidateId);
|
|
84
|
+
return path.join(workspace.promotedDir, `${nowCompact()}-${title}.md`);
|
|
85
|
+
}
|
|
86
|
+
const explicit = target.path?.trim();
|
|
87
|
+
if (explicit) {
|
|
88
|
+
if (isProtectedPath(explicit)) throw new Error('protected_core_path');
|
|
89
|
+
const absolute = absoluteFromMemoryPath(workspace, explicit);
|
|
90
|
+
return absolute;
|
|
91
|
+
}
|
|
92
|
+
const scope = safeFileSlug(target.scope || 'general', 'general');
|
|
93
|
+
const title = safeFileSlug(target.title || candidateId, candidateId);
|
|
94
|
+
const relative = path.join('scopes', scope, `${nowCompact()}-${title}.md`);
|
|
95
|
+
if (isProtectedPath(relative)) throw new Error('protected_core_path');
|
|
96
|
+
return path.join(workspace.memoryDir, relative);
|
|
97
|
+
}
|
|
98
|
+
function candidateDirForAgent(workspace, sourceAgent) {
|
|
99
|
+
if (workspace.mode === 'existing-memory-root') {
|
|
100
|
+
return path.join(workspace.candidatesDir, safeFileSlug(sourceAgent, 'unknown'));
|
|
101
|
+
}
|
|
102
|
+
return workspace.candidatesDir;
|
|
103
|
+
}
|
|
104
|
+
function isAllowedCandidatePath(workspace, relativePath, absolutePath) {
|
|
105
|
+
if (workspace.mode === 'existing-memory-root') {
|
|
106
|
+
return relativePath.startsWith('memory/_mcp/candidates/') && isMcpSandboxPath(workspace, absolutePath);
|
|
107
|
+
}
|
|
108
|
+
return relativePath.startsWith('memory/candidate/inbox/');
|
|
109
|
+
}
|
|
110
|
+
function isAllowedDurableTargetPath(relativePath) {
|
|
111
|
+
const normalized = normalizeRef(relativePath);
|
|
112
|
+
const memoryRelative = stripMemoryPrefix(normalized);
|
|
113
|
+
if (/^\d{4}-\d{2}-\d{2}\.md$/.test(memoryRelative)) return true;
|
|
114
|
+
if (memoryRelative.startsWith('scopes/')) return true;
|
|
115
|
+
if (memoryRelative.startsWith('inbox/')) return true;
|
|
116
|
+
if (normalized.startsWith('projects/')) return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
function isForbiddenDurableTargetPath(relativePath) {
|
|
120
|
+
const memoryRelative = stripMemoryPrefix(relativePath);
|
|
121
|
+
return memoryRelative === 'recent/latest.md' || memoryRelative === 'decisions.md' || memoryRelative === 'workflows.md' || memoryRelative === 'codex/current.md' || memoryRelative === 'claude-code/current.md' || memoryRelative.endsWith('/current.md');
|
|
122
|
+
}
|
|
123
|
+
function resolveDurableTargetPath(workspace, candidateId, target = {}) {
|
|
124
|
+
const explicit = target.path?.trim();
|
|
125
|
+
if (explicit) {
|
|
126
|
+
if (isProtectedPath(explicit)) throw new Error('protected_core_path');
|
|
127
|
+
const normalized = normalizeRef(explicit);
|
|
128
|
+
if (normalized.startsWith('projects/')) {
|
|
129
|
+
const workspaceRoot = workspace.mode === 'existing-memory-root' ? path.dirname(workspace.memoryDir) : workspace.spaceDir;
|
|
130
|
+
return path.resolve(workspaceRoot, normalized);
|
|
131
|
+
}
|
|
132
|
+
return absoluteFromMemoryPath(workspace, normalized);
|
|
133
|
+
}
|
|
134
|
+
const scope = safeFileSlug(target.scope || 'general', 'general');
|
|
135
|
+
const title = safeFileSlug(target.title || candidateId, candidateId);
|
|
136
|
+
return path.join(workspace.memoryDir, 'scopes', scope, `${nowCompact()}-${title}.md`);
|
|
137
|
+
}
|
|
138
|
+
function relativeDurableTarget(workspace, targetPath) {
|
|
139
|
+
const resolved = path.resolve(targetPath);
|
|
140
|
+
const memoryDir = path.resolve(workspace.memoryDir);
|
|
141
|
+
if (resolved === memoryDir || resolved.startsWith(`${memoryDir}${path.sep}`)) {
|
|
142
|
+
return relativeToSpace(workspace, resolved);
|
|
143
|
+
}
|
|
144
|
+
const workspaceRoot = workspace.mode === 'existing-memory-root' ? path.dirname(workspace.memoryDir) : workspace.spaceDir;
|
|
145
|
+
const root = path.resolve(workspaceRoot);
|
|
146
|
+
if (resolved === root || resolved.startsWith(`${root}${path.sep}`)) {
|
|
147
|
+
return path.relative(root, resolved).split(path.sep).join('/');
|
|
148
|
+
}
|
|
149
|
+
throw new Error('target_outside_workspace');
|
|
150
|
+
}
|
|
151
|
+
function durableAppendContent(candidateContent) {
|
|
152
|
+
return candidateContent.replace(/^status:\s*"candidate"\s*$/m, 'status: "promoted"').replace(/^type:\s*"memory_candidate"\s*$/m, 'type: "memory"').replace(/^---\n/, `---\npromoted_at: "${new Date().toISOString()}"\n`);
|
|
153
|
+
}
|
|
154
|
+
async function durableTargetContent(targetPath, appendContent) {
|
|
155
|
+
try {
|
|
156
|
+
const existing = await fs.readFile(targetPath, 'utf8');
|
|
157
|
+
return `${existing.replace(/\s*$/, '\n\n')}${appendContent}`;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error.code === 'ENOENT') return appendContent;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function assertCandidateFrontMatter(content) {
|
|
164
|
+
const hasCandidateType = /^type:\s*"memory_candidate"\s*$/m.test(content);
|
|
165
|
+
const hasCandidateStatus = /^status:\s*"candidate"\s*$/m.test(content);
|
|
166
|
+
if (!hasCandidateType || !hasCandidateStatus) {
|
|
167
|
+
throw new Error('candidate_frontmatter_required');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export async function writeCandidate(workspace, payload) {
|
|
171
|
+
return await withWorkspaceLock(workspace, async ()=>{
|
|
172
|
+
const candidateId = crypto.randomUUID();
|
|
173
|
+
const title = safeFileSlug(payload.title || candidateId, candidateId);
|
|
174
|
+
const sourceAgent = payload.sourceAgent || payload.source || 'unknown';
|
|
175
|
+
const filePath = path.join(candidateDirForAgent(workspace, sourceAgent), `${nowCompact()}-${title}.md`);
|
|
176
|
+
await atomicWriteFile(filePath, markdownCandidate(candidateId, payload));
|
|
177
|
+
const relativePath = relativeToSpace(workspace, filePath);
|
|
178
|
+
await appendEvent(workspace, {
|
|
179
|
+
type: 'candidate.created',
|
|
180
|
+
path: relativePath,
|
|
181
|
+
actor: sourceAgent,
|
|
182
|
+
metadata: {
|
|
183
|
+
candidateId,
|
|
184
|
+
status: 'candidate',
|
|
185
|
+
sandbox: workspace.mode === 'existing-memory-root' ? 'memory/_mcp' : undefined
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
candidateId,
|
|
190
|
+
path: relativePath,
|
|
191
|
+
status: 'candidate'
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
export async function promoteCandidate(workspace, candidateRef, target = {}) {
|
|
196
|
+
return await withWorkspaceLock(workspace, async ()=>{
|
|
197
|
+
const candidate = await readMemoryFile(workspace, candidateRef);
|
|
198
|
+
const candidateAbsolute = absoluteFromMemoryPath(workspace, candidate.path);
|
|
199
|
+
if (!isAllowedCandidatePath(workspace, candidate.path, candidateAbsolute)) {
|
|
200
|
+
throw new Error('candidate_must_be_from_inbox');
|
|
201
|
+
}
|
|
202
|
+
const candidateIdMatch = candidate.content.match(/^candidate_id:\s*"?(.*?)"?\s*$/m);
|
|
203
|
+
const candidateId = candidateIdMatch?.[1] || path.basename(candidate.path, '.md');
|
|
204
|
+
const targetPath = resolveTargetPath(workspace, candidateId, target);
|
|
205
|
+
const targetRelative = relativeToSpace(workspace, targetPath);
|
|
206
|
+
if (isProtectedPath(targetRelative)) throw new Error('protected_core_path');
|
|
207
|
+
const body = candidate.content.replace(/^status:\s*"candidate"\s*$/m, 'status: "promoted"').replace(/^type:\s*"memory_candidate"\s*$/m, 'type: "memory"').replace(/^---\n/, `---\npromoted_at: "${new Date().toISOString()}"\n`);
|
|
208
|
+
await atomicWriteFile(targetPath, body);
|
|
209
|
+
if (workspace.mode === 'existing-memory-root') {
|
|
210
|
+
if (!isMcpSandboxPath(workspace, targetPath)) throw new Error('target_outside_mcp_sandbox');
|
|
211
|
+
await fs.rm(candidateAbsolute, {
|
|
212
|
+
force: true
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
const historyPath = path.join(workspace.historyDir, 'promoted-candidates', path.basename(candidate.path));
|
|
216
|
+
await fs.mkdir(path.dirname(historyPath), {
|
|
217
|
+
recursive: true
|
|
218
|
+
});
|
|
219
|
+
await fs.rename(candidateAbsolute, historyPath);
|
|
220
|
+
}
|
|
221
|
+
const event = await appendEvent(workspace, {
|
|
222
|
+
type: 'memory.promoted',
|
|
223
|
+
candidatePath: candidate.path,
|
|
224
|
+
targetPath: targetRelative,
|
|
225
|
+
actor: 'core.promote',
|
|
226
|
+
metadata: {
|
|
227
|
+
candidateId,
|
|
228
|
+
target,
|
|
229
|
+
stagingOnly: workspace.mode === 'existing-memory-root',
|
|
230
|
+
targetMemoryPath: relativeToMemory(workspace, targetPath)
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
candidateId,
|
|
235
|
+
path: targetRelative,
|
|
236
|
+
status: 'promoted',
|
|
237
|
+
eventId: event.id
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
export async function durablePromoteCandidate(workspace, candidateRef, options = {}) {
|
|
242
|
+
if (options.dryRun === true && options.realWrite === true) {
|
|
243
|
+
throw new Error('durable_promote_mode_conflict');
|
|
244
|
+
}
|
|
245
|
+
if (options.dryRun !== true && options.realWrite !== true) {
|
|
246
|
+
throw new Error('durable_promote_requires_explicit_dry_run_or_real_write');
|
|
247
|
+
}
|
|
248
|
+
return await withWorkspaceLock(workspace, async ()=>{
|
|
249
|
+
const candidate = await readMemoryFile(workspace, candidateRef);
|
|
250
|
+
const candidateAbsolute = absoluteFromMemoryPath(workspace, candidate.path);
|
|
251
|
+
if (!isAllowedCandidatePath(workspace, candidate.path, candidateAbsolute)) {
|
|
252
|
+
throw new Error('candidate_must_be_from_inbox');
|
|
253
|
+
}
|
|
254
|
+
assertCandidateFrontMatter(candidate.content);
|
|
255
|
+
assertNoSecretLikeDurableCandidate(candidate.content);
|
|
256
|
+
const candidateIdMatch = candidate.content.match(/^candidate_id:\s*"?(.*?)"?\s*$/m);
|
|
257
|
+
const candidateId = candidateIdMatch?.[1] || path.basename(candidate.path, '.md');
|
|
258
|
+
const targetPath = resolveDurableTargetPath(workspace, candidateId, options.target || {});
|
|
259
|
+
const targetRelative = relativeDurableTarget(workspace, targetPath);
|
|
260
|
+
if (isProtectedPath(targetRelative)) throw new Error('protected_core_path');
|
|
261
|
+
if (isForbiddenDurableTargetPath(targetRelative)) throw new Error('durable_target_forbidden');
|
|
262
|
+
if (!isAllowedDurableTargetPath(targetRelative)) throw new Error('durable_target_not_whitelisted');
|
|
263
|
+
const appendContent = durableAppendContent(candidate.content);
|
|
264
|
+
assertNoSecretLikeDurableCandidate(appendContent);
|
|
265
|
+
const at = new Date().toISOString();
|
|
266
|
+
const eventId = crypto.randomUUID();
|
|
267
|
+
const actor = options.actor || 'core.durable-promote';
|
|
268
|
+
const archiveCandidateTo = relativeToSpace(workspace, path.join(workspace.historyDir, 'promoted-candidates', path.basename(candidate.path)));
|
|
269
|
+
const auditEventPath = relativeToSpace(workspace, path.join(workspace.eventsDir, `${at.slice(0, 10)}.ndjson`));
|
|
270
|
+
const dryRun = options.dryRun === true;
|
|
271
|
+
const writeGuards = [
|
|
272
|
+
'explicit-durable-promote-call',
|
|
273
|
+
'candidate-inbox-source-only',
|
|
274
|
+
'protected-core-blocked',
|
|
275
|
+
'target-whitelist-enforced',
|
|
276
|
+
'redact-check-before-write',
|
|
277
|
+
'withWorkspaceLock',
|
|
278
|
+
'atomicWriteFile-for-real-write',
|
|
279
|
+
dryRun ? 'dry-run-no-write' : 'real-write-explicitly-enabled'
|
|
280
|
+
];
|
|
281
|
+
const plan = {
|
|
282
|
+
candidatePath: candidate.path,
|
|
283
|
+
targetPath: targetRelative,
|
|
284
|
+
targetAbsolutePath: targetPath,
|
|
285
|
+
operation: 'append',
|
|
286
|
+
appendContent,
|
|
287
|
+
archiveCandidateTo,
|
|
288
|
+
auditEventPath,
|
|
289
|
+
auditEvent: {
|
|
290
|
+
id: eventId,
|
|
291
|
+
type: 'memory.promoted.durable',
|
|
292
|
+
at,
|
|
293
|
+
actor,
|
|
294
|
+
candidatePath: candidate.path,
|
|
295
|
+
targetPath: targetRelative,
|
|
296
|
+
metadata: {
|
|
297
|
+
candidateId,
|
|
298
|
+
target: options.target || {},
|
|
299
|
+
dryRun,
|
|
300
|
+
source: 'candidate/inbox',
|
|
301
|
+
archiveCandidateTo
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
writeGuards
|
|
305
|
+
};
|
|
306
|
+
if (dryRun) {
|
|
307
|
+
return {
|
|
308
|
+
candidateId,
|
|
309
|
+
status: 'dry-run',
|
|
310
|
+
dryRun: true,
|
|
311
|
+
plan,
|
|
312
|
+
proof: {
|
|
313
|
+
explicitDurableTrigger: true,
|
|
314
|
+
sourceCandidateInboxOnly: true,
|
|
315
|
+
protectedCoreBlocked: true,
|
|
316
|
+
targetWhitelistEnforced: true,
|
|
317
|
+
redactCheck: 'passed',
|
|
318
|
+
dryRunNoWrites: true
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
await atomicWriteFile(targetPath, await durableTargetContent(targetPath, appendContent));
|
|
323
|
+
const archiveAbsolute = path.join(workspace.historyDir, 'promoted-candidates', path.basename(candidate.path));
|
|
324
|
+
await fs.mkdir(path.dirname(archiveAbsolute), {
|
|
325
|
+
recursive: true
|
|
326
|
+
});
|
|
327
|
+
await fs.rename(candidateAbsolute, archiveAbsolute);
|
|
328
|
+
const event = await appendEvent(workspace, {
|
|
329
|
+
type: 'memory.promoted.durable',
|
|
330
|
+
candidatePath: candidate.path,
|
|
331
|
+
targetPath: targetRelative,
|
|
332
|
+
actor,
|
|
333
|
+
metadata: {
|
|
334
|
+
candidateId,
|
|
335
|
+
target: options.target || {},
|
|
336
|
+
dryRun: false,
|
|
337
|
+
source: 'candidate/inbox',
|
|
338
|
+
archiveCandidateTo
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
candidateId,
|
|
343
|
+
status: 'promoted',
|
|
344
|
+
dryRun: false,
|
|
345
|
+
eventId: event.id,
|
|
346
|
+
path: targetRelative,
|
|
347
|
+
archivedCandidatePath: archiveCandidateTo,
|
|
348
|
+
plan: {
|
|
349
|
+
...plan,
|
|
350
|
+
auditEvent: {
|
|
351
|
+
...plan.auditEvent,
|
|
352
|
+
id: event.id,
|
|
353
|
+
at: event.at
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
proof: {
|
|
357
|
+
explicitDurableTrigger: true,
|
|
358
|
+
sourceCandidateInboxOnly: true,
|
|
359
|
+
protectedCoreBlocked: true,
|
|
360
|
+
targetWhitelistEnforced: true,
|
|
361
|
+
redactCheck: 'passed',
|
|
362
|
+
dryRunNoWrites: false
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
//# sourceURL=governance.ts
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --experimental-strip-types
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { openCore } from '../core.js';
|
|
6
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
7
|
+
const DEFAULT_PORT = 8788;
|
|
8
|
+
const LOCAL_IPS = new Set([
|
|
9
|
+
'127.0.0.1',
|
|
10
|
+
'::1',
|
|
11
|
+
'0.0.0.0'
|
|
12
|
+
]);
|
|
13
|
+
function json(res, status, payload) {
|
|
14
|
+
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
|
15
|
+
res.writeHead(status, {
|
|
16
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
17
|
+
'Content-Length': Buffer.byteLength(body),
|
|
18
|
+
'Cache-Control': 'no-store'
|
|
19
|
+
});
|
|
20
|
+
res.end(body);
|
|
21
|
+
}
|
|
22
|
+
function html(res, status, body) {
|
|
23
|
+
res.writeHead(status, {
|
|
24
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
25
|
+
'Content-Length': Buffer.byteLength(body),
|
|
26
|
+
'Cache-Control': 'no-store'
|
|
27
|
+
});
|
|
28
|
+
res.end(body);
|
|
29
|
+
}
|
|
30
|
+
function remoteIp(req) {
|
|
31
|
+
return (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
|
32
|
+
}
|
|
33
|
+
function assertSafeRef(ref) {
|
|
34
|
+
if (typeof ref !== 'string' || !ref.trim()) throw new Error('ref_required');
|
|
35
|
+
const normalized = ref.replace(/\\/g, '/');
|
|
36
|
+
if (normalized.includes('\0')) throw new Error('invalid_ref');
|
|
37
|
+
if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
38
|
+
throw new Error('path_traversal_rejected');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function auditEvents(core, limit) {
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = await fs.readdir(core.workspace.eventsDir);
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const files = entries.filter((entry)=>entry.endsWith('.ndjson')).sort().slice(-10);
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const file of files){
|
|
51
|
+
const content = await fs.readFile(path.join(core.workspace.eventsDir, file), 'utf8');
|
|
52
|
+
for (const line of content.split('\n')){
|
|
53
|
+
if (!line.trim()) continue;
|
|
54
|
+
try {
|
|
55
|
+
events.push(JSON.parse(line));
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return events.slice(-limit);
|
|
60
|
+
}
|
|
61
|
+
export async function createConsoleServer(options = {}) {
|
|
62
|
+
const core = await openCore(options);
|
|
63
|
+
return http.createServer(async (req, res)=>{
|
|
64
|
+
try {
|
|
65
|
+
const ip = remoteIp(req);
|
|
66
|
+
if (ip && !LOCAL_IPS.has(ip)) return json(res, 403, {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: 'local_only'
|
|
69
|
+
});
|
|
70
|
+
if (req.method !== 'GET') return json(res, 405, {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: 'read_only_console'
|
|
73
|
+
});
|
|
74
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
75
|
+
const route = url.pathname;
|
|
76
|
+
if (route === '/' || route === '/index.html') return html(res, 200, PAGE);
|
|
77
|
+
if (route === '/health') return json(res, 200, {
|
|
78
|
+
ok: true,
|
|
79
|
+
mode: 'local-console',
|
|
80
|
+
readOnly: true
|
|
81
|
+
});
|
|
82
|
+
if (route === '/api/status') return json(res, 200, await core.status());
|
|
83
|
+
if (route === '/api/search') {
|
|
84
|
+
const query = url.searchParams.get('q') || '';
|
|
85
|
+
const limit = Math.min(Number(url.searchParams.get('limit') || 10), 50);
|
|
86
|
+
return json(res, 200, await core.search(query, {
|
|
87
|
+
limit
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
if (route === '/api/read') {
|
|
91
|
+
const ref = url.searchParams.get('ref') || '';
|
|
92
|
+
assertSafeRef(ref);
|
|
93
|
+
return json(res, 200, await core.read(ref));
|
|
94
|
+
}
|
|
95
|
+
if (route === '/api/audit') {
|
|
96
|
+
const limit = Math.min(Number(url.searchParams.get('limit') || 25), 100);
|
|
97
|
+
return json(res, 200, {
|
|
98
|
+
ok: true,
|
|
99
|
+
events: await auditEvents(core, limit)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return json(res, 404, {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: 'not_found'
|
|
105
|
+
});
|
|
106
|
+
} catch (caught) {
|
|
107
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
108
|
+
const status = message === 'path_traversal_rejected' ? 400 : message === 'ref_required' ? 400 : 400;
|
|
109
|
+
return json(res, status, {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: message || 'bad_request'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
export async function main() {
|
|
117
|
+
const argv = process.argv.slice(2);
|
|
118
|
+
const options = {};
|
|
119
|
+
for(let index = 0; index < argv.length; index += 1){
|
|
120
|
+
const arg = argv[index];
|
|
121
|
+
if (arg === '--host') options.host = argv[++index];
|
|
122
|
+
else if (arg === '--port') options.port = Number(argv[++index]);
|
|
123
|
+
else if (arg === '--space') options.space = argv[++index];
|
|
124
|
+
else if (arg === '--root') options.root = argv[++index];
|
|
125
|
+
else if (arg === '--memory-root') options.memoryRoot = argv[++index];
|
|
126
|
+
else if (arg === '--state-root') options.stateRoot = argv[++index];
|
|
127
|
+
else if (arg === '--cwd') options.cwd = argv[++index];
|
|
128
|
+
else if (arg === '--engine') options.engine = argv[++index];
|
|
129
|
+
}
|
|
130
|
+
const host = options.host || process.env.IHOW_MEMORY_CONSOLE_HOST || DEFAULT_HOST;
|
|
131
|
+
const port = options.port ?? Number(process.env.IHOW_MEMORY_CONSOLE_PORT || DEFAULT_PORT);
|
|
132
|
+
const server = await createConsoleServer(options);
|
|
133
|
+
server.listen(port, host, ()=>{
|
|
134
|
+
console.log('cloud: disabled / local only');
|
|
135
|
+
console.log(`iHow Memory console (read-only): http://${host}:${port}`);
|
|
136
|
+
console.log('Open the URL in a browser. Ctrl+C to stop.');
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const PAGE = `<!doctype html>
|
|
140
|
+
<html lang="en">
|
|
141
|
+
<head>
|
|
142
|
+
<meta charset="utf-8" />
|
|
143
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
144
|
+
<title>iHow Memory — Local Console</title>
|
|
145
|
+
<style>
|
|
146
|
+
:root { --bg:#0f1419; --panel:#fff; --ink:#1b2733; --muted:#6b7785; --line:#e3e8ee; --accent:#2f6f4f; --chip:#eef3f0; }
|
|
147
|
+
* { box-sizing: border-box; }
|
|
148
|
+
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color:var(--ink); background:#f5f7f9; }
|
|
149
|
+
header { background:var(--bg); color:#e8eef2; padding:14px 22px; display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
|
150
|
+
header h1 { font-size:16px; margin:0; font-weight:600; letter-spacing:.2px; }
|
|
151
|
+
.badge { font-size:11px; padding:2px 8px; border-radius:999px; background:#1d2b22; color:#7fd1a3; border:1px solid #2c4435; }
|
|
152
|
+
.badge.cloud { background:#2a2030; color:#d8a7d8; border-color:#43314a; }
|
|
153
|
+
.wrap { max-width:1080px; margin:0 auto; padding:22px; display:grid; gap:18px; grid-template-columns:1fr 1fr; }
|
|
154
|
+
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 18px; }
|
|
155
|
+
.card.full { grid-column:1 / -1; }
|
|
156
|
+
.card h2 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin:0 0 12px; }
|
|
157
|
+
.kv { display:grid; grid-template-columns:auto 1fr; gap:4px 14px; font-size:13px; }
|
|
158
|
+
.kv dt { color:var(--muted); }
|
|
159
|
+
.kv dd { margin:0; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; word-break:break-all; }
|
|
160
|
+
.row { display:flex; gap:8px; }
|
|
161
|
+
input[type=text] { flex:1; padding:9px 11px; border:1px solid var(--line); border-radius:8px; font:inherit; }
|
|
162
|
+
button { padding:9px 14px; border:0; border-radius:8px; background:var(--accent); color:#fff; font:inherit; cursor:pointer; }
|
|
163
|
+
button:hover { background:#27593f; }
|
|
164
|
+
.hit { padding:9px 10px; border:1px solid var(--line); border-radius:8px; margin-top:8px; cursor:pointer; }
|
|
165
|
+
.hit:hover { border-color:var(--accent); background:#fafdfb; }
|
|
166
|
+
.hit .path { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--accent); word-break:break-all; }
|
|
167
|
+
.hit .snip { color:var(--muted); margin-top:3px; font-size:12.5px; }
|
|
168
|
+
pre { background:#0f1419; color:#d6e2ea; padding:12px; border-radius:8px; overflow:auto; max-height:340px; font-size:12px; white-space:pre-wrap; word-break:break-word; }
|
|
169
|
+
.cite { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--accent); margin-bottom:8px; word-break:break-all; }
|
|
170
|
+
.ev { font-family:ui-monospace,Menlo,monospace; font-size:12px; padding:5px 0; border-bottom:1px solid var(--line); }
|
|
171
|
+
.ev .t { color:var(--accent); }
|
|
172
|
+
.muted { color:var(--muted); }
|
|
173
|
+
.empty { color:var(--muted); font-style:italic; padding:6px 0; }
|
|
174
|
+
</style>
|
|
175
|
+
</head>
|
|
176
|
+
<body>
|
|
177
|
+
<header>
|
|
178
|
+
<h1>iHow Memory</h1>
|
|
179
|
+
<span class="badge">read-only console</span>
|
|
180
|
+
<span class="badge cloud">cloud disabled · local only</span>
|
|
181
|
+
</header>
|
|
182
|
+
<div class="wrap">
|
|
183
|
+
<section class="card">
|
|
184
|
+
<h2>Status</h2>
|
|
185
|
+
<dl class="kv" id="status"><dd class="muted">loading…</dd></dl>
|
|
186
|
+
</section>
|
|
187
|
+
<section class="card">
|
|
188
|
+
<h2>Audit (recent)</h2>
|
|
189
|
+
<div id="audit"><div class="empty">loading…</div></div>
|
|
190
|
+
</section>
|
|
191
|
+
<section class="card">
|
|
192
|
+
<h2>Search</h2>
|
|
193
|
+
<div class="row">
|
|
194
|
+
<input type="text" id="q" placeholder="search memory… (e.g. alpha sprint)" />
|
|
195
|
+
<button id="go">Search</button>
|
|
196
|
+
</div>
|
|
197
|
+
<div id="hits"></div>
|
|
198
|
+
</section>
|
|
199
|
+
<section class="card">
|
|
200
|
+
<h2>Read · citation</h2>
|
|
201
|
+
<div id="cite" class="cite muted">click a search result to view its cited source</div>
|
|
202
|
+
<pre id="content" class="muted">—</pre>
|
|
203
|
+
</section>
|
|
204
|
+
</div>
|
|
205
|
+
<script>
|
|
206
|
+
const $ = (id) => document.getElementById(id);
|
|
207
|
+
async function getJSON(u) { const r = await fetch(u); return r.json(); }
|
|
208
|
+
|
|
209
|
+
async function loadStatus() {
|
|
210
|
+
try {
|
|
211
|
+
const s = await getJSON('/api/status');
|
|
212
|
+
const w = s.workspace || {}, p = s.provider || {}, i = s.index || {}, sync = s.sync || {};
|
|
213
|
+
$('status').innerHTML =
|
|
214
|
+
row('memory root', w.memoryRoot || w.path || '—') +
|
|
215
|
+
row('mode', w.mode || '—') +
|
|
216
|
+
row('provider', (p.id || '?') + ' (ready=' + (p.ready ?? '?') + ', cloud=' + (p.cloud ?? '?') + ')') +
|
|
217
|
+
(p.fallback ? row('fallback', (p.fallbackFrom||'?') + ' → fts (' + (p.lastError||'') + ')') : '') +
|
|
218
|
+
row('index', (i.status || '?') + ', documents=' + (i.documents ?? '?')) +
|
|
219
|
+
row('sync', 'enabled=' + (sync.enabled ?? false));
|
|
220
|
+
} catch (e) { $('status').innerHTML = '<dd class="muted">status error: ' + e + '</dd>'; }
|
|
221
|
+
}
|
|
222
|
+
function row(k, v) { return '<dt>' + esc(k) + '</dt><dd>' + esc(String(v)) + '</dd>'; }
|
|
223
|
+
|
|
224
|
+
async function loadAudit() {
|
|
225
|
+
try {
|
|
226
|
+
const a = await getJSON('/api/audit?limit=25');
|
|
227
|
+
const events = a.events || (Array.isArray(a) ? a : []);
|
|
228
|
+
if (!events.length) { $('audit').innerHTML = '<div class="empty">no audit events yet</div>'; return; }
|
|
229
|
+
$('audit').innerHTML = events.slice().reverse().map(function(e){
|
|
230
|
+
const t = e.type || e.event || e.kind || 'event';
|
|
231
|
+
const id = e.id || e.ref || e.path || '';
|
|
232
|
+
const when = e.at || e.ts || e.time || '';
|
|
233
|
+
return '<div class="ev"><span class="t">' + esc(t) + '</span> <span class="muted">' + esc(String(when)) + '</span><br>' + esc(String(id)) + '</div>';
|
|
234
|
+
}).join('');
|
|
235
|
+
} catch (e) { $('audit').innerHTML = '<div class="empty">audit error: ' + e + '</div>'; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function doSearch() {
|
|
239
|
+
const q = $('q').value.trim();
|
|
240
|
+
if (!q) return;
|
|
241
|
+
$('hits').innerHTML = '<div class="empty">searching…</div>';
|
|
242
|
+
try {
|
|
243
|
+
const data = await getJSON('/api/search?limit=10&q=' + encodeURIComponent(q));
|
|
244
|
+
const hits = Array.isArray(data) ? data : (data.hits || data.results || data.matches || []);
|
|
245
|
+
if (!hits.length) { $('hits').innerHTML = '<div class="empty">no matches</div>'; return; }
|
|
246
|
+
$('hits').innerHTML = '';
|
|
247
|
+
hits.forEach(function(h){
|
|
248
|
+
const ref = h.ref || h.path || (h.citation && h.citation.path) || '';
|
|
249
|
+
const snip = h.snippet || h.preview || h.text || h.excerpt || '';
|
|
250
|
+
const div = document.createElement('div');
|
|
251
|
+
div.className = 'hit';
|
|
252
|
+
div.innerHTML = '<div class="path">' + esc(ref || '(no path)') + '</div>' + (snip ? '<div class="snip">' + esc(String(snip).slice(0,180)) + '</div>' : '');
|
|
253
|
+
div.onclick = function(){ openRead(ref); };
|
|
254
|
+
$('hits').appendChild(div);
|
|
255
|
+
});
|
|
256
|
+
} catch (e) { $('hits').innerHTML = '<div class="empty">search error: ' + e + '</div>'; }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function openRead(ref) {
|
|
260
|
+
if (!ref) return;
|
|
261
|
+
$('cite').textContent = ref; $('cite').className = 'cite';
|
|
262
|
+
$('content').textContent = 'loading…'; $('content').className = '';
|
|
263
|
+
try {
|
|
264
|
+
const d = await getJSON('/api/read?ref=' + encodeURIComponent(ref));
|
|
265
|
+
const cite = (d.citation && (d.citation.path || d.citation)) || d.path || ref;
|
|
266
|
+
$('cite').textContent = String(cite);
|
|
267
|
+
$('content').textContent = d.content || d.text || JSON.stringify(d, null, 2);
|
|
268
|
+
} catch (e) { $('content').textContent = 'read error: ' + e; }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function esc(s){ return String(s).replace(/[&<>"]/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"'})[c]; }); }
|
|
272
|
+
|
|
273
|
+
$('go').onclick = doSearch;
|
|
274
|
+
$('q').addEventListener('keydown', function(e){ if (e.key === 'Enter') doSearch(); });
|
|
275
|
+
loadStatus(); loadAudit();
|
|
276
|
+
</script>
|
|
277
|
+
</body>
|
|
278
|
+
</html>`;
|
|
279
|
+
if (process.argv[1] && import.meta.url === new URL(process.argv[1], 'file://').href) {
|
|
280
|
+
main().catch((caught)=>{
|
|
281
|
+
console.error(caught instanceof Error ? caught.message : String(caught));
|
|
282
|
+
process.exitCode = 1;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
//# sourceURL=http/console.ts
|