verbalcoding 0.2.12 → 0.2.13
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/.env.example +74 -4
- package/README.es.md +3 -1
- package/README.fr.md +3 -1
- package/README.ja.md +3 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/app-node/agent_adapters.test.mjs +14 -0
- package/app-node/agent_routing.mjs +148 -0
- package/app-node/agent_routing.test.mjs +138 -0
- package/app-node/agent_turn.mjs +86 -0
- package/app-node/agent_turn.test.mjs +109 -0
- package/app-node/bridge_context.mjs +73 -0
- package/app-node/bridge_context.test.mjs +54 -0
- package/app-node/bridge_state.mjs +4 -0
- package/app-node/bridge_wireup.test.mjs +462 -0
- package/app-node/cli_install.test.mjs +31 -0
- package/app-node/cross_agent_routing.test.mjs +78 -0
- package/app-node/discord_command_router.mjs +204 -0
- package/app-node/discord_command_router.test.mjs +311 -0
- package/app-node/discord_voice_setup.mjs +251 -0
- package/app-node/discord_voice_setup.test.mjs +86 -0
- package/app-node/hermes_profiles.test.mjs +12 -1
- package/app-node/install_config.mjs +110 -3
- package/app-node/install_config.test.mjs +8 -0
- package/app-node/instance_doctor.test.mjs +9 -0
- package/app-node/instances.test.mjs +8 -1
- package/app-node/main.mjs +488 -1368
- package/app-node/mcp_tools.test.mjs +7 -0
- package/app-node/notification_handler.mjs +89 -0
- package/app-node/notification_handler.test.mjs +187 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +36 -7
- package/app-node/plan_mode.test.mjs +78 -0
- package/app-node/progress_handler.mjs +220 -0
- package/app-node/progress_handler.test.mjs +193 -0
- package/app-node/progress_speech.mjs +54 -32
- package/app-node/progress_speech.test.mjs +12 -3
- package/app-node/project_sessions.mjs +5 -2
- package/app-node/project_sessions.test.mjs +7 -0
- package/app-node/research_mode.mjs +282 -0
- package/app-node/research_mode.test.mjs +264 -0
- package/app-node/restart_notice.mjs +3 -0
- package/app-node/restart_notice.test.mjs +11 -0
- package/app-node/session_ontology.mjs +271 -0
- package/app-node/session_ontology.test.mjs +130 -0
- package/app-node/smart_progress.mjs +1 -1
- package/app-node/stream_sentencer.mjs +32 -2
- package/app-node/stream_sentencer.test.mjs +65 -0
- package/app-node/streaming_tts_queue.mjs +5 -1
- package/app-node/streaming_tts_queue.test.mjs +7 -1
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +4 -2
- package/app-node/tts_backends.mjs +537 -3
- package/app-node/tts_backends.test.mjs +454 -0
- package/app-node/tts_player.mjs +164 -0
- package/app-node/tts_player.test.mjs +202 -0
- package/app-node/tts_runtime.mjs +134 -0
- package/app-node/tts_runtime.test.mjs +89 -0
- package/app-node/tts_settings.mjs +150 -3
- package/app-node/tts_settings.test.mjs +204 -0
- package/app-node/tts_voice_config.mjs +136 -2
- package/app-node/tts_voice_config.test.mjs +94 -0
- package/app-node/utterance_router.mjs +216 -0
- package/app-node/utterance_router.test.mjs +236 -0
- package/app-node/voice_autojoin.mjs +37 -0
- package/app-node/voice_autojoin.test.mjs +59 -0
- package/app-node/voice_io.mjs +272 -0
- package/app-node/voice_io.test.mjs +102 -0
- package/app-node/voice_turn_runner.mjs +449 -0
- package/app-node/voice_turn_runner.test.mjs +289 -0
- package/docs/CONFIGURATION.md +12 -2
- package/docs/HARNESSES.md +58 -0
- package/docs/HARNESS_AIDER.md +50 -0
- package/docs/HARNESS_CLAUDE.md +56 -0
- package/docs/HARNESS_CODEX.md +56 -0
- package/docs/HARNESS_CURSOR.md +45 -0
- package/docs/HARNESS_GEMINI.md +45 -0
- package/docs/HARNESS_HERMES.md +57 -0
- package/docs/HARNESS_OPENCLAW.md +44 -0
- package/docs/HARNESS_OPENCODE.md +44 -0
- package/docs/README.md +1 -0
- package/docs/ROADMAP.md +20 -5
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +22 -0
- package/docs/i18n/AGENTS.es.md +34 -0
- package/docs/i18n/AGENTS.fr.md +34 -0
- package/docs/i18n/AGENTS.ja.md +34 -0
- package/docs/i18n/AGENTS.ko.md +34 -0
- package/docs/i18n/AGENTS.ru.md +34 -0
- package/docs/i18n/AGENTS.zh.md +34 -0
- package/docs/i18n/HARNESSES.es.md +58 -0
- package/docs/i18n/HARNESSES.fr.md +58 -0
- package/docs/i18n/HARNESSES.ja.md +58 -0
- package/docs/i18n/HARNESSES.ko.md +58 -0
- package/docs/i18n/HARNESSES.ru.md +58 -0
- package/docs/i18n/HARNESSES.zh.md +58 -0
- package/docs/i18n/HARNESS_AIDER.es.md +48 -0
- package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
- package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
- package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
- package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
- package/docs/i18n/HARNESS_CODEX.es.md +55 -0
- package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
- package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
- package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
- package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
- package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
- package/docs/i18n/HARNESS_HERMES.es.md +54 -0
- package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
- package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
- package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
- package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
- package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
- package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
- package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
- package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
- package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
- package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
- package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
- package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
- package/integrations/fireredtts2/mlx_llm.py +183 -0
- package/integrations/fireredtts2/synth.py +156 -0
- package/integrations/fireredtts2/synth_mlx.py +196 -0
- package/integrations/mlxaudio/synth.py +74 -0
- package/integrations/neuttsair/synth.py +104 -0
- package/integrations/omnivoice/synth.py +110 -0
- package/package.json +6 -1
- package/scripts/cli.mjs +84 -0
- package/scripts/doctor.mjs +104 -4
- package/scripts/install.mjs +5 -1
- package/scripts/install_fireredtts2.sh +109 -0
- package/scripts/install_mlxaudio.sh +34 -0
- package/scripts/install_mossttsnano.sh +46 -0
- package/scripts/postinstall.mjs +34 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
parseResearchCommand,
|
|
5
|
+
buildSynthesisPrompt,
|
|
6
|
+
parseSynthesisOutput,
|
|
7
|
+
renderResearchSpeech,
|
|
8
|
+
renderResearchMarkdown,
|
|
9
|
+
buildResearchEmbed,
|
|
10
|
+
runResearchTurn,
|
|
11
|
+
selectSearchBackend,
|
|
12
|
+
buildAgentDelegatedResearchPrompt,
|
|
13
|
+
parseAgentDelegatedOutput,
|
|
14
|
+
} from './research_mode.mjs';
|
|
15
|
+
|
|
16
|
+
test('parseResearchCommand picks up English "research X"', () => {
|
|
17
|
+
const c = parseResearchCommand('research the latest on STORM', 'en');
|
|
18
|
+
assert.equal(c.type, 'research');
|
|
19
|
+
assert.match(c.query, /STORM/);
|
|
20
|
+
assert.equal(c.depth, 'quick');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('parseResearchCommand flags deep mode', () => {
|
|
24
|
+
const c = parseResearchCommand('deep research GraphRAG community detection', 'en');
|
|
25
|
+
assert.equal(c.type, 'research');
|
|
26
|
+
assert.equal(c.depth, 'deep');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseResearchCommand recognizes Korean phrasing', () => {
|
|
30
|
+
const c = parseResearchCommand('Mem0 한번 리서치해줘', 'ko');
|
|
31
|
+
assert.equal(c.type, 'research');
|
|
32
|
+
assert.match(c.query, /Mem0/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parseResearchCommand returns none for unrelated input', () => {
|
|
36
|
+
assert.equal(parseResearchCommand('write a unit test for parseDecisionAnswer', 'en').type, 'none');
|
|
37
|
+
assert.equal(parseResearchCommand('hi how are you', 'en').type, 'none');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('parseResearchCommand does not hijack coding utterances containing the word "research"', () => {
|
|
41
|
+
assert.equal(parseResearchCommand('implement research mode', 'en').type, 'none');
|
|
42
|
+
assert.equal(parseResearchCommand('fix the research_mode regex', 'en').type, 'none');
|
|
43
|
+
assert.equal(parseResearchCommand('add docs about research turn', 'en').type, 'none');
|
|
44
|
+
assert.equal(parseResearchCommand('리서치 모드 코드 좀 봐', 'ko').type, 'none');
|
|
45
|
+
assert.equal(parseResearchCommand('리서치 코드 리팩토링해줘', 'ko').type, 'none');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('parseResearchCommand still triggers on agent-routed forms', () => {
|
|
49
|
+
const c = parseResearchCommand('ask codex to research Node.js streams', 'en');
|
|
50
|
+
assert.equal(c.type, 'research');
|
|
51
|
+
assert.equal(c.query, 'Node.js streams');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('parseResearchCommand preserves internal punctuation in technical queries', () => {
|
|
55
|
+
const cases = [
|
|
56
|
+
['research Node.js streams', 'Node.js streams'],
|
|
57
|
+
['look up GPT-4.1 vs Claude 4.7', 'GPT-4.1 vs Claude 4.7'],
|
|
58
|
+
['research https://example.com/foo?bar=1', 'https://example.com/foo?bar=1'],
|
|
59
|
+
['research what is 1.5 vs 2.0', 'what is 1.5 vs 2.0'],
|
|
60
|
+
];
|
|
61
|
+
for (const [input, expected] of cases) {
|
|
62
|
+
const c = parseResearchCommand(input, 'en');
|
|
63
|
+
assert.equal(c.type, 'research', `failed parse for ${input}`);
|
|
64
|
+
assert.equal(c.query, expected, `expected '${expected}' from '${input}', got '${c.query}'`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('parseResearchCommand strips only trailing sentence punctuation', () => {
|
|
69
|
+
const c = parseResearchCommand('research Node.js streams.', 'en');
|
|
70
|
+
assert.equal(c.query, 'Node.js streams');
|
|
71
|
+
const q = parseResearchCommand('research GPT-4.1?', 'en');
|
|
72
|
+
assert.equal(q.query, 'GPT-4.1');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('buildSynthesisPrompt embeds query and sources', () => {
|
|
76
|
+
const p = buildSynthesisPrompt({
|
|
77
|
+
query: 'STORM autoresearch',
|
|
78
|
+
sources: [{ title: 'STORM paper', url: 'https://arxiv.org/abs/2402.14207', snippet: 'Wikipedia-style writing' }],
|
|
79
|
+
language: 'en',
|
|
80
|
+
});
|
|
81
|
+
assert.match(p, /STORM autoresearch/);
|
|
82
|
+
assert.match(p, /\[1\] STORM paper/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('parseSynthesisOutput extracts bullets and sources block', () => {
|
|
86
|
+
const text = [
|
|
87
|
+
'- STORM uses perspective-grounded Q&A to draft long-form articles.',
|
|
88
|
+
'- It outperforms naive RAG on outline organization metrics.',
|
|
89
|
+
'- Known failure mode is over-association of unrelated facts.',
|
|
90
|
+
'',
|
|
91
|
+
'SOURCES:',
|
|
92
|
+
'[1] STORM paper',
|
|
93
|
+
'[2] STORM repo',
|
|
94
|
+
].join('\n');
|
|
95
|
+
const { bullets, sourceLines } = parseSynthesisOutput(text);
|
|
96
|
+
assert.equal(bullets.length, 3);
|
|
97
|
+
assert.equal(sourceLines.length, 2);
|
|
98
|
+
assert.match(bullets[0], /STORM uses/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('renderResearchSpeech joins intro plus bullets and tags with prefix', () => {
|
|
102
|
+
const speech = renderResearchSpeech(['A.', 'B.', 'C.'], 'en');
|
|
103
|
+
assert.match(speech, /^Research finding:/);
|
|
104
|
+
assert.match(speech, /A\./);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('renderResearchSpeech uses Korean prefix for ko', () => {
|
|
108
|
+
const speech = renderResearchSpeech(['ㄱ.', 'ㄴ.', 'ㄷ.'], 'ko');
|
|
109
|
+
assert.match(speech, /^리서치 결과:/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('renderResearchMarkdown numbers sources', () => {
|
|
113
|
+
const md = renderResearchMarkdown({
|
|
114
|
+
query: 'GraphRAG',
|
|
115
|
+
bullets: ['one'],
|
|
116
|
+
sources: [{ title: 'MS GraphRAG', url: 'https://example.com/g' }],
|
|
117
|
+
language: 'en',
|
|
118
|
+
});
|
|
119
|
+
assert.match(md, /1\. \[MS GraphRAG\]\(https:\/\/example\.com\/g\)/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('runResearchTurn happy path with mocked fetch and synth', async () => {
|
|
123
|
+
const env = { TAVILY_API_KEY: 'fake' };
|
|
124
|
+
const fetchImpl = async () => ({
|
|
125
|
+
ok: true,
|
|
126
|
+
json: async () => ({
|
|
127
|
+
answer: '',
|
|
128
|
+
results: [
|
|
129
|
+
{ title: 'STORM Stanford', url: 'https://arxiv.org/abs/2402.14207', content: 'Wikipedia-style article generation.' },
|
|
130
|
+
{ title: 'STORM Repo', url: 'https://github.com/stanford-oval/storm', content: 'Open-source implementation.' },
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
const synthesize = async () => '- STORM is a Wikipedia-style article writer.\n- Uses perspective-grounded Q&A.\n- Self-hostable and MIT-licensed.\n\nSOURCES:\n[1] STORM Stanford\n[2] STORM Repo';
|
|
135
|
+
const result = await runResearchTurn({ query: 'STORM autoresearch', env, fetchImpl, synthesize, language: 'en' });
|
|
136
|
+
assert.equal(result.status, 'ok');
|
|
137
|
+
assert.equal(result.bullets.length, 3);
|
|
138
|
+
assert.equal(result.sources.length, 2);
|
|
139
|
+
assert.match(result.speech, /STORM is a Wikipedia-style/);
|
|
140
|
+
assert.match(result.markdown, /arxiv\.org/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('runResearchTurn reports no_backend when both keys missing', async () => {
|
|
144
|
+
const result = await runResearchTurn({ query: 'x', env: {}, fetchImpl: async () => {}, synthesize: async () => '' });
|
|
145
|
+
assert.equal(result.status, 'no_backend');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('buildResearchEmbed numbers source fields', () => {
|
|
149
|
+
const embed = buildResearchEmbed({
|
|
150
|
+
query: 'GraphRAG',
|
|
151
|
+
bullets: ['summary'],
|
|
152
|
+
sources: [
|
|
153
|
+
{ title: 'MS GraphRAG', url: 'https://example.com/g' },
|
|
154
|
+
{ title: 'LightRAG', url: 'https://example.com/l' },
|
|
155
|
+
],
|
|
156
|
+
language: 'en',
|
|
157
|
+
});
|
|
158
|
+
assert.match(embed.title, /Research: GraphRAG/);
|
|
159
|
+
assert.equal(embed.fields.length, 2);
|
|
160
|
+
assert.match(embed.fields[0].name, /^\[1\] MS GraphRAG/);
|
|
161
|
+
assert.equal(embed.fields[1].value, 'https://example.com/l');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('selectSearchBackend honors explicit SEARCH_BACKEND override', () => {
|
|
165
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'agent' }).kind, 'agent');
|
|
166
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'tavily', TAVILY_API_KEY: 'k' }).kind, 'tavily');
|
|
167
|
+
// explicit pick without matching key falls through to discovery order
|
|
168
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'brave' }).kind, 'none');
|
|
169
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'brave', TAVILY_API_KEY: 'k' }).kind, 'tavily');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('selectSearchBackend default priority is tavily → brave → searxng → none', () => {
|
|
173
|
+
assert.equal(selectSearchBackend({}).kind, 'none');
|
|
174
|
+
assert.equal(selectSearchBackend({ SEARXNG_URL: 'http://localhost:8888' }).kind, 'searxng');
|
|
175
|
+
assert.equal(selectSearchBackend({ BRAVE_API_KEY: 'b', SEARXNG_URL: 'http://x' }).kind, 'brave');
|
|
176
|
+
assert.equal(selectSearchBackend({ TAVILY_API_KEY: 't', BRAVE_API_KEY: 'b' }).kind, 'tavily');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('selectSearchBackend uses agent fallback opt-in when no key is present', () => {
|
|
180
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND_AGENT_FALLBACK: '1' }).kind, 'agent');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('runResearchTurn with SEARXNG_URL builds JSON request and synthesises bullets', async () => {
|
|
184
|
+
const env = { SEARXNG_URL: 'http://searx.local' };
|
|
185
|
+
const captured = [];
|
|
186
|
+
const fetchImpl = async (url) => {
|
|
187
|
+
captured.push(url);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
json: async () => ({
|
|
191
|
+
results: [
|
|
192
|
+
{ title: 'LightRAG', url: 'https://arxiv.org/abs/2410.05779', content: 'LightRAG dual-level retrieval.' },
|
|
193
|
+
{ title: 'HippoRAG', url: 'https://arxiv.org/abs/2405.14831', content: 'PPR-based memory.' },
|
|
194
|
+
],
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
const synthesize = async () => '- LightRAG is dual-level retrieval.\n- HippoRAG uses PPR.\n- GraphRAG uses Leiden clusters.\n\nSOURCES:\n[1] LightRAG\n[2] HippoRAG';
|
|
199
|
+
const result = await runResearchTurn({ query: 'GraphRAG family', env, fetchImpl, synthesize, language: 'en' });
|
|
200
|
+
assert.equal(result.status, 'ok');
|
|
201
|
+
assert.equal(result.sources.length, 2);
|
|
202
|
+
assert.equal(result.bullets.length, 3);
|
|
203
|
+
assert.match(captured[0], /searx\.local\/search\?.*q=GraphRAG/);
|
|
204
|
+
assert.match(captured[0], /format=json/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('runResearchTurn agent-delegated path skips performSearch and parses agent output', async () => {
|
|
208
|
+
const env = { SEARCH_BACKEND: 'agent' };
|
|
209
|
+
let fetchCalled = false;
|
|
210
|
+
const fetchImpl = async () => { fetchCalled = true; return { ok: false, json: async () => ({}) }; };
|
|
211
|
+
const synthCalls = [];
|
|
212
|
+
const synthesize = async (prompt, opts) => {
|
|
213
|
+
synthCalls.push({ prompt, opts });
|
|
214
|
+
return [
|
|
215
|
+
'- ACE evolves a per-project playbook that beats fine-tuning.',
|
|
216
|
+
'- It compounds across sessions without changing model weights.',
|
|
217
|
+
'- Open implementation available from the paper authors.',
|
|
218
|
+
'',
|
|
219
|
+
'SOURCES:',
|
|
220
|
+
'[1] ACE paper | https://arxiv.org/abs/2510.04618',
|
|
221
|
+
'[2] Project README | https://github.com/example/ace-repo',
|
|
222
|
+
].join('\n');
|
|
223
|
+
};
|
|
224
|
+
const result = await runResearchTurn({ query: 'Agentic Context Engineering ACE', env, fetchImpl, synthesize, language: 'en' });
|
|
225
|
+
assert.equal(fetchCalled, false);
|
|
226
|
+
assert.equal(result.status, 'ok');
|
|
227
|
+
assert.equal(result.backend, 'agent');
|
|
228
|
+
assert.equal(result.bullets.length, 3);
|
|
229
|
+
assert.equal(result.sources.length, 2);
|
|
230
|
+
assert.equal(result.sources[0].url, 'https://arxiv.org/abs/2510.04618');
|
|
231
|
+
assert.equal(synthCalls[0].opts.task, true, 'agent-delegated synthesise call sets task=true for longer timeout');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('parseAgentDelegatedOutput tolerates dash separators and missing brackets', () => {
|
|
235
|
+
const text = [
|
|
236
|
+
'- A.',
|
|
237
|
+
'- B.',
|
|
238
|
+
'- C.',
|
|
239
|
+
'',
|
|
240
|
+
'SOURCES:',
|
|
241
|
+
'[1] Foo paper — https://example.com/foo',
|
|
242
|
+
'Bar - https://example.com/bar',
|
|
243
|
+
].join('\n');
|
|
244
|
+
const { bullets, sources } = parseAgentDelegatedOutput(text);
|
|
245
|
+
assert.equal(bullets.length, 3);
|
|
246
|
+
assert.equal(sources.length, 2);
|
|
247
|
+
assert.equal(sources[0].url, 'https://example.com/foo');
|
|
248
|
+
assert.equal(sources[1].url, 'https://example.com/bar');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('buildAgentDelegatedResearchPrompt names the query and the strict format', () => {
|
|
252
|
+
const p = buildAgentDelegatedResearchPrompt({ query: 'STORM autoresearch', language: 'en' });
|
|
253
|
+
assert.match(p, /You have web access/);
|
|
254
|
+
assert.match(p, /STORM autoresearch/);
|
|
255
|
+
assert.match(p, /SOURCES:/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('runResearchTurn handles empty search gracefully', async () => {
|
|
259
|
+
const env = { TAVILY_API_KEY: 'fake' };
|
|
260
|
+
const fetchImpl = async () => ({ ok: true, json: async () => ({ results: [] }) });
|
|
261
|
+
const result = await runResearchTurn({ query: 'X', env, fetchImpl, synthesize: async () => '', language: 'en' });
|
|
262
|
+
assert.equal(result.status, 'empty');
|
|
263
|
+
assert.match(result.speech, /could not find/);
|
|
264
|
+
});
|
|
@@ -15,6 +15,9 @@ export function cleanRestartDetail(detail = '', ttsVoice = '') {
|
|
|
15
15
|
.trim();
|
|
16
16
|
}
|
|
17
17
|
return raw
|
|
18
|
+
.replace(/\bI applied this change:\s*/igu, '')
|
|
19
|
+
.replace(/\bRestarting now\b[.!?\s]*/igu, '')
|
|
20
|
+
.replace(/\bVoice may cut out briefly\b[.!?\s]*/igu, '')
|
|
18
21
|
.replace(/이제\s*재시작할게[.!?。!?\s]*/gu, '')
|
|
19
22
|
.replace(/잠깐\s*음성이\s*끊길\s*수\s*있어[.!?。!?\s]*/gu, '')
|
|
20
23
|
.replace(/\s+/g, ' ')
|
|
@@ -35,3 +35,14 @@ test('restart detail strips restart boilerplate before formatting', () => {
|
|
|
35
35
|
'재시작 완료. 다시 온라인이야. 에이전트 안내 고쳤어.',
|
|
36
36
|
);
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
test('Korean restart detail strips stale English restart boilerplate', () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
cleanRestartDetail('I applied this change: OmniVoice 한국어 설정 반영. Restarting now. Voice may cut out briefly.', 'ko-KR-InJoonNeural'),
|
|
42
|
+
'OmniVoice 한국어 설정 반영.',
|
|
43
|
+
);
|
|
44
|
+
assert.equal(
|
|
45
|
+
formatRestartCompleteNotice('I applied this change: OmniVoice 한국어 설정 반영. Restarting now. Voice may cut out briefly.', 'ko-KR-InJoonNeural').speech,
|
|
46
|
+
'재시작 완료. 다시 온라인이야. OmniVoice 한국어 설정 반영.',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const SCHEMA_VERSION = 1;
|
|
6
|
+
const NODE_TYPES = new Set(['D', 'F', 'T', 'C', 'A', 'R']);
|
|
7
|
+
const EDGE_PREDICATES = new Set(['d', 't', 'u', 'p', 'r', 's']);
|
|
8
|
+
const TYPE_LABELS = {
|
|
9
|
+
D: { en: 'Decision', ko: '결정' },
|
|
10
|
+
F: { en: 'File', ko: '파일' },
|
|
11
|
+
T: { en: 'Tool', ko: '도구' },
|
|
12
|
+
C: { en: 'Concept', ko: '개념' },
|
|
13
|
+
A: { en: 'Agent', ko: '에이전트' },
|
|
14
|
+
R: { en: 'Result', ko: '결과' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const FILE_RE = /\b[a-zA-Z0-9_./-]+\.(?:mjs|js|ts|tsx|jsx|py|md|json|yaml|yml|toml|sh|sql|go|rs|java|kt|swift|c|cpp|h|hpp)\b/g;
|
|
18
|
+
const TOOL_RE = /\b(?:tool|command|cli|npm|yarn|pnpm|pip|cargo|docker|kubectl|gh|git|ffmpeg|whisper-cli|claude|codex|gemini|aider|cursor)\b/gi;
|
|
19
|
+
const DECISION_RE = /\b[a-z][a-z0-9_]{2,}=[\w./-]{1,40}\b/g;
|
|
20
|
+
|
|
21
|
+
function lc(value) { return String(value || '').trim().toLowerCase(); }
|
|
22
|
+
function nowSec() { return Math.floor(Date.now() / 1000); }
|
|
23
|
+
function safeChannelKey(value) { return String(value || 'default').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); }
|
|
24
|
+
function defaultRootDir() { return path.join(os.homedir(), '.verbalcoding', 'memory'); }
|
|
25
|
+
function emptyState(channelKey) {
|
|
26
|
+
return {
|
|
27
|
+
v: SCHEMA_VERSION,
|
|
28
|
+
channelKey,
|
|
29
|
+
nodes: [],
|
|
30
|
+
edges: [],
|
|
31
|
+
meta: { updatedAt: nowSec(), nodeCount: 0, edgeCount: 0 },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildExtractionPrompt({ text, language = 'en', knownNames = [] } = {}) {
|
|
36
|
+
const en = /^en/i.test(String(language || ''));
|
|
37
|
+
const lines = [];
|
|
38
|
+
lines.push(en
|
|
39
|
+
? 'Extract a tiny knowledge graph from the message below. Reply ONLY with JSON of shape {"nodes":[{"t":"D|F|T|C|A|R","n":"name"}],"edges":[{"s":"name","p":"d|t|u|p|r|s","o":"name"}]}.'
|
|
40
|
+
: '아래 메시지에서 작은 지식 그래프를 추출해. JSON만 응답: {"nodes":[{"t":"D|F|T|C|A|R","n":"name"}],"edges":[{"s":"name","p":"d|t|u|p|r|s","o":"name"}]}.');
|
|
41
|
+
lines.push(en
|
|
42
|
+
? 'Types: D=Decision (slot=value), F=File (path), T=Tool (command), C=Concept (noun), A=Agent (backend), R=Result (short summary).'
|
|
43
|
+
: '타입: D=결정(slot=value), F=파일, T=도구, C=개념, A=에이전트, R=결과 요약.');
|
|
44
|
+
lines.push(en
|
|
45
|
+
? 'Predicates: d=decided, t=touched, u=used, p=produced, r=referenced, s=superseded_by.'
|
|
46
|
+
: '엣지: d=결정, t=수정, u=사용, p=생성, r=참조, s=덮어쓰기.');
|
|
47
|
+
if (knownNames.length) {
|
|
48
|
+
lines.push(en ? `Already known names (prefer these for dedup): ${knownNames.slice(0, 20).join(', ')}` : `이미 등록된 이름(중복 방지에 우선): ${knownNames.slice(0, 20).join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push(en ? `Message:\n${text}` : `메시지:\n${text}`);
|
|
51
|
+
lines.push(en ? 'Return ONLY the JSON object, no markdown.' : 'JSON 객체만 반환, 마크다운 없이.');
|
|
52
|
+
return lines.join('\n\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseExtractionJson(raw) {
|
|
56
|
+
const t = String(raw || '').trim();
|
|
57
|
+
const fenceMatch = t.match(/```(?:json)?\s*([\s\S]+?)```/);
|
|
58
|
+
const body = fenceMatch ? fenceMatch[1] : t;
|
|
59
|
+
const firstBrace = body.indexOf('{');
|
|
60
|
+
const lastBrace = body.lastIndexOf('}');
|
|
61
|
+
if (firstBrace === -1 || lastBrace <= firstBrace) return { nodes: [], edges: [] };
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(body.slice(firstBrace, lastBrace + 1));
|
|
64
|
+
const nodes = Array.isArray(parsed?.nodes) ? parsed.nodes : [];
|
|
65
|
+
const edges = Array.isArray(parsed?.edges) ? parsed.edges : [];
|
|
66
|
+
const nameToId = new Map();
|
|
67
|
+
const parsedNodes = [];
|
|
68
|
+
for (const n of nodes) {
|
|
69
|
+
if (!n || !n.t || !n.n) continue;
|
|
70
|
+
const id = `e_${n.t}_${String(n.n).toLowerCase()}`;
|
|
71
|
+
nameToId.set(String(n.n).toLowerCase(), id);
|
|
72
|
+
parsedNodes.push({ id, t: n.t, n: n.n });
|
|
73
|
+
}
|
|
74
|
+
const parsedEdges = [];
|
|
75
|
+
for (const e of edges) {
|
|
76
|
+
if (!e || !e.p) continue;
|
|
77
|
+
const s = nameToId.get(String(e.s || '').toLowerCase());
|
|
78
|
+
const o = nameToId.get(String(e.o || '').toLowerCase());
|
|
79
|
+
if (!s || !o) continue;
|
|
80
|
+
parsedEdges.push({ s, p: e.p, o });
|
|
81
|
+
}
|
|
82
|
+
return { nodes: parsedNodes, edges: parsedEdges };
|
|
83
|
+
} catch { return { nodes: [], edges: [] }; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createSessionOntology({ rootDir, channelKey, maxNodes = 40, maxEdges = 80, fsApi = fs } = {}) {
|
|
87
|
+
const root = rootDir || defaultRootDir();
|
|
88
|
+
const channel = safeChannelKey(channelKey);
|
|
89
|
+
const filePath = path.join(root, `${channel}.json`);
|
|
90
|
+
let state = emptyState(channel);
|
|
91
|
+
let nextId = 1;
|
|
92
|
+
|
|
93
|
+
function nodeKey(node) { return `${node.t}::${lc(node.n)}`; }
|
|
94
|
+
function findNodeId(t, name) {
|
|
95
|
+
const key = `${t}::${lc(name)}`;
|
|
96
|
+
for (const node of state.nodes) if (nodeKey(node) === key) return node.id;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function evictIfNeeded() {
|
|
100
|
+
while (state.nodes.length > maxNodes) {
|
|
101
|
+
const idx = state.nodes.findIndex(n => n.t !== 'D');
|
|
102
|
+
if (idx === -1) break;
|
|
103
|
+
const evictedId = state.nodes[idx].id;
|
|
104
|
+
state.nodes.splice(idx, 1);
|
|
105
|
+
state.edges = state.edges.filter(e => e.s !== evictedId && e.o !== evictedId);
|
|
106
|
+
}
|
|
107
|
+
while (state.edges.length > maxEdges) state.edges.shift();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function add({ nodes = [], edges = [], supersedes = [] } = {}) {
|
|
111
|
+
const idMap = new Map();
|
|
112
|
+
const ts = nowSec();
|
|
113
|
+
const addedNodes = [];
|
|
114
|
+
for (const raw of nodes) {
|
|
115
|
+
if (!raw || !NODE_TYPES.has(raw.t)) continue;
|
|
116
|
+
const name = String(raw.n || '').trim();
|
|
117
|
+
if (!name) continue;
|
|
118
|
+
const existing = findNodeId(raw.t, name);
|
|
119
|
+
if (existing) { idMap.set(raw.id || existing, existing); continue; }
|
|
120
|
+
const node = { id: `n${nextId++}`, t: raw.t, n: name.slice(0, 80), ts: raw.ts || ts };
|
|
121
|
+
if (raw.by) node.by = String(raw.by).slice(0, 24);
|
|
122
|
+
state.nodes.push(node);
|
|
123
|
+
idMap.set(raw.id || node.id, node.id);
|
|
124
|
+
addedNodes.push(node.id);
|
|
125
|
+
}
|
|
126
|
+
for (const raw of edges) {
|
|
127
|
+
if (!raw || !EDGE_PREDICATES.has(raw.p)) continue;
|
|
128
|
+
const s = idMap.get(raw.s) || raw.s;
|
|
129
|
+
const o = idMap.get(raw.o) || raw.o;
|
|
130
|
+
if (!s || !o) continue;
|
|
131
|
+
const exists = state.edges.some(e => e.s === s && e.p === raw.p && e.o === o);
|
|
132
|
+
if (exists) continue;
|
|
133
|
+
state.edges.push({ s, p: raw.p, o, ts: raw.ts || ts });
|
|
134
|
+
}
|
|
135
|
+
for (const sup of supersedes) {
|
|
136
|
+
if (!sup) continue;
|
|
137
|
+
const oldId = idMap.get(sup.old) || sup.old;
|
|
138
|
+
const newId = idMap.get(sup.new) || sup.new;
|
|
139
|
+
if (!oldId || !newId) continue;
|
|
140
|
+
state.edges.push({ s: newId, p: 's', o: oldId, ts });
|
|
141
|
+
}
|
|
142
|
+
evictIfNeeded();
|
|
143
|
+
state.meta = { updatedAt: ts, nodeCount: state.nodes.length, edgeCount: state.edges.length };
|
|
144
|
+
return addedNodes;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function supersededIds() {
|
|
148
|
+
const sup = new Set();
|
|
149
|
+
for (const edge of state.edges) if (edge.p === 's') sup.add(edge.o);
|
|
150
|
+
return sup;
|
|
151
|
+
}
|
|
152
|
+
function neighborsOf(nodeIds) {
|
|
153
|
+
const out = new Set();
|
|
154
|
+
const ids = nodeIds instanceof Set ? nodeIds : new Set(nodeIds);
|
|
155
|
+
for (const edge of state.edges) {
|
|
156
|
+
if (ids.has(edge.s)) out.add(edge.o);
|
|
157
|
+
if (ids.has(edge.o)) out.add(edge.s);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function serializeForHandoff({ language = 'en', maxBytes = 1500 } = {}) {
|
|
163
|
+
const en = /^en/i.test(String(language || ''));
|
|
164
|
+
const labels = en
|
|
165
|
+
? { decisions: 'Active decisions', touched: 'Relevant files', tools: 'Tools in play', results: 'Recent results', empty: '(no prior session context)' }
|
|
166
|
+
: { decisions: '활성 결정', touched: '관련 파일', tools: '사용 도구', results: '최근 결과', empty: '(이전 세션 컨텍스트 없음)' };
|
|
167
|
+
const sup = supersededIds();
|
|
168
|
+
const live = state.nodes.filter(n => !sup.has(n.id));
|
|
169
|
+
if (!live.length) return labels.empty;
|
|
170
|
+
const decisionNodes = live.filter(n => n.t === 'D');
|
|
171
|
+
const decisionIds = new Set(decisionNodes.map(n => n.id));
|
|
172
|
+
const nearby = neighborsOf(decisionIds);
|
|
173
|
+
const fileNodes = live.filter(n => n.t === 'F' && nearby.has(n.id));
|
|
174
|
+
const toolNodes = live.filter(n => n.t === 'T' && nearby.has(n.id));
|
|
175
|
+
const resultNodes = live.filter(n => n.t === 'R').sort((a, b) => b.ts - a.ts).slice(0, 3);
|
|
176
|
+
const lines = [];
|
|
177
|
+
if (decisionNodes.length) {
|
|
178
|
+
lines.push(`### ${labels.decisions}`);
|
|
179
|
+
for (const n of decisionNodes.slice(-8)) lines.push(`- ${n.n}${n.by ? ` _(by ${n.by})_` : ''}`);
|
|
180
|
+
}
|
|
181
|
+
if (fileNodes.length) {
|
|
182
|
+
lines.push(`### ${labels.touched}`);
|
|
183
|
+
for (const n of fileNodes.slice(-6)) lines.push(`- ${n.n}`);
|
|
184
|
+
}
|
|
185
|
+
if (toolNodes.length) {
|
|
186
|
+
lines.push(`### ${labels.tools}`);
|
|
187
|
+
for (const n of toolNodes.slice(-6)) lines.push(`- ${n.n}`);
|
|
188
|
+
}
|
|
189
|
+
if (resultNodes.length) {
|
|
190
|
+
lines.push(`### ${labels.results}`);
|
|
191
|
+
for (const n of resultNodes) lines.push(`- ${n.n}${n.by ? ` _(${n.by})_` : ''}`);
|
|
192
|
+
}
|
|
193
|
+
let out = lines.join('\n');
|
|
194
|
+
if (Buffer.byteLength(out, 'utf8') > maxBytes) out = out.slice(0, maxBytes - 4) + '\n...';
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function entitiesFromText(text, { by = '', kind = 'utterance' } = {}) {
|
|
199
|
+
const t = String(text || '');
|
|
200
|
+
if (!t.trim()) return { nodes: [], edges: [] };
|
|
201
|
+
const nodes = [];
|
|
202
|
+
const edges = [];
|
|
203
|
+
const seenFile = new Set();
|
|
204
|
+
const seenTool = new Set();
|
|
205
|
+
const seenDecision = new Set();
|
|
206
|
+
for (const match of t.matchAll(FILE_RE)) {
|
|
207
|
+
const name = match[0];
|
|
208
|
+
if (seenFile.has(name)) continue;
|
|
209
|
+
seenFile.add(name);
|
|
210
|
+
nodes.push({ id: `f_${name}`, t: 'F', n: name, by });
|
|
211
|
+
}
|
|
212
|
+
for (const match of t.matchAll(TOOL_RE)) {
|
|
213
|
+
const tool = match[0].toLowerCase();
|
|
214
|
+
if (seenTool.has(tool)) continue;
|
|
215
|
+
seenTool.add(tool);
|
|
216
|
+
nodes.push({ id: `t_${tool}`, t: 'T', n: tool, by });
|
|
217
|
+
}
|
|
218
|
+
for (const match of t.matchAll(DECISION_RE)) {
|
|
219
|
+
const decision = match[0];
|
|
220
|
+
if (seenDecision.has(decision)) continue;
|
|
221
|
+
seenDecision.add(decision);
|
|
222
|
+
const id = `d_${decision}`;
|
|
223
|
+
nodes.push({ id, t: 'D', n: decision, by });
|
|
224
|
+
for (const fn of seenFile) edges.push({ s: id, p: 't', o: `f_${fn}` });
|
|
225
|
+
for (const tn of seenTool) edges.push({ s: id, p: 'u', o: `t_${tn}` });
|
|
226
|
+
}
|
|
227
|
+
if (kind === 'result' && t.trim()) {
|
|
228
|
+
const summary = t.trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
229
|
+
nodes.push({ id: `r_${nowSec()}_${Math.random().toString(16).slice(2, 8)}`, t: 'R', n: summary, by });
|
|
230
|
+
}
|
|
231
|
+
return { nodes, edges };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function save() {
|
|
235
|
+
try {
|
|
236
|
+
fsApi.mkdirSync(root, { recursive: true });
|
|
237
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
238
|
+
fsApi.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
239
|
+
fsApi.renameSync(tmp, filePath);
|
|
240
|
+
return true;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function load() {
|
|
246
|
+
try {
|
|
247
|
+
const raw = fsApi.readFileSync(filePath, 'utf8');
|
|
248
|
+
const parsed = JSON.parse(raw);
|
|
249
|
+
if (parsed && parsed.v === SCHEMA_VERSION && parsed.channelKey === channel) {
|
|
250
|
+
state = parsed;
|
|
251
|
+
for (const node of state.nodes) {
|
|
252
|
+
const m = String(node.id || '').match(/^n(\d+)$/);
|
|
253
|
+
if (m) nextId = Math.max(nextId, Number(m[1]) + 1);
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
state = emptyState(channel);
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
function snapshot() { return JSON.parse(JSON.stringify(state)); }
|
|
262
|
+
function reset() { state = emptyState(channel); nextId = 1; }
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
add, entitiesFromText, serializeForHandoff, save, load, snapshot, reset,
|
|
266
|
+
get nodeCount() { return state.nodes.length; },
|
|
267
|
+
get edgeCount() { return state.edges.length; },
|
|
268
|
+
get filePath() { return filePath; },
|
|
269
|
+
get typeLabels() { return TYPE_LABELS; },
|
|
270
|
+
};
|
|
271
|
+
}
|