mobile-automator 0.23.8
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 +17 -0
- package/README.md +167 -0
- package/bin/mauto-session-daemon.js +101 -0
- package/bin/mauto.js +13 -0
- package/package.json +49 -0
- package/src/assertion/evaluator.js +285 -0
- package/src/cli.js +1722 -0
- package/src/config/coerce.js +97 -0
- package/src/config/manager.js +75 -0
- package/src/config/schema.js +124 -0
- package/src/device/action-catalog.js +99 -0
- package/src/device/bridge.js +139 -0
- package/src/device/connection.js +65 -0
- package/src/device/device-model.js +59 -0
- package/src/device/device-resolver.js +34 -0
- package/src/device/element-model.js +82 -0
- package/src/device/mobile-mcp-client.js +163 -0
- package/src/device/resolve-connection.js +156 -0
- package/src/device/selection.js +80 -0
- package/src/device/semantic-press/index.js +38 -0
- package/src/device/semantic-press/labels.js +39 -0
- package/src/device/semantic-press/resolver.js +100 -0
- package/src/device/session-client.js +173 -0
- package/src/device/session-daemon.js +526 -0
- package/src/device/session-paths.js +80 -0
- package/src/device/session-protocol.js +60 -0
- package/src/device/session-spawn.js +67 -0
- package/src/device/tool-args.js +14 -0
- package/src/guide/content/execute.agnostic.md +207 -0
- package/src/guide/content/execute.aware.md +281 -0
- package/src/guide/content/execute.invariants.md +4 -0
- package/src/guide/content/generate.agnostic.md +236 -0
- package/src/guide/content/generate.aware.md +327 -0
- package/src/guide/content/generate.invariants.md +5 -0
- package/src/guide/content/setup.agnostic.md +63 -0
- package/src/guide/content/setup.aware.md +91 -0
- package/src/guide/content/setup.invariants.md +3 -0
- package/src/guide/emitter.js +137 -0
- package/src/guide/placeholders.js +132 -0
- package/src/init/adapters.js +162 -0
- package/src/init/skill-meta.js +31 -0
- package/src/init/skill-renderer.js +60 -0
- package/src/mcp/prompts.js +55 -0
- package/src/mcp/server.js +55 -0
- package/src/memory/entries.js +61 -0
- package/src/memory/history.js +86 -0
- package/src/memory/lock.js +5 -0
- package/src/memory/paths.js +47 -0
- package/src/memory/store.js +230 -0
- package/src/output/envelope.js +106 -0
- package/src/result/capability-catalog.js +144 -0
- package/src/result/flags.js +52 -0
- package/src/result/store.js +273 -0
- package/src/scenario/validator.js +30 -0
- package/src/schemas/config_schema.json +68 -0
- package/src/schemas/format-error.js +19 -0
- package/src/schemas/result_schema.json +211 -0
- package/src/schemas/scenario_schema.json +587 -0
- package/src/setup/scaffold.js +53 -0
- package/src/util/atomic.js +41 -0
- package/src/util/lock.js +85 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP prompt handlers for the `mauto mcp` server.
|
|
4
|
+
//
|
|
5
|
+
// The workflows are exposed as USER-CONTROLLED MCP prompts (not tools — the
|
|
6
|
+
// device stays behind CLI verbs). Each prompt injects the version-matched
|
|
7
|
+
// `mauto guide <topic>` content as a single user message, so an agent that
|
|
8
|
+
// selects e.g. the `generate` prompt receives the exact same guidance a human
|
|
9
|
+
// would get from `mauto guide generate`.
|
|
10
|
+
//
|
|
11
|
+
// Naming scheme: prompts are named by the BARE topic (`generate`, `execute`,
|
|
12
|
+
// `setup`) — identical to the `mauto guide <topic>` argument, so the
|
|
13
|
+
// two surfaces stay one-to-one and self-documenting.
|
|
14
|
+
|
|
15
|
+
const guideEmitter = require('../guide/emitter');
|
|
16
|
+
const configManager = require('../config/manager');
|
|
17
|
+
|
|
18
|
+
const PROMPT_TOPICS = ['generate', 'execute', 'setup'];
|
|
19
|
+
|
|
20
|
+
const DESCRIPTIONS = {
|
|
21
|
+
generate: 'Author a mobile test scenario by exploring the app with mauto verbs.',
|
|
22
|
+
execute: 'Replay a scenario step-by-step and record the run results.',
|
|
23
|
+
setup: 'Initialise the mobile-automator workspace and config.',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Build the { listPrompts, getPrompt } pair. emitGuide + config are injectable
|
|
27
|
+
// so the handlers can be unit-tested without touching the real guide files.
|
|
28
|
+
function buildPromptHandlers({ projectRoot, emitGuide, config = configManager } = {}) {
|
|
29
|
+
const emit = emitGuide || guideEmitter.emitGuide;
|
|
30
|
+
|
|
31
|
+
function listPrompts() {
|
|
32
|
+
return PROMPT_TOPICS.map((topic) => ({
|
|
33
|
+
name: topic,
|
|
34
|
+
description: DESCRIPTIONS[topic],
|
|
35
|
+
arguments: [],
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getPrompt(name) {
|
|
40
|
+
if (!PROMPT_TOPICS.includes(name)) {
|
|
41
|
+
throw new Error(`unknown prompt: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
const cfg = config.load(projectRoot);
|
|
44
|
+
const mode = config.resolveMode(cfg);
|
|
45
|
+
const text = emit(name, { mode, projectRoot });
|
|
46
|
+
return {
|
|
47
|
+
description: DESCRIPTIONS[name],
|
|
48
|
+
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { listPrompts, getPrompt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { buildPromptHandlers, PROMPT_TOPICS };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `mauto mcp` — the MCP prompts server.
|
|
4
|
+
//
|
|
5
|
+
// Advertises the PROMPTS capability only. The four mobile-QA workflows are
|
|
6
|
+
// surfaced as user-controlled prompts (see src/mcp/prompts.js); the device is
|
|
7
|
+
// NOT exposed as MCP tools — it stays behind the `mauto` CLI verbs by design.
|
|
8
|
+
//
|
|
9
|
+
// The SDK exports CJS entry points under ./<sub>; the exact paths were
|
|
10
|
+
// confirmed via require.resolve at build time:
|
|
11
|
+
// @modelcontextprotocol/sdk/server/index.js -> Server
|
|
12
|
+
// @modelcontextprotocol/sdk/server/stdio.js -> StdioServerTransport
|
|
13
|
+
// @modelcontextprotocol/sdk/types.js -> ListPromptsRequestSchema, GetPromptRequestSchema
|
|
14
|
+
|
|
15
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
16
|
+
const {
|
|
17
|
+
ListPromptsRequestSchema,
|
|
18
|
+
GetPromptRequestSchema,
|
|
19
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
20
|
+
|
|
21
|
+
const { buildPromptHandlers } = require('./prompts');
|
|
22
|
+
|
|
23
|
+
// Construct the Server, register the prompt request handlers, but DO NOT
|
|
24
|
+
// connect a transport — separated so it can be exercised in tests without
|
|
25
|
+
// hijacking stdio.
|
|
26
|
+
function buildServer({ projectRoot } = {}) {
|
|
27
|
+
const handlers = buildPromptHandlers({ projectRoot });
|
|
28
|
+
|
|
29
|
+
const server = new Server(
|
|
30
|
+
{ name: 'mauto', version: require('../../package.json').version },
|
|
31
|
+
{ capabilities: { prompts: {} } }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
35
|
+
prompts: handlers.listPrompts(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
39
|
+
return handlers.getPrompt(request.params.name);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return { server, handlers };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Long-lived: connect the stdio transport and keep serving. Never called in
|
|
46
|
+
// the test suite (it would seize stdio).
|
|
47
|
+
async function runServer({ projectRoot } = {}) {
|
|
48
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
49
|
+
const { server } = buildServer({ projectRoot });
|
|
50
|
+
const transport = new StdioServerTransport();
|
|
51
|
+
await server.connect(transport);
|
|
52
|
+
return server;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { runServer, buildServer };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { HEADERS } = require('./paths');
|
|
4
|
+
|
|
5
|
+
// Pure model for the agent-authored memory files (app-knowledge.md,
|
|
6
|
+
// preferences.md): a flat list of `- [YYYY-MM-DD][asserted] <text>` bullets
|
|
7
|
+
// under a `# Header`. No I/O here. `[asserted]` marks agent-claimed facts the
|
|
8
|
+
// reader should verify, distinct from run-history's `[observed]`.
|
|
9
|
+
|
|
10
|
+
const MAX_ENTRY_LEN = 500;
|
|
11
|
+
const ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[asserted\]\s+(.*)$/;
|
|
12
|
+
|
|
13
|
+
function sanitizeEntryText(raw) {
|
|
14
|
+
return String(raw == null ? '' : raw)
|
|
15
|
+
.replace(/[\u0000-\u001F\u007F]+/g, ' ') // control chars / newlines / tabs -> space
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateEntryText(raw) {
|
|
21
|
+
const value = sanitizeEntryText(raw);
|
|
22
|
+
if (!value) return { ok: false, reason: 'empty' };
|
|
23
|
+
if (value.length > MAX_ENTRY_LEN) return { ok: false, reason: 'too_long' };
|
|
24
|
+
if (value.includes('{{')) return { ok: false, reason: 'placeholder' };
|
|
25
|
+
return { ok: true, value };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseEntries(md) {
|
|
29
|
+
const out = [];
|
|
30
|
+
if (!md) return out;
|
|
31
|
+
for (const line of md.split(/\r?\n/)) {
|
|
32
|
+
const m = line.match(ENTRY_RE);
|
|
33
|
+
if (m) out.push({ date: m[1], text: m[2] });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderEntries(kind, entries) {
|
|
39
|
+
const out = [
|
|
40
|
+
HEADERS[kind] || `# ${kind}`,
|
|
41
|
+
"<!-- Managed by `mauto memory`. Edit an entry's text or delete whole bullets freely;",
|
|
42
|
+
' other lines are not preserved on the next write. [asserted] = agent-claimed; verify. -->',
|
|
43
|
+
'',
|
|
44
|
+
];
|
|
45
|
+
for (const e of entries) out.push(`- [${e.date}][asserted] ${e.text}`);
|
|
46
|
+
return out.join('\n').replace(/\n+$/, '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasText(entries, text) {
|
|
50
|
+
return entries.some((e) => e.text === text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
MAX_ENTRY_LEN,
|
|
55
|
+
ENTRY_RE,
|
|
56
|
+
sanitizeEntryText,
|
|
57
|
+
validateEntryText,
|
|
58
|
+
parseEntries,
|
|
59
|
+
renderEntries,
|
|
60
|
+
hasText,
|
|
61
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure model for run-history.md — a rolling per-scenario aggregate. Bounded by
|
|
4
|
+
// scenario count (not run count): each scenario keeps its last MAX_RUNS pass/fail
|
|
5
|
+
// letters and last MAX_NOTES observation bullets. No I/O here.
|
|
6
|
+
|
|
7
|
+
const MAX_RUNS = 5;
|
|
8
|
+
const MAX_NOTES = 10;
|
|
9
|
+
|
|
10
|
+
const HEADER = '# Run History';
|
|
11
|
+
const HEADER_COMMENT =
|
|
12
|
+
'<!-- Managed by `mauto memory`. Auto-harvested on `result finalize`. -->';
|
|
13
|
+
|
|
14
|
+
// `## <scenarioId> (last N runs: P F ...)` — scenarioId is everything before
|
|
15
|
+
// the two-space gap that precedes the "(last".
|
|
16
|
+
const HEADING_RE = /^##\s+(.+?)\s+\(last \d+ runs:\s*([^)]*)\)\s*$/;
|
|
17
|
+
const NOTE_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[observed\]\s+(.*)$/;
|
|
18
|
+
|
|
19
|
+
function emptyModel() {
|
|
20
|
+
return { order: [], byScenario: {} };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensure(model, scenarioId) {
|
|
24
|
+
if (!model.byScenario[scenarioId]) {
|
|
25
|
+
model.byScenario[scenarioId] = { runs: [], notes: [] };
|
|
26
|
+
model.order.push(scenarioId);
|
|
27
|
+
}
|
|
28
|
+
return model.byScenario[scenarioId];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recordInModel(model, { scenarioId, statusLetter, notes = [] }) {
|
|
32
|
+
const entry = ensure(model, scenarioId);
|
|
33
|
+
entry.runs.push(statusLetter);
|
|
34
|
+
if (entry.runs.length > MAX_RUNS) entry.runs = entry.runs.slice(-MAX_RUNS);
|
|
35
|
+
for (const n of notes) entry.notes.push(n);
|
|
36
|
+
if (entry.notes.length > MAX_NOTES) entry.notes = entry.notes.slice(-MAX_NOTES);
|
|
37
|
+
return model;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseRunHistory(md) {
|
|
41
|
+
const model = emptyModel();
|
|
42
|
+
if (!md) return model;
|
|
43
|
+
let current = null;
|
|
44
|
+
for (const line of md.split(/\r?\n/)) {
|
|
45
|
+
const h = line.match(HEADING_RE);
|
|
46
|
+
if (h) {
|
|
47
|
+
const id = h[1].trim();
|
|
48
|
+
current = ensure(model, id);
|
|
49
|
+
const letters = h[2].trim();
|
|
50
|
+
current.runs = letters ? letters.split(/\s+/).filter(Boolean) : [];
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const n = line.match(NOTE_RE);
|
|
54
|
+
if (n && current) {
|
|
55
|
+
current.notes.push({ date: n[1], text: n[2] });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return model;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderRunHistory(model) {
|
|
62
|
+
const out = [HEADER, HEADER_COMMENT, ''];
|
|
63
|
+
for (const id of model.order) {
|
|
64
|
+
const e = model.byScenario[id];
|
|
65
|
+
out.push(`## ${id} (last ${MAX_RUNS} runs: ${e.runs.join(' ')})`);
|
|
66
|
+
for (const note of e.notes) {
|
|
67
|
+
out.push(`- [${note.date}][observed] ${note.text}`);
|
|
68
|
+
}
|
|
69
|
+
out.push('');
|
|
70
|
+
}
|
|
71
|
+
return out.join('\n').replace(/\n+$/, '\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function countEntries(model) {
|
|
75
|
+
return model.order.reduce((sum, id) => sum + model.byScenario[id].notes.length, 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
MAX_RUNS,
|
|
80
|
+
MAX_NOTES,
|
|
81
|
+
emptyModel,
|
|
82
|
+
parseRunHistory,
|
|
83
|
+
renderRunHistory,
|
|
84
|
+
recordInModel,
|
|
85
|
+
countEntries,
|
|
86
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// Canonical on-disk names for the three memory files. Keys are the stable
|
|
6
|
+
// `--kind` identifiers used across verbs; values are the markdown filenames.
|
|
7
|
+
const FILES = {
|
|
8
|
+
'run-history': 'run-history.md',
|
|
9
|
+
'app-knowledge': 'app-knowledge.md',
|
|
10
|
+
preferences: 'preferences.md',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// The stable `--kind` identifiers, derived from FILES so there is a single
|
|
14
|
+
// source of truth for "which memory kinds exist".
|
|
15
|
+
const KINDS = Object.keys(FILES);
|
|
16
|
+
|
|
17
|
+
// The two hand-authorable kinds. `run-history` is machine-owned (auto-harvested
|
|
18
|
+
// on `result finalize`) and is NOT a valid `memory add`/`forget` target.
|
|
19
|
+
const AGENT_KINDS = ['app-knowledge', 'preferences'];
|
|
20
|
+
|
|
21
|
+
// Markdown headers per kind, colocated with FILES/KINDS since they're the
|
|
22
|
+
// same per-kind metadata.
|
|
23
|
+
const HEADERS = {
|
|
24
|
+
'run-history': '# Run History',
|
|
25
|
+
'app-knowledge': '# App Knowledge',
|
|
26
|
+
preferences: '# Preferences',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function memoryDir(projectRoot) {
|
|
30
|
+
return path.join(projectRoot, 'mobile-automator', 'memory');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function memoryFile(projectRoot, name) {
|
|
34
|
+
return path.join(memoryDir(projectRoot), FILES[name] || name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sessionDir(projectRoot) {
|
|
38
|
+
return path.join(projectRoot, 'mobile-automator', '.session');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The advisory lock guarding read-modify-write of the shared memory files.
|
|
42
|
+
// Lives in .session/ (already gitignored) alongside the device-session state.
|
|
43
|
+
function lockPath(projectRoot) {
|
|
44
|
+
return path.join(sessionDir(projectRoot), 'memory.lock');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { memoryDir, memoryFile, sessionDir, lockPath, FILES, KINDS, AGENT_KINDS, HEADERS };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { memoryFile, lockPath, KINDS, HEADERS } = require('./paths');
|
|
7
|
+
const { withLock } = require('../util/lock');
|
|
8
|
+
const { parseRunHistory, renderRunHistory, recordInModel, countEntries } = require('./history');
|
|
9
|
+
const { atomicWrite } = require('../util/atomic');
|
|
10
|
+
const { parseEntries, renderEntries, hasText } = require('./entries');
|
|
11
|
+
|
|
12
|
+
// Cross-session memory store. Mirrors ResultStore's one-shot-process,
|
|
13
|
+
// load-mutate-atomic-write shape, but the memory files are SHARED across runs
|
|
14
|
+
// (not keyed per-runId), so every read-modify-write goes through an advisory
|
|
15
|
+
// lock (see ./lock.js) to avoid lost updates.
|
|
16
|
+
|
|
17
|
+
function todayISO() {
|
|
18
|
+
return new Date().toISOString().slice(0, 10);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function statusLetter(status) {
|
|
22
|
+
return status === 'passed' || status === 'pass' ? 'P' : 'F';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fold a run's typed observations into dated note lines for the aggregate.
|
|
26
|
+
function notesFromResult(result) {
|
|
27
|
+
const date = todayISO();
|
|
28
|
+
return (result.observations || []).map((o) => {
|
|
29
|
+
const where = o.step_id ? ` (${o.step_id})` : '';
|
|
30
|
+
return { date, text: `${o.type}${where}: ${o.message}` };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CAP = 8000;
|
|
35
|
+
|
|
36
|
+
// Filter a run-history markdown body down to a single scenario's `##` section.
|
|
37
|
+
// Keeps the top-of-file header lines (everything before the first `##`
|
|
38
|
+
// heading) and, if present, the matching scenario's heading + its note lines;
|
|
39
|
+
// drops every other scenario's section.
|
|
40
|
+
//
|
|
41
|
+
// This is DELIBERATELY a string-level line filter, not a re-serialization
|
|
42
|
+
// from the parsed history model, mirroring the raw-preserving unfiltered
|
|
43
|
+
// render path above. Re-serializing from the model would silently drop any
|
|
44
|
+
// hand-edited line that doesn't fit the model's note shape — the files are
|
|
45
|
+
// designed to be hand-editable, so that data loss would be a behavior
|
|
46
|
+
// change, not a simplification. Do not "clean this up" into a model-based
|
|
47
|
+
// render without re-checking that invariant.
|
|
48
|
+
function filterScenario(md, scenario) {
|
|
49
|
+
const lines = md.split('\n');
|
|
50
|
+
const kept = [];
|
|
51
|
+
let sawHeading = false;
|
|
52
|
+
let inTarget = false;
|
|
53
|
+
// `scenario` is user-supplied (the CLI's --scenario flag); escape regex
|
|
54
|
+
// metacharacters so a value like "a(b" filters to zero matches instead of
|
|
55
|
+
// throwing an uncaught SyntaxError out of this read-only render path.
|
|
56
|
+
const safe = scenario.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
const headingRe = new RegExp(`^##\\s+${safe}\\s+\\(last`);
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const isHeading = /^##\s+/.test(line);
|
|
60
|
+
if (isHeading) {
|
|
61
|
+
sawHeading = true;
|
|
62
|
+
inTarget = headingRe.test(line);
|
|
63
|
+
if (!inTarget) continue;
|
|
64
|
+
kept.push(line);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!sawHeading) {
|
|
68
|
+
// top-of-file header lines, before any scenario heading
|
|
69
|
+
kept.push(line);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (inTarget) kept.push(line);
|
|
73
|
+
}
|
|
74
|
+
return kept.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Counts bullets for any file, reusing the history model for run-history.
|
|
78
|
+
function countEntriesFor(name, raw) {
|
|
79
|
+
if (!raw) return 0;
|
|
80
|
+
if (name === 'run-history') return countEntries(parseRunHistory(raw));
|
|
81
|
+
// app-knowledge / preferences: one bullet per entry.
|
|
82
|
+
return raw.split('\n').filter((l) => /^-\s+\[/.test(l)).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class MemoryStore {
|
|
86
|
+
constructor({ projectRoot } = {}) {
|
|
87
|
+
if (!projectRoot) throw new Error('MemoryStore requires a projectRoot');
|
|
88
|
+
this.projectRoot = projectRoot;
|
|
89
|
+
this.warnings = [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_file(name) {
|
|
93
|
+
return memoryFile(this.projectRoot, name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Read a memory file's raw text, or '' if absent. A read failure other than
|
|
97
|
+
// ENOENT is preserved as a sidecar + warning (defensive parity with
|
|
98
|
+
// ResultStore) and treated as empty so a write never clobbers unread bytes.
|
|
99
|
+
_readRaw(name) {
|
|
100
|
+
const file = this._file(name);
|
|
101
|
+
try {
|
|
102
|
+
return fs.readFileSync(file, 'utf8');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e && e.code === 'ENOENT') return '';
|
|
105
|
+
return this._preserveCorrupt(file, e);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_preserveCorrupt(file, err) {
|
|
110
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
111
|
+
const sidecar = `${file}.corrupt.${ts}`;
|
|
112
|
+
let preserved = false;
|
|
113
|
+
try {
|
|
114
|
+
fs.renameSync(file, sidecar);
|
|
115
|
+
preserved = true;
|
|
116
|
+
} catch (_) {
|
|
117
|
+
/* best effort */
|
|
118
|
+
}
|
|
119
|
+
const where = preserved ? `preserved as ${path.basename(sidecar)}` : 'COULD NOT be preserved';
|
|
120
|
+
this.warnings.push(
|
|
121
|
+
`memory file ${file} was unreadable (${err.message}); ${where} and a fresh file was started`
|
|
122
|
+
);
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Delegates to the shared util (see src/util/atomic.js).
|
|
127
|
+
_atomicWrite(file, contents) {
|
|
128
|
+
atomicWrite(file, contents);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Acquire the lock, read this kind's raw bytes, run the edit, and write ONLY when
|
|
132
|
+
// the edit produced new contents. Single owner of the "under lock + write-when-changed"
|
|
133
|
+
// invariant for every memory mutation.
|
|
134
|
+
_editUnderLock(kind, edit) {
|
|
135
|
+
return withLock(lockPath(this.projectRoot), () => {
|
|
136
|
+
const { contents, result } = edit(this._readRaw(kind));
|
|
137
|
+
if (contents != null) this._atomicWrite(this._file(kind), contents);
|
|
138
|
+
return result;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Auto-harvest: fold a finalized result into the rolling run-history aggregate.
|
|
143
|
+
recordRun(result = {}) {
|
|
144
|
+
const scenarioId = result.scenario_id || 'unknown';
|
|
145
|
+
return this._editUnderLock('run-history', (raw) => {
|
|
146
|
+
const model = parseRunHistory(raw);
|
|
147
|
+
recordInModel(model, {
|
|
148
|
+
scenarioId,
|
|
149
|
+
statusLetter: statusLetter(result.status),
|
|
150
|
+
notes: notesFromResult(result),
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
contents: renderRunHistory(model),
|
|
154
|
+
result: { scenarioId, runs: model.byScenario[scenarioId].runs.slice() },
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Agent-authored: append a durable [asserted] fact/preference under the lock.
|
|
160
|
+
// Exact-match de-dupe (same text already present → skip). Assumes `text` is
|
|
161
|
+
// already validated/sanitized by the verb handler.
|
|
162
|
+
add(kind, text) {
|
|
163
|
+
return this._editUnderLock(kind, (raw) => {
|
|
164
|
+
const entries = parseEntries(raw);
|
|
165
|
+
if (hasText(entries, text)) {
|
|
166
|
+
return { contents: undefined, result: { kind, added: false, deduped: true } };
|
|
167
|
+
}
|
|
168
|
+
entries.push({ date: todayISO(), text });
|
|
169
|
+
return { contents: renderEntries(kind, entries), result: { kind, added: true, deduped: false } };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The correction path: remove entries whose text contains `match`.
|
|
174
|
+
forget(kind, match) {
|
|
175
|
+
return this._editUnderLock(kind, (raw) => {
|
|
176
|
+
const entries = parseEntries(raw);
|
|
177
|
+
const kept = entries.filter((e) => !e.text.includes(match));
|
|
178
|
+
const removed = entries.length - kept.length;
|
|
179
|
+
return { contents: removed > 0 ? renderEntries(kind, kept) : null, result: { kind, removed } };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Render the raw markdown `mauto memory show` emits: a one-line summary
|
|
184
|
+
// comment (per-file entry counts + last-updated date) followed by the
|
|
185
|
+
// requested file bodies. Read-only — no lock needed.
|
|
186
|
+
render({ kind, scenario, cap = DEFAULT_CAP } = {}) {
|
|
187
|
+
const names = kind ? [kind] : KINDS;
|
|
188
|
+
const summary = [];
|
|
189
|
+
const sections = [];
|
|
190
|
+
|
|
191
|
+
for (const name of KINDS) {
|
|
192
|
+
const raw = this._safeRead(name);
|
|
193
|
+
const count = countEntriesFor(name, raw);
|
|
194
|
+
const updated = this._mtimeDate(name);
|
|
195
|
+
summary.push(`${name}: ${count} entrie(s)${updated ? `, updated ${updated}` : ''}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
let body = this._safeRead(name);
|
|
200
|
+
if (!body) body = `${HEADERS[name] || `# ${name}`}\n(no entries yet)\n`;
|
|
201
|
+
if (name === 'run-history' && scenario) body = filterScenario(body, scenario);
|
|
202
|
+
sections.push(body.trim());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let out = `<!-- mauto memory · ${summary.join(' · ')} -->\n\n${sections.join('\n\n')}\n`;
|
|
206
|
+
if (out.length > cap) {
|
|
207
|
+
out = `${out.slice(0, cap)}\n… (truncated at ${cap} chars — narrow with --kind/--scenario)\n`;
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Read without the corruption sidecar side effect (render is read-only).
|
|
213
|
+
_safeRead(name) {
|
|
214
|
+
try {
|
|
215
|
+
return fs.readFileSync(this._file(name), 'utf8');
|
|
216
|
+
} catch (_) {
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_mtimeDate(name) {
|
|
222
|
+
try {
|
|
223
|
+
return fs.statSync(this._file(name)).mtime.toISOString().slice(0, 10);
|
|
224
|
+
} catch (_) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { MemoryStore, todayISO, statusLetter };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Stable contract between the `mauto` CLI and any calling agent.
|
|
4
|
+
// Every command prints exactly one envelope on stdout.
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = '2.1';
|
|
7
|
+
|
|
8
|
+
const EXIT = {
|
|
9
|
+
OK: 0,
|
|
10
|
+
DEVICE: 2,
|
|
11
|
+
INVALID_INPUT: 3,
|
|
12
|
+
TARGET_NOT_FOUND: 4,
|
|
13
|
+
// Filesystem / workspace problem (perms, missing path, read-only FS, no
|
|
14
|
+
// disk) — an environment fault the caller can fix, distinct from exit 1
|
|
15
|
+
// ("the CLI itself broke"). Kept separate so agents/CI can branch on it.
|
|
16
|
+
ENVIRONMENT: 5,
|
|
17
|
+
CANCEL: 130,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// error.kind -> process exit code. `internal` (and anything unknown) -> 1.
|
|
21
|
+
const KIND_TO_CODE = {
|
|
22
|
+
ok: EXIT.OK,
|
|
23
|
+
device: EXIT.DEVICE,
|
|
24
|
+
// A daemon per-call timeout is a device-class failure (the action may have
|
|
25
|
+
// partially executed on the device) — exit 2 keeps the contract unchanged for
|
|
26
|
+
// callers branching on 2, while the envelope's error.kind stays 'timeout' so
|
|
27
|
+
// agents can distinguish "no response" from a hard device error.
|
|
28
|
+
timeout: EXIT.DEVICE,
|
|
29
|
+
invalid_input: EXIT.INVALID_INPUT,
|
|
30
|
+
target_not_found: EXIT.TARGET_NOT_FOUND,
|
|
31
|
+
environment: EXIT.ENVIRONMENT,
|
|
32
|
+
cancel: EXIT.CANCEL,
|
|
33
|
+
internal: 1,
|
|
34
|
+
// `init --agent all` partial failure: some hosts wired, some failed. Non-zero
|
|
35
|
+
// so callers/CI treat it as a failure; the envelope's data.agents[] is honest.
|
|
36
|
+
partial: 1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function ok(data, hint = null) {
|
|
40
|
+
const env = { ok: true, data, schema_version: SCHEMA_VERSION };
|
|
41
|
+
// A success can still carry an advisory hint (e.g. recovered corruption).
|
|
42
|
+
// The key is only present when there is something to say, so the common
|
|
43
|
+
// case stays the bare {ok,data,schema_version} envelope.
|
|
44
|
+
if (hint) env.hint = hint;
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(kind, message, hint = null, data = undefined) {
|
|
49
|
+
const env = {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: { kind, message },
|
|
52
|
+
hint,
|
|
53
|
+
schema_version: SCHEMA_VERSION,
|
|
54
|
+
};
|
|
55
|
+
// Some failures carry actionable structured detail (e.g. a per-agent
|
|
56
|
+
// ok/failed map for `init --agent all`); include `data` only when given so
|
|
57
|
+
// the common fail shape is unchanged.
|
|
58
|
+
if (data !== undefined) env.data = data;
|
|
59
|
+
return env;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function exitCodeFor(kind) {
|
|
63
|
+
if (Object.prototype.hasOwnProperty.call(KIND_TO_CODE, kind)) {
|
|
64
|
+
return KIND_TO_CODE[kind];
|
|
65
|
+
}
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function render(envelope, { human = false } = {}) {
|
|
70
|
+
if (!human) {
|
|
71
|
+
return JSON.stringify(envelope);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (envelope.ok) {
|
|
75
|
+
const data = envelope.data;
|
|
76
|
+
let line;
|
|
77
|
+
if (Array.isArray(data)) {
|
|
78
|
+
line = `ok: ${data.length} item(s)`;
|
|
79
|
+
} else if (data && typeof data === 'object') {
|
|
80
|
+
const keys = Object.keys(data);
|
|
81
|
+
line = `ok: ${keys.length ? keys.join(', ') : '(no data)'}`;
|
|
82
|
+
} else {
|
|
83
|
+
line = `ok: ${String(data)}`;
|
|
84
|
+
}
|
|
85
|
+
if (envelope.hint) {
|
|
86
|
+
line += `\nhint: ${envelope.hint}`;
|
|
87
|
+
}
|
|
88
|
+
return line;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { kind, message } = envelope.error || {};
|
|
92
|
+
let out = `error [${kind}]: ${message}`;
|
|
93
|
+
if (envelope.hint) {
|
|
94
|
+
out += `\nhint: ${envelope.hint}`;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
SCHEMA_VERSION,
|
|
101
|
+
EXIT,
|
|
102
|
+
ok,
|
|
103
|
+
fail,
|
|
104
|
+
exitCodeFor,
|
|
105
|
+
render,
|
|
106
|
+
};
|