metame-cli 1.5.19 → 1.5.21
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/index.js +157 -80
- package/package.json +2 -2
- package/scripts/bin/bootstrap-worktree.sh +20 -0
- package/scripts/core/audit.js +190 -0
- package/scripts/core/handoff.js +780 -0
- package/scripts/core/handoff.test.js +1074 -0
- package/scripts/core/memory-model.js +183 -0
- package/scripts/core/memory-model.test.js +486 -0
- package/scripts/core/reactive-paths.js +44 -0
- package/scripts/core/reactive-paths.test.js +35 -0
- package/scripts/core/reactive-prompt.js +51 -0
- package/scripts/core/reactive-prompt.test.js +88 -0
- package/scripts/core/reactive-signal.js +40 -0
- package/scripts/core/reactive-signal.test.js +88 -0
- package/scripts/core/thread-chat-id.js +52 -0
- package/scripts/core/thread-chat-id.test.js +113 -0
- package/scripts/daemon-bridges.js +92 -38
- package/scripts/daemon-claude-engine.js +373 -444
- package/scripts/daemon-command-router.js +82 -8
- package/scripts/daemon-engine-runtime.js +7 -10
- package/scripts/daemon-reactive-lifecycle.js +100 -33
- package/scripts/daemon-session-commands.js +133 -43
- package/scripts/daemon-session-store.js +300 -82
- package/scripts/daemon-team-dispatch.js +16 -16
- package/scripts/daemon.js +21 -175
- package/scripts/deploy-manifest.js +90 -0
- package/scripts/docs/maintenance-manual.md +14 -11
- package/scripts/docs/pointer-map.md +13 -4
- package/scripts/feishu-adapter.js +31 -27
- package/scripts/hooks/intent-engine.js +6 -3
- package/scripts/hooks/intent-memory-recall.js +1 -0
- package/scripts/hooks/intent-perpetual.js +1 -1
- package/scripts/memory-extract.js +5 -97
- package/scripts/memory-gc.js +35 -90
- package/scripts/memory-migrate-v2.js +304 -0
- package/scripts/memory-nightly-reflect.js +40 -41
- package/scripts/memory.js +340 -859
- package/scripts/migrate-reactive-paths.js +122 -0
- package/scripts/signal-capture.js +4 -0
- package/scripts/sync-plugin.js +56 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve new per-project reactive file paths.
|
|
7
|
+
*
|
|
8
|
+
* Structure: ~/.metame/reactive/<key>/{memory,l2cache,state,events,latest}.{md,jsonl}
|
|
9
|
+
*
|
|
10
|
+
* @param {string} projectKey
|
|
11
|
+
* @param {string} metameDir - e.g. ~/.metame
|
|
12
|
+
* @returns {{ dir: string, memory: string, l2cache: string, state: string, events: string, latest: string }}
|
|
13
|
+
*/
|
|
14
|
+
function resolveReactivePaths(projectKey, metameDir) {
|
|
15
|
+
const dir = path.join(metameDir, 'reactive', projectKey);
|
|
16
|
+
return {
|
|
17
|
+
dir,
|
|
18
|
+
memory: path.join(dir, 'memory.md'),
|
|
19
|
+
l2cache: path.join(dir, 'l2cache.md'),
|
|
20
|
+
state: path.join(dir, 'state.md'),
|
|
21
|
+
events: path.join(dir, 'events.jsonl'),
|
|
22
|
+
latest: path.join(dir, 'latest.md'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve legacy (pre-migration) flat paths for a project.
|
|
28
|
+
* Used by the migration script to locate old files.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} projectKey
|
|
31
|
+
* @param {string} metameDir
|
|
32
|
+
* @returns {{ memory: string, l2cache: string, state: string, events: string, latest: string }}
|
|
33
|
+
*/
|
|
34
|
+
function resolveLegacyPaths(projectKey, metameDir) {
|
|
35
|
+
return {
|
|
36
|
+
memory: path.join(metameDir, 'memory', 'now', `${projectKey}_memory.md`),
|
|
37
|
+
l2cache: path.join(metameDir, 'memory', 'now', `${projectKey}_l2cache.md`),
|
|
38
|
+
state: path.join(metameDir, 'memory', 'now', `${projectKey}.md`),
|
|
39
|
+
events: path.join(metameDir, 'events', `${projectKey}.jsonl`),
|
|
40
|
+
latest: path.join(metameDir, 'memory', 'agents', `${projectKey}_latest.md`),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { resolveReactivePaths, resolveLegacyPaths };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { resolveReactivePaths, resolveLegacyPaths } = require('./reactive-paths');
|
|
7
|
+
|
|
8
|
+
describe('resolveReactivePaths', () => {
|
|
9
|
+
it('returns correct directory structure', () => {
|
|
10
|
+
const p = resolveReactivePaths('scientist', '/home/user/.metame');
|
|
11
|
+
assert.equal(p.dir, path.join('/home/user/.metame', 'reactive', 'scientist'));
|
|
12
|
+
assert.equal(p.memory, path.join(p.dir, 'memory.md'));
|
|
13
|
+
assert.equal(p.l2cache, path.join(p.dir, 'l2cache.md'));
|
|
14
|
+
assert.equal(p.state, path.join(p.dir, 'state.md'));
|
|
15
|
+
assert.equal(p.events, path.join(p.dir, 'events.jsonl'));
|
|
16
|
+
assert.equal(p.latest, path.join(p.dir, 'latest.md'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('works with different keys', () => {
|
|
20
|
+
const p = resolveReactivePaths('my_project', '/tmp/meta');
|
|
21
|
+
assert.equal(p.dir, path.join('/tmp/meta', 'reactive', 'my_project'));
|
|
22
|
+
assert.equal(p.events, path.join('/tmp/meta', 'reactive', 'my_project', 'events.jsonl'));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('resolveLegacyPaths', () => {
|
|
27
|
+
it('returns flat legacy paths', () => {
|
|
28
|
+
const p = resolveLegacyPaths('scientist', '/home/user/.metame');
|
|
29
|
+
assert.equal(p.memory, path.join('/home/user/.metame', 'memory', 'now', 'scientist_memory.md'));
|
|
30
|
+
assert.equal(p.l2cache, path.join('/home/user/.metame', 'memory', 'now', 'scientist_l2cache.md'));
|
|
31
|
+
assert.equal(p.state, path.join('/home/user/.metame', 'memory', 'now', 'scientist.md'));
|
|
32
|
+
assert.equal(p.events, path.join('/home/user/.metame', 'events', 'scientist.jsonl'));
|
|
33
|
+
assert.equal(p.latest, path.join('/home/user/.metame', 'memory', 'agents', 'scientist_latest.md'));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/reactive-prompt.js — Pure function to wrap reactive prompts
|
|
5
|
+
*
|
|
6
|
+
* Injects reactive mode instructions, working memory, and retry warnings
|
|
7
|
+
* into agent prompts. Zero I/O, zero side effects.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a reactive-mode prompt wrapper around the original prompt.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} originalPrompt - The original prompt text
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {number} opts.depth - Current reactive depth
|
|
16
|
+
* @param {number} opts.maxDepth - Maximum allowed depth
|
|
17
|
+
* @param {string} opts.completionSignal - Signal string for mission completion
|
|
18
|
+
* @param {string} [opts.workingMemory] - Optional working memory content
|
|
19
|
+
* @param {boolean} [opts.isRetry] - Whether this is a retry after no signal
|
|
20
|
+
* @returns {string} Wrapped prompt string
|
|
21
|
+
*/
|
|
22
|
+
function buildReactivePrompt(originalPrompt, opts) {
|
|
23
|
+
const { depth, maxDepth, completionSignal, workingMemory, isRetry } = opts;
|
|
24
|
+
|
|
25
|
+
const parts = [];
|
|
26
|
+
|
|
27
|
+
parts.push(`[REACTIVE MODE] depth ${depth}/${maxDepth}`);
|
|
28
|
+
parts.push('Rules:');
|
|
29
|
+
parts.push('1. After completing the current step, you MUST output NEXT_DISPATCH: <target> "<prompt>" to trigger the next step');
|
|
30
|
+
parts.push(`2. Only output ${completionSignal} when ALL objectives are achieved`);
|
|
31
|
+
parts.push('3. Do NOT exit silently — failing to output a signal means the task chain breaks');
|
|
32
|
+
|
|
33
|
+
if (isRetry) {
|
|
34
|
+
parts.push('');
|
|
35
|
+
parts.push('Warning: you did not output any signal in the previous round, causing task interruption. Check progress and continue.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (workingMemory && workingMemory.trim()) {
|
|
39
|
+
parts.push('');
|
|
40
|
+
parts.push('[Working Memory]');
|
|
41
|
+
parts.push(workingMemory.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
parts.push('');
|
|
45
|
+
parts.push('---');
|
|
46
|
+
parts.push(originalPrompt);
|
|
47
|
+
|
|
48
|
+
return parts.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { buildReactivePrompt };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { buildReactivePrompt } = require('./reactive-prompt');
|
|
6
|
+
|
|
7
|
+
describe('buildReactivePrompt', () => {
|
|
8
|
+
it('wraps prompt with reactive mode header (no memory, no retry)', () => {
|
|
9
|
+
const result = buildReactivePrompt('Do the task', {
|
|
10
|
+
depth: 3,
|
|
11
|
+
maxDepth: 50,
|
|
12
|
+
completionSignal: 'MISSION_COMPLETE',
|
|
13
|
+
});
|
|
14
|
+
assert.ok(result.includes('[REACTIVE MODE] depth 3/50'));
|
|
15
|
+
assert.ok(result.includes('NEXT_DISPATCH'));
|
|
16
|
+
assert.ok(result.includes('MISSION_COMPLETE'));
|
|
17
|
+
assert.ok(result.includes('Do the task'));
|
|
18
|
+
assert.ok(!result.includes('[Working Memory]'));
|
|
19
|
+
assert.ok(!result.includes('Warning:'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('injects working memory when provided', () => {
|
|
23
|
+
const result = buildReactivePrompt('Do the task', {
|
|
24
|
+
depth: 1,
|
|
25
|
+
maxDepth: 10,
|
|
26
|
+
completionSignal: 'DONE',
|
|
27
|
+
workingMemory: '## Recent Decisions\n- chose option A',
|
|
28
|
+
});
|
|
29
|
+
assert.ok(result.includes('[Working Memory]'));
|
|
30
|
+
assert.ok(result.includes('## Recent Decisions'));
|
|
31
|
+
assert.ok(result.includes('chose option A'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('includes retry warning when isRetry is true', () => {
|
|
35
|
+
const result = buildReactivePrompt('Check progress', {
|
|
36
|
+
depth: 5,
|
|
37
|
+
maxDepth: 50,
|
|
38
|
+
completionSignal: 'MISSION_COMPLETE',
|
|
39
|
+
isRetry: true,
|
|
40
|
+
});
|
|
41
|
+
assert.ok(result.includes('Warning:'));
|
|
42
|
+
assert.ok(result.includes('previous round'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not inject Working Memory block when workingMemory is empty string', () => {
|
|
46
|
+
const result = buildReactivePrompt('Do stuff', {
|
|
47
|
+
depth: 2,
|
|
48
|
+
maxDepth: 20,
|
|
49
|
+
completionSignal: 'MISSION_COMPLETE',
|
|
50
|
+
workingMemory: '',
|
|
51
|
+
});
|
|
52
|
+
assert.ok(!result.includes('[Working Memory]'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does not inject Working Memory block when workingMemory is whitespace only', () => {
|
|
56
|
+
const result = buildReactivePrompt('Do stuff', {
|
|
57
|
+
depth: 2,
|
|
58
|
+
maxDepth: 20,
|
|
59
|
+
completionSignal: 'MISSION_COMPLETE',
|
|
60
|
+
workingMemory: ' \n ',
|
|
61
|
+
});
|
|
62
|
+
assert.ok(!result.includes('[Working Memory]'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not inject Working Memory block when workingMemory is undefined', () => {
|
|
66
|
+
const result = buildReactivePrompt('Do stuff', {
|
|
67
|
+
depth: 2,
|
|
68
|
+
maxDepth: 20,
|
|
69
|
+
completionSignal: 'MISSION_COMPLETE',
|
|
70
|
+
workingMemory: undefined,
|
|
71
|
+
});
|
|
72
|
+
assert.ok(!result.includes('[Working Memory]'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('includes both retry warning and working memory when both present', () => {
|
|
76
|
+
const result = buildReactivePrompt('Continue', {
|
|
77
|
+
depth: 4,
|
|
78
|
+
maxDepth: 50,
|
|
79
|
+
completionSignal: 'MISSION_COMPLETE',
|
|
80
|
+
workingMemory: 'Some context',
|
|
81
|
+
isRetry: true,
|
|
82
|
+
});
|
|
83
|
+
assert.ok(result.includes('Warning:'));
|
|
84
|
+
assert.ok(result.includes('[Working Memory]'));
|
|
85
|
+
assert.ok(result.includes('Some context'));
|
|
86
|
+
assert.ok(result.includes('Continue'));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/reactive-signal.js — Pure function for no-signal decision logic
|
|
5
|
+
*
|
|
6
|
+
* Determines the next action when a reactive agent completes output:
|
|
7
|
+
* proceed, retry (if no signal detected), or pause (after max retries).
|
|
8
|
+
* Zero I/O, zero side effects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calculate the next action based on signal presence and retry count.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} params
|
|
15
|
+
* @param {boolean} params.hasSignals - Whether any signals were detected in output
|
|
16
|
+
* @param {boolean} params.isComplete - Whether completion signal was detected
|
|
17
|
+
* @param {number} params.noSignalCount - Current consecutive no-signal count
|
|
18
|
+
* @param {number} params.maxRetries - Maximum retries before pausing
|
|
19
|
+
* @returns {{ action: 'proceed'|'retry'|'pause', nextNoSignalCount: number, pauseReason?: string }}
|
|
20
|
+
*/
|
|
21
|
+
function calculateNextAction({ hasSignals, isComplete, noSignalCount, maxRetries }) {
|
|
22
|
+
if (isComplete) {
|
|
23
|
+
return { action: 'proceed', nextNoSignalCount: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (hasSignals) {
|
|
27
|
+
return { action: 'proceed', nextNoSignalCount: 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// No signals detected
|
|
31
|
+
const nextCount = noSignalCount + 1;
|
|
32
|
+
|
|
33
|
+
if (nextCount >= maxRetries) {
|
|
34
|
+
return { action: 'pause', nextNoSignalCount: nextCount, pauseReason: 'no_signal_repeated' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { action: 'retry', nextNoSignalCount: nextCount };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { calculateNextAction };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { calculateNextAction } = require('./reactive-signal');
|
|
6
|
+
|
|
7
|
+
describe('calculateNextAction', () => {
|
|
8
|
+
it('returns proceed with count=0 when isComplete', () => {
|
|
9
|
+
const result = calculateNextAction({
|
|
10
|
+
hasSignals: false,
|
|
11
|
+
isComplete: true,
|
|
12
|
+
noSignalCount: 2,
|
|
13
|
+
maxRetries: 3,
|
|
14
|
+
});
|
|
15
|
+
assert.equal(result.action, 'proceed');
|
|
16
|
+
assert.equal(result.nextNoSignalCount, 0);
|
|
17
|
+
assert.equal(result.pauseReason, undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns proceed with count=0 when hasSignals (not complete)', () => {
|
|
21
|
+
const result = calculateNextAction({
|
|
22
|
+
hasSignals: true,
|
|
23
|
+
isComplete: false,
|
|
24
|
+
noSignalCount: 1,
|
|
25
|
+
maxRetries: 3,
|
|
26
|
+
});
|
|
27
|
+
assert.equal(result.action, 'proceed');
|
|
28
|
+
assert.equal(result.nextNoSignalCount, 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns retry when no signals and count+1 < maxRetries', () => {
|
|
32
|
+
const result = calculateNextAction({
|
|
33
|
+
hasSignals: false,
|
|
34
|
+
isComplete: false,
|
|
35
|
+
noSignalCount: 0,
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(result.action, 'retry');
|
|
39
|
+
assert.equal(result.nextNoSignalCount, 1);
|
|
40
|
+
assert.equal(result.pauseReason, undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns retry when count+1 is still less than maxRetries', () => {
|
|
44
|
+
const result = calculateNextAction({
|
|
45
|
+
hasSignals: false,
|
|
46
|
+
isComplete: false,
|
|
47
|
+
noSignalCount: 1,
|
|
48
|
+
maxRetries: 3,
|
|
49
|
+
});
|
|
50
|
+
assert.equal(result.action, 'retry');
|
|
51
|
+
assert.equal(result.nextNoSignalCount, 2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns pause when count+1 >= maxRetries', () => {
|
|
55
|
+
const result = calculateNextAction({
|
|
56
|
+
hasSignals: false,
|
|
57
|
+
isComplete: false,
|
|
58
|
+
noSignalCount: 2,
|
|
59
|
+
maxRetries: 3,
|
|
60
|
+
});
|
|
61
|
+
assert.equal(result.action, 'pause');
|
|
62
|
+
assert.equal(result.nextNoSignalCount, 3);
|
|
63
|
+
assert.equal(result.pauseReason, 'no_signal_repeated');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns pause when count already exceeds maxRetries', () => {
|
|
67
|
+
const result = calculateNextAction({
|
|
68
|
+
hasSignals: false,
|
|
69
|
+
isComplete: false,
|
|
70
|
+
noSignalCount: 5,
|
|
71
|
+
maxRetries: 3,
|
|
72
|
+
});
|
|
73
|
+
assert.equal(result.action, 'pause');
|
|
74
|
+
assert.equal(result.nextNoSignalCount, 6);
|
|
75
|
+
assert.equal(result.pauseReason, 'no_signal_repeated');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('isComplete takes priority over hasSignals', () => {
|
|
79
|
+
const result = calculateNextAction({
|
|
80
|
+
hasSignals: true,
|
|
81
|
+
isComplete: true,
|
|
82
|
+
noSignalCount: 2,
|
|
83
|
+
maxRetries: 3,
|
|
84
|
+
});
|
|
85
|
+
assert.equal(result.action, 'proceed');
|
|
86
|
+
assert.equal(result.nextNoSignalCount, 0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* thread-chat-id.js — Pure utilities for Feishu topic-based session isolation.
|
|
5
|
+
*
|
|
6
|
+
* Composite ID format: "thread:{chatId}:{threadId}"
|
|
7
|
+
* chatId = Feishu group chat ID (e.g. "oc_xxx")
|
|
8
|
+
* threadId = topic root message ID (e.g. "om_yyy")
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies. Zero side effects.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PREFIX = 'thread:';
|
|
14
|
+
|
|
15
|
+
function buildThreadChatId(chatId, threadId) {
|
|
16
|
+
const c = String(chatId || '').trim();
|
|
17
|
+
const t = String(threadId || '').trim();
|
|
18
|
+
if (!c || !t) return c || '';
|
|
19
|
+
return `${PREFIX}${c}:${t}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseThreadChatId(compositeId) {
|
|
23
|
+
const id = String(compositeId || '');
|
|
24
|
+
if (!id.startsWith(PREFIX)) return null;
|
|
25
|
+
const firstColon = PREFIX.length;
|
|
26
|
+
const secondColon = id.indexOf(':', firstColon);
|
|
27
|
+
if (secondColon === -1) return null;
|
|
28
|
+
const chatId = id.slice(firstColon, secondColon);
|
|
29
|
+
const threadId = id.slice(secondColon + 1);
|
|
30
|
+
if (!chatId || !threadId) return null;
|
|
31
|
+
return { chatId, threadId };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isThreadChatId(id) {
|
|
35
|
+
return typeof id === 'string' && id.startsWith(PREFIX) && parseThreadChatId(id) !== null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the raw Feishu chat ID regardless of whether the input
|
|
40
|
+
* is a composite thread ID or a plain chat ID.
|
|
41
|
+
*/
|
|
42
|
+
function rawChatId(id) {
|
|
43
|
+
const parsed = parseThreadChatId(id);
|
|
44
|
+
return parsed ? parsed.chatId : String(id || '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
buildThreadChatId,
|
|
49
|
+
parseThreadChatId,
|
|
50
|
+
isThreadChatId,
|
|
51
|
+
rawChatId,
|
|
52
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const {
|
|
6
|
+
buildThreadChatId,
|
|
7
|
+
parseThreadChatId,
|
|
8
|
+
isThreadChatId,
|
|
9
|
+
rawChatId,
|
|
10
|
+
} = require('./thread-chat-id');
|
|
11
|
+
|
|
12
|
+
describe('buildThreadChatId', () => {
|
|
13
|
+
it('builds composite ID from chatId and threadId', () => {
|
|
14
|
+
assert.equal(buildThreadChatId('oc_123', 'om_abc'), 'thread:oc_123:om_abc');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('trims whitespace', () => {
|
|
18
|
+
assert.equal(buildThreadChatId(' oc_123 ', ' om_abc '), 'thread:oc_123:om_abc');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns plain chatId when threadId is empty', () => {
|
|
22
|
+
assert.equal(buildThreadChatId('oc_123', ''), 'oc_123');
|
|
23
|
+
assert.equal(buildThreadChatId('oc_123', null), 'oc_123');
|
|
24
|
+
assert.equal(buildThreadChatId('oc_123', undefined), 'oc_123');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty string when chatId is empty', () => {
|
|
28
|
+
assert.equal(buildThreadChatId('', 'om_abc'), '');
|
|
29
|
+
assert.equal(buildThreadChatId(null, 'om_abc'), '');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns empty string when both are empty', () => {
|
|
33
|
+
assert.equal(buildThreadChatId('', ''), '');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('parseThreadChatId', () => {
|
|
38
|
+
it('parses valid composite ID', () => {
|
|
39
|
+
assert.deepEqual(parseThreadChatId('thread:oc_123:om_abc'), {
|
|
40
|
+
chatId: 'oc_123',
|
|
41
|
+
threadId: 'om_abc',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles threadId containing colons', () => {
|
|
46
|
+
assert.deepEqual(parseThreadChatId('thread:oc_123:om_abc:extra'), {
|
|
47
|
+
chatId: 'oc_123',
|
|
48
|
+
threadId: 'om_abc:extra',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns null for plain chatId', () => {
|
|
53
|
+
assert.equal(parseThreadChatId('oc_123'), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns null for empty string', () => {
|
|
57
|
+
assert.equal(parseThreadChatId(''), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns null for null/undefined', () => {
|
|
61
|
+
assert.equal(parseThreadChatId(null), null);
|
|
62
|
+
assert.equal(parseThreadChatId(undefined), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns null when prefix present but missing parts', () => {
|
|
66
|
+
assert.equal(parseThreadChatId('thread:'), null);
|
|
67
|
+
assert.equal(parseThreadChatId('thread:oc_123'), null);
|
|
68
|
+
assert.equal(parseThreadChatId('thread::om_abc'), null);
|
|
69
|
+
assert.equal(parseThreadChatId('thread:oc_123:'), null);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('isThreadChatId', () => {
|
|
74
|
+
it('returns true for valid composite ID', () => {
|
|
75
|
+
assert.equal(isThreadChatId('thread:oc_123:om_abc'), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns false for plain chatId', () => {
|
|
79
|
+
assert.equal(isThreadChatId('oc_123'), false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns false for malformed thread prefix', () => {
|
|
83
|
+
assert.equal(isThreadChatId('thread:'), false);
|
|
84
|
+
assert.equal(isThreadChatId('thread:oc_123'), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns false for non-string', () => {
|
|
88
|
+
assert.equal(isThreadChatId(null), false);
|
|
89
|
+
assert.equal(isThreadChatId(123), false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('rawChatId', () => {
|
|
94
|
+
it('extracts chatId from composite', () => {
|
|
95
|
+
assert.equal(rawChatId('thread:oc_123:om_abc'), 'oc_123');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns plain chatId as-is', () => {
|
|
99
|
+
assert.equal(rawChatId('oc_123'), 'oc_123');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns empty string for null', () => {
|
|
103
|
+
assert.equal(rawChatId(null), '');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('round-trip', () => {
|
|
108
|
+
it('build then parse preserves values', () => {
|
|
109
|
+
const built = buildThreadChatId('oc_foo', 'om_bar');
|
|
110
|
+
const parsed = parseThreadChatId(built);
|
|
111
|
+
assert.deepEqual(parsed, { chatId: 'oc_foo', threadId: 'om_bar' });
|
|
112
|
+
});
|
|
113
|
+
});
|