opc-agent 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +30 -24
  2. package/dist/channels/dingtalk.d.ts +17 -0
  3. package/dist/channels/dingtalk.js +38 -0
  4. package/dist/channels/googlechat.d.ts +14 -0
  5. package/dist/channels/googlechat.js +37 -0
  6. package/dist/channels/imessage.d.ts +13 -0
  7. package/dist/channels/imessage.js +28 -0
  8. package/dist/channels/irc.d.ts +20 -0
  9. package/dist/channels/irc.js +71 -0
  10. package/dist/channels/line.d.ts +14 -0
  11. package/dist/channels/line.js +28 -0
  12. package/dist/channels/matrix.d.ts +15 -0
  13. package/dist/channels/matrix.js +28 -0
  14. package/dist/channels/mattermost.d.ts +18 -0
  15. package/dist/channels/mattermost.js +49 -0
  16. package/dist/channels/msteams.d.ts +14 -0
  17. package/dist/channels/msteams.js +28 -0
  18. package/dist/channels/nostr.d.ts +14 -0
  19. package/dist/channels/nostr.js +28 -0
  20. package/dist/channels/qq.d.ts +15 -0
  21. package/dist/channels/qq.js +28 -0
  22. package/dist/channels/signal.d.ts +14 -0
  23. package/dist/channels/signal.js +28 -0
  24. package/dist/channels/sms.d.ts +15 -0
  25. package/dist/channels/sms.js +28 -0
  26. package/dist/channels/twitch.d.ts +17 -0
  27. package/dist/channels/twitch.js +59 -0
  28. package/dist/channels/voice-call.d.ts +27 -0
  29. package/dist/channels/voice-call.js +82 -0
  30. package/dist/channels/whatsapp.d.ts +14 -0
  31. package/dist/channels/whatsapp.js +28 -0
  32. package/dist/cli.js +36 -0
  33. package/dist/core/api-server.d.ts +25 -0
  34. package/dist/core/api-server.js +286 -0
  35. package/dist/core/audio.d.ts +50 -0
  36. package/dist/core/audio.js +68 -0
  37. package/dist/core/context-discovery.d.ts +16 -0
  38. package/dist/core/context-discovery.js +107 -0
  39. package/dist/core/context-refs.d.ts +29 -0
  40. package/dist/core/context-refs.js +162 -0
  41. package/dist/core/gateway.d.ts +53 -0
  42. package/dist/core/gateway.js +80 -0
  43. package/dist/core/heartbeat.d.ts +19 -0
  44. package/dist/core/heartbeat.js +50 -0
  45. package/dist/core/hooks.d.ts +28 -0
  46. package/dist/core/hooks.js +82 -0
  47. package/dist/core/ide-bridge.d.ts +53 -0
  48. package/dist/core/ide-bridge.js +97 -0
  49. package/dist/core/node-network.d.ts +23 -0
  50. package/dist/core/node-network.js +77 -0
  51. package/dist/core/profiles.d.ts +27 -0
  52. package/dist/core/profiles.js +131 -0
  53. package/dist/core/sandbox.d.ts +25 -0
  54. package/dist/core/sandbox.js +84 -1
  55. package/dist/core/session-manager.d.ts +33 -0
  56. package/dist/core/session-manager.js +157 -0
  57. package/dist/core/vision.d.ts +45 -0
  58. package/dist/core/vision.js +177 -0
  59. package/dist/index.d.ts +64 -1
  60. package/dist/index.js +86 -3
  61. package/dist/memory/context-compressor.d.ts +43 -0
  62. package/dist/memory/context-compressor.js +167 -0
  63. package/dist/memory/index.d.ts +4 -0
  64. package/dist/memory/index.js +5 -1
  65. package/dist/memory/user-profiler.d.ts +50 -0
  66. package/dist/memory/user-profiler.js +201 -0
  67. package/dist/schema/oad.d.ts +12 -12
  68. package/dist/security/approvals.d.ts +53 -0
  69. package/dist/security/approvals.js +115 -0
  70. package/dist/security/elevated.d.ts +41 -0
  71. package/dist/security/elevated.js +89 -0
  72. package/dist/security/index.d.ts +6 -0
  73. package/dist/security/index.js +7 -1
  74. package/dist/security/secrets.d.ts +34 -0
  75. package/dist/security/secrets.js +115 -0
  76. package/dist/tools/builtin/browser.d.ts +47 -0
  77. package/dist/tools/builtin/browser.js +284 -0
  78. package/dist/tools/builtin/home-assistant.d.ts +12 -0
  79. package/dist/tools/builtin/home-assistant.js +126 -0
  80. package/dist/tools/builtin/index.d.ts +6 -1
  81. package/dist/tools/builtin/index.js +18 -2
  82. package/dist/tools/builtin/rl-tools.d.ts +13 -0
  83. package/dist/tools/builtin/rl-tools.js +228 -0
  84. package/dist/tools/builtin/vision.d.ts +6 -0
  85. package/dist/tools/builtin/vision.js +61 -0
  86. package/package.json +3 -3
  87. package/src/channels/dingtalk.ts +46 -0
  88. package/src/channels/googlechat.ts +42 -0
  89. package/src/channels/imessage.ts +32 -0
  90. package/src/channels/irc.ts +82 -0
  91. package/src/channels/line.ts +33 -0
  92. package/src/channels/matrix.ts +34 -0
  93. package/src/channels/mattermost.ts +57 -0
  94. package/src/channels/msteams.ts +33 -0
  95. package/src/channels/nostr.ts +33 -0
  96. package/src/channels/qq.ts +34 -0
  97. package/src/channels/signal.ts +33 -0
  98. package/src/channels/sms.ts +34 -0
  99. package/src/channels/twitch.ts +65 -0
  100. package/src/channels/voice-call.ts +100 -0
  101. package/src/channels/whatsapp.ts +33 -0
  102. package/src/cli.ts +40 -0
  103. package/src/core/api-server.ts +277 -0
  104. package/src/core/audio.ts +98 -0
  105. package/src/core/context-discovery.ts +85 -0
  106. package/src/core/context-refs.ts +140 -0
  107. package/src/core/gateway.ts +106 -0
  108. package/src/core/heartbeat.ts +51 -0
  109. package/src/core/hooks.ts +105 -0
  110. package/src/core/ide-bridge.ts +133 -0
  111. package/src/core/node-network.ts +86 -0
  112. package/src/core/profiles.ts +122 -0
  113. package/src/core/sandbox.ts +100 -0
  114. package/src/core/session-manager.ts +137 -0
  115. package/src/core/vision.ts +180 -0
  116. package/src/index.ts +84 -1
  117. package/src/memory/context-compressor.ts +189 -0
  118. package/src/memory/index.ts +4 -0
  119. package/src/memory/user-profiler.ts +215 -0
  120. package/src/security/approvals.ts +143 -0
  121. package/src/security/elevated.ts +105 -0
  122. package/src/security/index.ts +6 -0
  123. package/src/security/secrets.ts +129 -0
  124. package/src/tools/builtin/browser.ts +299 -0
  125. package/src/tools/builtin/home-assistant.ts +116 -0
  126. package/src/tools/builtin/index.ts +9 -2
  127. package/src/tools/builtin/rl-tools.ts +243 -0
  128. package/src/tools/builtin/vision.ts +64 -0
  129. package/tests/api-server.test.ts +148 -0
  130. package/tests/approvals.test.ts +89 -0
  131. package/tests/audio.test.ts +40 -0
  132. package/tests/browser.test.ts +179 -0
  133. package/tests/builtin-tools.test.ts +83 -83
  134. package/tests/channels-extra.test.ts +45 -0
  135. package/tests/context-compressor.test.ts +172 -0
  136. package/tests/context-refs.test.ts +121 -0
  137. package/tests/elevated.test.ts +69 -0
  138. package/tests/gateway.test.ts +63 -71
  139. package/tests/home-assistant.test.ts +40 -0
  140. package/tests/hooks.test.ts +79 -0
  141. package/tests/ide-bridge.test.ts +38 -0
  142. package/tests/node-network.test.ts +74 -0
  143. package/tests/profiles.test.ts +61 -0
  144. package/tests/rl-tools.test.ts +93 -0
  145. package/tests/sandbox-manager.test.ts +46 -0
  146. package/tests/secrets.test.ts +107 -0
  147. package/tests/tools/builtin-extended.test.ts +138 -138
  148. package/tests/user-profiler.test.ts +169 -0
  149. package/tests/v090-features.test.ts +254 -0
  150. package/tests/vision.test.ts +61 -0
  151. package/tests/voice-call.test.ts +47 -0
