@yeaft/webchat-agent 0.1.595 → 0.1.596
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/package.json +1 -1
- package/unify/dream-v2/diff-gate.js +89 -0
- package/unify/dream-v2/scope-sig.js +42 -0
- package/unify/dream-v2/tick.js +99 -0
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/diff-gate.js — DESIGN.md §9.14.
|
|
3
|
+
*
|
|
4
|
+
* Hourly dream tick is cheap by default: read a per-scope cursor, check
|
|
5
|
+
* whether anything has changed since last pass, skip everything if not.
|
|
6
|
+
*
|
|
7
|
+
* The cursor is a tiny JSON file `<scopeDir>/.dream-cursor.json`:
|
|
8
|
+
*
|
|
9
|
+
* { "lastTickAt": "<ISO>", "lastSeenSig": "<opaque>" }
|
|
10
|
+
*
|
|
11
|
+
* `lastSeenSig` is whatever the caller wants to put there — typically a
|
|
12
|
+
* hash of the entries dir mtime + index.md mtime. The diff-gate doesn't
|
|
13
|
+
* compute the signature; it just compares the supplied "current" against
|
|
14
|
+
* the stored "last". That keeps signatures pluggable (mtime today, content
|
|
15
|
+
* hash later, ETag on a remote scope etc.).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { promises as fs } from 'fs';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
20
|
+
|
|
21
|
+
const FILE = '.dream-cursor.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} root
|
|
25
|
+
* @param {string} scopeDir
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function cursorPath(root, scopeDir) {
|
|
29
|
+
if (!root || !scopeDir) throw new Error('cursorPath: root + scopeDir required');
|
|
30
|
+
return join(root, scopeDir, FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ root: string, scopeDir: string }} args
|
|
35
|
+
* @returns {Promise<{ lastTickAt: string|null, lastSeenSig: string|null }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function readCursor({ root, scopeDir }) {
|
|
38
|
+
const path = cursorPath(root, scopeDir);
|
|
39
|
+
try {
|
|
40
|
+
const content = await fs.readFile(path, 'utf8');
|
|
41
|
+
const parsed = JSON.parse(content);
|
|
42
|
+
return {
|
|
43
|
+
lastTickAt: typeof parsed?.lastTickAt === 'string' ? parsed.lastTickAt : null,
|
|
44
|
+
lastSeenSig: typeof parsed?.lastSeenSig === 'string' ? parsed.lastSeenSig : null,
|
|
45
|
+
};
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err && err.code === 'ENOENT') return { lastTickAt: null, lastSeenSig: null };
|
|
48
|
+
if (err instanceof SyntaxError) return { lastTickAt: null, lastSeenSig: null };
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{
|
|
55
|
+
* root: string,
|
|
56
|
+
* scopeDir: string,
|
|
57
|
+
* sig: string,
|
|
58
|
+
* tickAt?: string,
|
|
59
|
+
* }} args
|
|
60
|
+
*/
|
|
61
|
+
export async function writeCursor({ root, scopeDir, sig, tickAt }) {
|
|
62
|
+
if (typeof sig !== 'string') throw new Error('writeCursor: sig must be string');
|
|
63
|
+
const path = cursorPath(root, scopeDir);
|
|
64
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
65
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
66
|
+
await fs.writeFile(tmp, JSON.stringify({
|
|
67
|
+
lastTickAt: tickAt || new Date().toISOString(),
|
|
68
|
+
lastSeenSig: sig,
|
|
69
|
+
}), 'utf8');
|
|
70
|
+
await fs.rename(tmp, path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Diff-gate decision. Pure: takes (last, current) and returns whether
|
|
75
|
+
* dream should run. Caller decides what `currentSig` means.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ lastSeenSig: string|null }} last
|
|
78
|
+
* @param {string} currentSig
|
|
79
|
+
* @returns {{ skip: boolean, reason: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function shouldRunDream(last, currentSig) {
|
|
82
|
+
if (!last || last.lastSeenSig == null) {
|
|
83
|
+
return { skip: false, reason: 'no_cursor' };
|
|
84
|
+
}
|
|
85
|
+
if (last.lastSeenSig !== currentSig) {
|
|
86
|
+
return { skip: false, reason: 'diff' };
|
|
87
|
+
}
|
|
88
|
+
return { skip: true, reason: 'no_diff' };
|
|
89
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/scope-sig.js — DESIGN.md §9.14.
|
|
3
|
+
*
|
|
4
|
+
* Default signature for a scope dir: combine the mtimes of `entries/`,
|
|
5
|
+
* `index.md`, `summary.md` into a stable opaque string. Cheap and
|
|
6
|
+
* dependency-free — we explicitly stay out of "compute SHA over all
|
|
7
|
+
* entry bodies" territory because the dream tick is meant to be
|
|
8
|
+
* fingertip-cheap when nothing changed.
|
|
9
|
+
*
|
|
10
|
+
* Missing files contribute `0` to the signature, so cold-start scopes
|
|
11
|
+
* have a stable "empty" signature until the first entry lands.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promises as fs } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ root: string, scopeDir: string }} args
|
|
19
|
+
* @returns {Promise<string>}
|
|
20
|
+
*/
|
|
21
|
+
export async function computeScopeSig({ root, scopeDir }) {
|
|
22
|
+
if (!root || !scopeDir) throw new Error('computeScopeSig: root + scopeDir required');
|
|
23
|
+
const targets = [
|
|
24
|
+
join(root, scopeDir, 'entries'),
|
|
25
|
+
join(root, scopeDir, 'index.md'),
|
|
26
|
+
join(root, scopeDir, 'summary.md'),
|
|
27
|
+
];
|
|
28
|
+
const stamps = [];
|
|
29
|
+
for (const p of targets) {
|
|
30
|
+
try {
|
|
31
|
+
const s = await fs.stat(p);
|
|
32
|
+
stamps.push(`${s.mtimeMs.toFixed(0)}:${s.size}`);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err && err.code === 'ENOENT') {
|
|
35
|
+
stamps.push('0:0');
|
|
36
|
+
} else {
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return stamps.join('|');
|
|
42
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/tick.js — DESIGN.md §9.14 dream cadence.
|
|
3
|
+
*
|
|
4
|
+
* Hourly tick:
|
|
5
|
+
* 1. For each registered scope, compute current signature.
|
|
6
|
+
* 2. Read the scope cursor; if no diff, skip.
|
|
7
|
+
* 3. On diff (or `force`), call the supplied `refresh(scope)` hook,
|
|
8
|
+
* which is responsible for rewriting `summary.md` / `index.md`.
|
|
9
|
+
* Any errors per scope are captured; other scopes still run.
|
|
10
|
+
* 4. Write the new cursor.
|
|
11
|
+
*
|
|
12
|
+
* Phase 6 is intentionally refresh-only (DESIGN.md §8 line 395:
|
|
13
|
+
* "Thin: skip pruning/demotion; refresh-only in v1"). The `refresh`
|
|
14
|
+
* hook gets to decide what "refresh" means; this file just sequences
|
|
15
|
+
* the diff-gated calls.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { computeScopeSig } from './scope-sig.js';
|
|
19
|
+
import { readCursor, writeCursor, shouldRunDream } from './diff-gate.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{ kind: 'user'|'group'|'vp'|'task', id?: string, scopeDir: string }} ScopeRef
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{
|
|
27
|
+
* root: string,
|
|
28
|
+
* scopes: ScopeRef[],
|
|
29
|
+
* refresh: (scope: ScopeRef) => Promise<void>,
|
|
30
|
+
* force?: boolean,
|
|
31
|
+
* computeSig?: (scope: ScopeRef) => Promise<string>,
|
|
32
|
+
* now?: () => string,
|
|
33
|
+
* }} args
|
|
34
|
+
* @returns {Promise<{
|
|
35
|
+
* ran: Array<{ scopeDir: string, reason: string }>,
|
|
36
|
+
* skipped: Array<{ scopeDir: string, reason: string }>,
|
|
37
|
+
* errors: Array<{ scopeDir: string, error: Error }>,
|
|
38
|
+
* }>}
|
|
39
|
+
*/
|
|
40
|
+
export async function runDreamTick({
|
|
41
|
+
root, scopes, refresh, force = false,
|
|
42
|
+
computeSig, now,
|
|
43
|
+
}) {
|
|
44
|
+
if (!root) throw new Error('runDreamTick: root required');
|
|
45
|
+
if (!Array.isArray(scopes)) throw new Error('runDreamTick: scopes array required');
|
|
46
|
+
if (typeof refresh !== 'function') throw new Error('runDreamTick: refresh fn required');
|
|
47
|
+
|
|
48
|
+
const sigOf = typeof computeSig === 'function'
|
|
49
|
+
? computeSig
|
|
50
|
+
: (s) => computeScopeSig({ root, scopeDir: s.scopeDir });
|
|
51
|
+
const stamp = typeof now === 'function' ? now : () => new Date().toISOString();
|
|
52
|
+
|
|
53
|
+
const ran = [];
|
|
54
|
+
const skipped = [];
|
|
55
|
+
const errors = [];
|
|
56
|
+
|
|
57
|
+
for (const scope of scopes) {
|
|
58
|
+
if (!scope || !scope.scopeDir) continue;
|
|
59
|
+
let sig;
|
|
60
|
+
try {
|
|
61
|
+
sig = await sigOf(scope);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
errors.push({ scopeDir: scope.scopeDir, error: err });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const last = await readCursor({ root, scopeDir: scope.scopeDir });
|
|
67
|
+
const decision = force
|
|
68
|
+
? { skip: false, reason: 'forced' }
|
|
69
|
+
: shouldRunDream(last, sig);
|
|
70
|
+
|
|
71
|
+
if (decision.skip) {
|
|
72
|
+
skipped.push({ scopeDir: scope.scopeDir, reason: decision.reason });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await refresh(scope);
|
|
78
|
+
ran.push({ scopeDir: scope.scopeDir, reason: decision.reason });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
errors.push({ scopeDir: scope.scopeDir, error: err });
|
|
81
|
+
// Do NOT advance the cursor on failure — next tick should retry.
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Recompute the sig AFTER refresh in case the refresh hook itself
|
|
86
|
+
// wrote files; this is the value we want to compare against next tick.
|
|
87
|
+
let postSig;
|
|
88
|
+
try {
|
|
89
|
+
postSig = await sigOf(scope);
|
|
90
|
+
} catch {
|
|
91
|
+
postSig = sig;
|
|
92
|
+
}
|
|
93
|
+
await writeCursor({
|
|
94
|
+
root, scopeDir: scope.scopeDir, sig: postSig, tickAt: stamp(),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { ran, skipped, errors };
|
|
99
|
+
}
|