@fuxi-ailabs/crowdmemory-codex-plugin 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ // Crowd Memory adapter core — edit in distributions/memory-adapter-core, then `npm run sync` there (copies into every adapter).
2
+ /**
3
+ * The shell command of a tool call as a replay can rerun it (v1.1 FR-44): the workspace root written as `.`, and
4
+ * nothing recorded when the command is too long or still names a secret or a home directory after that (a cut or
5
+ * redacted command would replay something else).
6
+ */
7
+ import { isAbsolute } from 'node:path';
8
+ import { redact } from './redact.js';
9
+ /** The command is recorded only up to this many code points (longer: not recorded, never cut). */
10
+ export const COMMAND_MAX = 4096;
11
+ const PATH_CHAR = /[A-Za-z0-9._~+@%-]/u;
12
+ /** `command` with the absolute workspace root written as `.` (a replay restores the workspace somewhere else). */
13
+ export function relativeToWorkspace(command, root) {
14
+ if (root === undefined || !isAbsolute(root))
15
+ return command;
16
+ const base = root.replace(/\/+$/u, '');
17
+ if (base === '')
18
+ return command;
19
+ let out = '';
20
+ let from = 0;
21
+ for (let at = command.indexOf(base); at >= 0; at = command.indexOf(base, from)) {
22
+ const before = at === 0 ? '' : command[at - 1] ?? '';
23
+ const after = command[at + base.length] ?? '';
24
+ const startsPath = before === '' || (before !== '/' && !PATH_CHAR.test(before));
25
+ const endsPath = after === '' || after === '/' || !PATH_CHAR.test(after);
26
+ if (startsPath && endsPath) {
27
+ out += `${command.slice(from, at)}.`;
28
+ from = at + base.length;
29
+ }
30
+ else {
31
+ out += command.slice(from, at + 1);
32
+ from = at + 1;
33
+ }
34
+ }
35
+ return out + command.slice(from);
36
+ }
37
+ /** The command as recorded, or `undefined` (see the module comment). */
38
+ export function replayCommand(command, workspaceRoot) {
39
+ if (command.length > COMMAND_MAX * 2)
40
+ return undefined;
41
+ const relative = relativeToWorkspace(command, workspaceRoot).trim();
42
+ if (relative === '' || Array.from(relative).length > COMMAND_MAX)
43
+ return undefined;
44
+ const redacted = redact(relative);
45
+ return redacted.secrets || redacted.home ? undefined : relative;
46
+ }
@@ -0,0 +1,45 @@
1
+ // Crowd Memory adapter core — edit in distributions/memory-adapter-core, then `npm run sync` there (copies into every adapter).
2
+ /**
3
+ * Error signature `crowdmemory-error-signature/v1` (COLLAB §3.2, spec FR-05), the TypeScript twin of
4
+ * `crowdmemory_contracts.signature`. The regular-expression sources below are copied
5
+ * verbatim from the contract and checked against the published vectors file by the tests, so a
6
+ * contract change that is not mirrored here fails loudly.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ export const ALGORITHM_ID = 'crowdmemory-error-signature/v1';
10
+ // Plain string literals (not String.raw): bundlers may cook \u escapes inside raw templates.
11
+ export const LINE_SPLIT_PATTERN = '\\r\\n|\\r|\\n';
12
+ export const WHITESPACE_CLASS = '[ \\t\\u000b\\u000c\\u00a0\\u3000\\ufeff]';
13
+ const PATH_STOP = ' \\t\\u000b\\u000c\\u00a0\\u3000\\ufeff\'"`:;,()\\[\\]{}<>|'
14
+ + '\\uff0c\\u3002\\uff1a\\uff1b\\u3001\\uff08\\uff09\\u3010\\u3011\\u300c\\u300d\\u300a\\u300b\\uff01\\uff1f';
15
+ export const PATH_PATTERN = '(?<![A-Za-z0-9_.~/\\-])(?:~/[^' + PATH_STOP + ']*|/[^' + PATH_STOP + ']+)';
16
+ export const HEX_PATTERN = '0[xX][0-9a-fA-F]+';
17
+ export const DIGITS_PATTERN = '[0-9]+';
18
+ export const PATH_TOKEN = '<path>';
19
+ export const NUMBER_TOKEN = '#';
20
+ const LINE_SPLIT = new RegExp(LINE_SPLIT_PATTERN, 'u');
21
+ const STRIP = new RegExp(`^${WHITESPACE_CLASS}+|${WHITESPACE_CLASS}+$`, 'gu');
22
+ const PATH = new RegExp(PATH_PATTERN, 'gu');
23
+ const HEX = new RegExp(HEX_PATTERN, 'gu');
24
+ const DIGITS = new RegExp(DIGITS_PATTERN, 'gu');
25
+ function strip(text) {
26
+ return text.replace(STRIP, '');
27
+ }
28
+ /** The normalized first non-empty line, or `''` when the text has none. */
29
+ export function normalizeErrorLine(text) {
30
+ if (text === null || text === undefined || text === '')
31
+ return '';
32
+ for (const line of text.split(LINE_SPLIT)) {
33
+ if (strip(line) === '')
34
+ continue;
35
+ return strip(line.replace(PATH, PATH_TOKEN).replace(HEX, NUMBER_TOKEN).replace(DIGITS, NUMBER_TOKEN));
36
+ }
37
+ return '';
38
+ }
39
+ /** `sha256:<hex>` of the normalized line, or `undefined` when the text carries no content. */
40
+ export function errorSignature(text) {
41
+ const normalized = normalizeErrorLine(text);
42
+ if (normalized === '')
43
+ return undefined;
44
+ return `sha256:${createHash('sha256').update(normalized, 'utf8').digest('hex')}`;
45
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Per-session routing state, shared by the short-lived hook processes and the MCP server of one host session.
3
+ *
4
+ * Each session ref has one JSON file in a private directory (owned by this user, mode 0700; files 0600), changed
5
+ * only under an exclusive lock file. Callers keep Runtime requests outside `update` except for `session.start`, so
6
+ * parallel hooks of one session do not queue behind each other's network calls.
7
+ */
8
+ import { constants, realpathSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { lstat, mkdir, open, readdir, rename, stat, unlink } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ export const STATE_MAX_BYTES = 1024 * 1024;
14
+ const LOCK_WAIT_MS = 8000;
15
+ const LOCK_STALE_MS = 30000;
16
+ /** Session files untouched for this long are removed when a new session starts. */
17
+ export const STATE_RETENTION_MS = 7 * 24 * 3600 * 1000;
18
+ function keyOf(ref) {
19
+ return createHash('sha256').update(ref, 'utf8').digest('hex').slice(0, 40);
20
+ }
21
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
22
+ export class StateStore {
23
+ dir;
24
+ constructor(dir) {
25
+ this.dir = dir;
26
+ }
27
+ async ensureDir() {
28
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
29
+ const info = await lstat(this.dir);
30
+ if (!info.isDirectory() || (process.getuid !== undefined && info.uid !== process.getuid()) || (info.mode & 0o077) !== 0) {
31
+ throw new Error('crowdmemory state directory must be a private directory owned by this user');
32
+ }
33
+ }
34
+ async lock(key) {
35
+ const path = join(this.dir, `${key}.lock`);
36
+ const deadline = Date.now() + LOCK_WAIT_MS;
37
+ for (;;) {
38
+ try {
39
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
40
+ await handle.write(String(process.pid));
41
+ await handle.close();
42
+ return async () => { await unlink(path).catch(() => undefined); };
43
+ }
44
+ catch (error) {
45
+ if (error.code !== 'EEXIST')
46
+ throw error;
47
+ }
48
+ try {
49
+ // a holder that crashed leaves its lock behind: take it over once it is clearly stale
50
+ if (Date.now() - (await lstat(path)).mtimeMs > LOCK_STALE_MS)
51
+ await unlink(path).catch(() => undefined);
52
+ }
53
+ catch {
54
+ // gone in between: retry at once
55
+ }
56
+ if (Date.now() > deadline)
57
+ throw new Error('crowdmemory state lock timed out');
58
+ await sleep(15 + Math.floor(Math.random() * 15));
59
+ }
60
+ }
61
+ async load(path) {
62
+ let handle;
63
+ try {
64
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
65
+ }
66
+ catch (error) {
67
+ if (error.code === 'ENOENT')
68
+ return undefined;
69
+ throw error;
70
+ }
71
+ try {
72
+ const info = await handle.stat();
73
+ if (!info.isFile() || (process.getuid !== undefined && info.uid !== process.getuid()) || (info.mode & 0o077) !== 0) {
74
+ throw new Error('crowdmemory state file must be a private regular file owned by this user');
75
+ }
76
+ if (info.size > STATE_MAX_BYTES)
77
+ throw new Error('crowdmemory state file too large');
78
+ const value = JSON.parse(await handle.readFile({ encoding: 'utf8' }));
79
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
80
+ throw new Error('invalid crowdmemory state');
81
+ return value;
82
+ }
83
+ finally {
84
+ await handle.close();
85
+ }
86
+ }
87
+ async save(path, record) {
88
+ const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`;
89
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
90
+ try {
91
+ await handle.writeFile(JSON.stringify(record), 'utf8');
92
+ await handle.sync();
93
+ }
94
+ finally {
95
+ await handle.close();
96
+ }
97
+ try {
98
+ await rename(temporary, path);
99
+ }
100
+ catch (error) {
101
+ await unlink(temporary).catch(() => undefined);
102
+ throw error;
103
+ }
104
+ }
105
+ /** The record of `ref` (or `undefined`), read without the lock. */
106
+ async read(ref) {
107
+ await this.ensureDir();
108
+ return this.load(join(this.dir, `${keyOf(ref)}.json`));
109
+ }
110
+ /**
111
+ * Run `change` on the record of `ref` under its lock and save the result; `create` gives the record when there is
112
+ * none yet (without it, `change` gets `undefined` and nothing is saved). A throw saves nothing.
113
+ */
114
+ async update(ref, change, create) {
115
+ await this.ensureDir();
116
+ const key = keyOf(ref);
117
+ const path = join(this.dir, `${key}.json`);
118
+ const release = await this.lock(key);
119
+ try {
120
+ const record = (await this.load(path)) ?? create?.();
121
+ if (record === undefined)
122
+ return undefined;
123
+ const result = await change(record);
124
+ await this.save(path, record);
125
+ return result;
126
+ }
127
+ finally {
128
+ await release();
129
+ }
130
+ }
131
+ /** Remove session files not touched for `STATE_RETENTION_MS` (best effort). */
132
+ async prune(now = Date.now()) {
133
+ let removed = 0;
134
+ try {
135
+ await this.ensureDir();
136
+ for (const name of await readdir(this.dir)) {
137
+ if (!name.endsWith('.json'))
138
+ continue;
139
+ try {
140
+ if (now - (await stat(join(this.dir, name))).mtimeMs > STATE_RETENTION_MS) {
141
+ await unlink(join(this.dir, name));
142
+ removed += 1;
143
+ }
144
+ }
145
+ catch {
146
+ // raced with another session: skip
147
+ }
148
+ }
149
+ }
150
+ catch {
151
+ // pruning never fails a hook
152
+ }
153
+ return removed;
154
+ }
155
+ }
156
+ /** True when this module is the script node was started with (paths compared after resolving symlinks, e.g. /tmp on macOS). */
157
+ export function isEntry(moduleUrl) {
158
+ const script = process.argv[1];
159
+ if (script === undefined)
160
+ return false;
161
+ try {
162
+ return realpathSync(script) === realpathSync(fileURLToPath(moduleUrl));
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
@@ -0,0 +1,60 @@
1
+ // Crowd Memory adapter core — edit in distributions/memory-adapter-core, then `npm run sync` there (copies into every adapter).
2
+ import { createHash } from 'node:crypto';
3
+ /** Canonical JSON: object keys sorted by code unit, no insignificant whitespace, `undefined` members dropped. */
4
+ export function canonicalJson(value) {
5
+ if (value === null || typeof value !== 'object') {
6
+ const text = JSON.stringify(value);
7
+ return text === undefined ? 'null' : text;
8
+ }
9
+ if (Array.isArray(value))
10
+ return `[${value.map(item => canonicalJson(item === undefined ? null : item)).join(',')}]`;
11
+ const entries = Object.entries(value)
12
+ .filter(([, item]) => item !== undefined)
13
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
14
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
15
+ }
16
+ export function sha256Digest(text) {
17
+ return `sha256:${createHash('sha256').update(text, 'utf8').digest('hex')}`;
18
+ }
19
+ /** Whole-second UTC timestamp (the contract accepts fractions; whole seconds keep fixtures stable). */
20
+ export function isoNow(now = new Date()) {
21
+ return now.toISOString().replace(/\.\d{3}Z$/u, 'Z');
22
+ }
23
+ /** Last `maxBytes` UTF-8 bytes of `text`, cut on a code-point boundary; drops a partial leading line when cut. */
24
+ export function utf8Tail(text, maxBytes) {
25
+ const bytes = Buffer.from(text, 'utf8');
26
+ if (bytes.length <= maxBytes)
27
+ return text;
28
+ let start = bytes.length - maxBytes;
29
+ while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80)
30
+ start += 1;
31
+ const tail = bytes.subarray(start).toString('utf8');
32
+ const newline = tail.indexOf('\n');
33
+ return newline >= 0 && newline < tail.length - 1 ? tail.slice(newline + 1) : tail;
34
+ }
35
+ /** First `maxBytes` UTF-8 bytes of `text`, cut on a code-point boundary. */
36
+ export function utf8Head(text, maxBytes) {
37
+ const bytes = Buffer.from(text, 'utf8');
38
+ if (bytes.length <= maxBytes)
39
+ return text;
40
+ let end = maxBytes;
41
+ while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80)
42
+ end -= 1;
43
+ return bytes.subarray(0, end).toString('utf8');
44
+ }
45
+ /** At most `max` code points (JSON Schema `maxLength` counts code points). */
46
+ export function codePoints(text, max) {
47
+ const points = Array.from(text);
48
+ return points.length <= max ? text : points.slice(0, max).join('');
49
+ }
50
+ /** Text of content blocks (`{type:'text', text}`), joined by newlines. */
51
+ export function blockText(blocks) {
52
+ if (typeof blocks === 'string')
53
+ return blocks;
54
+ if (!Array.isArray(blocks))
55
+ return '';
56
+ return blocks
57
+ .map(block => block?.type === 'text' ? String(block.text ?? '') : '')
58
+ .filter(text => text !== '')
59
+ .join('\n');
60
+ }
package/lib/hook.js ADDED
@@ -0,0 +1,26 @@
1
+ import { CodexMemory, configFromEnv } from './memory.js';
2
+ import { isEntry } from './core/state.js';
3
+ export async function main() {
4
+ try {
5
+ const chunks = [];
6
+ let size = 0;
7
+ for await (const chunk of process.stdin) {
8
+ size += Buffer.byteLength(chunk);
9
+ if (size > 1024 * 1024)
10
+ return;
11
+ chunks.push(Buffer.from(chunk));
12
+ }
13
+ const event = JSON.parse(Buffer.concat(chunks).toString('utf8'));
14
+ if (!event || typeof event !== 'object' || Array.isArray(event))
15
+ return;
16
+ const result = await new CodexMemory(configFromEnv()).hook(event);
17
+ if (result)
18
+ process.stdout.write(JSON.stringify(result) + '\n');
19
+ }
20
+ catch {
21
+ if (process.env['CROWDMEMORY_DEBUG'] === '1')
22
+ process.stderr.write('crowdmemory: hook unavailable; continuing\n');
23
+ }
24
+ }
25
+ if (isEntry(import.meta.url))
26
+ void main();
package/lib/mcp.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * MCP server (stdio, newline-delimited JSON-RPC 2.0) that offers memory_search / memory_read / memory_note to
3
+ * Codex and its subagents. One server runs per Codex session; it learns the calling session from the
4
+ * argument the plugin's PreToolUse hook adds, so it keeps no session state of its own. No MCP SDK: the plugin has no
5
+ * runtime dependencies.
6
+ */
7
+ import { createInterface } from 'node:readline';
8
+ import { ADAPTER_VERSION, configFromEnv, CodexMemory } from './memory.js';
9
+ import { memoryTools } from './tools.js';
10
+ import { isEntry } from './core/state.js';
11
+ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
12
+ const MAX_LINE = 4 * 1024 * 1024;
13
+ export class McpServer {
14
+ write;
15
+ tools;
16
+ constructor(memory, write) {
17
+ this.write = write;
18
+ this.tools = new Map(memoryTools(memory).map(tool => [tool.name, tool]));
19
+ }
20
+ reply(id, result) {
21
+ this.write(JSON.stringify({ jsonrpc: '2.0', id, result }));
22
+ }
23
+ error(id, code, message) {
24
+ this.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }));
25
+ }
26
+ async handle(line) {
27
+ let request;
28
+ try {
29
+ request = JSON.parse(line);
30
+ }
31
+ catch {
32
+ this.error(null, -32700, 'parse error');
33
+ return;
34
+ }
35
+ if (request === null || typeof request !== 'object' || typeof request.method !== 'string') {
36
+ this.error(request?.id ?? null, -32600, 'invalid request');
37
+ return;
38
+ }
39
+ const notification = !('id' in request);
40
+ const id = request.id ?? null;
41
+ const params = (request.params ?? {});
42
+ switch (request.method) {
43
+ case 'initialize': {
44
+ const asked = typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : '';
45
+ this.reply(id, {
46
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
47
+ capabilities: { tools: {} },
48
+ serverInfo: { name: 'crowdmemory', version: ADAPTER_VERSION },
49
+ });
50
+ return;
51
+ }
52
+ case 'ping':
53
+ this.reply(id, {});
54
+ return;
55
+ case 'tools/list':
56
+ // Codex discovers tools through MCP tools/list.
57
+ this.reply(id, { tools: [...this.tools.values()].map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
58
+ return;
59
+ case 'tools/call': {
60
+ const tool = this.tools.get(String(params['name'] ?? ''));
61
+ if (tool === undefined) {
62
+ this.error(id, -32602, `unknown tool ${String(params['name'] ?? '')}`);
63
+ return;
64
+ }
65
+ const args = params['arguments'] !== null && typeof params['arguments'] === 'object' && !Array.isArray(params['arguments'])
66
+ ? params['arguments'] : {};
67
+ let text;
68
+ try {
69
+ text = await tool.execute(args, String(id));
70
+ }
71
+ catch {
72
+ text = '记忆暂不可用 (memory temporarily unavailable: internal_adapter_error). Continue without it.';
73
+ }
74
+ this.reply(id, { content: [{ type: 'text', text }], isError: false });
75
+ return;
76
+ }
77
+ default:
78
+ if (!notification)
79
+ this.error(id, -32601, `method not found: ${request.method}`);
80
+ }
81
+ }
82
+ }
83
+ export function serve() {
84
+ const server = new McpServer(new CodexMemory(configFromEnv()), line => { process.stdout.write(`${line}\n`); });
85
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
86
+ lines.on('line', line => {
87
+ if (line.trim() === '' || line.length > MAX_LINE)
88
+ return;
89
+ void server.handle(line);
90
+ });
91
+ lines.on('close', () => { process.exitCode = 0; });
92
+ }
93
+ if (isEntry(import.meta.url))
94
+ serve();