@@ -0,0 +1,133 @@
1
+ import { execSync } from 'child_process';
2
+
3
+ export interface IDEConfig {
4
+ editor: 'vscode' | 'jetbrains' | 'zed' | 'cursor';
5
+ workspacePath?: string;
6
+ }
7
+
8
+ export interface Diagnostic {
9
+ path: string;
10
+ line: number;
11
+ column: number;
12
+ severity: 'error' | 'warning' | 'info';
13
+ message: string;
14
+ }
15
+
16
+ export interface TextEdit {
17
+ range: Range;
18
+ newText: string;
19
+ }
20
+
21
+ export interface Range {
22
+ startLine: number;
23
+ startColumn: number;
24
+ endLine: number;
25
+ endColumn: number;
26
+ }
27
+
28
+ export interface SearchOptions {
29
+ caseSensitive?: boolean;
30
+ regex?: boolean;
31
+ include?: string;
32
+ exclude?: string;
33
+ maxResults?: number;
34
+ }
35
+
36
+ export interface SearchResult {
37
+ path: string;
38
+ line: number;
39
+ column: number;
40
+ text: string;
41
+ }
42
+
43
+ export class IDEBridge {
44
+ private config: IDEConfig;
45
+
46
+ constructor(config: IDEConfig) {
47
+ this.config = config;
48
+ }
49
+
50
+ private getCliCommand(): string {
51
+ switch (this.config.editor) {
52
+ case 'vscode': case 'cursor': return this.config.editor === 'cursor' ? 'cursor' : 'code';
53
+ case 'jetbrains': return 'idea';
54
+ case 'zed': return 'zed';
55
+ }
56
+ }
57
+
58
+ private exec(cmd: string): string {
59
+ try {
60
+ return execSync(cmd, { encoding: 'utf-8', timeout: 10000, cwd: this.config.workspacePath }).trim();
61
+ } catch (e: any) {
62
+ throw new Error(`IDE command failed: ${e.message}`);
63
+ }
64
+ }
65
+
66
+ async openFile(path: string, line?: number): Promise<void> {
67
+ const cli = this.getCliCommand();
68
+ const target = line ? `${path}:${line}` : path;
69
+ if (this.config.editor === 'vscode' || this.config.editor === 'cursor') {
70
+ this.exec(`${cli} --goto "${target}"`);
71
+ } else if (this.config.editor === 'zed') {
72
+ this.exec(`zed "${target}"`);
73
+ } else {
74
+ this.exec(`${cli} --line ${line || 1} "${path}"`);
75
+ }
76
+ }
77
+
78
+ async getDiagnostics(path?: string): Promise<Diagnostic[]> {
79
+ // VS Code doesn't expose diagnostics via CLI; return empty as stub
80
+ return [];
81
+ }
82
+
83
+ async runCommand(command: string): Promise<string> {
84
+ const cli = this.getCliCommand();
85
+ if (this.config.editor === 'vscode' || this.config.editor === 'cursor') {
86
+ return this.exec(`${cli} --command "${command}"`);
87
+ }
88
+ throw new Error(`runCommand not supported for ${this.config.editor}`);
89
+ }
90
+
91
+ async getOpenFiles(): Promise<string[]> {
92
+ // Stub — no standard CLI to get open files
93
+ return [];
94
+ }
95
+
96
+ async applyEdit(path: string, edits: TextEdit[]): Promise<void> {
97
+ // Stub — would use editor's API/extension
98
+ if (edits.length === 0) return;
99
+ throw new Error('applyEdit requires an IDE extension to be installed. Use file system edits as fallback.');
100
+ }
101
+
102
+ async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
103
+ const cli = this.getCliCommand();
104
+ if (this.config.editor === 'vscode' || this.config.editor === 'cursor') {
105
+ try {
106
+ const args = [`--search "${query}"`];
107
+ if (options?.include) args.push(`--include "${options.include}"`);
108
+ const output = this.exec(`${cli} ${args.join(' ')}`);
109
+ // Parse output lines
110
+ return output.split('\n').filter(Boolean).map(line => {
111
+ const match = line.match(/^(.+):(\d+):(\d+):(.*)$/);
112
+ if (!match) return { path: '', line: 0, column: 0, text: line };
113
+ return { path: match[1], line: parseInt(match[2]), column: parseInt(match[3]), text: match[4] };
114
+ });
115
+ } catch { return []; }
116
+ }
117
+ return [];
118
+ }
119
+
120
+ async getSelection(): Promise<{ path: string; text: string; range: Range } | null> {
121
+ // Stub — requires editor extension
122
+ return null;
123
+ }
124
+
125
+ async installExtension(extensionId: string): Promise<void> {
126
+ const cli = this.getCliCommand();
127
+ if (this.config.editor === 'vscode' || this.config.editor === 'cursor') {
128
+ this.exec(`${cli} --install-extension ${extensionId}`);
129
+ } else {
130
+ throw new Error(`installExtension not supported for ${this.config.editor}`);
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { EventEmitter } from 'events';
3
+
4
+ export interface RemoteNode {
5
+ id: string;
6
+ name: string;
7
+ host: string;
8
+ port: number;
9
+ type: 'vps' | 'pi' | 'phone' | 'desktop';
10
+ status: 'online' | 'offline' | 'pairing';
11
+ capabilities: string[];
12
+ lastSeen: number;
13
+ }
14
+
15
+ export class NodeNetwork extends EventEmitter {
16
+ private nodes = new Map<string, RemoteNode>();
17
+
18
+ addNode(config: Partial<RemoteNode>): RemoteNode {
19
+ const node: RemoteNode = {
20
+ id: config.id || randomUUID(),
21
+ name: config.name || 'unnamed',
22
+ host: config.host || 'localhost',
23
+ port: config.port || 8080,
24
+ type: config.type || 'desktop',
25
+ status: config.status || 'offline',
26
+ capabilities: config.capabilities || [],
27
+ lastSeen: Date.now(),
28
+ };
29
+ this.nodes.set(node.id, node);
30
+ return node;
31
+ }
32
+
33
+ removeNode(id: string): void {
34
+ if (!this.nodes.has(id)) throw new Error(`Node ${id} not found`);
35
+ this.nodes.delete(id);
36
+ }
37
+
38
+ listNodes(): RemoteNode[] {
39
+ return Array.from(this.nodes.values());
40
+ }
41
+
42
+ getNode(id: string): RemoteNode | null {
43
+ return this.nodes.get(id) || null;
44
+ }
45
+
46
+ async pair(nodeId: string, pairingCode: string): Promise<boolean> {
47
+ const node = this.nodes.get(nodeId);
48
+ if (!node) throw new Error(`Node ${nodeId} not found`);
49
+ if (!pairingCode || pairingCode.length < 4) return false;
50
+ node.status = 'online';
51
+ node.lastSeen = Date.now();
52
+ return true;
53
+ }
54
+
55
+ async sendCommand(nodeId: string, command: string): Promise<any> {
56
+ const node = this.nodes.get(nodeId);
57
+ if (!node) throw new Error(`Node ${nodeId} not found`);
58
+ if (node.status !== 'online') throw new Error(`Node ${nodeId} is ${node.status}`);
59
+ node.lastSeen = Date.now();
60
+ // Stub: return command echo
61
+ return { nodeId, command, status: 'sent', timestamp: Date.now() };
62
+ }
63
+
64
+ async broadcast(command: string): Promise<Map<string, any>> {
65
+ const results = new Map<string, any>();
66
+ for (const [id, node] of this.nodes) {
67
+ if (node.status === 'online') {
68
+ try {
69
+ results.set(id, await this.sendCommand(id, command));
70
+ } catch (e: any) {
71
+ results.set(id, { error: e.message });
72
+ }
73
+ }
74
+ }
75
+ return results;
76
+ }
77
+
78
+ async healthCheck(): Promise<Map<string, boolean>> {
79
+ const results = new Map<string, boolean>();
80
+ for (const [id, node] of this.nodes) {
81
+ results.set(id, node.status === 'online');
82
+ node.lastSeen = Date.now();
83
+ }
84
+ return results;
85
+ }
86
+ }
@@ -0,0 +1,122 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ // ─── Types ───────────────────────────────────────────────────
6
+
7
+ export interface ProfileConfig {
8
+ model?: string;
9
+ systemPrompt?: string;
10
+ temperature?: number;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export interface Profile {
15
+ name: string;
16
+ config: ProfileConfig;
17
+ createdAt: number;
18
+ lastUsed: number;
19
+ }
20
+
21
+ // ─── ProfileManager ─────────────────────────────────────────
22
+
23
+ export class ProfileManager {
24
+ private profilesDir: string;
25
+ private currentName: string = 'default';
26
+
27
+ constructor(baseDir?: string) {
28
+ this.profilesDir = baseDir ?? path.join(os.homedir(), '.opc', 'profiles');
29
+ this.ensureDir(this.profilesDir);
30
+
31
+ // Load current profile pointer
32
+ const pointerFile = path.join(this.profilesDir, '.current');
33
+ if (fs.existsSync(pointerFile)) {
34
+ this.currentName = fs.readFileSync(pointerFile, 'utf-8').trim();
35
+ }
36
+ }
37
+
38
+ private ensureDir(dir: string): void {
39
+ if (!fs.existsSync(dir)) {
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ }
42
+ }
43
+
44
+ private profileDir(name: string): string {
45
+ return path.join(this.profilesDir, name);
46
+ }
47
+
48
+ private profileFile(name: string): string {
49
+ return path.join(this.profileDir(name), 'profile.json');
50
+ }
51
+
52
+ create(name: string, config: ProfileConfig = {}): Profile {
53
+ const dir = this.profileDir(name);
54
+ if (fs.existsSync(dir)) {
55
+ throw new Error(`Profile '${name}' already exists`);
56
+ }
57
+ this.ensureDir(dir);
58
+ // Also create memory and sessions dirs
59
+ this.ensureDir(path.join(dir, 'memory'));
60
+ this.ensureDir(path.join(dir, 'sessions'));
61
+
62
+ const profile: Profile = {
63
+ name,
64
+ config,
65
+ createdAt: Date.now(),
66
+ lastUsed: Date.now(),
67
+ };
68
+ fs.writeFileSync(this.profileFile(name), JSON.stringify(profile, null, 2));
69
+ return profile;
70
+ }
71
+
72
+ switch(name: string): void {
73
+ if (!fs.existsSync(this.profileDir(name))) {
74
+ throw new Error(`Profile '${name}' does not exist`);
75
+ }
76
+ this.currentName = name;
77
+ fs.writeFileSync(path.join(this.profilesDir, '.current'), name);
78
+
79
+ // Update lastUsed
80
+ const profile = this.get(name);
81
+ profile.lastUsed = Date.now();
82
+ fs.writeFileSync(this.profileFile(name), JSON.stringify(profile, null, 2));
83
+ }
84
+
85
+ list(): Profile[] {
86
+ if (!fs.existsSync(this.profilesDir)) return [];
87
+ return fs.readdirSync(this.profilesDir)
88
+ .filter(f => {
89
+ const full = path.join(this.profilesDir, f);
90
+ return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'profile.json'));
91
+ })
92
+ .map(f => this.get(f));
93
+ }
94
+
95
+ get(name: string): Profile {
96
+ const file = this.profileFile(name);
97
+ if (!fs.existsSync(file)) {
98
+ throw new Error(`Profile '${name}' not found`);
99
+ }
100
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
101
+ }
102
+
103
+ delete(name: string): void {
104
+ if (name === this.currentName) {
105
+ throw new Error(`Cannot delete the current active profile '${name}'`);
106
+ }
107
+ const dir = this.profileDir(name);
108
+ if (!fs.existsSync(dir)) {
109
+ throw new Error(`Profile '${name}' does not exist`);
110
+ }
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ }
113
+
114
+ current(): Profile {
115
+ try {
116
+ return this.get(this.currentName);
117
+ } catch {
118
+ // Auto-create default
119
+ return this.create(this.currentName);
120
+ }
121
+ }
122
+ }
@@ -242,3 +242,103 @@ export class Sandbox {
242
242
  return this.maxFiles;
243
243
  }
244
244
  }
