strom-research 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The roles people have in a record, whatever language they are written in:
|
|
2
|
+
// "kmotr", "Pate", "svědek", "Hebamme", "oddávající" — and RELA of other programs.
|
|
3
|
+
import { foldText } from "./text.js";
|
|
4
|
+
import { PARTICIPANT_ROLES } from "./model.js";
|
|
5
|
+
/** The role a word names, in any of the languages records and programs use; undefined if none. */
|
|
6
|
+
export function roleWord(word) {
|
|
7
|
+
const r = foldText(word ?? "");
|
|
8
|
+
if (!r)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (PARTICIPANT_ROLES.includes(r))
|
|
11
|
+
return r;
|
|
12
|
+
// Latin registers: patrinus/patrina, levans (who lifts the child), susceptor; paranymphus (at a wedding) is a witness
|
|
13
|
+
if (/godparent|godfather|godmother|sponsor|kmotr|pate|patin|patrin|levan|suscept|compater|commater|chrzest|parrain|marraine|padrino|madrina/.test(r))
|
|
14
|
+
return "godparent";
|
|
15
|
+
if (/witness|svedek|svedk|zeuge|swiadek|swiadk|temoin|testigo|testis|testes|paranymph|druzba|druzic/.test(r))
|
|
16
|
+
return "witness";
|
|
17
|
+
if (/officiant|clergy|priest|minister|pastor|knez|farar|kaplan|pfarrer|ksiadz|cure|sacerdo|parochus|capellan|cooperator|vicari|baptizans|copulans|copulavi|oddavajici|krtici/.test(r))
|
|
18
|
+
return "officiant";
|
|
19
|
+
if (/informant|oznamovatel|anzeig|declarant/.test(r))
|
|
20
|
+
return "informant";
|
|
21
|
+
if (/midwife|porodni|baba|hebamme|polozna|sage-femme|obstetrix/.test(r))
|
|
22
|
+
return "midwife";
|
|
23
|
+
if (/participant|ucastnik|teilnehmer|uczestnik/.test(r))
|
|
24
|
+
return "other";
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** RELA as other programs write it → our role. */
|
|
28
|
+
export function roleOf(rela, fallback = "other") {
|
|
29
|
+
return roleWord(rela) ?? fallback;
|
|
30
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Declarative field rules for record types. Used for write-time validation
|
|
2
|
+
// and by `strom check` (including references between records).
|
|
3
|
+
import { ACCESS, AUTOMATION, INPUT_KINDS, JURISDICTIONS, LESSON_MAX, LESSON_SCOPES, SEARCH_METHODS, SEARCH_RESULTS, SOURCE_KINDS, TASK_LEVELS, TASK_STATES, } from "./model.js";
|
|
4
|
+
import { normalizeDate } from "./gdate.js";
|
|
5
|
+
const S = (extra = {}) => ({ t: "string", ...extra });
|
|
6
|
+
const R = (to, req = false) => ({ t: "ref", to, req });
|
|
7
|
+
const RS = (to, min = 0) => ({ t: "refs", to, req: true, min });
|
|
8
|
+
/** A fraction of an image (0–1). */
|
|
9
|
+
const F = { t: "number", req: true, min: 0, max: 1 };
|
|
10
|
+
const ANY_SUBJECT = ["person", "family", "place", "recordset", "repository", "source", "research", "input"];
|
|
11
|
+
/** A task can also be about a conflict or a hypothesis: the work that decides it. */
|
|
12
|
+
const TASK_SUBJECT = [...ANY_SUBJECT, "conflict", "hypothesis"];
|
|
13
|
+
export const SCHEMAS = {
|
|
14
|
+
source: {
|
|
15
|
+
kind: S({ req: true, enum: SOURCE_KINDS }),
|
|
16
|
+
title: S({ req: true, max: 300 }),
|
|
17
|
+
recordset: R("recordset"),
|
|
18
|
+
repository: R("repository"),
|
|
19
|
+
input: R("input"),
|
|
20
|
+
locator: S({ max: 300 }),
|
|
21
|
+
date: S({ date: true }),
|
|
22
|
+
transcript: S(),
|
|
23
|
+
translation: S(),
|
|
24
|
+
language: S(),
|
|
25
|
+
information: S({ req: true, enum: ["primary", "secondary", "unknown"] }),
|
|
26
|
+
form: S({ req: true, enum: ["original", "derivative", "authored"] }),
|
|
27
|
+
url: S(),
|
|
28
|
+
accessed: S(),
|
|
29
|
+
media: { t: "refs", to: "media" },
|
|
30
|
+
},
|
|
31
|
+
repository: {
|
|
32
|
+
name: S({ req: true, max: 200 }),
|
|
33
|
+
country: S(),
|
|
34
|
+
region: S(),
|
|
35
|
+
url: S(),
|
|
36
|
+
terms: S({ max: 1000 }),
|
|
37
|
+
automation: S({ req: true, enum: AUTOMATION }),
|
|
38
|
+
},
|
|
39
|
+
recordset: {
|
|
40
|
+
title: S({ req: true, max: 300 }),
|
|
41
|
+
repository: R("repository"),
|
|
42
|
+
callNumber: S(),
|
|
43
|
+
kinds: { t: "strings", req: true },
|
|
44
|
+
places: { t: "strings", req: true },
|
|
45
|
+
years: S(),
|
|
46
|
+
access: S({ req: true, enum: ACCESS }),
|
|
47
|
+
url: S(),
|
|
48
|
+
images: { t: "number", int: true, min: 1 },
|
|
49
|
+
calibration: { t: "array", req: true, of: { image: { t: "number", req: true, int: true, min: 1 }, page: S({ req: true }) } },
|
|
50
|
+
layout: S({ max: 1000 }),
|
|
51
|
+
},
|
|
52
|
+
place: {
|
|
53
|
+
names: { t: "array", req: true, min: 1, of: { name: S({ req: true }), lang: S(), from: { t: "number", int: true }, to: { t: "number", int: true } } },
|
|
54
|
+
kind: S(),
|
|
55
|
+
coords: { t: "object", of: { lat: { t: "number", req: true, min: -90, max: 90 }, lon: { t: "number", req: true, min: -180, max: 180 } } },
|
|
56
|
+
parent: R("place"),
|
|
57
|
+
jurisdictions: {
|
|
58
|
+
t: "array",
|
|
59
|
+
req: true,
|
|
60
|
+
of: {
|
|
61
|
+
kind: S({ req: true, enum: JURISDICTIONS }),
|
|
62
|
+
name: S({ req: true }),
|
|
63
|
+
from: { t: "number", int: true },
|
|
64
|
+
to: { t: "number", int: true },
|
|
65
|
+
repository: R("repository"),
|
|
66
|
+
recordsets: RS("recordset"),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
search: {
|
|
71
|
+
question: S({ req: true, max: 300 }),
|
|
72
|
+
recordsets: RS("recordset"),
|
|
73
|
+
scope: { t: "object", req: true, of: { years: S(), surnames: { t: "strings" }, places: { t: "strings" }, pages: S() } },
|
|
74
|
+
method: S({ req: true, enum: SEARCH_METHODS }),
|
|
75
|
+
result: S({ req: true, enum: SEARCH_RESULTS }),
|
|
76
|
+
findings: RS("source"),
|
|
77
|
+
task: R("task"),
|
|
78
|
+
by: S({ req: true, enum: ["main", "reader", "user"] }),
|
|
79
|
+
},
|
|
80
|
+
task: {
|
|
81
|
+
level: S({ req: true, enum: TASK_LEVELS }),
|
|
82
|
+
priority: { t: "number", req: true, int: true, min: 1, max: 5 },
|
|
83
|
+
what: S({ req: true, max: 200 }),
|
|
84
|
+
where: { t: "strings", req: true, min: 1 },
|
|
85
|
+
why: S({ req: true, max: 1000 }),
|
|
86
|
+
doneWhen: S({ req: true, max: 500 }),
|
|
87
|
+
subject: RS(TASK_SUBJECT),
|
|
88
|
+
research: R("research"),
|
|
89
|
+
state: S({ req: true, enum: TASK_STATES }),
|
|
90
|
+
parkedUntil: S(),
|
|
91
|
+
waitingOn: S(),
|
|
92
|
+
awaits: { t: "object", of: { recordset: R("recordset", true), images: S({ req: true, max: 200 }), folder: S({ req: true, max: 200 }) } },
|
|
93
|
+
result: S({ max: 1000 }),
|
|
94
|
+
produced: { t: "strings" },
|
|
95
|
+
origin: S({ req: true }),
|
|
96
|
+
},
|
|
97
|
+
conflict: {
|
|
98
|
+
title: S({ req: true, max: 200 }),
|
|
99
|
+
subject: RS(ANY_SUBJECT, 1),
|
|
100
|
+
claims: { t: "array", req: true, min: 2, of: { source: R("source"), value: S({ req: true, max: 500 }), note: S({ max: 500 }) } },
|
|
101
|
+
state: S({ req: true, enum: ["open", "resolved"] }),
|
|
102
|
+
resolution: S({ max: 500 }),
|
|
103
|
+
reasoning: S({ max: 2000 }),
|
|
104
|
+
},
|
|
105
|
+
hypothesis: {
|
|
106
|
+
question: S({ req: true, max: 300 }),
|
|
107
|
+
subject: RS(ANY_SUBJECT, 1),
|
|
108
|
+
variants: {
|
|
109
|
+
t: "array",
|
|
110
|
+
req: true,
|
|
111
|
+
min: 2,
|
|
112
|
+
of: { label: S({ req: true, max: 20 }), claim: S({ req: true, max: 500 }), support: { t: "strings", req: true }, against: { t: "strings", req: true } },
|
|
113
|
+
},
|
|
114
|
+
state: S({ req: true, enum: ["open", "decided", "abandoned"] }),
|
|
115
|
+
decision: S({ max: 1000 }),
|
|
116
|
+
},
|
|
117
|
+
lesson: {
|
|
118
|
+
scope: S({ req: true, enum: LESSON_SCOPES }),
|
|
119
|
+
target: R(["repository", "recordset", "place"]),
|
|
120
|
+
rule: S({ req: true, max: LESSON_MAX }),
|
|
121
|
+
detail: S({ max: 2000 }),
|
|
122
|
+
},
|
|
123
|
+
session: {
|
|
124
|
+
task: R("task"),
|
|
125
|
+
research: R("research"),
|
|
126
|
+
state: S({ req: true, enum: ["open", "closed", "interrupted"] }),
|
|
127
|
+
runner: S(),
|
|
128
|
+
worker: S(),
|
|
129
|
+
agent: S(),
|
|
130
|
+
endedBy: S({ enum: ["user", "run", "chat", "agent"] }),
|
|
131
|
+
started: S({ req: true }),
|
|
132
|
+
ended: S(),
|
|
133
|
+
summary: S({ max: 2000 }),
|
|
134
|
+
next: S({ max: 1000 }),
|
|
135
|
+
metrics: { t: "any" },
|
|
136
|
+
},
|
|
137
|
+
input: {
|
|
138
|
+
name: S({ req: true }),
|
|
139
|
+
file: S(),
|
|
140
|
+
text: S(),
|
|
141
|
+
sha: S(),
|
|
142
|
+
size: { t: "number", int: true, min: 0 },
|
|
143
|
+
mime: S(),
|
|
144
|
+
from: S(),
|
|
145
|
+
kind: S({ req: true, enum: INPUT_KINDS }),
|
|
146
|
+
state: S({ req: true, enum: ["new", "processed", "skipped"] }),
|
|
147
|
+
source: R("source"),
|
|
148
|
+
imported: { t: "any" },
|
|
149
|
+
},
|
|
150
|
+
media: {
|
|
151
|
+
sha: S({ req: true }),
|
|
152
|
+
file: S({ req: true }),
|
|
153
|
+
mime: S({ req: true }),
|
|
154
|
+
size: { t: "number", req: true, int: true, min: 0 },
|
|
155
|
+
width: { t: "number", int: true, min: 1 },
|
|
156
|
+
height: { t: "number", int: true, min: 1 },
|
|
157
|
+
recordset: R("recordset"),
|
|
158
|
+
image: { t: "number", int: true, min: 0 },
|
|
159
|
+
part: { t: "object", of: { x: F, y: F, w: F, h: F } },
|
|
160
|
+
page: S({ max: 100 }),
|
|
161
|
+
url: S(),
|
|
162
|
+
from: S(),
|
|
163
|
+
fetched: { t: "object", of: { connector: S({ req: true, max: 100 }), book: S({ req: true, max: 300 }), via: S({ enum: ["browser"] }) } },
|
|
164
|
+
accessed: S(),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
function prefixOk(id, to, prefixes) {
|
|
168
|
+
return to.some((t) => id.startsWith(prefixes[t]) && /^[A-Z]\d{4,}$/.test(id));
|
|
169
|
+
}
|
|
170
|
+
/** Validate fields against a spec; collect references for `check`. */
|
|
171
|
+
export function checkFields(value, spec, prefixes, path, out, refs) {
|
|
172
|
+
for (const [key, rule] of Object.entries(spec)) {
|
|
173
|
+
const v = value[key];
|
|
174
|
+
const p = path ? `${path}.${key}` : key;
|
|
175
|
+
if (v === undefined || v === null) {
|
|
176
|
+
if ("req" in rule && rule.req)
|
|
177
|
+
out.push({ path: p, message: "is required" });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
switch (rule.t) {
|
|
181
|
+
case "any":
|
|
182
|
+
break;
|
|
183
|
+
case "string":
|
|
184
|
+
if (typeof v !== "string")
|
|
185
|
+
out.push({ path: p, message: "must be text" });
|
|
186
|
+
else {
|
|
187
|
+
if (rule.req && v.trim() === "")
|
|
188
|
+
out.push({ path: p, message: "must not be empty" });
|
|
189
|
+
if (rule.enum && !rule.enum.includes(v))
|
|
190
|
+
out.push({ path: p, message: `must be one of ${rule.enum.join(", ")}` });
|
|
191
|
+
if (rule.max && [...v].length > rule.max)
|
|
192
|
+
out.push({ path: p, message: `is longer than ${rule.max} characters` });
|
|
193
|
+
if (rule.date && normalizeDate(v) !== v)
|
|
194
|
+
out.push({ path: p, message: `"${v}" is not a normalized GEDCOM date` });
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
case "number":
|
|
198
|
+
if (typeof v !== "number" || Number.isNaN(v))
|
|
199
|
+
out.push({ path: p, message: "must be a number" });
|
|
200
|
+
else {
|
|
201
|
+
if (rule.int && !Number.isInteger(v))
|
|
202
|
+
out.push({ path: p, message: "must be a whole number" });
|
|
203
|
+
if (rule.min !== undefined && v < rule.min)
|
|
204
|
+
out.push({ path: p, message: `must be at least ${rule.min}` });
|
|
205
|
+
if (rule.max !== undefined && v > rule.max)
|
|
206
|
+
out.push({ path: p, message: `must be at most ${rule.max}` });
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case "ref": {
|
|
210
|
+
const to = Array.isArray(rule.to) ? rule.to : [rule.to];
|
|
211
|
+
if (typeof v !== "string" || !prefixOk(v, to, prefixes))
|
|
212
|
+
out.push({ path: p, message: `must be a ${to.join("/")} ID` });
|
|
213
|
+
else
|
|
214
|
+
refs.push({ path: p, id: v, to });
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "refs": {
|
|
218
|
+
const to = Array.isArray(rule.to) ? rule.to : [rule.to];
|
|
219
|
+
if (!Array.isArray(v))
|
|
220
|
+
out.push({ path: p, message: "must be a list of IDs" });
|
|
221
|
+
else {
|
|
222
|
+
if (rule.min && v.length < rule.min)
|
|
223
|
+
out.push({ path: p, message: `needs at least ${rule.min}` });
|
|
224
|
+
v.forEach((id, i) => {
|
|
225
|
+
if (typeof id !== "string" || !prefixOk(id, to, prefixes))
|
|
226
|
+
out.push({ path: `${p}[${i}]`, message: `must be a ${to.join("/")} ID` });
|
|
227
|
+
else
|
|
228
|
+
refs.push({ path: `${p}[${i}]`, id, to });
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "strings":
|
|
234
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
|
|
235
|
+
out.push({ path: p, message: "must be a list of texts" });
|
|
236
|
+
else if (rule.min && v.length < rule.min)
|
|
237
|
+
out.push({ path: p, message: `needs at least ${rule.min}` });
|
|
238
|
+
break;
|
|
239
|
+
case "array":
|
|
240
|
+
if (!Array.isArray(v))
|
|
241
|
+
out.push({ path: p, message: "must be a list" });
|
|
242
|
+
else {
|
|
243
|
+
if (rule.min && v.length < rule.min)
|
|
244
|
+
out.push({ path: p, message: `needs at least ${rule.min}` });
|
|
245
|
+
v.forEach((item, i) => {
|
|
246
|
+
if (item === null || typeof item !== "object")
|
|
247
|
+
out.push({ path: `${p}[${i}]`, message: "must be an object" });
|
|
248
|
+
else
|
|
249
|
+
checkFields(item, rule.of, prefixes, `${p}[${i}]`, out, refs);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
case "object":
|
|
254
|
+
if (typeof v !== "object" || Array.isArray(v))
|
|
255
|
+
out.push({ path: p, message: "must be an object" });
|
|
256
|
+
else
|
|
257
|
+
checkFields(v, rule.of, prefixes, p, out, refs);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Tamper evidence.
|
|
2
|
+
//
|
|
3
|
+
// - Every operation strom writes records the SHA-256 of each file it wrote,
|
|
4
|
+
// links to the previous operation (hash chain) and is signed with an HMAC
|
|
5
|
+
// key that lives OUTSIDE the tree: <config>/keys/<tree id>.key (agents are
|
|
6
|
+
// not permitted to read it).
|
|
7
|
+
// - Every commit strom makes carries a trailer `Strom-Seal: <HMAC of the git
|
|
8
|
+
// tree hash>`, so a commit made outside strom is detected by looking at
|
|
9
|
+
// HEAD alone — cheap enough to run before every command.
|
|
10
|
+
//
|
|
11
|
+
// Agents bypass tools out of convenience, not malice; this catches every
|
|
12
|
+
// convenience bypass, and deliberate forgery needs a secret the agent was
|
|
13
|
+
// told not to read.
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { configDir } from "./paths.js";
|
|
18
|
+
import { canonicalize } from "./json.js";
|
|
19
|
+
export function sha256(content) {
|
|
20
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
export function fileSha(file) {
|
|
23
|
+
try {
|
|
24
|
+
return sha256(fs.readFileSync(file));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function keyFile(env, treeId) {
|
|
31
|
+
return path.join(configDir(env), "keys", `${treeId}.key`);
|
|
32
|
+
}
|
|
33
|
+
export function loadKey(env, treeId) {
|
|
34
|
+
try {
|
|
35
|
+
return Buffer.from(fs.readFileSync(keyFile(env, treeId), "utf8").trim(), "hex");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Create the key for a tree on this computer (0600). Fails if it exists. */
|
|
42
|
+
export function createKey(env, treeId) {
|
|
43
|
+
const file = keyFile(env, treeId);
|
|
44
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
45
|
+
const key = crypto.randomBytes(32);
|
|
46
|
+
fs.writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
/** Short public fingerprint of a key, stored on each operation. */
|
|
50
|
+
export function fingerprint(key) {
|
|
51
|
+
return sha256(key).slice(0, 12);
|
|
52
|
+
}
|
|
53
|
+
function hmac(key, text) {
|
|
54
|
+
return crypto.createHmac("sha256", key).update(text).digest("hex").slice(0, 32);
|
|
55
|
+
}
|
|
56
|
+
/** Signature over the operation without its own `sig` field. */
|
|
57
|
+
export function signOp(op, key) {
|
|
58
|
+
const { sig: _omit, ...rest } = op;
|
|
59
|
+
return hmac(key, JSON.stringify(canonicalize(rest)));
|
|
60
|
+
}
|
|
61
|
+
function safeEqual(a, b) {
|
|
62
|
+
return a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
63
|
+
}
|
|
64
|
+
export function verifyOp(op, key) {
|
|
65
|
+
return typeof op.sig === "string" && safeEqual(op.sig, signOp(op, key));
|
|
66
|
+
}
|
|
67
|
+
export const SEAL_TRAILER = "Strom-Seal";
|
|
68
|
+
export function commitSeal(key, treeHash) {
|
|
69
|
+
return `${SEAL_TRAILER}: ${hmac(key, `tree ${treeHash}`)}`;
|
|
70
|
+
}
|
|
71
|
+
export function readCommitSeal(message) {
|
|
72
|
+
return new RegExp(`^${SEAL_TRAILER}: ([0-9a-f]{32})\\s*$`, "m").exec(message)?.[1];
|
|
73
|
+
}
|
|
74
|
+
export function verifyCommitSeal(key, treeHash, message) {
|
|
75
|
+
const seal = readCommitSeal(message);
|
|
76
|
+
return seal !== undefined && safeEqual(seal, hmac(key, `tree ${treeHash}`));
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// This very installation of strom: how it was installed and how to start it
|
|
2
|
+
// again — for the agent's `strom` on PATH, the shortcut on the desktop, a
|
|
3
|
+
// process strom starts itself.
|
|
4
|
+
//
|
|
5
|
+
// strom is JavaScript on Node, installed one of three ways:
|
|
6
|
+
// installed by strom's installer: a folder with the official Node from
|
|
7
|
+
// nodejs.org (node/) and strom's code (app/), install.json beside
|
|
8
|
+
// them naming the command(s) on PATH that start it
|
|
9
|
+
// npm npm install -g strom-research, on the Node of the computer
|
|
10
|
+
// source run from the repository (node src/cli.ts)
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
const here = fileURLToPath(import.meta.url);
|
|
15
|
+
/** How this strom was installed (read once). */
|
|
16
|
+
let cached;
|
|
17
|
+
export function installation() {
|
|
18
|
+
if (cached)
|
|
19
|
+
return cached;
|
|
20
|
+
if (here.endsWith(".ts"))
|
|
21
|
+
return (cached = { kind: "source" });
|
|
22
|
+
// …/app/dist/core/self.js
|
|
23
|
+
const app = path.resolve(path.dirname(here), "..", "..");
|
|
24
|
+
const root = path.dirname(app);
|
|
25
|
+
try {
|
|
26
|
+
if (path.basename(app) === "app") {
|
|
27
|
+
const info = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8"));
|
|
28
|
+
return (cached = { kind: "installed", root, launchers: info.launchers ?? [], ...(info.node ? { node: info.node } : {}) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// not the installer's folder
|
|
33
|
+
}
|
|
34
|
+
return (cached = { kind: "npm" });
|
|
35
|
+
}
|
|
36
|
+
/** The program and arguments that run strom: this Node and its entry script (cli.ts from sources, cli.js built). */
|
|
37
|
+
export function stromLauncher() {
|
|
38
|
+
const cli = path.resolve(path.dirname(here), "..", here.endsWith(".ts") ? "cli.ts" : "cli.js");
|
|
39
|
+
return { command: process.execPath, args: [cli] };
|
|
40
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Sessions: one agent working on one task. While a session is open, every
|
|
2
|
+
// operation is logged under its ID (data/ops/N0001.jsonl) and the tree knows
|
|
3
|
+
// who wrote what. Several agents may work side by side, each in a session of
|
|
4
|
+
// its own on a task of its own: the current session of an agent strom started
|
|
5
|
+
// lives in .strom/session-<worker>.json (STROM_WORKER, set by strom chat), of
|
|
6
|
+
// any other in .strom/session.json (local state, not evidence), or in
|
|
7
|
+
// STROM_SESSION (set by `strom run`).
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { UsageError } from "./errors.js";
|
|
11
|
+
import { now } from "./tree.js";
|
|
12
|
+
import { makeNote } from "./actions.js";
|
|
13
|
+
import { detectAgent } from "./which.js";
|
|
14
|
+
import { isLiveWorker, isWorkerId } from "./workers.js";
|
|
15
|
+
function currentFile(tree, env = tree.env) {
|
|
16
|
+
const w = env.STROM_WORKER;
|
|
17
|
+
return path.join(tree.root, ".strom", isWorkerId(w) ? `session-${w}.json` : "session.json");
|
|
18
|
+
}
|
|
19
|
+
/** Who holds a session, for keeping agents apart: its worker, a run, or anyone else. */
|
|
20
|
+
function holder(s) {
|
|
21
|
+
return s.worker ?? (s.runner ? "run" : "");
|
|
22
|
+
}
|
|
23
|
+
/** Who this process works for. */
|
|
24
|
+
function holderOf(env, runner) {
|
|
25
|
+
return isWorkerId(env.STROM_WORKER) ? env.STROM_WORKER : runner ? "run" : "";
|
|
26
|
+
}
|
|
27
|
+
/** Open sessions of other agents, and the tasks they hold. */
|
|
28
|
+
export function othersAtWork(tree, env = tree.env, runner) {
|
|
29
|
+
const me = holderOf(env, runner);
|
|
30
|
+
const sessions = openSessions(tree).filter((s) => holder(s) !== me);
|
|
31
|
+
return { sessions, tasks: new Set(sessions.flatMap((s) => (s.task ? [s.task] : []))) };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sessions whose agent is gone: strom chat ended (or the computer went down)
|
|
35
|
+
* with the session still open. The tree knows it from the worker's presence.
|
|
36
|
+
*/
|
|
37
|
+
export function abandonedSessions(tree) {
|
|
38
|
+
return openSessions(tree).filter((s) => s.worker && !isLiveWorker(tree.root, s.worker));
|
|
39
|
+
}
|
|
40
|
+
/** The open session of this tree, if any. */
|
|
41
|
+
export function currentSession(tree, env) {
|
|
42
|
+
let id = env.STROM_SESSION;
|
|
43
|
+
if (!id) {
|
|
44
|
+
try {
|
|
45
|
+
id = JSON.parse(fs.readFileSync(currentFile(tree, env), "utf8")).id;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const s = id ? tree.get(id) : undefined;
|
|
52
|
+
return s?.state === "open" ? s : undefined;
|
|
53
|
+
}
|
|
54
|
+
export function setCurrent(tree, id) {
|
|
55
|
+
if (tree.dryRun)
|
|
56
|
+
return;
|
|
57
|
+
const file = currentFile(tree);
|
|
58
|
+
if (id === undefined)
|
|
59
|
+
fs.rmSync(file, { force: true });
|
|
60
|
+
else {
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
62
|
+
fs.writeFileSync(file, JSON.stringify({ id, at: now() }) + "\n");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function openSessions(tree) {
|
|
66
|
+
return tree.list("session").filter((s) => s.state === "open");
|
|
67
|
+
}
|
|
68
|
+
/** Start a session on a task (the task goes to "doing"). One open session per agent; one agent per task. */
|
|
69
|
+
export function startSession(tree, opts) {
|
|
70
|
+
return tree.withTreeLock(() => {
|
|
71
|
+
const me = holderOf(tree.env, opts.runner);
|
|
72
|
+
const busy = openSessions(tree).filter((s) => holder(s) === me && (!opts.research || !s.research || s.research === opts.research));
|
|
73
|
+
if (busy.length)
|
|
74
|
+
throw new UsageError(`session ${busy[0].id} is still open${busy[0].task ? ` on ${busy[0].task}` : ""}`, {
|
|
75
|
+
hint: `finish it: strom session close --summary "…" --next "…" (or, if it died: strom session close ${busy[0].id} --interrupted)`,
|
|
76
|
+
});
|
|
77
|
+
const taken = opts.task ? othersAtWork(tree, tree.env, opts.runner).sessions.find((s) => s.task === opts.task.id) : undefined;
|
|
78
|
+
if (taken)
|
|
79
|
+
throw new UsageError(`${opts.task.id} is being worked on in session ${taken.id}${taken.agent ? ` (${taken.agent})` : ""}`, {
|
|
80
|
+
hint: "take another task: strom session start (the next free one)",
|
|
81
|
+
});
|
|
82
|
+
const task = opts.task ? (tree.get(opts.task.id) ?? opts.task) : undefined;
|
|
83
|
+
const agent = detectAgent(tree.env) ?? (opts.runner && opts.runner !== "script" ? opts.runner : undefined);
|
|
84
|
+
const t = now();
|
|
85
|
+
const s = {
|
|
86
|
+
id: tree.allocate("N"),
|
|
87
|
+
type: "session",
|
|
88
|
+
state: "open",
|
|
89
|
+
started: t,
|
|
90
|
+
notes: [],
|
|
91
|
+
created: t,
|
|
92
|
+
updated: t,
|
|
93
|
+
...(opts.task ? { task: opts.task.id } : {}),
|
|
94
|
+
...(opts.research ?? opts.task?.research ? { research: (opts.research ?? opts.task?.research) } : {}),
|
|
95
|
+
...(opts.runner ? { runner: opts.runner } : {}),
|
|
96
|
+
...(isWorkerId(tree.env.STROM_WORKER) ? { worker: tree.env.STROM_WORKER } : {}),
|
|
97
|
+
...(agent ? { agent } : {}),
|
|
98
|
+
};
|
|
99
|
+
tree.put(s, { op: "session.start", targets: [s.id, ...(task ? [task.id] : [])], summary: `${s.id} session started${task ? ` on ${task.id}` : ""}` });
|
|
100
|
+
tree.actor = s.id;
|
|
101
|
+
if (task && task.state !== "doing") {
|
|
102
|
+
const doing = { ...task, state: "doing", updated: now() };
|
|
103
|
+
tree.put(doing, { op: "task.start", targets: [doing.id, s.id], summary: `${doing.id} start` });
|
|
104
|
+
}
|
|
105
|
+
setCurrent(tree, s.id);
|
|
106
|
+
return s;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function sessionNote(tree, s, text) {
|
|
110
|
+
return tree.withTreeLock(() => {
|
|
111
|
+
const next = { ...s, notes: [...s.notes, makeNote(tree, text)], updated: now() };
|
|
112
|
+
tree.put(next, { op: "session.note", targets: [s.id], summary: `${s.id} note: ${text.slice(0, 60)}` });
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Close a session. The task must not be left "doing" — done, parked, waiting, or explicitly continued. */
|
|
117
|
+
export function closeSession(tree, s, input) {
|
|
118
|
+
if (!input.interrupted && (!input.summary.trim() || !input.next.trim()))
|
|
119
|
+
throw new UsageError("closing needs --summary and --next", { hint: 'e.g. --summary "found the baptism, parents Josef and Marie" --next "marriage of Josef ~1898 in B0002"' });
|
|
120
|
+
return tree.withTreeLock(() => {
|
|
121
|
+
s = tree.get(s.id) ?? s;
|
|
122
|
+
const task = s.task ? tree.get(s.task) : undefined;
|
|
123
|
+
if (task && task.state === "doing") {
|
|
124
|
+
if (!input.continueTask && !input.interrupted)
|
|
125
|
+
throw new UsageError(`task ${task.id} is still in progress`, {
|
|
126
|
+
hint: `close it first: strom task done ${task.id} --result "…" | task park | task wait — or keep it for the next session: --continue`,
|
|
127
|
+
});
|
|
128
|
+
const back = { ...task, state: "open", notes: [...task.notes, makeNote(tree, `${input.interrupted ? "interrupted" : "continued"} in ${s.id}: ${input.next || "(no handover)"}`.slice(0, 500))], updated: now() };
|
|
129
|
+
tree.put(back, { op: "task.continue", targets: [task.id, s.id], summary: `${task.id} back to the queue` });
|
|
130
|
+
}
|
|
131
|
+
const closed = {
|
|
132
|
+
...s,
|
|
133
|
+
state: input.interrupted ? "interrupted" : "closed",
|
|
134
|
+
ended: now(),
|
|
135
|
+
updated: now(),
|
|
136
|
+
...(input.summary.trim() ? { summary: input.summary.trim() } : {}),
|
|
137
|
+
...(input.next.trim() ? { next: input.next.trim() } : {}),
|
|
138
|
+
...(input.metrics ? { metrics: input.metrics } : {}),
|
|
139
|
+
...(input.endedBy ? { endedBy: input.endedBy } : {}),
|
|
140
|
+
};
|
|
141
|
+
tree.put(closed, { op: "session.close", targets: [s.id], summary: `${s.id} ${closed.state}: ${(input.summary || "no summary").slice(0, 80)}` });
|
|
142
|
+
// Only the session this agent calls its current one is forgotten — not another agent's.
|
|
143
|
+
if (currentSession(tree, tree.env)?.id === s.id)
|
|
144
|
+
setCurrent(tree, undefined);
|
|
145
|
+
return closed;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** The last closed sessions (newest first), optionally of one research. */
|
|
149
|
+
export function recentSessions(tree, research, n = 2) {
|
|
150
|
+
return tree
|
|
151
|
+
.list("session")
|
|
152
|
+
.filter((s) => s.state !== "open" && (!research || !s.research || s.research === research))
|
|
153
|
+
.sort((a, b) => (b.ended ?? b.updated).localeCompare(a.ended ?? a.updated))
|
|
154
|
+
.slice(0, n);
|
|
155
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A "Strom research" shortcut on the desktop: a double-click opens a terminal
|
|
2
|
+
// window with strom's menu — for users who never type a command.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { desktopDir, userHome } from "./paths.js";
|
|
7
|
+
import { stromLauncher } from "./self.js";
|
|
8
|
+
/** Make (or refresh) the shortcut; returns the files written. */
|
|
9
|
+
export function createShortcut(name, env, platform = process.platform) {
|
|
10
|
+
const { command, args } = stromLauncher();
|
|
11
|
+
const run = [command, ...args].map((a) => `"${a}"`).join(" ");
|
|
12
|
+
const desktop = desktopDir(env, platform);
|
|
13
|
+
fs.mkdirSync(desktop, { recursive: true });
|
|
14
|
+
if (platform === "darwin") {
|
|
15
|
+
// A .command file opens in Terminal on a double-click.
|
|
16
|
+
const file = path.join(desktop, `${name}.command`);
|
|
17
|
+
fs.writeFileSync(file, `#!/bin/sh\ncd "$HOME"\nexec ${run}\n`, { mode: 0o755 });
|
|
18
|
+
fs.chmodSync(file, 0o755);
|
|
19
|
+
return [file];
|
|
20
|
+
}
|
|
21
|
+
if (platform === "win32") {
|
|
22
|
+
// chcp 65001: the console shows diacritics; the window stays when strom ends with an error.
|
|
23
|
+
const file = path.join(desktop, `${name}.cmd`);
|
|
24
|
+
fs.writeFileSync(file, `@echo off\r\nchcp 65001 >nul\r\ncd /d "%USERPROFILE%"\r\n${run}\r\nif errorlevel 1 pause\r\n`);
|
|
25
|
+
return [file];
|
|
26
|
+
}
|
|
27
|
+
// Linux: an entry in the applications menu, and the same on the desktop.
|
|
28
|
+
const entry = [
|
|
29
|
+
"[Desktop Entry]",
|
|
30
|
+
"Type=Application",
|
|
31
|
+
`Name=${name}`,
|
|
32
|
+
`Exec=${run}`,
|
|
33
|
+
"Terminal=true",
|
|
34
|
+
"Categories=Office;",
|
|
35
|
+
"",
|
|
36
|
+
].join("\n");
|
|
37
|
+
const apps = path.join(env.XDG_DATA_HOME ?? path.join(userHome(env), ".local", "share"), "applications");
|
|
38
|
+
fs.mkdirSync(apps, { recursive: true });
|
|
39
|
+
const files = [path.join(apps, "strom-research.desktop"), path.join(desktop, "strom-research.desktop")];
|
|
40
|
+
for (const f of files)
|
|
41
|
+
fs.writeFileSync(f, entry, { mode: 0o755 });
|
|
42
|
+
return files;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run strom with these arguments in a new terminal window, in this folder — an
|
|
46
|
+
* agent hands the research over to its own conversation (strom chat) without
|
|
47
|
+
* starting one inside itself. strom's own settings from the environment go
|
|
48
|
+
* along (another config folder, language, …), an agent's marks do not.
|
|
49
|
+
* False when this computer cannot open a window (or a test asks not to).
|
|
50
|
+
*/
|
|
51
|
+
export function openInNewTerminal(argv, cwd, env, platform = process.platform) {
|
|
52
|
+
if (env.STROM_NO_OPEN === "1")
|
|
53
|
+
return false;
|
|
54
|
+
const { command, args } = stromLauncher();
|
|
55
|
+
const skip = new Set(["STROM_WORKER", "STROM_SESSION", "STROM_NONINTERACTIVE"]);
|
|
56
|
+
const own = Object.entries(env).filter(([k, v]) => k.startsWith("STROM_") && !skip.has(k) && v !== undefined);
|
|
57
|
+
const dir = path.join(cwd, ".strom", "open");
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
const stamp = Date.now().toString(36);
|
|
60
|
+
if (platform === "win32") {
|
|
61
|
+
const q = (a) => `"${a.replace(/"/g, '""')}"`;
|
|
62
|
+
const file = path.join(dir, `strom-${stamp}.cmd`);
|
|
63
|
+
const lines = ["@echo off", "chcp 65001 >nul", ...own.map(([k, v]) => `set "${k}=${v}"`), `cd /d ${q(cwd)}`, [command, ...args, ...argv].map(q).join(" "), "if errorlevel 1 pause", ""];
|
|
64
|
+
fs.writeFileSync(file, lines.join("\r\n"));
|
|
65
|
+
const r = spawn(env.ComSpec ?? "cmd.exe", ["/d", "/c", "start", '""', file], { detached: true, stdio: "ignore", windowsHide: true });
|
|
66
|
+
r.on("error", () => undefined);
|
|
67
|
+
r.unref();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const q = (a) => `'${a.replace(/'/g, `'\\''`)}'`;
|
|
71
|
+
const file = path.join(dir, `strom-${stamp}.command`);
|
|
72
|
+
const body = ["#!/bin/sh", ...own.map(([k, v]) => `export ${k}=${q(v)}`), `cd ${q(cwd)}`, `exec ${[command, ...args, ...argv].map(q).join(" ")}`, ""];
|
|
73
|
+
fs.writeFileSync(file, body.join("\n"), { mode: 0o755 });
|
|
74
|
+
fs.chmodSync(file, 0o755);
|
|
75
|
+
// macOS: a .command file opens in Terminal. Linux: the desktop's terminal, when there is one.
|
|
76
|
+
const how = platform === "darwin"
|
|
77
|
+
? [["open", [file]]]
|
|
78
|
+
: env.DISPLAY || env.WAYLAND_DISPLAY
|
|
79
|
+
? [["x-terminal-emulator", ["-e", file]], ["gnome-terminal", ["--", file]], ["konsole", ["-e", file]], ["xterm", ["-e", file]]]
|
|
80
|
+
: [];
|
|
81
|
+
for (const [cmd, a] of how) {
|
|
82
|
+
if (platform !== "darwin" && spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" }).status !== 0)
|
|
83
|
+
continue;
|
|
84
|
+
const r = spawn(cmd, a, { detached: true, stdio: "ignore", env: env });
|
|
85
|
+
r.on("error", () => undefined);
|
|
86
|
+
r.unref();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|