opc-agent 3.0.1 → 4.0.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.
- package/README.md +404 -74
- package/README.zh-CN.md +82 -0
- package/dist/channels/dingtalk.d.ts +17 -0
- package/dist/channels/dingtalk.js +38 -0
- package/dist/channels/googlechat.d.ts +14 -0
- package/dist/channels/googlechat.js +37 -0
- package/dist/channels/imessage.d.ts +13 -0
- package/dist/channels/imessage.js +28 -0
- package/dist/channels/irc.d.ts +20 -0
- package/dist/channels/irc.js +71 -0
- package/dist/channels/line.d.ts +14 -0
- package/dist/channels/line.js +28 -0
- package/dist/channels/matrix.d.ts +15 -0
- package/dist/channels/matrix.js +28 -0
- package/dist/channels/mattermost.d.ts +18 -0
- package/dist/channels/mattermost.js +49 -0
- package/dist/channels/msteams.d.ts +14 -0
- package/dist/channels/msteams.js +28 -0
- package/dist/channels/nostr.d.ts +14 -0
- package/dist/channels/nostr.js +28 -0
- package/dist/channels/qq.d.ts +15 -0
- package/dist/channels/qq.js +28 -0
- package/dist/channels/signal.d.ts +14 -0
- package/dist/channels/signal.js +28 -0
- package/dist/channels/sms.d.ts +15 -0
- package/dist/channels/sms.js +28 -0
- package/dist/channels/twitch.d.ts +17 -0
- package/dist/channels/twitch.js +59 -0
- package/dist/channels/voice-call.d.ts +27 -0
- package/dist/channels/voice-call.js +82 -0
- package/dist/channels/whatsapp.d.ts +14 -0
- package/dist/channels/whatsapp.js +28 -0
- package/dist/cli/chat.d.ts +2 -0
- package/dist/cli/chat.js +134 -0
- package/dist/cli/setup.d.ts +4 -0
- package/dist/cli/setup.js +303 -0
- package/dist/cli.js +142 -6
- package/dist/core/api-server.d.ts +25 -0
- package/dist/core/api-server.js +286 -0
- package/dist/core/audio.d.ts +50 -0
- package/dist/core/audio.js +68 -0
- package/dist/core/context-discovery.d.ts +16 -0
- package/dist/core/context-discovery.js +107 -0
- package/dist/core/context-refs.d.ts +29 -0
- package/dist/core/context-refs.js +162 -0
- package/dist/core/gateway.d.ts +53 -0
- package/dist/core/gateway.js +80 -0
- package/dist/core/heartbeat.d.ts +19 -0
- package/dist/core/heartbeat.js +50 -0
- package/dist/core/hooks.d.ts +28 -0
- package/dist/core/hooks.js +82 -0
- package/dist/core/ide-bridge.d.ts +53 -0
- package/dist/core/ide-bridge.js +97 -0
- package/dist/core/node-network.d.ts +23 -0
- package/dist/core/node-network.js +77 -0
- package/dist/core/profiles.d.ts +27 -0
- package/dist/core/profiles.js +131 -0
- package/dist/core/sandbox.d.ts +25 -0
- package/dist/core/sandbox.js +84 -1
- package/dist/core/session-manager.d.ts +33 -0
- package/dist/core/session-manager.js +157 -0
- package/dist/core/vision.d.ts +45 -0
- package/dist/core/vision.js +177 -0
- package/dist/hub/brain-seed.d.ts +14 -0
- package/dist/hub/brain-seed.js +77 -0
- package/dist/hub/client.d.ts +25 -0
- package/dist/hub/client.js +44 -0
- package/dist/index.d.ts +66 -1
- package/dist/index.js +95 -3
- package/dist/memory/context-compressor.d.ts +43 -0
- package/dist/memory/context-compressor.js +167 -0
- package/dist/memory/index.d.ts +4 -0
- package/dist/memory/index.js +5 -1
- package/dist/memory/user-profiler.d.ts +50 -0
- package/dist/memory/user-profiler.js +201 -0
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.js +54 -1
- package/dist/scheduler/cron-engine.d.ts +41 -0
- package/dist/scheduler/cron-engine.js +200 -0
- package/dist/scheduler/index.d.ts +3 -0
- package/dist/scheduler/index.js +7 -0
- package/dist/schema/oad.d.ts +12 -12
- package/dist/security/approvals.d.ts +53 -0
- package/dist/security/approvals.js +115 -0
- package/dist/security/elevated.d.ts +41 -0
- package/dist/security/elevated.js +89 -0
- package/dist/security/index.d.ts +6 -0
- package/dist/security/index.js +7 -1
- package/dist/security/secrets.d.ts +34 -0
- package/dist/security/secrets.js +115 -0
- package/dist/skills/builtin/index.d.ts +6 -0
- package/dist/skills/builtin/index.js +402 -0
- package/dist/skills/marketplace.d.ts +30 -0
- package/dist/skills/marketplace.js +142 -0
- package/dist/skills/types.d.ts +34 -0
- package/dist/skills/types.js +16 -0
- package/dist/studio/server.d.ts +25 -0
- package/dist/studio/server.js +780 -0
- package/dist/studio/templates-data.d.ts +21 -0
- package/dist/studio/templates-data.js +148 -0
- package/dist/studio-ui/index.html +2502 -1073
- package/dist/tools/builtin/browser.d.ts +47 -0
- package/dist/tools/builtin/browser.js +284 -0
- package/dist/tools/builtin/home-assistant.d.ts +12 -0
- package/dist/tools/builtin/home-assistant.js +126 -0
- package/dist/tools/builtin/index.d.ts +7 -1
- package/dist/tools/builtin/index.js +23 -2
- package/dist/tools/builtin/rl-tools.d.ts +13 -0
- package/dist/tools/builtin/rl-tools.js +228 -0
- package/dist/tools/builtin/vision.d.ts +6 -0
- package/dist/tools/builtin/vision.js +61 -0
- package/dist/tools/builtin/web-search.d.ts +9 -0
- package/dist/tools/builtin/web-search.js +150 -0
- package/dist/tools/document-processor.d.ts +39 -0
- package/dist/tools/document-processor.js +188 -0
- package/dist/tools/image-generator.d.ts +42 -0
- package/dist/tools/image-generator.js +136 -0
- package/dist/tools/web-scraper.d.ts +20 -0
- package/dist/tools/web-scraper.js +148 -0
- package/dist/tools/web-search.d.ts +51 -0
- package/dist/tools/web-search.js +152 -0
- package/install.ps1 +154 -0
- package/install.sh +164 -0
- package/package.json +63 -52
- package/src/channels/dingtalk.ts +46 -0
- package/src/channels/googlechat.ts +42 -0
- package/src/channels/imessage.ts +32 -0
- package/src/channels/irc.ts +82 -0
- package/src/channels/line.ts +33 -0
- package/src/channels/matrix.ts +34 -0
- package/src/channels/mattermost.ts +57 -0
- package/src/channels/msteams.ts +33 -0
- package/src/channels/nostr.ts +33 -0
- package/src/channels/qq.ts +34 -0
- package/src/channels/signal.ts +33 -0
- package/src/channels/sms.ts +34 -0
- package/src/channels/twitch.ts +65 -0
- package/src/channels/voice-call.ts +100 -0
- package/src/channels/whatsapp.ts +33 -0
- package/src/cli/chat.ts +99 -0
- package/src/cli/setup.ts +314 -0
- package/src/cli.ts +148 -6
- package/src/core/api-server.ts +277 -0
- package/src/core/audio.ts +98 -0
- package/src/core/context-discovery.ts +85 -0
- package/src/core/context-refs.ts +140 -0
- package/src/core/gateway.ts +106 -0
- package/src/core/heartbeat.ts +51 -0
- package/src/core/hooks.ts +105 -0
- package/src/core/ide-bridge.ts +133 -0
- package/src/core/node-network.ts +86 -0
- package/src/core/profiles.ts +122 -0
- package/src/core/sandbox.ts +100 -0
- package/src/core/session-manager.ts +137 -0
- package/src/core/vision.ts +180 -0
- package/src/hub/brain-seed.ts +54 -0
- package/src/hub/client.ts +60 -0
- package/src/index.ts +86 -1
- package/src/memory/context-compressor.ts +189 -0
- package/src/memory/index.ts +4 -0
- package/src/memory/user-profiler.ts +215 -0
- package/src/providers/index.ts +64 -1
- package/src/scheduler/cron-engine.ts +191 -0
- package/src/scheduler/index.ts +2 -0
- package/src/security/approvals.ts +143 -0
- package/src/security/elevated.ts +105 -0
- package/src/security/index.ts +6 -0
- package/src/security/secrets.ts +129 -0
- package/src/skills/builtin/index.ts +408 -0
- package/src/skills/marketplace.ts +113 -0
- package/src/skills/types.ts +42 -0
- package/src/studio/server.ts +1591 -791
- package/src/studio/templates-data.ts +178 -0
- package/src/studio-ui/index.html +2502 -1073
- package/src/tools/builtin/browser.ts +299 -0
- package/src/tools/builtin/home-assistant.ts +116 -0
- package/src/tools/builtin/index.ts +37 -28
- package/src/tools/builtin/rl-tools.ts +243 -0
- package/src/tools/builtin/vision.ts +64 -0
- package/src/tools/builtin/web-search.ts +126 -0
- package/src/tools/document-processor.ts +213 -0
- package/src/tools/image-generator.ts +150 -0
- package/src/tools/web-scraper.ts +179 -0
- package/src/tools/web-search.ts +180 -0
- package/tests/api-server.test.ts +148 -0
- package/tests/approvals.test.ts +89 -0
- package/tests/audio.test.ts +40 -0
- package/tests/browser.test.ts +179 -0
- package/tests/builtin-tools.test.ts +83 -83
- package/tests/channels-extra.test.ts +45 -0
- package/tests/context-compressor.test.ts +172 -0
- package/tests/context-refs.test.ts +121 -0
- package/tests/cron-engine.test.ts +101 -0
- package/tests/document-processor.test.ts +69 -0
- package/tests/e2e-nocode.test.ts +442 -0
- package/tests/elevated.test.ts +69 -0
- package/tests/gateway.test.ts +63 -71
- package/tests/home-assistant.test.ts +40 -0
- package/tests/hooks.test.ts +79 -0
- package/tests/ide-bridge.test.ts +38 -0
- package/tests/image-generator.test.ts +84 -0
- package/tests/node-network.test.ts +74 -0
- package/tests/profiles.test.ts +61 -0
- package/tests/rl-tools.test.ts +93 -0
- package/tests/sandbox-manager.test.ts +46 -0
- package/tests/secrets.test.ts +107 -0
- package/tests/settings-api.test.ts +148 -0
- package/tests/setup.test.ts +73 -0
- package/tests/studio.test.ts +402 -229
- package/tests/tools/builtin-extended.test.ts +138 -138
- package/tests/user-profiler.test.ts +169 -0
- package/tests/v090-features.test.ts +254 -0
- package/tests/vision.test.ts +61 -0
- package/tests/voice-call.test.ts +47 -0
- package/tests/voice-interaction.test.ts +38 -0
- package/tests/web-search.test.ts +155 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Message } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export interface CompressorConfig {
|
|
4
|
+
maxTokens: number;
|
|
5
|
+
compressThreshold: number;
|
|
6
|
+
preserveRecent: number;
|
|
7
|
+
brain?: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CompressResult {
|
|
11
|
+
messages: Message[];
|
|
12
|
+
learnedCount: number;
|
|
13
|
+
savedTokens: number;
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONFIG: CompressorConfig = {
|
|
18
|
+
maxTokens: 8000,
|
|
19
|
+
compressThreshold: 0.8,
|
|
20
|
+
preserveRecent: 10,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Context compression with optional DeepBrain memory offloading.
|
|
25
|
+
*/
|
|
26
|
+
export class ContextCompressor {
|
|
27
|
+
private config: CompressorConfig;
|
|
28
|
+
|
|
29
|
+
constructor(config: Partial<CompressorConfig> = {}) {
|
|
30
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Estimate token count using language-aware heuristic.
|
|
35
|
+
* English: ~1 token per 4 chars. Chinese: ~1 token per 2 chars.
|
|
36
|
+
*/
|
|
37
|
+
estimateTokens(text: string): number {
|
|
38
|
+
let tokens = 0;
|
|
39
|
+
for (const char of text) {
|
|
40
|
+
// CJK Unicode range detection
|
|
41
|
+
const code = char.codePointAt(0) ?? 0;
|
|
42
|
+
if (
|
|
43
|
+
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified
|
|
44
|
+
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
|
|
45
|
+
(code >= 0x3000 && code <= 0x303f) // CJK Punctuation
|
|
46
|
+
) {
|
|
47
|
+
tokens += 0.5; // 1 token per 2 chars
|
|
48
|
+
} else {
|
|
49
|
+
tokens += 0.25; // 1 token per 4 chars
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return Math.ceil(tokens);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private estimateMessagesTokens(messages: Message[]): number {
|
|
56
|
+
return messages.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract key insights from messages for brain storage.
|
|
61
|
+
*/
|
|
62
|
+
private extractInsights(messages: Message[]): Array<{ content: string; type: string }> {
|
|
63
|
+
const insights: Array<{ content: string; type: string }> = [];
|
|
64
|
+
for (const msg of messages) {
|
|
65
|
+
const c = msg.content;
|
|
66
|
+
// Decisions
|
|
67
|
+
if (/\b(decided|decision|choose|chose|will use|going with|let's go|确定|决定)\b/i.test(c)) {
|
|
68
|
+
insights.push({ content: c.slice(0, 500), type: 'decision' });
|
|
69
|
+
}
|
|
70
|
+
// Facts / definitions
|
|
71
|
+
else if (/\b(is defined as|means|equals|refers to|是指|定义)\b/i.test(c)) {
|
|
72
|
+
insights.push({ content: c.slice(0, 500), type: 'fact' });
|
|
73
|
+
}
|
|
74
|
+
// Preferences
|
|
75
|
+
else if (/\b(prefer|like|want|don't like|不喜欢|喜欢|偏好)\b/i.test(c)) {
|
|
76
|
+
insights.push({ content: c.slice(0, 500), type: 'preference' });
|
|
77
|
+
}
|
|
78
|
+
// Code snippets
|
|
79
|
+
else if (/```[\s\S]{20,}```/.test(c)) {
|
|
80
|
+
insights.push({ content: c.slice(0, 800), type: 'code' });
|
|
81
|
+
}
|
|
82
|
+
// Long assistant messages likely contain useful info
|
|
83
|
+
else if (msg.role === 'assistant' && c.length > 200) {
|
|
84
|
+
insights.push({ content: c.slice(0, 500), type: 'knowledge' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return insights;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate a simple summary from messages (no-brain fallback).
|
|
92
|
+
*/
|
|
93
|
+
private summarize(messages: Message[]): string {
|
|
94
|
+
const topics = new Set<string>();
|
|
95
|
+
const keyLines: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (const msg of messages) {
|
|
98
|
+
// Extract first meaningful sentence
|
|
99
|
+
const firstLine = msg.content.split(/[.\n!?。!?]/)[0]?.trim();
|
|
100
|
+
if (firstLine && firstLine.length > 10 && firstLine.length < 200) {
|
|
101
|
+
if (keyLines.length < 5) keyLines.push(`[${msg.role}] ${firstLine}`);
|
|
102
|
+
}
|
|
103
|
+
// Extract topic words (capitalized words, Chinese phrases)
|
|
104
|
+
const words = msg.content.match(/[A-Z][a-z]{2,}/g) ?? [];
|
|
105
|
+
words.forEach(w => topics.add(w));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const topicStr = [...topics].slice(0, 10).join(', ');
|
|
109
|
+
const linesStr = keyLines.join('; ');
|
|
110
|
+
return `Topics: ${topicStr || 'general discussion'}. Key points: ${linesStr || 'varied conversation'}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Compress messages when token count exceeds threshold.
|
|
115
|
+
*/
|
|
116
|
+
async compress(messages: Message[], config?: Partial<CompressorConfig>): Promise<CompressResult> {
|
|
117
|
+
const cfg = { ...this.config, ...config };
|
|
118
|
+
const totalTokens = this.estimateMessagesTokens(messages);
|
|
119
|
+
const threshold = cfg.maxTokens * cfg.compressThreshold;
|
|
120
|
+
|
|
121
|
+
// Under threshold — return as-is
|
|
122
|
+
if (totalTokens <= threshold) {
|
|
123
|
+
return {
|
|
124
|
+
messages: [...messages],
|
|
125
|
+
learnedCount: 0,
|
|
126
|
+
savedTokens: 0,
|
|
127
|
+
summary: '',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const recentCount = Math.min(cfg.preserveRecent, messages.length);
|
|
132
|
+
const splitIdx = messages.length - recentCount;
|
|
133
|
+
const oldMessages = messages.slice(0, splitIdx);
|
|
134
|
+
const recentMessages = messages.slice(splitIdx);
|
|
135
|
+
|
|
136
|
+
if (oldMessages.length === 0) {
|
|
137
|
+
return { messages: [...messages], learnedCount: 0, savedTokens: 0, summary: '' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const oldTokens = this.estimateMessagesTokens(oldMessages);
|
|
141
|
+
let learnedCount = 0;
|
|
142
|
+
let summary: string;
|
|
143
|
+
|
|
144
|
+
if (cfg.brain) {
|
|
145
|
+
// Extract and learn insights
|
|
146
|
+
const insights = this.extractInsights(oldMessages);
|
|
147
|
+
for (const insight of insights) {
|
|
148
|
+
try {
|
|
149
|
+
await cfg.brain.learn(insight.content, { insight_type: insight.type });
|
|
150
|
+
learnedCount++;
|
|
151
|
+
} catch { /* non-critical */ }
|
|
152
|
+
}
|
|
153
|
+
summary = `${oldMessages.length} messages compressed. Extracted ${learnedCount} insights (${insights.map(i => i.type).filter((v, i, a) => a.indexOf(v) === i).join(', ')}).`;
|
|
154
|
+
} else {
|
|
155
|
+
summary = this.summarize(oldMessages);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const compressionMessage: Message = {
|
|
159
|
+
id: `compressed-${Date.now()}`,
|
|
160
|
+
role: 'system',
|
|
161
|
+
content: `[Context compressed: ${oldMessages.length} messages → ${summary}${cfg.brain ? ' Details stored in Brain, use recall() to retrieve.' : ''}]`,
|
|
162
|
+
timestamp: Date.now(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
messages: [compressionMessage, ...recentMessages],
|
|
167
|
+
learnedCount,
|
|
168
|
+
savedTokens: oldTokens - this.estimateTokens(compressionMessage.content),
|
|
169
|
+
summary,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Restore context from brain for a given query.
|
|
175
|
+
*/
|
|
176
|
+
async restore(query: string, brain: any): Promise<string[]> {
|
|
177
|
+
if (!brain?.recall) return [];
|
|
178
|
+
try {
|
|
179
|
+
const results = await brain.recall(query);
|
|
180
|
+
if (Array.isArray(results)) {
|
|
181
|
+
return results.map((r: any) => typeof r === 'string' ? r : r.content ?? JSON.stringify(r));
|
|
182
|
+
}
|
|
183
|
+
if (typeof results === 'string') return [results];
|
|
184
|
+
return [];
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/memory/index.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { Message, MemoryStore } from '../core/types';
|
|
|
2
2
|
|
|
3
3
|
export { BrainSeedLoader, KnowledgeEvolver } from './seed-loader';
|
|
4
4
|
export type { BrainSeedConfig, SeedPage, SeedResult, PromotionResult, PromotionCandidate } from './seed-loader';
|
|
5
|
+
export { ContextCompressor } from './context-compressor';
|
|
6
|
+
export type { CompressorConfig, CompressResult } from './context-compressor';
|
|
7
|
+
export { UserProfiler } from './user-profiler';
|
|
8
|
+
export type { UserProfile } from './user-profiler';
|
|
5
9
|
|
|
6
10
|
export class InMemoryStore implements MemoryStore {
|
|
7
11
|
private store: Map<string, unknown> = new Map();
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { Message } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export interface UserProfile {
|
|
4
|
+
preferences: Record<string, string>;
|
|
5
|
+
communication_style: string;
|
|
6
|
+
expertise_areas: string[];
|
|
7
|
+
common_requests: string[];
|
|
8
|
+
last_updated: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const EMPTY_PROFILE: UserProfile = {
|
|
12
|
+
preferences: {},
|
|
13
|
+
communication_style: 'unknown',
|
|
14
|
+
expertise_areas: [],
|
|
15
|
+
common_requests: [],
|
|
16
|
+
last_updated: 0,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DeepBrain-powered user profiling from conversation signals.
|
|
21
|
+
*/
|
|
22
|
+
export class UserProfiler {
|
|
23
|
+
private observationCount = 0;
|
|
24
|
+
private signals = {
|
|
25
|
+
languages: new Map<string, number>(),
|
|
26
|
+
techKeywords: new Set<string>(),
|
|
27
|
+
styleSignals: { brief: 0, detailed: 0, formal: 0, casual: 0 },
|
|
28
|
+
requestTypes: new Map<string, number>(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
private readonly LEARN_INTERVAL = 20;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect language mix of a text.
|
|
35
|
+
*/
|
|
36
|
+
private detectLanguage(text: string): string {
|
|
37
|
+
let cn = 0, en = 0;
|
|
38
|
+
for (const char of text) {
|
|
39
|
+
const code = char.codePointAt(0) ?? 0;
|
|
40
|
+
if (code >= 0x4e00 && code <= 0x9fff) cn++;
|
|
41
|
+
else if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) en++;
|
|
42
|
+
}
|
|
43
|
+
if (cn > 0 && en > 0) return 'mixed';
|
|
44
|
+
if (cn > en) return 'chinese';
|
|
45
|
+
return 'english';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect technical level from content.
|
|
50
|
+
*/
|
|
51
|
+
private detectTechLevel(text: string): string {
|
|
52
|
+
const expertTerms = /\b(kubernetes|k8s|microservice|architecture|distributed|consensus|raft|paxos|sharding|vector db|embedding|fine-?tun|transformer|CUDA|inference|quantiz|LoRA|RAG)\b/i;
|
|
53
|
+
const intermediateTerms = /\b(API|REST|GraphQL|Docker|CI\/CD|database|deploy|cloud|React|TypeScript|Python|async|cache|redis|nginx)\b/i;
|
|
54
|
+
|
|
55
|
+
if (expertTerms.test(text)) return 'expert';
|
|
56
|
+
if (intermediateTerms.test(text)) return 'intermediate';
|
|
57
|
+
return 'beginner';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect communication style.
|
|
62
|
+
*/
|
|
63
|
+
private detectStyle(text: string): void {
|
|
64
|
+
if (text.length < 50) this.signals.styleSignals.brief++;
|
|
65
|
+
else if (text.length > 300) this.signals.styleSignals.detailed++;
|
|
66
|
+
|
|
67
|
+
if (/\b(please|kindly|would you|could you|请问|烦请|麻烦)\b/i.test(text)) {
|
|
68
|
+
this.signals.styleSignals.formal++;
|
|
69
|
+
}
|
|
70
|
+
if (/[!]{2,}|lol|haha|😂|🤣|哈哈|牛|666|👍/i.test(text)) {
|
|
71
|
+
this.signals.styleSignals.casual++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract domain keywords.
|
|
77
|
+
*/
|
|
78
|
+
private extractDomainKeywords(text: string): void {
|
|
79
|
+
const techWords = text.match(/\b[A-Z][a-zA-Z]{2,}(?:\.js|\.ts|\.py)?\b/g) ?? [];
|
|
80
|
+
techWords.forEach(w => this.signals.techKeywords.add(w));
|
|
81
|
+
// Chinese tech terms
|
|
82
|
+
const cnTerms = text.match(/(?:人工智能|机器学习|深度学习|大模型|微服务|架构|部署|运维|前端|后端|数据库|缓存|分布式)/g) ?? [];
|
|
83
|
+
cnTerms.forEach(w => this.signals.techKeywords.add(w));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Classify request type.
|
|
88
|
+
*/
|
|
89
|
+
private classifyRequest(text: string): string {
|
|
90
|
+
if (/\b(how to|怎么|如何|how do)\b/i.test(text)) return 'how-to';
|
|
91
|
+
if (/\b(why|为什么|原因)\b/i.test(text)) return 'explanation';
|
|
92
|
+
if (/\b(fix|error|bug|报错|出错|failed)\b/i.test(text)) return 'debugging';
|
|
93
|
+
if (/\b(review|评审|看看|check)\b/i.test(text)) return 'review';
|
|
94
|
+
if (/\b(create|build|write|写|创建|生成)\b/i.test(text)) return 'creation';
|
|
95
|
+
return 'general';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Observe a user message and accumulate profile signals.
|
|
100
|
+
*/
|
|
101
|
+
async observe(message: Message, brain?: any): Promise<void> {
|
|
102
|
+
if (message.role !== 'user') return;
|
|
103
|
+
|
|
104
|
+
const text = message.content;
|
|
105
|
+
|
|
106
|
+
// Language
|
|
107
|
+
const lang = this.detectLanguage(text);
|
|
108
|
+
this.signals.languages.set(lang, (this.signals.languages.get(lang) ?? 0) + 1);
|
|
109
|
+
|
|
110
|
+
// Style
|
|
111
|
+
this.detectStyle(text);
|
|
112
|
+
|
|
113
|
+
// Domain keywords
|
|
114
|
+
this.extractDomainKeywords(text);
|
|
115
|
+
|
|
116
|
+
// Request type
|
|
117
|
+
const reqType = this.classifyRequest(text);
|
|
118
|
+
this.signals.requestTypes.set(reqType, (this.signals.requestTypes.get(reqType) ?? 0) + 1);
|
|
119
|
+
|
|
120
|
+
this.observationCount++;
|
|
121
|
+
|
|
122
|
+
// Periodically persist to brain
|
|
123
|
+
if (brain?.learn && this.observationCount % this.LEARN_INTERVAL === 0) {
|
|
124
|
+
const profile = this.buildProfileFromSignals();
|
|
125
|
+
try {
|
|
126
|
+
await brain.learn(JSON.stringify(profile), { insight_type: 'user_profile' });
|
|
127
|
+
} catch { /* non-critical */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private buildProfileFromSignals(): UserProfile {
|
|
132
|
+
// Language preference
|
|
133
|
+
let topLang = 'english';
|
|
134
|
+
let maxCount = 0;
|
|
135
|
+
for (const [lang, count] of this.signals.languages) {
|
|
136
|
+
if (count > maxCount) { topLang = lang; maxCount = count; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Communication style
|
|
140
|
+
const ss = this.signals.styleSignals;
|
|
141
|
+
const styles = [
|
|
142
|
+
['brief', ss.brief], ['detailed', ss.detailed],
|
|
143
|
+
['formal', ss.formal], ['casual', ss.casual],
|
|
144
|
+
] as const;
|
|
145
|
+
const topStyle = styles.reduce((a, b) => (b[1] > a[1] ? b : a))[0];
|
|
146
|
+
|
|
147
|
+
// Top request types
|
|
148
|
+
const topRequests = [...this.signals.requestTypes.entries()]
|
|
149
|
+
.sort((a, b) => b[1] - a[1])
|
|
150
|
+
.slice(0, 5)
|
|
151
|
+
.map(([t]) => t);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
preferences: { language: topLang },
|
|
155
|
+
communication_style: topStyle,
|
|
156
|
+
expertise_areas: [...this.signals.techKeywords].slice(0, 20),
|
|
157
|
+
common_requests: topRequests,
|
|
158
|
+
last_updated: Date.now(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get user profile, optionally from brain recall.
|
|
164
|
+
*/
|
|
165
|
+
async getProfile(brain?: any): Promise<UserProfile> {
|
|
166
|
+
// First try local signals
|
|
167
|
+
if (this.observationCount > 0) {
|
|
168
|
+
return this.buildProfileFromSignals();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fallback to brain recall
|
|
172
|
+
if (brain?.recall) {
|
|
173
|
+
try {
|
|
174
|
+
const results = await brain.recall('user profile preferences style');
|
|
175
|
+
if (Array.isArray(results) && results.length > 0) {
|
|
176
|
+
const raw = typeof results[0] === 'string' ? results[0] : results[0].content;
|
|
177
|
+
return { ...EMPTY_PROFILE, ...JSON.parse(raw) };
|
|
178
|
+
}
|
|
179
|
+
} catch { /* ignore parse errors */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ...EMPTY_PROFILE };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Enhance a system prompt with user profile context.
|
|
187
|
+
*/
|
|
188
|
+
enhance(systemPrompt: string, profile: UserProfile): string {
|
|
189
|
+
const hints: string[] = [];
|
|
190
|
+
|
|
191
|
+
if (profile.preferences.language) {
|
|
192
|
+
const langMap: Record<string, string> = {
|
|
193
|
+
chinese: 'User prefers Chinese responses.',
|
|
194
|
+
english: 'User prefers English responses.',
|
|
195
|
+
mixed: 'User uses mixed Chinese/English. Match their style.',
|
|
196
|
+
};
|
|
197
|
+
if (langMap[profile.preferences.language]) hints.push(langMap[profile.preferences.language]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (profile.communication_style && profile.communication_style !== 'unknown') {
|
|
201
|
+
hints.push(`User communication style: ${profile.communication_style}.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (profile.expertise_areas.length > 0) {
|
|
205
|
+
hints.push(`User expertise: ${profile.expertise_areas.slice(0, 10).join(', ')}.`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (profile.common_requests.length > 0) {
|
|
209
|
+
hints.push(`Common request types: ${profile.common_requests.join(', ')}.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (hints.length === 0) return systemPrompt;
|
|
213
|
+
return `${systemPrompt}\n\n[User Profile] ${hints.join(' ')}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -325,9 +325,72 @@ function isGeminiNative(): boolean {
|
|
|
325
325
|
return key.startsWith('AQ.') || (baseUrl.includes('googleapis.com') && !baseUrl.includes('/openai'));
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
class ClaudeCLIProvider implements LLMProvider {
|
|
329
|
+
name = 'claude-cli';
|
|
330
|
+
private model: string;
|
|
331
|
+
|
|
332
|
+
constructor(model?: string) {
|
|
333
|
+
this.model = model || 'sonnet';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async chat(messages: Message[], systemPrompt?: string, options?: ChatOptions): Promise<string> {
|
|
337
|
+
const { execFile } = await import('child_process');
|
|
338
|
+
const { promisify } = await import('util');
|
|
339
|
+
const execFileAsync = promisify(execFile);
|
|
340
|
+
|
|
341
|
+
// Build the prompt from messages
|
|
342
|
+
const lastMessage = messages[messages.length - 1];
|
|
343
|
+
if (!lastMessage) return '';
|
|
344
|
+
|
|
345
|
+
let prompt = lastMessage.content;
|
|
346
|
+
|
|
347
|
+
// Add tool prompt if tools provided
|
|
348
|
+
if (options?.tools && options.tools.length > 0) {
|
|
349
|
+
prompt += buildToolPrompt(options.tools);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const args = ['--print'];
|
|
353
|
+
if (systemPrompt) {
|
|
354
|
+
args.push('--system-prompt', systemPrompt);
|
|
355
|
+
}
|
|
356
|
+
if (this.model) {
|
|
357
|
+
args.push('--model', this.model);
|
|
358
|
+
}
|
|
359
|
+
args.push(prompt);
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const { stdout } = await execFileAsync('claude', args, {
|
|
363
|
+
timeout: 120_000,
|
|
364
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
365
|
+
env: { ...process.env },
|
|
366
|
+
});
|
|
367
|
+
return stdout.trim();
|
|
368
|
+
} catch (err: any) {
|
|
369
|
+
if (err.code === 'ENOENT') {
|
|
370
|
+
throw new Error(
|
|
371
|
+
'Claude CLI not found. Install it: npm install -g @anthropic-ai/claude-code\n' +
|
|
372
|
+
'Then authenticate: claude login'
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`Claude CLI error: ${err.message}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async *chatStream(messages: Message[], systemPrompt?: string): AsyncIterable<string> {
|
|
380
|
+
// Claude CLI --print doesn't support streaming well, so we do single-shot
|
|
381
|
+
const result = await this.chat(messages, systemPrompt);
|
|
382
|
+
yield result;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
328
386
|
export function createProvider(name: string = 'openai', model?: string, baseUrl?: string, apiKey?: string): LLMProvider {
|
|
329
387
|
const finalModel = model || process.env.OPC_LLM_MODEL || 'gpt-4o-mini';
|
|
330
388
|
|
|
389
|
+
// Claude CLI mode: use local claude command (Claude Max/Pro subscription)
|
|
390
|
+
if (name === 'claude-cli' || process.env.OPC_LLM_PROVIDER === 'claude-cli') {
|
|
391
|
+
return new ClaudeCLIProvider(finalModel !== 'gpt-4o-mini' ? finalModel : undefined);
|
|
392
|
+
}
|
|
393
|
+
|
|
331
394
|
if (name === 'ollama') {
|
|
332
395
|
const ollamaBase = baseUrl || process.env.OPC_LLM_BASE_URL || 'http://localhost:11434/v1';
|
|
333
396
|
const ollamaKey = apiKey || process.env.OPC_LLM_API_KEY || 'ollama';
|
|
@@ -351,4 +414,4 @@ export function createProvider(name: string = 'openai', model?: string, baseUrl?
|
|
|
351
414
|
return new OpenAICompatibleProvider(resolvedName, finalModel, baseUrl, apiKey);
|
|
352
415
|
}
|
|
353
416
|
|
|
354
|
-
export const SUPPORTED_PROVIDERS = ['openai', 'ollama', 'deepseek', 'qwen', 'gemini', 'dashscope', 'zhipu', 'moonshot'] as const;
|
|
417
|
+
export const SUPPORTED_PROVIDERS = ['openai', 'ollama', 'claude-cli', 'deepseek', 'qwen', 'gemini', 'dashscope', 'zhipu', 'moonshot'] as const;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron Engine — persistent scheduler with file-based storage.
|
|
3
|
+
* Manages scheduled tasks with cron expressions, persists to ~/.opc/schedules.json,
|
|
4
|
+
* and auto-recovers on startup.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import { parseCron, cronMatches, Scheduler } from '../core/scheduler';
|
|
11
|
+
import type { CronJob, JobHandler } from '../core/scheduler';
|
|
12
|
+
|
|
13
|
+
export interface ScheduleTask {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
schedule: string; // cron expression
|
|
17
|
+
description: string; // natural language description
|
|
18
|
+
frequency: 'daily' | 'weekly' | 'monthly' | 'custom';
|
|
19
|
+
time?: string; // HH:mm for simple schedules
|
|
20
|
+
outputChannel: 'telegram' | 'email' | 'web';
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
lastRun?: string;
|
|
25
|
+
nextRun?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SchedulesStore {
|
|
29
|
+
tasks: ScheduleTask[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getSchedulesPath(): string {
|
|
33
|
+
const dir = join(os.homedir(), '.opc');
|
|
34
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
35
|
+
return join(dir, 'schedules.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadSchedules(): SchedulesStore {
|
|
39
|
+
const p = getSchedulesPath();
|
|
40
|
+
if (existsSync(p)) {
|
|
41
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
return { tasks: [] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveSchedules(store: SchedulesStore): void {
|
|
47
|
+
writeFileSync(getSchedulesPath(), JSON.stringify(store, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Convert frequency + time to cron expression */
|
|
51
|
+
export function frequencyToCron(frequency: string, time?: string): string {
|
|
52
|
+
const [hour, minute] = (time || '09:00').split(':').map(Number);
|
|
53
|
+
switch (frequency) {
|
|
54
|
+
case 'daily': return `${minute} ${hour} * * *`;
|
|
55
|
+
case 'weekly': return `${minute} ${hour} * * 1`;
|
|
56
|
+
case 'monthly': return `${minute} ${hour} 1 * *`;
|
|
57
|
+
default: return '0 9 * * *'; // fallback
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Compute next run from a cron expression */
|
|
62
|
+
function computeNextRun(cronExpr: string): string | undefined {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = parseCron(cronExpr);
|
|
65
|
+
const d = new Date();
|
|
66
|
+
d.setSeconds(0, 0);
|
|
67
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
68
|
+
for (let i = 0; i < 48 * 60; i++) {
|
|
69
|
+
if (cronMatches(parsed, d)) return d.toISOString();
|
|
70
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
71
|
+
}
|
|
72
|
+
} catch { /* ignore */ }
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class CronEngine {
|
|
77
|
+
private scheduler: Scheduler;
|
|
78
|
+
private store: SchedulesStore;
|
|
79
|
+
private handler: JobHandler;
|
|
80
|
+
|
|
81
|
+
constructor(handler?: JobHandler) {
|
|
82
|
+
this.handler = handler || (async (job) => {
|
|
83
|
+
console.log(`[cron-engine] Executing job: ${job.name} (${job.id})`);
|
|
84
|
+
});
|
|
85
|
+
this.scheduler = new Scheduler(this.handler);
|
|
86
|
+
this.store = loadSchedules();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Initialize and recover persisted tasks */
|
|
90
|
+
start(): void {
|
|
91
|
+
for (const task of this.store.tasks) {
|
|
92
|
+
if (task.enabled) {
|
|
93
|
+
this.scheduler.addJob({
|
|
94
|
+
id: task.id,
|
|
95
|
+
name: task.name,
|
|
96
|
+
schedule: task.schedule,
|
|
97
|
+
task: task.description,
|
|
98
|
+
enabled: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.scheduler.start();
|
|
103
|
+
console.log(`[cron-engine] Started with ${this.store.tasks.filter(t => t.enabled).length} active tasks`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stop(): void {
|
|
107
|
+
this.scheduler.stop();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
listTasks(): ScheduleTask[] {
|
|
111
|
+
// Refresh nextRun
|
|
112
|
+
return this.store.tasks.map(t => ({
|
|
113
|
+
...t,
|
|
114
|
+
nextRun: t.enabled ? computeNextRun(t.schedule) : undefined,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getTask(id: string): ScheduleTask | undefined {
|
|
119
|
+
return this.store.tasks.find(t => t.id === id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
createTask(input: Omit<ScheduleTask, 'id' | 'createdAt' | 'updatedAt' | 'lastRun' | 'nextRun'>): ScheduleTask {
|
|
123
|
+
const id = `sched-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
const schedule = input.schedule || frequencyToCron(input.frequency, input.time);
|
|
126
|
+
const task: ScheduleTask = {
|
|
127
|
+
...input,
|
|
128
|
+
id,
|
|
129
|
+
schedule,
|
|
130
|
+
createdAt: now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
nextRun: input.enabled ? computeNextRun(schedule) : undefined,
|
|
133
|
+
};
|
|
134
|
+
this.store.tasks.push(task);
|
|
135
|
+
saveSchedules(this.store);
|
|
136
|
+
|
|
137
|
+
if (task.enabled) {
|
|
138
|
+
this.scheduler.addJob({
|
|
139
|
+
id: task.id,
|
|
140
|
+
name: task.name,
|
|
141
|
+
schedule: task.schedule,
|
|
142
|
+
task: task.description,
|
|
143
|
+
enabled: true,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return task;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
updateTask(id: string, updates: Partial<ScheduleTask>): ScheduleTask | null {
|
|
150
|
+
const idx = this.store.tasks.findIndex(t => t.id === id);
|
|
151
|
+
if (idx === -1) return null;
|
|
152
|
+
|
|
153
|
+
const task = { ...this.store.tasks[idx], ...updates, id, updatedAt: new Date().toISOString() };
|
|
154
|
+
if (updates.frequency || updates.time) {
|
|
155
|
+
task.schedule = updates.schedule || frequencyToCron(task.frequency, task.time);
|
|
156
|
+
}
|
|
157
|
+
task.nextRun = task.enabled ? computeNextRun(task.schedule) : undefined;
|
|
158
|
+
this.store.tasks[idx] = task;
|
|
159
|
+
saveSchedules(this.store);
|
|
160
|
+
|
|
161
|
+
// Update scheduler
|
|
162
|
+
this.scheduler.removeJob(id);
|
|
163
|
+
if (task.enabled) {
|
|
164
|
+
this.scheduler.addJob({
|
|
165
|
+
id: task.id,
|
|
166
|
+
name: task.name,
|
|
167
|
+
schedule: task.schedule,
|
|
168
|
+
task: task.description,
|
|
169
|
+
enabled: true,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return task;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
deleteTask(id: string): boolean {
|
|
176
|
+
const idx = this.store.tasks.findIndex(t => t.id === id);
|
|
177
|
+
if (idx === -1) return false;
|
|
178
|
+
this.store.tasks.splice(idx, 1);
|
|
179
|
+
saveSchedules(this.store);
|
|
180
|
+
this.scheduler.removeJob(id);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async runTask(id: string): Promise<boolean> {
|
|
185
|
+
const task = this.store.tasks.find(t => t.id === id);
|
|
186
|
+
if (!task) return false;
|
|
187
|
+
task.lastRun = new Date().toISOString();
|
|
188
|
+
saveSchedules(this.store);
|
|
189
|
+
return this.scheduler.runJob(id);
|
|
190
|
+
}
|
|
191
|
+
}
|