245
+
246
+ // ─── Remote Sandbox Manager (v2.2.0) ────────────────────────
247
+
248
+ export interface RemoteSandboxConfig {
249
+ backend: 'local' | 'docker' | 'ssh';
250
+ docker?: { image: string; volumes?: string[] };
251
+ ssh?: { host: string; user: string; keyPath?: string };
252
+ timeout?: number;
253
+ }
254
+
255
+ export interface ExecResult {
256
+ stdout: string;
257
+ stderr: string;
258
+ exitCode: number;
259
+ }
260
+
261
+ export class SandboxManager {
262
+ private defaultConfig: RemoteSandboxConfig;
263
+
264
+ constructor(config?: Partial<RemoteSandboxConfig>) {
265
+ this.defaultConfig = {
266
+ backend: config?.backend ?? 'local',
267
+ docker: config?.docker,
268
+ ssh: config?.ssh,
269
+ timeout: config?.timeout ?? 30000,
270
+ };
271
+ }
272
+
273
+ async exec(command: string, config?: Partial<RemoteSandboxConfig>): Promise<ExecResult> {
274
+ const cfg = { ...this.defaultConfig, ...config };
275
+ const { execSync } = await import('child_process');
276
+
277
+ switch (cfg.backend) {
278
+ case 'local': {
279
+ try {
280
+ const stdout = execSync(command, {
281
+ timeout: cfg.timeout,
282
+ encoding: 'utf-8',
283
+ stdio: ['pipe', 'pipe', 'pipe'],
284
+ });
285
+ return { stdout: stdout ?? '', stderr: '', exitCode: 0 };
286
+ } catch (err: any) {
287
+ return {
288
+ stdout: err.stdout ?? '',
289
+ stderr: err.stderr ?? '',
290
+ exitCode: err.status ?? 1,
291
+ };
292
+ }
293
+ }
294
+ case 'docker': {
295
+ if (!cfg.docker?.image) throw new Error('Docker image is required');
296
+ const volumes = (cfg.docker.volumes ?? []).map(v => `-v ${v}`).join(' ');
297
+ const dockerCmd = `docker run --rm ${volumes} ${cfg.docker.image} sh -c "${command.replace(/"/g, '\\"')}"`;
298
+ return this.exec(dockerCmd, { backend: 'local', timeout: cfg.timeout });
299
+ }
300
+ case 'ssh': {
301
+ if (!cfg.ssh?.host || !cfg.ssh?.user) throw new Error('SSH host and user are required');
302
+ const keyArg = cfg.ssh.keyPath ? `-i ${cfg.ssh.keyPath}` : '';
303
+ const sshCmd = `ssh ${keyArg} ${cfg.ssh.user}@${cfg.ssh.host} "${command.replace(/"/g, '\\"')}"`;
304
+ return this.exec(sshCmd, { backend: 'local', timeout: cfg.timeout });
305
+ }
306
+ default:
307
+ throw new Error(`Unknown sandbox backend: ${cfg.backend}`);
308
+ }
309
+ }
310
+
311
+ async upload(localPath: string, remotePath: string, config?: Partial<RemoteSandboxConfig>): Promise<void> {
312
+ const cfg = { ...this.defaultConfig, ...config };
313
+ if (cfg.backend === 'local') {
314
+ const fsp = await import('fs');
315
+ fsp.copyFileSync(localPath, remotePath);
316
+ return;
317
+ }
318
+ if (cfg.backend === 'ssh') {
319
+ const keyArg = cfg.ssh?.keyPath ? `-i ${cfg.ssh.keyPath}` : '';
320
+ await this.exec(`scp ${keyArg} "${localPath}" ${cfg.ssh!.user}@${cfg.ssh!.host}:"${remotePath}"`, { backend: 'local' });
321
+ return;
322
+ }
323
+ if (cfg.backend === 'docker') {
324
+ throw new Error('Upload to docker not yet supported. Use volumes instead.');
325
+ }
326
+ }
327
+
328
+ async download(remotePath: string, localPath: string, config?: Partial<RemoteSandboxConfig>): Promise<void> {
329
+ const cfg = { ...this.defaultConfig, ...config };
330
+ if (cfg.backend === 'local') {
331
+ const fsp = await import('fs');
332
+ fsp.copyFileSync(remotePath, localPath);
333
+ return;
334
+ }
335
+ if (cfg.backend === 'ssh') {
336
+ const keyArg = cfg.ssh?.keyPath ? `-i ${cfg.ssh.keyPath}` : '';
337
+ await this.exec(`scp ${keyArg} ${cfg.ssh!.user}@${cfg.ssh!.host}:"${remotePath}" "${localPath}"`, { backend: 'local' });
338
+ return;
339
+ }
340
+ if (cfg.backend === 'docker') {
341
+ throw new Error('Download from docker not yet supported. Use volumes instead.');
342
+ }
343
+ }
344
+ }
@@ -0,0 +1,137 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as crypto from 'crypto';
4
+ import type { Message } from './types';
5
+
6
+ export interface Session {
7
+ id: string;
8
+ agentId: string;
9
+ channel: string;
10
+ messages: Message[];
11
+ metadata: Record<string, any>;
12
+ createdAt: number;
13
+ lastActivity: number;
14
+ parentId?: string;
15
+ compactedAt?: number;
16
+ }
17
+
18
+ export class SessionManager {
19
+ private sessions: Map<string, Session> = new Map();
20
+ private storageDir: string;
21
+
22
+ constructor(storageDir?: string) {
23
+ this.storageDir = storageDir || path.join(process.env.HOME || process.env.USERPROFILE || '~', '.opc', 'sessions');
24
+ }
25
+
26
+ create(agentId: string, channel: string, parentId?: string): Session {
27
+ const session: Session = {
28
+ id: crypto.randomUUID(),
29
+ agentId,
30
+ channel,
31
+ messages: [],
32
+ metadata: {},
33
+ createdAt: Date.now(),
34
+ lastActivity: Date.now(),
35
+ parentId,
36
+ };
37
+ this.sessions.set(session.id, session);
38
+ return session;
39
+ }
40
+
41
+ get(id: string): Session | null {
42
+ return this.sessions.get(id) || null;
43
+ }
44
+
45
+ list(filter?: { agentId?: string; channel?: string; active?: boolean }): Session[] {
46
+ let result = Array.from(this.sessions.values());
47
+ if (filter?.agentId) result = result.filter(s => s.agentId === filter.agentId);
48
+ if (filter?.channel) result = result.filter(s => s.channel === filter.channel);
49
+ if (filter?.active !== undefined) {
50
+ const cutoff = Date.now() - 30 * 60 * 1000; // 30 min
51
+ result = result.filter(s => filter.active ? s.lastActivity > cutoff : s.lastActivity <= cutoff);
52
+ }
53
+ return result;
54
+ }
55
+
56
+ addMessage(sessionId: string, message: Message): void {
57
+ const session = this.sessions.get(sessionId);
58
+ if (!session) throw new Error(`Session ${sessionId} not found`);
59
+ session.messages.push(message);
60
+ session.lastActivity = Date.now();
61
+ }
62
+
63
+ async compact(sessionId: string, brain?: any): Promise<void> {
64
+ const session = this.sessions.get(sessionId);
65
+ if (!session) throw new Error(`Session ${sessionId} not found`);
66
+ if (brain && typeof brain.compress === 'function') {
67
+ const compressed = await brain.compress(session.messages);
68
+ session.messages = [{ id: 'compacted', role: 'system', content: compressed, timestamp: Date.now() }];
69
+ } else {
70
+ // Simple: keep first and last 5 messages
71
+ if (session.messages.length > 10) {
72
+ const first = session.messages.slice(0, 2);
73
+ const last = session.messages.slice(-5);
74
+ session.messages = [...first, { id: 'compacted', role: 'system', content: `[${session.messages.length - 7} messages compacted]`, timestamp: Date.now() }, ...last];
75
+ }
76
+ }
77
+ session.compactedAt = Date.now();
78
+ }
79
+
80
+ prune(maxAge: number): number {
81
+ const cutoff = Date.now() - maxAge;
82
+ let pruned = 0;
83
+ for (const [id, session] of this.sessions) {
84
+ if (session.lastActivity < cutoff) {
85
+ this.sessions.delete(id);
86
+ pruned++;
87
+ }
88
+ }
89
+ return pruned;
90
+ }
91
+
92
+ getLineage(sessionId: string): Session[] {
93
+ const lineage: Session[] = [];
94
+ let current = this.sessions.get(sessionId);
95
+ while (current) {
96
+ lineage.unshift(current);
97
+ current = current.parentId ? this.sessions.get(current.parentId) || undefined : undefined;
98
+ }
99
+ return lineage;
100
+ }
101
+
102
+ fork(sessionId: string): Session {
103
+ const parent = this.sessions.get(sessionId);
104
+ if (!parent) throw new Error(`Session ${sessionId} not found`);
105
+ return this.create(parent.agentId, parent.channel, parent.id);
106
+ }
107
+
108
+ export(sessionId: string): string {
109
+ const session = this.sessions.get(sessionId);
110
+ if (!session) throw new Error(`Session ${sessionId} not found`);
111
+ const lines = [`# Session ${session.id}`, `Agent: ${session.agentId} | Channel: ${session.channel}`, `Created: ${new Date(session.createdAt).toISOString()}`, ''];
112
+ for (const msg of session.messages) {
113
+ lines.push(`**${msg.role}** (${new Date(msg.timestamp).toISOString()}):`);
114
+ lines.push(msg.content);
115
+ lines.push('');
116
+ }
117
+ return lines.join('\n');
118
+ }
119
+
120
+ save(): void {
121
+ if (!fs.existsSync(this.storageDir)) {
122
+ fs.mkdirSync(this.storageDir, { recursive: true });
123
+ }
124
+ for (const [id, session] of this.sessions) {
125
+ fs.writeFileSync(path.join(this.storageDir, `${id}.json`), JSON.stringify(session, null, 2));
126
+ }
127
+ }
128
+
129
+ load(): void {
130
+ if (!fs.existsSync(this.storageDir)) return;
131
+ const files = fs.readdirSync(this.storageDir).filter(f => f.endsWith('.json'));
132
+ for (const file of files) {
133
+ const data = JSON.parse(fs.readFileSync(path.join(this.storageDir, file), 'utf-8'));
134
+ this.sessions.set(data.id, data);
135
+ }
136
+ }
137
+ }