smart-home-engine 1.51.3 → 1.52.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/README.md +1 -0
- package/dist/web/assets/index-BswjzIm1.css +1 -0
- package/dist/web/assets/index-CD6lY3HK.js +296 -0
- package/dist/web/assets/{tsMode-sNUSGo5H.js → tsMode-CQwwRC_t.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +67 -47
- package/src/lib/log-file.js +81 -0
- package/src/lib/logger.js +33 -0
- package/src/lib/script-timers.js +1 -0
- package/src/lib/text-diff.js +116 -0
- package/src/web/ai-api.js +364 -44
- package/src/web/ai-context.js +172 -61
- package/src/web/ai-drafts.js +68 -0
- package/src/web/ai-memory.js +106 -0
- package/src/web/ai-tools.js +938 -70
- package/src/web/log-ws.js +27 -7
- package/src/web/prompts/api-ref-compact.md +16 -0
- package/src/web/prompts/api-ref.md +60 -0
- package/src/web/prompts/compact.md +5 -0
- package/src/web/prompts/conventions.md +5 -0
- package/src/web/prompts/db-doc.md +1 -1
- package/src/web/prompts/db-view.md +3 -3
- package/src/web/prompts/formats.md +14 -0
- package/src/web/prompts/role.md +6 -0
- package/src/web/prompts/tools.md +9 -0
- package/dist/web/assets/index-D6H4drtd.js +0 -295
- package/dist/web/assets/index-DPDW78ru.css +0 -1
- package/src/web/prompts/scripts-base.md +0 -25
- package/src/web/prompts/she-api-ref.md +0 -94
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CD6lY3HK.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -175,10 +175,10 @@
|
|
|
175
175
|
}
|
|
176
176
|
})();
|
|
177
177
|
</script>
|
|
178
|
-
<script type="module" crossorigin src="/assets/index-
|
|
178
|
+
<script type="module" crossorigin src="/assets/index-CD6lY3HK.js"></script>
|
|
179
179
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
|
|
180
180
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
181
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
181
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BswjzIm1.css">
|
|
182
182
|
</head>
|
|
183
183
|
<body>
|
|
184
184
|
<div id="app"></div>
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -44,24 +44,14 @@ require('./lib/storage').ensureRoot();
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// ---------------------------------------------------------------------------
|
|
47
|
-
// Persistent JSON-Lines log file — written alongside the pino-pretty stream
|
|
48
|
-
//
|
|
47
|
+
// Persistent JSON-Lines log file — written alongside the pino-pretty stream,
|
|
48
|
+
// rotated on every start and by size (roadmap B-12; src/lib/log-file.js).
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
const _fs = require('fs');
|
|
51
51
|
const _path = require('path');
|
|
52
52
|
const { LOGS_DIR } = require('./lib/storage');
|
|
53
53
|
const secrets = require('./lib/secrets');
|
|
54
|
-
const
|
|
55
|
-
const _logFilePrev = _path.join(LOGS_DIR, 'she.jsonl.1');
|
|
56
|
-
try {
|
|
57
|
-
_fs.renameSync(_logFileCurrent, _logFilePrev);
|
|
58
|
-
} catch {
|
|
59
|
-
/* no previous file — ignore */
|
|
60
|
-
}
|
|
61
|
-
const _logFileStream = _fs.createWriteStream(_logFileCurrent, { flags: 'w' });
|
|
62
|
-
function _writeLogLine(level, msg) {
|
|
63
|
-
_logFileStream.write(JSON.stringify({ level, msg, ts: Date.now() }) + '\n');
|
|
64
|
-
}
|
|
54
|
+
const _logFile = require('./lib/log-file').createLogFile({ dir: LOGS_DIR });
|
|
65
55
|
// ---------------------------------------------------------------------------
|
|
66
56
|
|
|
67
57
|
const config = require('./config');
|
|
@@ -83,35 +73,11 @@ const _pino = require('pino')(
|
|
|
83
73
|
// Lazy import — log-ws exports a no-op broadcastLog when the HTTP server is not started.
|
|
84
74
|
const { broadcastLog, broadcast, setWelcomeProvider } = require('./web/log-ws');
|
|
85
75
|
const shedb = require('./web/shedb');
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
_writeLogLine('debug', msg);
|
|
92
|
-
},
|
|
93
|
-
info: (...args) => {
|
|
94
|
-
const msg = secrets.redact(args.join(' '));
|
|
95
|
-
_pino.info(msg);
|
|
96
|
-
broadcastLog({ level: 'info', msg, ts: Date.now() });
|
|
97
|
-
_writeLogLine('info', msg);
|
|
98
|
-
},
|
|
99
|
-
warn: (...args) => {
|
|
100
|
-
const msg = secrets.redact(args.join(' '));
|
|
101
|
-
_pino.warn(msg);
|
|
102
|
-
broadcastLog({ level: 'warn', msg, ts: Date.now() });
|
|
103
|
-
_writeLogLine('warn', msg);
|
|
104
|
-
},
|
|
105
|
-
error: (...args) => {
|
|
106
|
-
const msg = secrets.redact(args.join(' '));
|
|
107
|
-
_pino.error(msg);
|
|
108
|
-
broadcastLog({ level: 'error', msg, ts: Date.now() });
|
|
109
|
-
_writeLogLine('error', msg);
|
|
110
|
-
},
|
|
111
|
-
setLevel: (level) => {
|
|
112
|
-
_pino.level = level;
|
|
113
|
-
},
|
|
114
|
-
};
|
|
76
|
+
// One level for every sink (roadmap B-12): a line below it is dropped before any work on it.
|
|
77
|
+
const log = require('./lib/logger').createLogger({
|
|
78
|
+
redact: (s) => secrets.redact(s),
|
|
79
|
+
sinks: [(level, msg) => _pino[level](msg), (level, msg) => broadcastLog({ level, msg, ts: Date.now() }), (level, msg) => _logFile.write(level, msg)],
|
|
80
|
+
});
|
|
115
81
|
|
|
116
82
|
// Secrets store (roadmap A5): read once at startup; the UI/CLI keep it current afterwards.
|
|
117
83
|
{
|
|
@@ -177,6 +143,16 @@ process.on('exit', _clearSentinel);
|
|
|
177
143
|
// Wall-clock budget for a script's synchronous top-level code (0 = no limit).
|
|
178
144
|
const SCRIPT_TIMEOUT_MS = typeof config.scriptTimeout === 'number' && config.scriptTimeout >= 0 ? config.scriptTimeout : 5000;
|
|
179
145
|
|
|
146
|
+
/** remember which topics a script publishes to (roadmap I19), at most 200 per script */
|
|
147
|
+
function recordPublish(scriptFile, topic) {
|
|
148
|
+
let set = scriptPublishes.get(scriptFile);
|
|
149
|
+
if (!set) {
|
|
150
|
+
set = new Set();
|
|
151
|
+
scriptPublishes.set(scriptFile, set);
|
|
152
|
+
}
|
|
153
|
+
if (set.size < 200 || set.has(topic)) set.add(topic);
|
|
154
|
+
}
|
|
155
|
+
|
|
180
156
|
/**
|
|
181
157
|
* Build a short log label for a script file.
|
|
182
158
|
* Uses the path relative to the configured script dir(s) when possible,
|
|
@@ -193,6 +169,7 @@ function makeLabel(filePath) {
|
|
|
193
169
|
}
|
|
194
170
|
|
|
195
171
|
log.setLevel(['debug', 'info', 'warn', 'error'].indexOf(config.verbosity) === -1 ? 'info' : config.verbosity);
|
|
172
|
+
_pino.level = log.level;
|
|
196
173
|
|
|
197
174
|
// Safety net: unhandled Promise rejections from async script callbacks are not caught
|
|
198
175
|
// by the per-script domain (Node.js domains don't intercept Promise rejections).
|
|
@@ -271,6 +248,7 @@ const varSubscriptions = []; // store-based var:: subscriptions { key, handler,
|
|
|
271
248
|
|
|
272
249
|
// Per-script resource tracking for hot-reload
|
|
273
250
|
const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
|
|
251
|
+
const scriptPublishes = new Map(); // scriptFile → Set<topic> a script published to (roadmap I19; capped)
|
|
274
252
|
const scriptTimers = new Map(); // scriptFile → Set<timer id>
|
|
275
253
|
|
|
276
254
|
const _global = {};
|
|
@@ -448,7 +426,6 @@ require('./web/mqtt-api').init(store, () => mqtt);
|
|
|
448
426
|
require('./web/services-api').init(store, () => mqtt, {
|
|
449
427
|
getMqttConfig: () => ({ url: config.url, username: config.mqttUsername, password: config.mqttPassword }),
|
|
450
428
|
});
|
|
451
|
-
require('./web/ai-api').init(store, log);
|
|
452
429
|
|
|
453
430
|
// MQTT message rate counter — reset on each stats poll
|
|
454
431
|
let _mqttMsgCount = 0;
|
|
@@ -473,7 +450,7 @@ new PerformanceObserver((list) => {
|
|
|
473
450
|
}).observe({ type: 'gc', buffered: false });
|
|
474
451
|
|
|
475
452
|
// Register runtime stats provider for GET /she/status
|
|
476
|
-
|
|
453
|
+
const statsSnapshot = () => {
|
|
477
454
|
let topics = 0;
|
|
478
455
|
// eslint-disable-next-line no-unused-vars
|
|
479
456
|
for (const _ of store.mqttEntries()) topics++;
|
|
@@ -532,16 +509,55 @@ require('./web/server').setStatsProvider(() => {
|
|
|
532
509
|
elMaxMs,
|
|
533
510
|
safeMode: SAFE_MODE,
|
|
534
511
|
};
|
|
535
|
-
}
|
|
512
|
+
};
|
|
513
|
+
require('./web/server').setStatsProvider(statsSnapshot);
|
|
536
514
|
|
|
537
515
|
// Health probe for GET /she/health (roadmap A1) — cheap, no counting, no allocation.
|
|
538
|
-
|
|
516
|
+
const healthSnapshot = () => ({
|
|
539
517
|
started: _started,
|
|
540
518
|
mqttConfigured: !!config.url,
|
|
541
519
|
mqttConnected: connected,
|
|
542
520
|
scripts: Object.keys(scripts).length,
|
|
543
521
|
safeMode: SAFE_MODE,
|
|
544
|
-
})
|
|
522
|
+
});
|
|
523
|
+
require('./web/server').setHealthProvider(healthSnapshot);
|
|
524
|
+
|
|
525
|
+
// What the AI chat may look at (roadmap I19, I27, I28): read-only views of the daemon's state.
|
|
526
|
+
const nextOf = (job) => {
|
|
527
|
+
try {
|
|
528
|
+
const d = job && typeof job.nextInvocation === 'function' ? job.nextInvocation() : null;
|
|
529
|
+
const date = d && typeof d.toDate === 'function' ? d.toDate() : d;
|
|
530
|
+
return date instanceof Date ? date.getTime() : null;
|
|
531
|
+
} catch {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
const introspect = {
|
|
536
|
+
// the AI's publish tool (roadmap I20), guarded and confirmed in ai-api; here only the broker call
|
|
537
|
+
publish: (topic, payload, opts) =>
|
|
538
|
+
new Promise((resolve, reject) => {
|
|
539
|
+
if (!mqtt || !connected) return reject(new Error('MQTT is not connected'));
|
|
540
|
+
mqtt.publish(String(topic), typeof payload === 'object' ? JSON.stringify(payload) : String(payload), opts || {}, (err) => (err ? reject(err) : resolve()));
|
|
541
|
+
}),
|
|
542
|
+
config: () => ({ name: config.name, variablePrefix: config.variablePrefix || 'var', version: pkg.version }),
|
|
543
|
+
scripts: () =>
|
|
544
|
+
Object.keys(scripts).map((file) => ({
|
|
545
|
+
file,
|
|
546
|
+
label: makeLabel(file).slice(0, -1),
|
|
547
|
+
origin: scriptOrigins.get(file) || 'user',
|
|
548
|
+
subscriptions: subscriptions.filter((x) => x._script === file).flatMap((x) => (Array.isArray(x.topic) ? x.topic : [x.topic])),
|
|
549
|
+
varSubscriptions: varSubscriptions.filter((x) => x._script === file).map((x) => x.key),
|
|
550
|
+
publishes: [...(scriptPublishes.get(file) || [])],
|
|
551
|
+
jobs: (scriptJobs.get(file) || []).filter(Boolean).map((job) => ({ next: nextOf(job) })),
|
|
552
|
+
sunEvents: sunEvents.filter((e) => e._script === file).map((e) => ({ pattern: e.pattern, next: nextOf(e._job) })),
|
|
553
|
+
timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null })),
|
|
554
|
+
})),
|
|
555
|
+
stats: statsSnapshot,
|
|
556
|
+
health: healthSnapshot,
|
|
557
|
+
instances: () => require('./lib/services-inventory').analyzeServices(store.mqttEntries()).instances,
|
|
558
|
+
devices: () => require('./lib/ha-discovery').analyzeDiscovery(store.mqttEntries()).devices,
|
|
559
|
+
};
|
|
560
|
+
require('./web/ai-api').init(store, log, introspect);
|
|
545
561
|
|
|
546
562
|
// Push current script:running state so the UI green dots survive a browser reload.
|
|
547
563
|
setWelcomeProvider(() => Object.keys(scripts).map((f) => ({ type: 'script:running', path: makeLabel(f).slice(0, -1), running: true })));
|
|
@@ -1089,6 +1105,7 @@ function runScript(script, name, _origin) {
|
|
|
1089
1105
|
}
|
|
1090
1106
|
|
|
1091
1107
|
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
|
|
1108
|
+
recordPublish(name, topic);
|
|
1092
1109
|
|
|
1093
1110
|
if (typeof payload === 'object') {
|
|
1094
1111
|
payload = JSON.stringify(payload);
|
|
@@ -1216,6 +1233,8 @@ function runScript(script, name, _origin) {
|
|
|
1216
1233
|
setInterval: (fn, delay, ...args) => {
|
|
1217
1234
|
const wrapped = args.length ? () => fn(...args) : fn;
|
|
1218
1235
|
const id = setInterval(() => _dispatch(name, wrapped), delay);
|
|
1236
|
+
id.every = delay;
|
|
1237
|
+
id.due = Date.now() + delay;
|
|
1219
1238
|
_myTimers.add(id);
|
|
1220
1239
|
return id;
|
|
1221
1240
|
},
|
|
@@ -1442,6 +1461,7 @@ function unloadScript(file) {
|
|
|
1442
1461
|
removedTimers += timers.size;
|
|
1443
1462
|
scriptTimers.delete(file);
|
|
1444
1463
|
}
|
|
1464
|
+
scriptPublishes.delete(file);
|
|
1445
1465
|
|
|
1446
1466
|
// Remove store-based var:: subscriptions belonging to this script
|
|
1447
1467
|
for (let i = varSubscriptions.length - 1; i >= 0; i--) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON-lines log file with rotation by size (roadmap B-12).
|
|
5
|
+
*
|
|
6
|
+
* `she.jsonl` is rotated on every daemon start and whenever it passes `maxBytes`:
|
|
7
|
+
* `.jsonl` → `.jsonl.1` → … → `.jsonl.<keep>`; the oldest file is dropped.
|
|
8
|
+
* The stream is written without waiting for the disk; what the thread pool has not
|
|
9
|
+
* written yet sits in the stream's buffer, which is transient.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const DEFAULT_KEEP = 5;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ dir: string, name?: string, maxBytes?: number, keep?: number }} opts
|
|
20
|
+
*/
|
|
21
|
+
function createLogFile({ dir, name = 'she.jsonl', maxBytes = DEFAULT_MAX_BYTES, keep = DEFAULT_KEEP }) {
|
|
22
|
+
const current = path.join(dir, name);
|
|
23
|
+
let stream = null;
|
|
24
|
+
let bytes = 0;
|
|
25
|
+
|
|
26
|
+
function shift() {
|
|
27
|
+
// .keep is dropped, .(keep-1) → .keep, …, current → .1
|
|
28
|
+
for (let i = keep; i >= 1; i--) {
|
|
29
|
+
const from = i === 1 ? current : `${current}.${i - 1}`;
|
|
30
|
+
const to = `${current}.${i}`;
|
|
31
|
+
try {
|
|
32
|
+
if (i === keep) fs.rmSync(to, { force: true });
|
|
33
|
+
fs.renameSync(from, to);
|
|
34
|
+
} catch {
|
|
35
|
+
/* a missing file in the chain — nothing to move */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function open() {
|
|
41
|
+
shift();
|
|
42
|
+
if (stream) stream.end();
|
|
43
|
+
// open synchronously: the file must exist and be bound before the next rotation renames it,
|
|
44
|
+
// and a stream opened lazily would bind to whatever carries the name by then
|
|
45
|
+
stream = fs.createWriteStream(null, { fd: fs.openSync(current, 'w') });
|
|
46
|
+
stream.on('error', () => {
|
|
47
|
+
/* the log file is a convenience: a disk error must not take the daemon down */
|
|
48
|
+
});
|
|
49
|
+
bytes = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
open();
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
/** @param {string} level @param {string} msg */
|
|
56
|
+
write(level, msg) {
|
|
57
|
+
const line = JSON.stringify({ level, msg, ts: Date.now() }) + '\n';
|
|
58
|
+
bytes += Buffer.byteLength(line);
|
|
59
|
+
stream.write(line);
|
|
60
|
+
if (bytes >= maxBytes) open();
|
|
61
|
+
},
|
|
62
|
+
/** the files that exist, newest first — what the AI log tool reads */
|
|
63
|
+
files() {
|
|
64
|
+
const list = [];
|
|
65
|
+
for (let i = 0; i <= keep; i++) {
|
|
66
|
+
const f = i === 0 ? current : `${current}.${i}`;
|
|
67
|
+
if (fs.existsSync(f)) list.push(f);
|
|
68
|
+
}
|
|
69
|
+
return list;
|
|
70
|
+
},
|
|
71
|
+
close() {
|
|
72
|
+
if (stream) stream.end();
|
|
73
|
+
stream = null;
|
|
74
|
+
},
|
|
75
|
+
get path() {
|
|
76
|
+
return current;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { createLogFile, DEFAULT_MAX_BYTES, DEFAULT_KEEP };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The daemon's logger: one level for every sink (roadmap B-12).
|
|
5
|
+
*
|
|
6
|
+
* A line below the configured level is dropped here, before redaction, serialisation
|
|
7
|
+
* and the sinks — the journal (pino), the UI (ring buffer and websocket) and the file.
|
|
8
|
+
* Until B-12 only pino honoured the level; the other two got every debug line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ level?: string, redact?: (s: string) => string, sinks: Array<(level: string, msg: string) => void> }} opts
|
|
15
|
+
*/
|
|
16
|
+
function createLogger({ level = 'info', redact = (s) => s, sinks = [] }) {
|
|
17
|
+
let threshold = LEVELS[level] ?? LEVELS.info;
|
|
18
|
+
const logger = {};
|
|
19
|
+
for (const name of Object.keys(LEVELS)) {
|
|
20
|
+
logger[name] = (...args) => {
|
|
21
|
+
if (LEVELS[name] < threshold) return;
|
|
22
|
+
const msg = redact(args.join(' '));
|
|
23
|
+
for (const sink of sinks) sink(name, msg);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
logger.setLevel = (l) => {
|
|
27
|
+
threshold = LEVELS[l] ?? LEVELS.info;
|
|
28
|
+
};
|
|
29
|
+
Object.defineProperty(logger, 'level', { get: () => Object.keys(LEVELS).find((k) => LEVELS[k] === threshold) });
|
|
30
|
+
return logger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { createLogger, LEVELS };
|
package/src/lib/script-timers.js
CHANGED
|
@@ -19,6 +19,7 @@ function trackTimeout(timers, fn, delay, setTimeoutFn = setTimeout) {
|
|
|
19
19
|
timers.delete(id);
|
|
20
20
|
fn();
|
|
21
21
|
}, delay);
|
|
22
|
+
if (id && typeof id === 'object') id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
|
|
22
23
|
timers.add(id);
|
|
23
24
|
return id;
|
|
24
25
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A unified diff of two texts, line based (roadmap I16): what the chat shows for a script draft.
|
|
5
|
+
* Plain LCS over lines — scripts are a few hundred lines, so quadratic is fine.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function lcsTable(a, b) {
|
|
9
|
+
const n = a.length;
|
|
10
|
+
const m = b.length;
|
|
11
|
+
const t = new Array(n + 1);
|
|
12
|
+
for (let i = 0; i <= n; i++) t[i] = new Uint32Array(m + 1);
|
|
13
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
14
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
15
|
+
t[i][j] = a[i] === b[j] ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @returns {Array<{ kind: ' '|'-'|'+', line: string, a?: number, b?: number }>} */
|
|
22
|
+
function diffLines(a, b) {
|
|
23
|
+
const t = lcsTable(a, b);
|
|
24
|
+
const out = [];
|
|
25
|
+
let i = 0;
|
|
26
|
+
let j = 0;
|
|
27
|
+
while (i < a.length && j < b.length) {
|
|
28
|
+
if (a[i] === b[j]) {
|
|
29
|
+
out.push({ kind: ' ', line: a[i], a: i, b: j });
|
|
30
|
+
i++;
|
|
31
|
+
j++;
|
|
32
|
+
} else if (t[i + 1][j] >= t[i][j + 1]) {
|
|
33
|
+
out.push({ kind: '-', line: a[i], a: i });
|
|
34
|
+
i++;
|
|
35
|
+
} else {
|
|
36
|
+
out.push({ kind: '+', line: b[j], b: j });
|
|
37
|
+
j++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
while (i < a.length) out.push({ kind: '-', line: a[i++] });
|
|
41
|
+
while (j < b.length) out.push({ kind: '+', line: b[j++] });
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} oldText
|
|
47
|
+
* @param {string} newText
|
|
48
|
+
* @param {{ context?: number, fromName?: string, toName?: string }} [opts]
|
|
49
|
+
* @returns {{ text: string, added: number, removed: number }}
|
|
50
|
+
*/
|
|
51
|
+
// a text's lines, without the empty one a trailing newline would produce
|
|
52
|
+
function toLines(text) {
|
|
53
|
+
const str = String(text ?? '');
|
|
54
|
+
if (str === '') return [];
|
|
55
|
+
const lines = str.split('\n');
|
|
56
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unifiedDiff(oldText, newText, opts = {}) {
|
|
61
|
+
const context = opts.context ?? 3;
|
|
62
|
+
const a = toLines(oldText);
|
|
63
|
+
const b = toLines(newText);
|
|
64
|
+
const ops = diffLines(a, b);
|
|
65
|
+
let added = 0;
|
|
66
|
+
let removed = 0;
|
|
67
|
+
for (const op of ops) {
|
|
68
|
+
if (op.kind === '+') added++;
|
|
69
|
+
else if (op.kind === '-') removed++;
|
|
70
|
+
}
|
|
71
|
+
const lines = [`--- ${opts.fromName || 'a'}`, `+++ ${opts.toName || 'b'}`];
|
|
72
|
+
if (added === 0 && removed === 0) return { text: '', added, removed };
|
|
73
|
+
// hunks: runs of changes with `context` lines around them
|
|
74
|
+
let idx = 0;
|
|
75
|
+
while (idx < ops.length) {
|
|
76
|
+
if (ops[idx].kind === ' ') {
|
|
77
|
+
idx++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let start = Math.max(0, idx - context);
|
|
81
|
+
let end = idx;
|
|
82
|
+
while (end < ops.length) {
|
|
83
|
+
// extend the hunk while changes are within 2*context of each other
|
|
84
|
+
let next = end;
|
|
85
|
+
while (next < ops.length && ops[next].kind !== ' ') next++;
|
|
86
|
+
let gap = next;
|
|
87
|
+
while (gap < ops.length && ops[gap].kind === ' ' && gap - next < 2 * context) gap++;
|
|
88
|
+
if (gap < ops.length && ops[gap].kind !== ' ' && gap - next < 2 * context) {
|
|
89
|
+
end = gap;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
end = Math.min(ops.length, next + context);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const hunk = ops.slice(start, end);
|
|
96
|
+
const aStart = (hunk.find((o) => o.a !== undefined)?.a ?? (hunk[0].b !== undefined ? aIndexBefore(ops, start) : 0)) + 1;
|
|
97
|
+
const bStart = (hunk.find((o) => o.b !== undefined)?.b ?? bIndexBefore(ops, start)) + 1;
|
|
98
|
+
const aLen = hunk.filter((o) => o.kind !== '+').length;
|
|
99
|
+
const bLen = hunk.filter((o) => o.kind !== '-').length;
|
|
100
|
+
lines.push(`@@ -${aStart},${aLen} +${bStart},${bLen} @@`);
|
|
101
|
+
for (const o of hunk) lines.push(o.kind + o.line);
|
|
102
|
+
idx = end;
|
|
103
|
+
}
|
|
104
|
+
return { text: lines.join('\n'), added, removed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function aIndexBefore(ops, k) {
|
|
108
|
+
for (let i = k - 1; i >= 0; i--) if (ops[i].a !== undefined) return ops[i].a + 1;
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
function bIndexBefore(ops, k) {
|
|
112
|
+
for (let i = k - 1; i >= 0; i--) if (ops[i].b !== undefined) return ops[i].b + 1;
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { unifiedDiff, diffLines };
|