@stackmemoryai/stackmemory 1.12.0 → 1.14.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.
- package/LICENSE +131 -64
- package/README.md +3 -1
- package/bin/claude-sm +16 -1
- package/bin/claude-smd +16 -1
- package/bin/codex-smd +16 -1
- package/bin/gemini-sm +16 -1
- package/bin/hermes-sm +21 -0
- package/bin/hermes-smd +21 -0
- package/bin/opencode-sm +16 -1
- package/dist/src/cli/codex-sm.js +51 -11
- package/dist/src/cli/commands/brain.js +206 -0
- package/dist/src/cli/commands/company-os.js +184 -0
- package/dist/src/cli/commands/context.js +5 -0
- package/dist/src/cli/commands/operator.js +127 -0
- package/dist/src/cli/commands/orchestrate.js +2 -0
- package/dist/src/cli/commands/orchestrator.js +3 -2
- package/dist/src/cli/commands/patterns.js +254 -0
- package/dist/src/cli/commands/portal.js +161 -0
- package/dist/src/cli/commands/scaffold.js +92 -0
- package/dist/src/cli/commands/setup.js +1 -4
- package/dist/src/cli/commands/sync.js +253 -0
- package/dist/src/cli/commands/tasks.js +130 -1
- package/dist/src/cli/commands/vision.js +221 -0
- package/dist/src/cli/hermes-sm.js +224 -0
- package/dist/src/cli/index.js +15 -10
- package/dist/src/cli/utils/real-cli-bin.js +72 -0
- package/dist/src/core/brain/brain-store.js +187 -0
- package/dist/src/core/brain/brain-sync.js +193 -0
- package/dist/src/core/brain/index.js +78 -0
- package/dist/src/core/brain/types.js +10 -0
- package/dist/src/core/cache/token-estimator.js +24 -1
- package/dist/src/core/config/feature-flags.js +2 -6
- package/dist/src/core/context/frame-database.js +44 -0
- package/dist/src/core/context/recursive-context-manager.js +1 -1
- package/dist/src/core/context/rehydration.js +2 -1
- package/dist/src/core/database/sqlite-adapter.js +14 -1
- package/dist/src/core/models/model-router.js +33 -1
- package/dist/src/core/models/provider-pricing.js +58 -4
- package/dist/src/core/patterns/index.js +22 -0
- package/dist/src/core/patterns/pattern-applier.js +39 -0
- package/dist/src/core/patterns/pattern-observer.js +157 -0
- package/dist/src/core/patterns/pattern-store.js +259 -0
- package/dist/src/core/patterns/types.js +19 -0
- package/dist/src/core/retrieval/llm-context-retrieval.js +5 -4
- package/dist/src/core/retrieval/unified-context-assembler.js +11 -66
- package/dist/src/core/skill-packs/types.js +14 -1
- package/dist/src/core/storage/cloud-sync-manager.js +116 -0
- package/dist/src/core/storage/cloud-sync.js +574 -0
- package/dist/src/core/storage/two-tier-storage.js +5 -1
- package/dist/src/core/tasks/master-tasks-template.js +43 -0
- package/dist/src/core/tasks/md-task-parser.js +138 -0
- package/dist/src/core/vision/index.js +27 -0
- package/dist/src/core/vision/signals.js +79 -0
- package/dist/src/core/vision/types.js +22 -0
- package/dist/src/core/vision/vision-file.js +146 -0
- package/dist/src/core/vision/vision-loop.js +220 -0
- package/dist/src/core/wiki/wiki-compiler.js +103 -1
- package/dist/src/daemon/daemon-config.js +45 -0
- package/dist/src/daemon/services/desire-path-service.js +566 -0
- package/dist/src/daemon/services/research-stream-service.js +320 -0
- package/dist/src/daemon/services/telemetry-service.js +192 -0
- package/dist/src/daemon/unified-daemon.js +28 -1
- package/dist/src/features/browser/cli-browser-agent.js +417 -0
- package/dist/src/features/browser/stagehand-workflows.js +578 -0
- package/dist/src/features/operator/adapter-factory.js +62 -0
- package/dist/src/features/operator/browser-adapter.js +109 -0
- package/dist/src/features/operator/desktop-adapter.js +125 -0
- package/dist/src/features/operator/index.js +39 -0
- package/dist/src/features/operator/llm-decision.js +137 -0
- package/dist/src/features/operator/operator-logger.js +92 -0
- package/dist/src/features/operator/overnight-runner.js +327 -0
- package/dist/src/features/operator/screen-adapter.js +91 -0
- package/dist/src/features/operator/session-manager.js +127 -0
- package/dist/src/features/operator/state-machine.js +227 -0
- package/dist/src/features/operator/task-queue.js +81 -0
- package/dist/src/features/portal/index.js +26 -0
- package/dist/src/features/portal/server.js +240 -0
- package/dist/src/features/portal/types.js +14 -0
- package/dist/src/features/portal/ui.js +195 -0
- package/dist/src/features/tasks/task-aware-context.js +2 -1
- package/dist/src/features/tui/simple-monitor.js +0 -23
- package/dist/src/features/tui/swarm-monitor.js +8 -66
- package/dist/src/{integrations/diffmem/index.js → features/web/client/hooks/use-socket.js} +6 -5
- package/dist/src/features/web/client/lib/utils.js +12 -0
- package/dist/src/features/web/client/stores/session-store.js +12 -0
- package/dist/src/features/web/server/gcp-billing.js +76 -0
- package/dist/src/features/web/server/index.js +10 -0
- package/dist/src/features/web/server/spend-calculator.js +228 -0
- package/dist/src/hooks/schemas.js +4 -1
- package/dist/src/integrations/anthropic/client.js +3 -2
- package/dist/src/integrations/claude-code/agent-bridge.js +0 -3
- package/dist/src/integrations/claude-code/subagent-client.js +218 -11
- package/dist/src/integrations/claude-code/task-coordinator.js +2 -1
- package/dist/src/integrations/linear/webhook-retry.js +196 -0
- package/dist/src/integrations/linear/webhook-server.js +18 -22
- package/dist/src/integrations/mcp/handlers/cloud-sync-handlers.js +101 -0
- package/dist/src/integrations/mcp/handlers/index.js +27 -52
- package/dist/src/integrations/mcp/server.js +122 -335
- package/dist/src/integrations/mcp/tool-alias-registry.js +0 -73
- package/dist/src/integrations/mcp/tool-definitions.js +111 -510
- package/dist/src/mcp/stackmemory-mcp-server.js +404 -379
- package/dist/src/orchestrators/multimodal/determinism.js +2 -1
- package/dist/src/orchestrators/multimodal/harness.js +2 -1
- package/dist/src/skills/recursive-agent-orchestrator.js +2 -4
- package/dist/src/utils/process-cleanup.js +1 -7
- package/docs/README.md +42 -0
- package/docs/guides/README_INSTALL.md +208 -0
- package/package.json +18 -9
- package/scripts/claude-code-wrapper.sh +11 -0
- package/scripts/claude-sm-setup.sh +12 -1
- package/scripts/codex-wrapper.sh +11 -0
- package/scripts/git-hooks/branch-context-manager.sh +11 -0
- package/scripts/git-hooks/post-checkout-stackmemory.sh +11 -0
- package/scripts/git-hooks/post-commit-stackmemory.sh +11 -0
- package/scripts/git-hooks/pre-commit-stackmemory.sh +11 -0
- package/scripts/hooks/cleanup-shell.sh +12 -1
- package/scripts/hooks/task-complete.sh +12 -1
- package/scripts/install-code-execution-hooks.sh +12 -1
- package/scripts/install-sweep-hook.sh +12 -0
- package/scripts/install.sh +11 -0
- package/scripts/opencode-wrapper.sh +11 -0
- package/scripts/portal/cloud-init.yaml +69 -0
- package/scripts/portal/setup.sh +69 -0
- package/scripts/portal/stackmemory-portal.service +34 -0
- package/scripts/setup-claude-integration.sh +12 -1
- package/scripts/smoke-init-db.sh +23 -0
- package/scripts/stackmemory-daemon.sh +11 -0
- package/scripts/verify-dist.cjs +11 -4
- package/dist/src/cli/commands/ralph.js +0 -1053
- package/dist/src/hooks/diffmem-hooks.js +0 -376
- package/dist/src/integrations/diffmem/client.js +0 -208
- package/dist/src/integrations/diffmem/config.js +0 -14
- package/dist/src/integrations/greptile/client.js +0 -101
- package/dist/src/integrations/greptile/config.js +0 -14
- package/dist/src/integrations/greptile/index.js +0 -11
- package/dist/src/integrations/mcp/handlers/cross-search-handlers.js +0 -188
- package/dist/src/integrations/mcp/handlers/diffmem-handlers.js +0 -455
- package/dist/src/integrations/mcp/handlers/greptile-handlers.js +0 -456
- package/dist/src/integrations/mcp/handlers/provider-handlers.js +0 -227
- package/dist/src/integrations/ralph/bridge/ralph-stackmemory-bridge.js +0 -863
- package/dist/src/integrations/ralph/context/context-budget-manager.js +0 -308
- package/dist/src/integrations/ralph/context/stackmemory-context-loader.js +0 -354
- package/dist/src/integrations/ralph/index.js +0 -17
- package/dist/src/integrations/ralph/learning/pattern-learner.js +0 -416
- package/dist/src/integrations/ralph/lifecycle/iteration-lifecycle.js +0 -448
- package/dist/src/integrations/ralph/loopmax.js +0 -488
- package/dist/src/integrations/ralph/monitoring/swarm-dashboard.js +0 -293
- package/dist/src/integrations/ralph/monitoring/swarm-registry.js +0 -107
- package/dist/src/integrations/ralph/orchestration/multi-loop-orchestrator.js +0 -508
- package/dist/src/integrations/ralph/patterns/compounding-engineering-pattern.js +0 -407
- package/dist/src/integrations/ralph/patterns/extended-coherence-sessions.js +0 -495
- package/dist/src/integrations/ralph/patterns/oracle-worker-pattern.js +0 -387
- package/dist/src/integrations/ralph/performance/performance-optimizer.js +0 -357
- package/dist/src/integrations/ralph/recovery/crash-recovery.js +0 -461
- package/dist/src/integrations/ralph/state/state-reconciler.js +0 -420
- package/dist/src/integrations/ralph/swarm/git-workflow-manager.js +0 -444
- package/dist/src/integrations/ralph/swarm/swarm-coordinator.js +0 -1005
- package/dist/src/integrations/ralph/visualization/ralph-debugger.js +0 -635
- package/scripts/ralph-loop-implementation.js +0 -404
- /package/dist/src/{integrations/diffmem/types.js → core/storage/cloud-sync-types.js} +0 -0
- /package/dist/src/{integrations/greptile → features/operator}/types.js +0 -0
- /package/dist/src/{integrations/ralph/types.js → features/web/client/next-env.d.js} +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
3
|
+
import { dirname as __pathDirname } from 'path';
|
|
4
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = __pathDirname(__filename);
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, extname } from "node:path";
|
|
10
|
+
const WIKI_DIR = join(process.cwd(), "wiki");
|
|
11
|
+
const WIKI_SOPS_DIR = join(WIKI_DIR, "sops");
|
|
12
|
+
const PROSE_SPEC_PATH = join(
|
|
13
|
+
process.cwd(),
|
|
14
|
+
"docs",
|
|
15
|
+
"specs",
|
|
16
|
+
"COMPANY-OS-PROSE.md"
|
|
17
|
+
);
|
|
18
|
+
function collectSopFiles() {
|
|
19
|
+
const files = [];
|
|
20
|
+
const dirs = [WIKI_DIR];
|
|
21
|
+
if (existsSync(WIKI_SOPS_DIR)) dirs.push(WIKI_SOPS_DIR);
|
|
22
|
+
for (const dir of dirs) {
|
|
23
|
+
if (!existsSync(dir)) continue;
|
|
24
|
+
for (const entry of readdirSync(dir)) {
|
|
25
|
+
if (extname(entry) !== ".md") continue;
|
|
26
|
+
const filePath = join(dir, entry);
|
|
27
|
+
const content = readFileSync(filePath, "utf8");
|
|
28
|
+
const idMatch = content.match(/^# (SOP-\d+)/m);
|
|
29
|
+
const titleMatch = content.match(/^# SOP-\d+\s+(.+)/m);
|
|
30
|
+
const statusMatch = content.match(/\*\*Status:\*\*\s*(\w+)/);
|
|
31
|
+
const proseMatch = content.match(/Related PROSE Expectation.*\[(E\.\d+)/);
|
|
32
|
+
files.push({
|
|
33
|
+
id: idMatch?.[1] ?? entry.replace(".md", ""),
|
|
34
|
+
title: titleMatch?.[1] ?? entry,
|
|
35
|
+
status: statusMatch?.[1] ?? "Unknown",
|
|
36
|
+
proseId: proseMatch?.[1] ?? null,
|
|
37
|
+
path: filePath
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
function loadValidProseIds() {
|
|
44
|
+
const ids = /* @__PURE__ */ new Set();
|
|
45
|
+
if (!existsSync(PROSE_SPEC_PATH)) return ids;
|
|
46
|
+
const content = readFileSync(PROSE_SPEC_PATH, "utf8");
|
|
47
|
+
const headingRegex = /^###\s+(E\.\d+)\s+/gm;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = headingRegex.exec(content)) !== null) {
|
|
50
|
+
ids.add(match[1]);
|
|
51
|
+
}
|
|
52
|
+
return ids;
|
|
53
|
+
}
|
|
54
|
+
function validateSop(content) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
const required = ["## Objective", "## Procedure", "## Verification"];
|
|
57
|
+
for (const section of required) {
|
|
58
|
+
if (!content.includes(section)) {
|
|
59
|
+
errors.push(`Missing section: ${section}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!content.match(/Related PROSE Expectation.*\[E\.\d+/)) {
|
|
63
|
+
errors.push("Missing or invalid Related PROSE Expectation");
|
|
64
|
+
}
|
|
65
|
+
if (!content.match(/^# SOP-\d+/m)) {
|
|
66
|
+
errors.push("Missing or invalid SOP ID (expected # SOP-NNN ...)");
|
|
67
|
+
}
|
|
68
|
+
return errors;
|
|
69
|
+
}
|
|
70
|
+
function createCompanyOsCommand() {
|
|
71
|
+
const cmd = new Command("company-os").alias("cos").description("Manage Company OS processes, SOPs, and audits");
|
|
72
|
+
cmd.command("list").description("List SOPs in the Company OS wiki").option("--json", "Output as JSON").action(() => {
|
|
73
|
+
const sops = collectSopFiles();
|
|
74
|
+
if (sops.length === 0) {
|
|
75
|
+
console.log(chalk.yellow("No SOPs found in wiki/"));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log(chalk.cyan(`
|
|
79
|
+
Company OS SOPs (${sops.length})
|
|
80
|
+
`));
|
|
81
|
+
for (const sop of sops.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
82
|
+
console.log(
|
|
83
|
+
` ${chalk.bold(sop.id)} ${chalk.white(sop.title.slice(0, 50))}`
|
|
84
|
+
);
|
|
85
|
+
console.log(
|
|
86
|
+
chalk.gray(
|
|
87
|
+
` status: ${sop.status} PROSE: ${sop.proseId ?? "none"} ${sop.path}`
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
92
|
+
});
|
|
93
|
+
cmd.command("validate").description("Validate all Company OS SOPs against the schema").option("--json", "Output as JSON").action(() => {
|
|
94
|
+
const sops = collectSopFiles();
|
|
95
|
+
const validProseIds = loadValidProseIds();
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const sop of sops) {
|
|
98
|
+
const content = readFileSync(sop.path, "utf8");
|
|
99
|
+
const errors = validateSop(content);
|
|
100
|
+
if (sop.proseId && !validProseIds.has(sop.proseId)) {
|
|
101
|
+
errors.push(`Invalid PROSE Expectation: ${sop.proseId}`);
|
|
102
|
+
}
|
|
103
|
+
results.push({
|
|
104
|
+
id: sop.id,
|
|
105
|
+
path: sop.path,
|
|
106
|
+
valid: errors.length === 0,
|
|
107
|
+
errors
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const validCount = results.filter((r) => r.valid).length;
|
|
111
|
+
if (results.every((r) => r.valid)) {
|
|
112
|
+
console.log(chalk.green(`
|
|
113
|
+
\u2713 All ${validCount} SOPs are valid.
|
|
114
|
+
`));
|
|
115
|
+
} else {
|
|
116
|
+
console.log(chalk.yellow(`
|
|
117
|
+
SOP Validation Results
|
|
118
|
+
`));
|
|
119
|
+
for (const result of results) {
|
|
120
|
+
const icon = result.valid ? chalk.green("\u2713") : chalk.red("\u2717");
|
|
121
|
+
console.log(`${icon} ${result.id}`);
|
|
122
|
+
for (const error of result.errors) {
|
|
123
|
+
console.log(chalk.gray(` - ${error}`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
console.log(chalk.gray(`
|
|
127
|
+
Valid: ${validCount} / ${results.length}
|
|
128
|
+
`));
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
cmd.command("audit <process>").description(
|
|
133
|
+
"Audit a Company OS process against its SOP (e.g. onboarding, pto)"
|
|
134
|
+
).option("--json", "Output as JSON").action((processName) => {
|
|
135
|
+
const sops = collectSopFiles();
|
|
136
|
+
const normalized = processName.toLowerCase().replace(/\s+/g, "-");
|
|
137
|
+
const sop = sops.find((s) => {
|
|
138
|
+
const sopNormalized = s.title.toLowerCase().replace(/\s+/g, "-");
|
|
139
|
+
return sopNormalized.includes(normalized) || s.id.toLowerCase().includes(normalized);
|
|
140
|
+
});
|
|
141
|
+
if (!sop) {
|
|
142
|
+
console.log(
|
|
143
|
+
chalk.red(`No SOP found matching process "${processName}"`)
|
|
144
|
+
);
|
|
145
|
+
console.log(chalk.gray("Run: stackmemory company-os list"));
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const content = readFileSync(sop.path, "utf8");
|
|
150
|
+
const errors = validateSop(content);
|
|
151
|
+
const validProseIds = loadValidProseIds();
|
|
152
|
+
if (sop.proseId && !validProseIds.has(sop.proseId)) {
|
|
153
|
+
errors.push(`Invalid PROSE Expectation: ${sop.proseId}`);
|
|
154
|
+
}
|
|
155
|
+
const compliant = errors.length === 0;
|
|
156
|
+
console.log(chalk.cyan(`
|
|
157
|
+
Company OS Audit: ${sop.id} ${sop.title}
|
|
158
|
+
`));
|
|
159
|
+
console.log(` PROSE Expectation: ${sop.proseId ?? "none"}`);
|
|
160
|
+
console.log(
|
|
161
|
+
` Status: ${compliant ? chalk.green("compliant") : chalk.red("non-compliant")}`
|
|
162
|
+
);
|
|
163
|
+
if (errors.length > 0) {
|
|
164
|
+
console.log(chalk.yellow("\n Findings:"));
|
|
165
|
+
for (const error of errors) {
|
|
166
|
+
console.log(chalk.gray(` - ${error}`));
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
console.log(
|
|
170
|
+
chalk.gray(
|
|
171
|
+
"\n Note: This POC audit validates SOP structure only. A full audit would check operational data against the SOP."
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
console.log();
|
|
176
|
+
if (!compliant) {
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return cmd;
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
createCompanyOsCommand
|
|
184
|
+
};
|
|
@@ -34,6 +34,7 @@ function createContextCommands() {
|
|
|
34
34
|
const frameManager = new FrameManager(db, projectId, {
|
|
35
35
|
skipContextBridge: true
|
|
36
36
|
});
|
|
37
|
+
await frameManager.initialize();
|
|
37
38
|
const depth = frameManager.getStackDepth();
|
|
38
39
|
const activePath = frameManager.getActiveFramePath();
|
|
39
40
|
console.log(`
|
|
@@ -101,6 +102,7 @@ function createContextCommands() {
|
|
|
101
102
|
const frameManager = new FrameManager(db, projectId, {
|
|
102
103
|
skipContextBridge: true
|
|
103
104
|
});
|
|
105
|
+
await frameManager.initialize();
|
|
104
106
|
const activePath = frameManager.getActiveFramePath();
|
|
105
107
|
const parentId = activePath.length > 0 ? activePath[activePath.length - 1].frame_id : void 0;
|
|
106
108
|
let inputs = {};
|
|
@@ -147,6 +149,7 @@ function createContextCommands() {
|
|
|
147
149
|
const frameManager = new FrameManager(db, projectId, {
|
|
148
150
|
skipContextBridge: true
|
|
149
151
|
});
|
|
152
|
+
await frameManager.initialize();
|
|
150
153
|
const activePath = frameManager.getActiveFramePath();
|
|
151
154
|
if (activePath.length === 0) {
|
|
152
155
|
console.log("\u{1F4DA} Stack is already empty.");
|
|
@@ -193,6 +196,7 @@ function createContextCommands() {
|
|
|
193
196
|
const frameManager = new FrameManager(db, projectId, {
|
|
194
197
|
skipContextBridge: true
|
|
195
198
|
});
|
|
199
|
+
await frameManager.initialize();
|
|
196
200
|
const activePath = frameManager.getActiveFramePath();
|
|
197
201
|
if (activePath.length === 0) {
|
|
198
202
|
console.log("\u26A0\uFE0F No active context frame. Creating one...");
|
|
@@ -248,6 +252,7 @@ function createContextCommands() {
|
|
|
248
252
|
const frameManager = new FrameManager(db, projectId, {
|
|
249
253
|
skipContextBridge: true
|
|
250
254
|
});
|
|
255
|
+
await frameManager.initialize();
|
|
251
256
|
if (options.list || action === "list") {
|
|
252
257
|
const worktreeFrames = db.prepare(
|
|
253
258
|
`
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { existsSync, writeFileSync, mkdirSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { OvernightRunner } from "../../features/operator/overnight-runner.js";
|
|
10
|
+
import { OperatorLogger } from "../../features/operator/operator-logger.js";
|
|
11
|
+
const OPERATOR_DIR = join(homedir(), ".stackmemory", "operator");
|
|
12
|
+
const STOP_SIGNAL_FILE = join(OPERATOR_DIR, "stop-signal");
|
|
13
|
+
const LOG_DIR = join(OPERATOR_DIR, "logs");
|
|
14
|
+
function createOperatorCommands() {
|
|
15
|
+
const operator = new Command("operator").description(
|
|
16
|
+
"Autonomous Claude Code operator \u2014 drives sessions overnight via tmux"
|
|
17
|
+
);
|
|
18
|
+
operator.command("start").description(
|
|
19
|
+
"Start the operator \u2014 drains task queue using Claude Code in tmux"
|
|
20
|
+
).requiredOption("-f, --file <path>", "Path to master-tasks.md").option("--cwd <dir>", "Working directory for Claude", process.cwd()).option("--mode <mode>", "Adapter: tmux | desktop | browser | auto", "auto").option("--poll <ms>", "Poll interval in ms", "2000").option("--stuck-timeout <ms>", "Stuck detection threshold in ms", "300000").option("--session <name>", "tmux session name", "operator").option("--model <model>", "Claude model override (inner agent)").option(
|
|
21
|
+
"--api-key <key>",
|
|
22
|
+
"Anthropic API key for LLM outer loop (or ANTHROPIC_API_KEY env)"
|
|
23
|
+
).option(
|
|
24
|
+
"--llm-model <model>",
|
|
25
|
+
"LLM model for outer loop decisions",
|
|
26
|
+
"claude-haiku-4-5-20251001"
|
|
27
|
+
).option("--no-auto-commit", "Disable auto-commit after each task").option(
|
|
28
|
+
"--max-restarts <n>",
|
|
29
|
+
"Max consecutive restarts before stopping",
|
|
30
|
+
"10"
|
|
31
|
+
).option("--log-dir <dir>", "Log directory", LOG_DIR).action(async (opts) => {
|
|
32
|
+
if (!existsSync(opts.file)) {
|
|
33
|
+
process.stderr.write(`Error: task file not found: ${opts.file}
|
|
34
|
+
`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
38
|
+
if (opts.mode === "desktop" && !apiKey) {
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
"Error: desktop mode requires --api-key or ANTHROPIC_API_KEY for screenshot interpretation\n"
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const config = {
|
|
45
|
+
taskFilePath: opts.file,
|
|
46
|
+
cwd: opts.cwd,
|
|
47
|
+
adapterMode: opts.mode,
|
|
48
|
+
pollIntervalMs: parseInt(opts.poll, 10),
|
|
49
|
+
stuckTimeoutMs: parseInt(opts.stuckTimeout, 10),
|
|
50
|
+
rateLimitBackoffMs: 6e4,
|
|
51
|
+
maxRateLimitBackoffMs: 9e5,
|
|
52
|
+
maxConsecutiveRestarts: parseInt(opts.maxRestarts, 10),
|
|
53
|
+
sessionName: opts.session,
|
|
54
|
+
model: opts.model,
|
|
55
|
+
anthropicApiKey: apiKey,
|
|
56
|
+
llmModel: opts.llmModel,
|
|
57
|
+
autoCommit: opts.autoCommit !== false,
|
|
58
|
+
logDir: opts.logDir
|
|
59
|
+
};
|
|
60
|
+
const runner = new OvernightRunner(config);
|
|
61
|
+
try {
|
|
62
|
+
await runner.run();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
process.stderr.write(`Operator error: ${msg}
|
|
66
|
+
`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
operator.command("stop").description("Signal the running operator to stop gracefully").option("--session <name>", "tmux session name", "operator").action((_opts) => {
|
|
71
|
+
mkdirSync(OPERATOR_DIR, { recursive: true });
|
|
72
|
+
writeFileSync(STOP_SIGNAL_FILE, String(Date.now()), "utf-8");
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
"Stop signal sent. Operator will shut down after current tick.\n"
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
operator.command("status").description("Show current operator status from checkpoint").option("--json", "Output raw JSON").action((opts) => {
|
|
78
|
+
const checkpoint = OperatorLogger.readCheckpoint(OPERATOR_DIR);
|
|
79
|
+
if (!checkpoint) {
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
"No operator checkpoint found. Is the operator running?\n"
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
if (opts.json) {
|
|
86
|
+
process.stdout.write(JSON.stringify(checkpoint, null, 2) + "\n");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const runtime = Date.now() - checkpoint.startedAt;
|
|
90
|
+
const hrs = Math.floor(runtime / 36e5);
|
|
91
|
+
const mins = Math.floor(runtime % 36e5 / 6e4);
|
|
92
|
+
const lines = [
|
|
93
|
+
`State: ${checkpoint.currentState}`,
|
|
94
|
+
`Active task: ${checkpoint.currentTaskId ?? "none"}`,
|
|
95
|
+
`Completed: ${checkpoint.tasksCompleted.length}`,
|
|
96
|
+
`Blocked: ${checkpoint.tasksBlocked.length}`,
|
|
97
|
+
`Runtime: ${hrs}h ${mins}m`,
|
|
98
|
+
`Restarts: ${checkpoint.totalRestarts}`,
|
|
99
|
+
`Approvals: ${checkpoint.totalPermissionApprovals}`,
|
|
100
|
+
`Rate limits: ${checkpoint.totalRateLimitHits}`
|
|
101
|
+
];
|
|
102
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
103
|
+
});
|
|
104
|
+
operator.command("attach").description(
|
|
105
|
+
"Attach to the operator tmux session for live observation (Ctrl-B d to detach)"
|
|
106
|
+
).option("--session <name>", "tmux session name", "operator").action((opts) => {
|
|
107
|
+
try {
|
|
108
|
+
const {
|
|
109
|
+
SessionManager
|
|
110
|
+
} = require("../../features/operator/session-manager.js");
|
|
111
|
+
const mgr = new SessionManager({
|
|
112
|
+
sessionName: opts.session,
|
|
113
|
+
cwd: process.cwd()
|
|
114
|
+
});
|
|
115
|
+
mgr.attach();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
process.stderr.write(`Attach error: ${msg}
|
|
119
|
+
`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return operator;
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
createOperatorCommands
|
|
127
|
+
};
|
|
@@ -18,6 +18,7 @@ import Database from "better-sqlite3";
|
|
|
18
18
|
import { logger } from "../../core/monitoring/logger.js";
|
|
19
19
|
import { isProcessAlive } from "../../utils/process-cleanup.js";
|
|
20
20
|
import { Conductor } from "./orchestrator.js";
|
|
21
|
+
import { createVisionCommand } from "./vision.js";
|
|
21
22
|
import {
|
|
22
23
|
getAgentStatusDir,
|
|
23
24
|
getOutcomesLogPath
|
|
@@ -668,6 +669,7 @@ function createConductorCommands() {
|
|
|
668
669
|
}
|
|
669
670
|
cmd.help();
|
|
670
671
|
});
|
|
672
|
+
cmd.addCommand(createVisionCommand());
|
|
671
673
|
cmd.command("capture").description("Capture workspace context after an agent run").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--attempt <n>", "Attempt number", "1").action(async (options) => {
|
|
672
674
|
const workspace = options.workspace;
|
|
673
675
|
const issueId = options.issue;
|
|
@@ -3,6 +3,7 @@ import { dirname as __pathDirname } from 'path';
|
|
|
3
3
|
const __filename = __fileURLToPath(import.meta.url);
|
|
4
4
|
const __dirname = __pathDirname(__filename);
|
|
5
5
|
import { spawn, execSync } from "child_process";
|
|
6
|
+
import { estimateTokens } from "../../core/cache/token-estimator.js";
|
|
6
7
|
import {
|
|
7
8
|
appendFileSync,
|
|
8
9
|
existsSync,
|
|
@@ -1602,7 +1603,7 @@ class Conductor {
|
|
|
1602
1603
|
}
|
|
1603
1604
|
if (block.type === "text" && block.text) {
|
|
1604
1605
|
const text = block.text;
|
|
1605
|
-
run.tokensUsed +=
|
|
1606
|
+
run.tokensUsed += estimateTokens(text);
|
|
1606
1607
|
turnTextParts.push(text);
|
|
1607
1608
|
}
|
|
1608
1609
|
}
|
|
@@ -1738,7 +1739,7 @@ class Conductor {
|
|
|
1738
1739
|
}
|
|
1739
1740
|
}
|
|
1740
1741
|
if (msg.method === "item/text" && params?.text) {
|
|
1741
|
-
run.tokensUsed +=
|
|
1742
|
+
run.tokensUsed += estimateTokens(params.text);
|
|
1742
1743
|
}
|
|
1743
1744
|
if (run.toolCalls % 5 === 0 || phase) {
|
|
1744
1745
|
this.writeAgentStatus(issue.identifier, run);
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { PatternStore } from "../../core/patterns/pattern-store.js";
|
|
11
|
+
function getDb() {
|
|
12
|
+
const dbPath = join(homedir(), ".stackmemory", "stackmemory.db");
|
|
13
|
+
return new Database(dbPath);
|
|
14
|
+
}
|
|
15
|
+
function getStore() {
|
|
16
|
+
return new PatternStore(getDb());
|
|
17
|
+
}
|
|
18
|
+
function createPatternsCommand() {
|
|
19
|
+
const patterns = new Command("patterns").description(
|
|
20
|
+
"Manage learned behavioral patterns"
|
|
21
|
+
);
|
|
22
|
+
patterns.command("list").description("List learned patterns").option("-d, --domain <domain>", "Filter by domain").option("-s, --status <status>", "Filter by status", "active").option("--min-confidence <n>", "Minimum confidence", "0").option("--json", "Output JSON").action((opts) => {
|
|
23
|
+
const store = getStore();
|
|
24
|
+
const list = store.list({
|
|
25
|
+
domain: opts.domain,
|
|
26
|
+
status: opts.status,
|
|
27
|
+
minConfidence: parseFloat(opts.minConfidence)
|
|
28
|
+
});
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
process.stdout.write(JSON.stringify(list, null, 2) + "\n");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (list.length === 0) {
|
|
34
|
+
process.stdout.write("No patterns found.\n");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const p of list) {
|
|
38
|
+
const bar = confidenceBar(p.confidence);
|
|
39
|
+
const scope = p.scope === "global" ? "[global]" : "[project]";
|
|
40
|
+
process.stdout.write(
|
|
41
|
+
`${bar} ${p.confidence.toFixed(2)} ${scope} ${p.id}
|
|
42
|
+
trigger: ${p.trigger}
|
|
43
|
+
action: ${p.action}
|
|
44
|
+
domain: ${p.domain} obs: ${p.observationCount} status: ${p.status}
|
|
45
|
+
|
|
46
|
+
`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
patterns.command("learn").description("Manually record a pattern").requiredOption("-t, --trigger <text>", "When this happens").requiredOption("-a, --action <text>", "Do this").option("-d, --domain <domain>", "Pattern domain", "general").option("--scope <scope>", "project or global", "project").option("--id <id>", "Pattern ID (auto-generated if omitted)").action((opts) => {
|
|
51
|
+
const store = getStore();
|
|
52
|
+
const id = opts.id ?? slugify(`${opts.domain}-${opts.trigger.slice(0, 40)}`);
|
|
53
|
+
const input = {
|
|
54
|
+
id,
|
|
55
|
+
domain: opts.domain,
|
|
56
|
+
trigger: opts.trigger,
|
|
57
|
+
action: opts.action,
|
|
58
|
+
scope: opts.scope,
|
|
59
|
+
source: "manual",
|
|
60
|
+
confidence: 0.5
|
|
61
|
+
};
|
|
62
|
+
const pattern = store.create(input);
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
`Pattern created: ${pattern.id} (confidence: ${pattern.confidence})
|
|
65
|
+
`
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
patterns.command("stats").description("Show pattern statistics").option("--json", "Output JSON").action((opts) => {
|
|
69
|
+
const store = getStore();
|
|
70
|
+
const s = store.stats();
|
|
71
|
+
if (opts.json) {
|
|
72
|
+
process.stdout.write(JSON.stringify(s, null, 2) + "\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const lines = [
|
|
76
|
+
`Total patterns: ${s.total}`,
|
|
77
|
+
`Avg confidence: ${s.avgConfidence.toFixed(2)}`,
|
|
78
|
+
"",
|
|
79
|
+
"By domain:",
|
|
80
|
+
...Object.entries(s.byDomain).map(([d, n]) => ` ${d}: ${n}`),
|
|
81
|
+
"",
|
|
82
|
+
"By status:",
|
|
83
|
+
...Object.entries(s.byStatus).map(([d, n]) => ` ${d}: ${n}`)
|
|
84
|
+
];
|
|
85
|
+
if (s.topPatterns.length > 0) {
|
|
86
|
+
lines.push("", "Top patterns:");
|
|
87
|
+
for (const p of s.topPatterns) {
|
|
88
|
+
lines.push(
|
|
89
|
+
` ${confidenceBar(p.confidence)} ${p.confidence.toFixed(2)} ${p.id}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
94
|
+
});
|
|
95
|
+
patterns.command("prune").description("Remove old pending patterns").option("--days <n>", "Max age in days", "30").action((opts) => {
|
|
96
|
+
const store = getStore();
|
|
97
|
+
const removed = store.prune(parseInt(opts.days, 10));
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
`Pruned ${removed} pending patterns older than ${opts.days} days.
|
|
100
|
+
`
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
patterns.command("export").description("Export patterns to JSON file").option("-o, --output <path>", "Output file", "patterns-export.json").option("--min-confidence <n>", "Minimum confidence", "0.3").action((opts) => {
|
|
104
|
+
const store = getStore();
|
|
105
|
+
const list = store.list({
|
|
106
|
+
status: "active",
|
|
107
|
+
minConfidence: parseFloat(opts.minConfidence)
|
|
108
|
+
});
|
|
109
|
+
writeFileSync(opts.output, JSON.stringify(list, null, 2), "utf-8");
|
|
110
|
+
process.stdout.write(
|
|
111
|
+
`Exported ${list.length} patterns to ${opts.output}
|
|
112
|
+
`
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
patterns.command("import").description("Import patterns from JSON file").argument("<file>", "JSON file to import").option("--scope <scope>", "Override scope", "project").action((file, opts) => {
|
|
116
|
+
if (!existsSync(file)) {
|
|
117
|
+
process.stderr.write(`File not found: ${file}
|
|
118
|
+
`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
const store = getStore();
|
|
122
|
+
const data = JSON.parse(readFileSync(file, "utf-8"));
|
|
123
|
+
const patterns2 = Array.isArray(data) ? data : [data];
|
|
124
|
+
let imported = 0;
|
|
125
|
+
for (const p of patterns2) {
|
|
126
|
+
if (!p.id || !p.trigger || !p.action) continue;
|
|
127
|
+
const existing = store.get(p.id);
|
|
128
|
+
if (existing) {
|
|
129
|
+
store.reinforce(p.id, "imported");
|
|
130
|
+
} else {
|
|
131
|
+
store.create({
|
|
132
|
+
id: p.id,
|
|
133
|
+
domain: p.domain ?? "general",
|
|
134
|
+
trigger: p.trigger,
|
|
135
|
+
action: p.action,
|
|
136
|
+
evidence: p.evidence ?? ["imported"],
|
|
137
|
+
scope: opts.scope ?? p.scope ?? "project",
|
|
138
|
+
source: "imported",
|
|
139
|
+
confidence: p.confidence ?? 0.5
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
imported++;
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(`Imported ${imported} patterns.
|
|
145
|
+
`);
|
|
146
|
+
});
|
|
147
|
+
patterns.command("promote").description("Promote a project-scoped pattern to global").argument("[id]", "Pattern ID to promote (omit for auto-candidates)").option("--dry-run", "Show candidates without promoting").action((id, opts) => {
|
|
148
|
+
const store = getStore();
|
|
149
|
+
if (id) {
|
|
150
|
+
const pattern = store.get(id);
|
|
151
|
+
if (!pattern) {
|
|
152
|
+
process.stderr.write(`Pattern not found: ${id}
|
|
153
|
+
`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
if (pattern.scope === "global") {
|
|
157
|
+
process.stdout.write(`Already global: ${id}
|
|
158
|
+
`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!opts.dryRun) {
|
|
162
|
+
store.promote(id);
|
|
163
|
+
process.stdout.write(`Promoted to global: ${id}
|
|
164
|
+
`);
|
|
165
|
+
} else {
|
|
166
|
+
process.stdout.write(
|
|
167
|
+
`Would promote: ${id} (confidence: ${pattern.confidence.toFixed(2)})
|
|
168
|
+
`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const candidates = store.promotionCandidates(0.7);
|
|
174
|
+
if (candidates.length === 0) {
|
|
175
|
+
process.stdout.write(
|
|
176
|
+
"No promotion candidates found (need 0.7+ confidence in 2+ projects).\n"
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const c of candidates) {
|
|
181
|
+
const action = opts.dryRun ? "candidate" : "promoted";
|
|
182
|
+
if (!opts.dryRun) store.promote(c.id);
|
|
183
|
+
process.stdout.write(
|
|
184
|
+
`${action}: ${c.id} (${c.confidence.toFixed(2)}, project: ${c.projectId})
|
|
185
|
+
`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (opts.dryRun) {
|
|
189
|
+
process.stdout.write(
|
|
190
|
+
`
|
|
191
|
+
${candidates.length} candidates. Run without --dry-run to promote.
|
|
192
|
+
`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
patterns.command("projects").description("List projects with pattern counts").option("--json", "Output JSON").action((opts) => {
|
|
197
|
+
const store = getStore();
|
|
198
|
+
const projs = store.projects();
|
|
199
|
+
if (opts.json) {
|
|
200
|
+
process.stdout.write(JSON.stringify(projs, null, 2) + "\n");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (projs.length === 0) {
|
|
204
|
+
process.stdout.write("No project-scoped patterns found.\n");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
for (const p of projs) {
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
`${p.projectId} ${p.count} patterns avg confidence: ${p.avgConfidence.toFixed(2)}
|
|
210
|
+
`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
patterns.command("evolve").description("Analyze pattern clusters and suggest evolved structures").option("--min-cluster <n>", "Minimum cluster size", "2").option("--json", "Output JSON").action((opts) => {
|
|
215
|
+
const store = getStore();
|
|
216
|
+
const clusters = store.findClusters(parseInt(opts.minCluster, 10));
|
|
217
|
+
if (opts.json) {
|
|
218
|
+
process.stdout.write(JSON.stringify(clusters, null, 2) + "\n");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (clusters.length === 0) {
|
|
222
|
+
process.stdout.write(
|
|
223
|
+
"No pattern clusters found. Need 2+ active patterns in a domain.\n"
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const cluster of clusters) {
|
|
228
|
+
const avgConf = cluster.patterns.reduce((s, p) => s + p.confidence, 0) / cluster.patterns.length;
|
|
229
|
+
const label = avgConf >= 0.8 ? "SKILL candidate" : avgConf >= 0.6 ? "command candidate" : "cluster";
|
|
230
|
+
process.stdout.write(
|
|
231
|
+
`
|
|
232
|
+
[${cluster.domain}] ${cluster.patterns.length} patterns \u2014 ${label} (avg: ${avgConf.toFixed(2)})
|
|
233
|
+
`
|
|
234
|
+
);
|
|
235
|
+
for (const p of cluster.patterns) {
|
|
236
|
+
process.stdout.write(
|
|
237
|
+
` ${confidenceBar(p.confidence)} ${p.id}: ${p.trigger}
|
|
238
|
+
`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return patterns;
|
|
244
|
+
}
|
|
245
|
+
function confidenceBar(confidence) {
|
|
246
|
+
const filled = Math.round(confidence * 10);
|
|
247
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
|
|
248
|
+
}
|
|
249
|
+
function slugify(s) {
|
|
250
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
251
|
+
}
|
|
252
|
+
export {
|
|
253
|
+
createPatternsCommand
|
|
254
|
+
};
|