memoryintel 1.0.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/adapters/claudeCode.js +96 -0
- package/dist/adapters/genericPointer.js +39 -0
- package/dist/cli.js +157 -0
- package/dist/commands/daemonStart.js +8 -0
- package/dist/commands/dashboardToggle.js +17 -0
- package/dist/commands/init.js +111 -0
- package/dist/commands/load.js +82 -0
- package/dist/commands/status.js +24 -0
- package/dist/commands/update.js +108 -0
- package/dist/core/atomicWrite.js +6 -0
- package/dist/core/compressionConfig.js +37 -0
- package/dist/core/discovery.js +14 -0
- package/dist/core/eventLog.js +4 -0
- package/dist/core/gitPorcelain.js +45 -0
- package/dist/core/headingMatch.js +44 -0
- package/dist/core/lock.js +67 -0
- package/dist/core/memoryIndex.js +19 -0
- package/dist/core/pathSafety.js +43 -0
- package/dist/core/sectionWriter.js +91 -0
- package/dist/core/toon.js +118 -0
- package/dist/daemon/daemonHandle.js +52 -0
- package/dist/daemon/globalPaths.js +15 -0
- package/dist/daemon/health.js +14 -0
- package/dist/daemon/lifecycle.js +54 -0
- package/dist/daemon/registry.js +60 -0
- package/dist/daemon/server.js +92 -0
- package/dist/daemon/settings.js +13 -0
- package/dist/daemon/views/layout.js +233 -0
- package/dist/daemon/views/projectPage.js +111 -0
- package/dist/daemon/views/registryPage.js +54 -0
- package/dist/skill.js +46 -0
- package/dist/templates/starterFiles.js +22 -0
- package/hooks/hooks.json +11 -0
- package/package.json +52 -0
- package/skills/memoryintel/SKILL.md +55 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { extractHeadings, findHeadingMatch, suggestHeading, normalizeHeading } from './headingMatch.js';
|
|
2
|
+
export class SectionRejectedError extends Error {
|
|
3
|
+
section;
|
|
4
|
+
suggestion;
|
|
5
|
+
constructor(section, suggestion) {
|
|
6
|
+
super(suggestion
|
|
7
|
+
? `Section "${section}" not found. Did you mean "${suggestion}"?`
|
|
8
|
+
: `Section "${section}" not found and no similar heading exists.`);
|
|
9
|
+
this.section = section;
|
|
10
|
+
this.suggestion = suggestion;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Returns [startLine, endLine) of the section's content, and [headingLine] index.
|
|
14
|
+
function findSectionBounds(lines, headingText) {
|
|
15
|
+
const target = normalizeHeading(headingText);
|
|
16
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17
|
+
const match = /^##[ \t]+(.+?)\s*$/.exec(lines[i]);
|
|
18
|
+
if (match && normalizeHeading(match[1].trim()) === target) {
|
|
19
|
+
let end = lines.length;
|
|
20
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
21
|
+
if (/^##[ \t]+.+/.test(lines[j])) {
|
|
22
|
+
end = j;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { headingLine: i, contentStart: i + 1, contentEnd: end };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// Returns just the matched section's content block (the lines between its heading and the
|
|
32
|
+
// next ## heading, or end of file), or null if no heading matches `section`.
|
|
33
|
+
export function getSectionContent(markdown, section) {
|
|
34
|
+
const lines = markdown.split('\n');
|
|
35
|
+
const headings = extractHeadings(markdown);
|
|
36
|
+
const existingHeading = findHeadingMatch(headings, section);
|
|
37
|
+
if (!existingHeading)
|
|
38
|
+
return null;
|
|
39
|
+
const bounds = findSectionBounds(lines, existingHeading);
|
|
40
|
+
if (!bounds)
|
|
41
|
+
return null;
|
|
42
|
+
// Strip the trailing empty-string artifact produced by split('\n') when the section's content
|
|
43
|
+
// (or the file itself, for the last section) ends with a newline — mirrors the same stripping
|
|
44
|
+
// applySectionUpdate does when building on top of existing content for 'append'.
|
|
45
|
+
const contentLines = lines.slice(bounds.contentStart, bounds.contentEnd);
|
|
46
|
+
if (contentLines.length > 0 && contentLines[contentLines.length - 1] === '') {
|
|
47
|
+
contentLines.pop();
|
|
48
|
+
}
|
|
49
|
+
return contentLines.join('\n');
|
|
50
|
+
}
|
|
51
|
+
const VALID_ACTIONS = ['append', 'replace', 'create-section'];
|
|
52
|
+
export function applySectionUpdate(markdown, section, action, content) {
|
|
53
|
+
// Anything that is not exactly one of the three known actions used to fall through to the
|
|
54
|
+
// append branch, so a typo'd (or hostile) action string silently wrote content anyway.
|
|
55
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
56
|
+
throw new Error(`Unknown action "${action}". Expected one of: ${VALID_ACTIONS.join(', ')}.`);
|
|
57
|
+
}
|
|
58
|
+
const lines = markdown.split('\n');
|
|
59
|
+
const headings = extractHeadings(markdown);
|
|
60
|
+
const existingHeading = findHeadingMatch(headings, section);
|
|
61
|
+
if (action === 'create-section' && !existingHeading) {
|
|
62
|
+
const needsTrailingNewline = lines.length > 0 && lines[lines.length - 1] !== '';
|
|
63
|
+
const prefix = needsTrailingNewline ? lines.join('\n') + '\n' : lines.join('\n');
|
|
64
|
+
return `${prefix}## ${section}\n${content}\n`;
|
|
65
|
+
}
|
|
66
|
+
// create-section on an existing heading degrades to append; append/replace require an existing match.
|
|
67
|
+
if (!existingHeading) {
|
|
68
|
+
const suggestion = suggestHeading(headings, section);
|
|
69
|
+
throw new SectionRejectedError(section, suggestion);
|
|
70
|
+
}
|
|
71
|
+
const bounds = findSectionBounds(lines, existingHeading);
|
|
72
|
+
const before = lines.slice(0, bounds.contentStart);
|
|
73
|
+
const existingContentLines = lines.slice(bounds.contentStart, bounds.contentEnd);
|
|
74
|
+
const after = lines.slice(bounds.contentEnd);
|
|
75
|
+
const newContentLines = action === 'replace'
|
|
76
|
+
? [content]
|
|
77
|
+
: (() => {
|
|
78
|
+
// Preserve all interior blank lines; only strip the trailing empty string
|
|
79
|
+
// artifact produced by split('\n') when content ends with \n.
|
|
80
|
+
const filtered = existingContentLines.slice();
|
|
81
|
+
if (filtered.length > 0 && filtered[filtered.length - 1] === '') {
|
|
82
|
+
filtered.pop();
|
|
83
|
+
}
|
|
84
|
+
return [...filtered, content];
|
|
85
|
+
})();
|
|
86
|
+
const result = [...before, ...newContentLines, ...after].join('\n');
|
|
87
|
+
return result.endsWith('\n') ? result : result + '\n';
|
|
88
|
+
}
|
|
89
|
+
export function isNearDuplicate(existingBlock, newContent) {
|
|
90
|
+
return normalizeHeading(existingBlock).includes(normalizeHeading(newContent));
|
|
91
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
function quoteField(value) {
|
|
2
|
+
// Leading whitespace must be quoted as well: rows are written with a two-space indent, and
|
|
3
|
+
// the decoder strips a row's leading whitespace to find its first field. An unquoted
|
|
4
|
+
// ' foo' in the first column would therefore come back as 'foo'.
|
|
5
|
+
if (value.includes(',') || value.includes('"') || value.includes('\n') || /^[ \t]/.test(value)) {
|
|
6
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
// Parses the whole body of a TOON table (everything after the header line) into rows of fields.
|
|
11
|
+
//
|
|
12
|
+
// This MUST scan character-by-character across the entire text rather than splitting on '\n'
|
|
13
|
+
// first: `quoteField` deliberately quotes any value containing a newline, so a row's field can
|
|
14
|
+
// legitimately span several physical lines. Splitting on '\n' up front would tear such a field
|
|
15
|
+
// apart and silently drop everything after its first physical line.
|
|
16
|
+
function parseCsvRows(text) {
|
|
17
|
+
const rows = [];
|
|
18
|
+
let fields = [];
|
|
19
|
+
let current = '';
|
|
20
|
+
let inQuotes = false;
|
|
21
|
+
// True once any field content has been seen on the current row. Used both to strip a row's
|
|
22
|
+
// leading indentation (the encoder writes rows with a two-space prefix) and to ignore blank
|
|
23
|
+
// separator lines between rows without discarding blank lines *inside* a quoted field.
|
|
24
|
+
let rowStarted = false;
|
|
25
|
+
const endRow = () => {
|
|
26
|
+
fields.push(current);
|
|
27
|
+
rows.push(fields);
|
|
28
|
+
fields = [];
|
|
29
|
+
current = '';
|
|
30
|
+
rowStarted = false;
|
|
31
|
+
};
|
|
32
|
+
for (let i = 0; i < text.length; i++) {
|
|
33
|
+
const ch = text[i];
|
|
34
|
+
if (inQuotes) {
|
|
35
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
36
|
+
current += '"';
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
else if (ch === '"') {
|
|
40
|
+
inQuotes = false;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
current += ch;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '\r' && text[i + 1] === '\n')
|
|
48
|
+
continue; // normalize CRLF outside quotes
|
|
49
|
+
if (ch === '\n') {
|
|
50
|
+
if (rowStarted)
|
|
51
|
+
endRow();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!rowStarted && (ch === ' ' || ch === '\t'))
|
|
55
|
+
continue; // row indentation
|
|
56
|
+
rowStarted = true;
|
|
57
|
+
if (ch === '"') {
|
|
58
|
+
inQuotes = true;
|
|
59
|
+
}
|
|
60
|
+
else if (ch === ',') {
|
|
61
|
+
fields.push(current);
|
|
62
|
+
current = '';
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
current += ch;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (inQuotes) {
|
|
69
|
+
throw new Error('Malformed TOON table: unterminated quoted field.');
|
|
70
|
+
}
|
|
71
|
+
if (rowStarted)
|
|
72
|
+
endRow();
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
export function encodeToonTable(rows) {
|
|
76
|
+
if (rows.length === 0)
|
|
77
|
+
return `items[0]{}:\n`;
|
|
78
|
+
const fields = Object.keys(rows[0]);
|
|
79
|
+
const header = `items[${rows.length}]{${fields.join(',')}}:`;
|
|
80
|
+
const lines = rows.map((row) => ' ' + fields.map((f) => quoteField(row[f] ?? '')).join(','));
|
|
81
|
+
return [header, ...lines].join('\n') + '\n';
|
|
82
|
+
}
|
|
83
|
+
// Splits off the first non-blank line (the header) and returns it plus the untouched remainder.
|
|
84
|
+
function splitHeaderLine(text) {
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
while (cursor < text.length) {
|
|
87
|
+
const newlineIndex = text.indexOf('\n', cursor);
|
|
88
|
+
const rawLine = newlineIndex === -1 ? text.slice(cursor) : text.slice(cursor, newlineIndex);
|
|
89
|
+
const line = rawLine.replace(/\r$/, '');
|
|
90
|
+
if (line.trim().length > 0) {
|
|
91
|
+
return { headerLine: line.trim(), body: newlineIndex === -1 ? '' : text.slice(newlineIndex + 1) };
|
|
92
|
+
}
|
|
93
|
+
if (newlineIndex === -1)
|
|
94
|
+
break;
|
|
95
|
+
cursor = newlineIndex + 1;
|
|
96
|
+
}
|
|
97
|
+
return { headerLine: '', body: '' };
|
|
98
|
+
}
|
|
99
|
+
export function decodeToonTable(text) {
|
|
100
|
+
const { headerLine, body } = splitHeaderLine(text);
|
|
101
|
+
const headerMatch = /^items\[(\d+)\]\{(.*)\}:$/.exec(headerLine);
|
|
102
|
+
if (!headerMatch)
|
|
103
|
+
throw new Error(`Malformed TOON table header: ${headerLine}`);
|
|
104
|
+
const count = Number(headerMatch[1]);
|
|
105
|
+
const fields = headerMatch[2].length > 0 ? headerMatch[2].split(',') : [];
|
|
106
|
+
const rawRows = parseCsvRows(body);
|
|
107
|
+
if (rawRows.length !== count) {
|
|
108
|
+
throw new Error(`Malformed TOON table: header declares ${count} row(s) but ${rawRows.length} row(s) were found.`);
|
|
109
|
+
}
|
|
110
|
+
return rawRows.map((values, rowIndex) => {
|
|
111
|
+
if (values.length !== fields.length) {
|
|
112
|
+
throw new Error(`Malformed TOON table: row ${rowIndex} has ${values.length} field(s) but the header declares ${fields.length}.`);
|
|
113
|
+
}
|
|
114
|
+
const row = {};
|
|
115
|
+
fields.forEach((f, idx) => { row[f] = values[idx]; });
|
|
116
|
+
return row;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { ensureGlobalDir, daemonHandlePath } from './globalPaths.js';
|
|
4
|
+
export function readDaemonHandle() {
|
|
5
|
+
const path = daemonHandlePath();
|
|
6
|
+
if (!existsSync(path))
|
|
7
|
+
return null;
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
9
|
+
}
|
|
10
|
+
export function writeDaemonHandle(handle) {
|
|
11
|
+
ensureGlobalDir();
|
|
12
|
+
writeFileSync(daemonHandlePath(), JSON.stringify(handle, null, 2) + '\n');
|
|
13
|
+
}
|
|
14
|
+
export function clearDaemonHandle() {
|
|
15
|
+
const path = daemonHandlePath();
|
|
16
|
+
if (existsSync(path))
|
|
17
|
+
unlinkSync(path);
|
|
18
|
+
}
|
|
19
|
+
export function isProcessAlive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return err.code === 'EPERM';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function pickFreePort(startPort) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const tryPort = (port) => {
|
|
31
|
+
const tester = createServer();
|
|
32
|
+
tester.once('error', (err) => {
|
|
33
|
+
if (err.code === 'EADDRINUSE') {
|
|
34
|
+
tester.close(() => tryPort(port + 1));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
reject(err);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
tester.once('listening', () => {
|
|
41
|
+
const address = tester.address();
|
|
42
|
+
// When startPort is 0, the OS assigns a random free port — address().port carries
|
|
43
|
+
// the real value; `port` itself would still be 0. For an explicit non-zero startPort,
|
|
44
|
+
// address().port equals it, so this is safe for both cases.
|
|
45
|
+
const boundPort = typeof address === 'object' && address !== null ? address.port : port;
|
|
46
|
+
tester.close(() => resolve(boundPort));
|
|
47
|
+
});
|
|
48
|
+
tester.listen(port, '127.0.0.1');
|
|
49
|
+
};
|
|
50
|
+
tryPort(startPort);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
export function getGlobalDir() {
|
|
5
|
+
return process.env.MEMORYINTEL_GLOBAL_DIR ?? join(homedir(), '.memoryintel');
|
|
6
|
+
}
|
|
7
|
+
export function ensureGlobalDir() {
|
|
8
|
+
const dir = getGlobalDir();
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
export function registryPath() { return join(getGlobalDir(), 'registry.json'); }
|
|
13
|
+
export function settingsPath() { return join(getGlobalDir(), 'settings.json'); }
|
|
14
|
+
export function daemonHandlePath() { return join(getGlobalDir(), 'daemon.json'); }
|
|
15
|
+
export function daemonLockPath() { return join(getGlobalDir(), 'daemon.lock'); }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { WRITABLE_FILES } from '../core/pathSafety.js';
|
|
2
|
+
import { readIndex } from '../core/memoryIndex.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export function computeFileHealth(memoryRoot) {
|
|
5
|
+
const index = readIndex(join(memoryRoot, 'memory-index.json'));
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
return WRITABLE_FILES.map((file) => {
|
|
8
|
+
const entry = index[file];
|
|
9
|
+
if (!entry)
|
|
10
|
+
return { file, lastUpdated: null, staleDays: null };
|
|
11
|
+
const staleDays = Math.floor((now - new Date(entry.lastUpdated).getTime()) / (24 * 60 * 60 * 1000));
|
|
12
|
+
return { file, lastUpdated: entry.lastUpdated, staleDays };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { readGlobalSettings } from './settings.js';
|
|
5
|
+
import { readDaemonHandle, writeDaemonHandle, isProcessAlive } from './daemonHandle.js';
|
|
6
|
+
import { ensureGlobalDir, daemonLockPath } from './globalPaths.js';
|
|
7
|
+
import { withLockSync } from '../core/lock.js';
|
|
8
|
+
export function shouldSpawnDaemon() {
|
|
9
|
+
if (!readGlobalSettings().dashboardEnabled)
|
|
10
|
+
return false;
|
|
11
|
+
const handle = readDaemonHandle();
|
|
12
|
+
if (!handle)
|
|
13
|
+
return true;
|
|
14
|
+
return !isProcessAlive(handle.pid);
|
|
15
|
+
}
|
|
16
|
+
// Not deeply unit tested — it starts a real detached process. Exercised by Task 13's
|
|
17
|
+
// e2e test only insofar as `ensureDaemonRunning` decides *whether* to call it.
|
|
18
|
+
export function spawnDaemonProcess() {
|
|
19
|
+
const cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
|
|
20
|
+
const child = spawn(process.execPath, [cliPath, 'daemon', 'start'], {
|
|
21
|
+
detached: true,
|
|
22
|
+
stdio: 'ignore'
|
|
23
|
+
});
|
|
24
|
+
child.unref();
|
|
25
|
+
return child.pid;
|
|
26
|
+
}
|
|
27
|
+
// shouldSpawnDaemon() (read handle, check liveness) and spawnDaemonProcess() (spawn a detached
|
|
28
|
+
// child) are two separate steps with no atomicity between them - two `load`/`update` calls
|
|
29
|
+
// running close together can both see "no live daemon" and both spawn one, and the loser's
|
|
30
|
+
// daemon then has no handle pointing at it at all once the winner's write lands last. That's a
|
|
31
|
+
// genuinely orphaned process with no way to discover it later short of `ps` - confirmed as the
|
|
32
|
+
// root cause of exactly that happening once already on a real project. withLockSync closes the
|
|
33
|
+
// gap: only one caller can be inside the check-and-spawn section at a time. It's not enough on
|
|
34
|
+
// its own, though - the real daemon only writes its OWN full handle (with the real port) once
|
|
35
|
+
// it's actually listening (see server.ts's startDaemon), which happens after this function has
|
|
36
|
+
// already returned. A second caller arriving in that window would still see a stale/missing
|
|
37
|
+
// handle and spawn again. Writing a provisional handle - same pid the child was just spawned
|
|
38
|
+
// with, port unknown - closes that second gap too: isProcessAlive(handle.pid) is true the
|
|
39
|
+
// instant the child exists, before it has bound a port or written anything itself, and the
|
|
40
|
+
// child's own later write (same pid) just fills in the real port over this placeholder.
|
|
41
|
+
export function ensureDaemonRunning() {
|
|
42
|
+
try {
|
|
43
|
+
ensureGlobalDir();
|
|
44
|
+
withLockSync(daemonLockPath(), () => {
|
|
45
|
+
if (shouldSpawnDaemon()) {
|
|
46
|
+
const pid = spawnDaemonProcess();
|
|
47
|
+
writeDaemonHandle({ pid, port: 0 });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best-effort only — dashboard visibility must never break the calling command.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureGlobalDir, registryPath } from './globalPaths.js';
|
|
4
|
+
import { readGlobalSettings } from './settings.js';
|
|
5
|
+
const MARKER = 'memoryintel:managed:start';
|
|
6
|
+
export function detectToolsWired(projectRoot) {
|
|
7
|
+
const tools = [];
|
|
8
|
+
// Claude Code automation comes entirely from this package's bundled plugin
|
|
9
|
+
// (hooks/hooks.json), never from writing to the project's own .claude/settings.json - init
|
|
10
|
+
// has never touched that file (see src/commands/init.ts). Checking it here was dead code: it
|
|
11
|
+
// could never be true for any project using the documented setup, which is why a real project
|
|
12
|
+
// (distilled-docs) never showed claude-code despite Claude driving every session. The
|
|
13
|
+
// Stop-hook's `.session-marker.json` (written by check-stop / resolveCheckStopMarker, see
|
|
14
|
+
// src/adapters/claudeCode.ts) only ever exists once the plugin's Stop hook has actually fired
|
|
15
|
+
// for this project - real evidence of Claude Code automation running, not just installed.
|
|
16
|
+
// Still also honor a manually-wired settings.json, for anyone who set one up by hand.
|
|
17
|
+
const claudeSettingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
18
|
+
const settingsWired = existsSync(claudeSettingsPath) && readFileSync(claudeSettingsPath, 'utf-8').includes('memoryintel load');
|
|
19
|
+
const sessionMarkerPath = join(projectRoot, '.memoryintel', '.session-marker.json');
|
|
20
|
+
if (settingsWired || existsSync(sessionMarkerPath)) {
|
|
21
|
+
tools.push('claude-code');
|
|
22
|
+
}
|
|
23
|
+
if (existsSync(join(projectRoot, '.cursor', 'rules', 'memoryintel.mdc'))) {
|
|
24
|
+
tools.push('cursor');
|
|
25
|
+
}
|
|
26
|
+
const agentsPath = join(projectRoot, 'AGENTS.md');
|
|
27
|
+
if (existsSync(agentsPath) && readFileSync(agentsPath, 'utf-8').includes(MARKER)) {
|
|
28
|
+
tools.push('agents-md');
|
|
29
|
+
}
|
|
30
|
+
const geminiPath = join(projectRoot, 'GEMINI.md');
|
|
31
|
+
if (existsSync(geminiPath) && readFileSync(geminiPath, 'utf-8').includes(MARKER)) {
|
|
32
|
+
tools.push('gemini');
|
|
33
|
+
}
|
|
34
|
+
return tools;
|
|
35
|
+
}
|
|
36
|
+
export function readRegistry() {
|
|
37
|
+
const path = registryPath();
|
|
38
|
+
if (!existsSync(path))
|
|
39
|
+
return {};
|
|
40
|
+
const raw = readFileSync(path, 'utf-8').trim();
|
|
41
|
+
return raw.length === 0 ? {} : JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
function writeRegistry(registry) {
|
|
44
|
+
ensureGlobalDir();
|
|
45
|
+
writeFileSync(registryPath(), JSON.stringify(registry, null, 2) + '\n');
|
|
46
|
+
}
|
|
47
|
+
export function upsertRegistryEntry(projectRoot) {
|
|
48
|
+
if (!readGlobalSettings().dashboardEnabled)
|
|
49
|
+
return;
|
|
50
|
+
const registry = readRegistry();
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const existing = registry[projectRoot];
|
|
53
|
+
registry[projectRoot] = {
|
|
54
|
+
path: projectRoot,
|
|
55
|
+
initializedAt: existing?.initializedAt ?? now,
|
|
56
|
+
lastSessionAt: now,
|
|
57
|
+
toolsWired: detectToolsWired(projectRoot)
|
|
58
|
+
};
|
|
59
|
+
writeRegistry(registry);
|
|
60
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readRegistry } from './registry.js';
|
|
3
|
+
import { renderRegistryPage } from './views/registryPage.js';
|
|
4
|
+
import { renderProjectPage } from './views/projectPage.js';
|
|
5
|
+
import { pageShell } from './views/layout.js';
|
|
6
|
+
import { pickFreePort, writeDaemonHandle, clearDaemonHandle } from './daemonHandle.js';
|
|
7
|
+
import { readGlobalSettings, writeGlobalSettings } from './settings.js';
|
|
8
|
+
// Loopback binding alone isn't enough for a mutating route: any browser tab already open on
|
|
9
|
+
// this machine can still address 127.0.0.1:<port> regardless of which site served that tab (the
|
|
10
|
+
// DNS-rebinding class of attack) - only a page's own JS can be forced to send a real Origin
|
|
11
|
+
// header, a CLI caller like curl simply never sets one. Block only when Origin is present and
|
|
12
|
+
// doesn't match, never on its absence alone - that would reject every legitimate non-browser
|
|
13
|
+
// caller over a header browsers alone are trusted to set honestly.
|
|
14
|
+
const ALLOWED_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
15
|
+
function isSameOriginRequest(req) {
|
|
16
|
+
const origin = req.headers.origin;
|
|
17
|
+
if (!origin)
|
|
18
|
+
return true;
|
|
19
|
+
try {
|
|
20
|
+
const originUrl = new URL(origin);
|
|
21
|
+
if (!ALLOWED_HOSTNAMES.has(originUrl.hostname))
|
|
22
|
+
return false;
|
|
23
|
+
return originUrl.host === req.headers.host;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function renderStoppedPage() {
|
|
30
|
+
return pageShell('Memory Intel — dashboard stopped', `
|
|
31
|
+
<div class="eyebrow">Memory Intel</div>
|
|
32
|
+
<h1>Dashboard stopped</h1>
|
|
33
|
+
<p>The shared dashboard is stopped and disabled for every Memory Intel project on this machine. Run <code>memoryintel dashboard enable</code> to turn it back on, or run any <code>memoryintel</code> command and it starts itself again the next time a project needs it.</p>
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
export function createRequestHandler(server) {
|
|
37
|
+
return (req, res) => {
|
|
38
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
39
|
+
if (req.method === 'POST' && url.pathname === '/stop') {
|
|
40
|
+
if (!isSameOriginRequest(req)) {
|
|
41
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
42
|
+
res.end('Forbidden: Origin does not match this server');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
writeGlobalSettings({ ...readGlobalSettings(), dashboardEnabled: false });
|
|
46
|
+
clearDaemonHandle();
|
|
47
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
48
|
+
res.end(renderStoppedPage(), () => {
|
|
49
|
+
// Closing here, not process.exit(): this lets the response above actually finish
|
|
50
|
+
// flushing to the client first, and a process.exit() call would be untestable in-process
|
|
51
|
+
// (the daemon in a real deployment has nothing else keeping the event loop alive, so it
|
|
52
|
+
// exits on its own once the server stops accepting connections and this request
|
|
53
|
+
// completes - no explicit exit needed, and nothing abruptly drops the in-flight response
|
|
54
|
+
// the way process.exit() would).
|
|
55
|
+
server?.close();
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (url.pathname === '/') {
|
|
60
|
+
const html = renderRegistryPage(readRegistry());
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
62
|
+
res.end(html);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (url.pathname === '/project') {
|
|
66
|
+
const path = url.searchParams.get('path');
|
|
67
|
+
if (!path) {
|
|
68
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
69
|
+
res.end('Missing ?path=');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const typeFilter = url.searchParams.get('type') ?? undefined;
|
|
73
|
+
const html = renderProjectPage(path, { typeFilter });
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
75
|
+
res.end(html);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
79
|
+
res.end('Not found');
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function startDaemon(preferredPort = 4390) {
|
|
83
|
+
const port = await pickFreePort(preferredPort);
|
|
84
|
+
const server = createServer();
|
|
85
|
+
server.on('request', createRequestHandler(server));
|
|
86
|
+
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
|
|
87
|
+
writeDaemonHandle({ port, pid: process.pid });
|
|
88
|
+
const cleanup = () => { clearDaemonHandle(); process.exit(0); };
|
|
89
|
+
process.on('SIGTERM', cleanup);
|
|
90
|
+
process.on('SIGINT', cleanup);
|
|
91
|
+
return { port, server };
|
|
92
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { ensureGlobalDir, settingsPath } from './globalPaths.js';
|
|
3
|
+
const DEFAULT_SETTINGS = { dashboardEnabled: true };
|
|
4
|
+
export function readGlobalSettings() {
|
|
5
|
+
const path = settingsPath();
|
|
6
|
+
if (!existsSync(path))
|
|
7
|
+
return { ...DEFAULT_SETTINGS };
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
9
|
+
}
|
|
10
|
+
export function writeGlobalSettings(settings) {
|
|
11
|
+
ensureGlobalDir();
|
|
12
|
+
writeFileSync(settingsPath(), JSON.stringify(settings, null, 2) + '\n');
|
|
13
|
+
}
|