Expressions — reading, combining, and generating values
Expressions — reading, combining, and generating values #
Everywhere an automation needs a value — a write field, an if condition, an item in an extraction's data list — it uses the same expression grammar. To choose between two values inside one, use <cond> ? <a> : <b>; the statement if branches whole statements instead, and has its own chapter.
interpolation #
Message: "New enquiry from ${msg-[:Sender]->.Name}:
${company.url}"
Double-quoted strings span newlines and interpolate with ${…}. A value that may not be there prints as nothing, so "${x}" needs no fallback — in an ai("…") prompt too. Whenever you are composing prose a person will read, write it as a template rather than as a Text.concat — the template shows the paragraphs it produces.
ai #
Name: ai("the company name this email is about.
Prefer the legal entity name over the brand name;
ignore the sender's own firm.
The email:
Subject: ${msg.Subject}
${msg.Body}")
ai("…") computes one value from an instruction in plain prose. The model sees only your prompt — not the triggering event, not the message body, nothing else from the run — so interpolate in the content the judgement needs. A prompt with no interpolated content is answered blind, and that is the usual cause of an ai() value that comes back empty or generic. For several records with several fields each, declare an extract tree instead: one pass rather than many independent calls.
A settings record says how much thinking the answer is worth, as { tier: … }. Three tiers, and nothing else to choose:
"quick"— fast and cheap. Right for reformatting, tidying and classification: work where the answer is already in the prompt and only has to be picked out or restated."careful"— a solid general answer. Right when the judgement is real but not hard."thorough"— slow and expensive, and it genuinely reasons. Worth it for ranking, weighing trade-offs, or working something out over a long input; wasted on anything else.
Leave it off and you get "quick". Nothing here names a model: which one answers, and how long it may take over the answer, is ours to keep current, and it changes without your automation changing.
Category: ai("one of: bug, feature, question — for: ${msg.Subject}")
Summary: ai(`Digest Prompt`, { tier: "careful" })
Ranking: ai("rank these by how well each fits the thesis: ${`Candidates`}", { tier: "thorough" })
Long prompts read best bound once at file scope under a name of their own, then passed by that name where the call needs them.
ai() may decide nothing applies, and then resolves to a real null rather than placeholder text like "none" or "N/A". Write the prompt so that no answer is a legitimate outcome ("only if…", "if any"), and gate on the result:
`Suggested Action` = ai("a brief, actionable next step — only if one is
genuinely needed — suggested by this email: ${msg.bodyText}")
if (`Suggested Action` != null) {
channel = only(team-[ch:Channels WHERE ch.Name == "follow-ups"]->)
if (channel == null) { fail("no #follow-ups channel") }
write channel-[:Messages]-> { Message: `Suggested Action` }
}
meta-fields #
write crm-[:Companies]-> {
Name: msg.Subject
Description ?: "Owner: ${@user_email} — forwarded in by ${@actor_name} on ${@current_date}"
}
Values that come from the run itself rather than from any record wear an @ prefix and read like any other value, anywhere an expression can appear. There are eight, in three groups:
@current_date("2026-03-12") and@current_timestamp, the moment the run fires, in UTC —Deadline: Date.addDays(@current_date, 14). Every clock reading in a run gives that same moment, however long the run takes. For the date somewhere in particular, reach forDate.today(zone).@user_email,@user_name,@user_id— the identity the host resolves for the run: the person it acts on behalf of. The platform resolves it through its workspace members,lfthrough the project. Forwarders are followed back to the real person: a message forwarded in by a teammate still names the original sender, the person whose work this is.@actor_email,@actor_name,@actor_id— whoever literally triggered the event in the source system, exactly as it reported them, member or not.
The two agree until a forward, relay, or service account sits between the originator and the responsible person. A meta-field the run can't resolve — an event the host maps to no identity, a source that reports no sender — is simply empty, never an error.
aggregates #
n = count(co-[t:Team]->)
owner = only(co-[t:Team WHERE t.Role == "Owner"]->)
newest = first(crm-[c:Companies ORDER BY c.`Created At` DESC]->.Name)
thread = Text.join(ch-[m:Messages LIMIT 20]->.Text, "
")
roster = Text.join(sort(co-[t:Team]->.Name), ", ")
An aggregate reads a whole traversal (or a bound block's list) and answers one value. Which ones you may reach for depends on whether what you are reading has an order.
count,List.sum,List.avg,List.min,List.max,List.fromanswer the same whatever order the records come in, so they read anything.only(…)is the one that matched: it answers that record or value, and fails the run if there turns out to be more than one. Reach for it whenever aWHEREnarrows to a single thing.Text.join,firstandList.lastread a sequence, so they need one — anORDER BYon the hop, or a relationship the source keeps in order (a channel's messages, an email's attachments). Without either they are refused while you write. The traversal chapter's record-order has the full rules.Text.join(list, ", ")takes the separator as its second argument; over a bound block it joins what the iterations returned.sort(list)answers the same members in order, so aText.join,first,List.lastorxs[0]over it reads a sequence. Plain values sort by themselves —sort(scores),sort(scores, { order: "desc" }). Anything with fields takes a key function:sort(rows, (r) => r.score, { order: "desc" })ranks each member by that field, and the key may be a short path off the member. The settings record is last, and"asc"is the default.
keyed-values #
sections = { intro: "Welcome", body: "The details", sign_off: "the team" }
line = sections.body
A dict is a set of values looked up by name — write it in braces, read it with d["key"]. Keys are text and nothing else: a key of some other kind is refused when you save, with the coercion for you to write (Date.format(d, "YYYY-MM-DD") for a day, or a template for anything else) — there is no one right spelling, and a silent choice is how two halves of the same automation come to disagree about the same day.
A dict written in braces knows its keys, each with its own type. Looked up by a key written in quotes — or read with a dot, sections.body, which is the same lookup — it answers that key's value, always there; a key it was not written with is refused when you save, naming the closest one. Looked up by a key worked out while the automation runs, it answers T | absent: a key that is not there is the everyday case, not a failure, so discharge it the way you discharge any other possibly-missing value. A dict whose keys came from data — List.groupBy's answer — is always looked up that way.
iterating-values #
lines = ch-[m:Messages]-> { return m.Text }
bulleted = map(lines, (t) => "• ${t}")
kept = filter(lines, (t) => Text.length(t) > 0)
total = reduce(lines, (carried, t) => carried + Text.length(t), 0)
Iterate a collection with these five, each given a function that runs once per member. A record is a value too, so a hop written on its own — ch-[m:Messages]-> — is a collection of records, and these read it like any other.
map(list, f)answers whatfreturned, member by member.filter(list, f)keeps the membersfansweredtruefor. Both hand back a list in the order they were given one.reduce(list, f, <start>)carries a value forward —fis given what it has so far and the next member. It reads the members one after another, so it needs a list with an order, exactly asText.joindoes.List.groupBy(list, key)files each member under the key its function answers, and hands back a dict of lists.List.keyBy(list, key)does the same where each key names one member, and hands back a dict of members — a repeated key fails the run, naming it.rows = map(ch-[m:Messages]->, (t) => t)hands the records back as records, so a block head walks the answer:rows-[a:Author]-> { … }. Return a map instead —{ who: t }— and the answer is a list of maps, one key of each holding a record.- A map written in braces keeps its keys through all five, so
r.whoin a later function reads the key's own type, and a misspelt key is caught when you save. - Read a record's fields with
., walk it with a block, and test whether two are the same one with==— a record reached two ways is one record. Putting a record into a field or into text is refused where you write it: write a field off it, or connect the two records with a link.
A function written in place takes its parameter's type from the collection, so there is nothing to annotate, and its body may be a single expression (see closures). It may await: a member that waits parks on its own while the others go on — a waiting member holds its place, so no more than concurrency members are in flight — and the op hands back its value, in member order, once every member has finished. filter and reduce must return something — a filter needs a boolean, a reduce needs the value it is carrying. map alone allows a function with no return: each slot is then absent, and the writes inside it still run — map(ch-[m:Messages]->, (m) => { write graph-[:note]-> { text: m.Text } }) is legal on its own, with no binding, run purely for what it writes.
theses = members(<Thesis>)
members(<T>) lists a closed type's values in the order they were declared — a refinement you wrote, or a field's option set borrowed from a system. That order is a fact about the type, so a report's sections come from the declaration instead of a list kept beside it. A field whose values are merely known (other values are legal there too) has no complete membership, and is refused.
map-and-filter-settings #
map and filter take a settings record as their last argument:
profiles = map(companies, (c) => {
return extractOne([...content, Json.stringify(c)], Profile, { tier: "careful" })
}, { onError: "warn", concurrency: 4, initialConcurrency: 1 })
onErrorsays what a member whose function fails does:"error"(the default) fails the run,"warn"leaves the member out of the answer and puts a warning naming it and the failure on the run's trace, and"ignore"leaves it out silently. Afiltermember whose predicate fails is not kept. Only the member's own failure is forgiven; a cancelled run ends as it always does.concurrencyruns that many members at once.initialConcurrencyruns a first batch of that size to the end before the rest start. When every member's model call opens with the same content, the first batch warms the provider's prompt cache and the rest read it cheaply.- The answer is in input order whatever order members finish in, and so is the trace.
- Writes inside members run at once unless they contend for the same record. Two members writing the same
unique bykey (or the target's own unique field) take turns, so the record is made once; writes to different keys run together. AFUZZYkey has no exact value to take turns on, so fuzzy writes to one record type take turns with every identity write to that type. - Each setting is written down, not computed. An unknown key, an
onErroroutside its three words, aconcurrencybelow 1 or not whole, and a first batch wider than the rest are refused when you save. reduce,List.groupByandList.keyBytake no settings.
closures #
A function written in place is a value. Its body is a block, or a single expression as in TypeScript:
inc = (v: <number>) => v + 1
shout = (t: <text>) => Text.upper(t)
doubled = map(counts, (x) => x * 2)
(v) => v * 2means(v) => { return v * 2 }.- Bind a closure to a name and call it like any function:
inc(2),double(inc(x)) + 1, ornote(m.Subject)on its own line. Its parameters check the arguments, and the call is typed by what the body returns. - Calling it does what its body does: a model call or a write in the body counts towards the function that calls it. Binding it does nothing.
- The name is read exactly as written (
inc(2), notInc(2)). One that matches a core word or namespace hides it, with a warning (upper = (t) => t). A name holding a plain value is still not callable. - A closure that may wait is called on its own line, as a function that waits is, and can be an argument of
race(f, g)orall(f, g). - A closure is a value, but not data. It lives in a variable (
g = madetheng(), a closure a function returns) or a parameter, or is passed as an argument to a parameter that takes one (map(xs, (x) => …),all(f, g)). It is never kept in a list, under a key of a record or map, or in a field: a dot always reads a field. It is never an operand either:? :,coalesce, a comparison or interpolation does not take one. The call is typed by what the closure returns, so a record it returns is refused where data goes. Something that may not be a closure is not called. - A closure's type is written
<(params) => <return>>, as TypeScript's function type:handler: <(c: <Company>) => <text>>takes a closure and calls it, so a closure can be handed to a function:go(label). A closure fits when it takes what the type hands it and returns what the type returns; one written in place,go((c) => c.name), has its parameters typed by the type. - A closure is also what
map,filterand the rest take: pass it inline or by name (map(xs, inc)).
calls-inside-expressions #
Because a function's arguments are ordinary expressions, a call can sit inside any expression: double(n) + 1, extractOne(content, Company), count(map(xs, f)), map(map(xs, f), g), log(map(xs, f)).
- A nested call means exactly what binding it to a name first and reading the name means, and it is checked and typed that way. A call that returns text written into a number field is refused. Its effects count towards the function around it.
- A field reads off any call's result, as it reads off a name:
extractOne(content, Company).name,first(people).Name,lookup(id).email. A call whose result may be absent gives a field that may be absent too. - Order is left to right, as the text reads. Every operand before a nested call is read before the call runs, and a call's arguments are all evaluated before its body runs, so
Text.concat(mark("a"), mark("b"))writesaand thenb. ? :,&&,||andcoalesceshort-circuit, as TypeScript's? :,&&,||and??do. An operand they do not need is never evaluated, so a call in an arm that is not taken never runs and its writes never happen.coalesce(x, only(list))does not fail whenxis present, however many valueslistholds.- Suspension is for statements only. A call that may wait (a function that
awaits) is refused inside an expression, because a wait parks the run where it is written and a place inside an expression has nowhere to come back to. Call it on its own line and use the name. - A walk's
WHERE,ORDER BYand settings are read once per landing, so a function call,map,filter,reduce,List.groupBy,List.keyByormembersinside one is refused, naming the binding to write instead. A built-in that only computes a value (LENGTH(p.Name)) is fine there.
lists-spread-and-tuples #
pdfs = m-[a:Attachments WHERE a.`Content Type` == "application/pdf"]->.File
content = [m.Body, ...pdfs, "end"]
inline = [m.Body, ...m-[a:Attachments]->.File]
...listsplices a list into the literal, at the start, the middle or the end, as TypeScript's spread does.- A walk read for a field spreads too: it splices one value per record the walk landed on, and nothing when there are none. A field that itself holds a list spreads its members.
- Spreading something that is not a list (text, a number, a record, a dict) is refused, and so is spreading a list that may be absent. A
<json>field is refused too, since one landing's list cannot be told apart from several landings. - A list literal is a tuple, one slot per member, as in TypeScript:
[m.Subject, file]is a text then a file.t[0]reads the first slot exactly, always present. With a spread the tuple is open-ended:[text, ...files]. - Where a list is expected (
map,filter,Text.join, a list-typed field or parameter) a tuple reads as a list of the union of its members, as TypeScript reads[string, number]as(string | number)[].Text.join([name, amount], " ")validates, and[name, amount]written into a list of numbers is refused. - A literal holding both records and plain values (
[company, "label"]) is accepted as a tuple, but it is refused wherever it is read as a list, such asmap(both, …)or a write into a list field. - Unions follow the same way.
race(…)with arms whose results differ reads as a list of the union of those results, so each slot carries the type of any arm, and may still be absent.all(f, g)keeps each slot's own type:r[1]is what the second returned.
a-table-you-declare-once #
roster = [
{ Name: "Ada", Theme: "Consumer" },
{ Name: "Grace", Theme: "Infra" },
{ Name: "Alan", Theme: "Health" },
]
node Mention: "each company named, weighed against the team's own themes" {
name: <text> "the company's name"
}
function `Match Mentions`(m: <inbox-[:Email]->>) {
themes = Text.join(map(roster, (r) => "${r.Name} (${r.Theme})"), ", ")
byName = List.keyBy(roster, (r) => r.Name)
found = extract([m.Body], Mention)
owner = byName.Ada
if (owner == null) { fail("no such teammate on the roster") }
map(found, (c) => {
write crm-[:Companies]-> { unique by (FUZZY Name), Name: c.name, Description ?: "flagged by ${owner.Name}; team themes: ${themes}" }
})
}
A file-scope list of dicts is a small table every function in the file reads. map and Text.join turn it into one string for a prompt — here, an extraction's description. List.keyBy turns it into a dict keyed by one field, so a lookup is a plain read; a computed key still needs the guard, since List.keyBy's keys are data.
spreading-maps-and-records #
base = { stage: "Seed", source: "email" }
row = { ...base, stage: "Series A" }
merged = { ...c, ...details }
...mcopies a dict's keys into the literal, and...rcopies a record's fields (its values, not its nested nodes), as TypeScript's object spread does. A later key wins over an earlier one, in the order written:row.stageis"Series A".- A key written before a spread that always has it is refused when you save, since the spread overwrites it. Move it after the spread to override.
- A spread of something that may not be there copies nothing when it isn't, so its keys may be absent: with
details = extractOne(…),merged.summaryistext | absent.coalesceit before a field that needs a value. - A graph literal orders its members the same way:
graph<Note> { ...v, text: "mine" }keeps"mine", andgraph<Note> { text: "mine", ...v }is refused whenvalways hastext. - The result is a dict. Read it with a dot or with
d[key]; a misspelt key is caught when you save. - A record read live from a system has no field list in hand, so spreading one into a dict is refused: build the dict from the fields you want. To walk the result, write into it, or check it against a declaration, build a graph instead —
graph<Detailed> { ...c, ...details }copies the record's fields as a snapshot, the declaration deciding which (see the extraction call in the extraction chapter). - A spread of text, a number or a list is refused. A settings record (
map's,extract's, a built-in's options) is written out key by key, so a spread is refused there.
values-that-may-not-be-there #
Name: coalesce(msg-[:Sender]->.Name, msg-[:Sender]->.Email)
A traversal that yields nothing makes the value empty rather than failing, and coalesce takes the first non-empty of its arguments. To test rather than fall back: exists(msg-[:Sender]->) tells whether a relationship has any records, and x != null or x == null tells whether a binding or a field read off one has a value — `Suggested Action != null,channel.Topic == null`.
A separate and stronger thing is a value typed as possibly not there at all — T | absent. That is a real part of the type, not a caution. Five things produce one:
- an aggregate that can come up empty.
only,first,List.last,List.min,List.max, andxs[0]all answer null when there's nothing to aggregate over, soonly(msg-[:Attachments]->.Name)may be absent exactly when there are no attachments. Over a bare traversal (no field on the end) they bind the whole record, not one of its fields —channel = only(chat-[ch:Channels WHERE …]->)— see looking-up-existing-records in the writes chapter for that idiom; - a named
match.existing = match crm-[:Companies]-> { … }is the record or absent; testexisting == null(theelsearm sees it present) or!= nullbefore reading a field off it or linking it; - a landing that can resolve empty — awaiting an answer that was cancelled rather than given yields nothing to read;
- a
raceslot —r[0]reads what the first arm of aracereturned, and only the arm that settled first has its slot filled, so every slot off aracemay be absent. (Amapover a list of functions runs every one, so its slots are not.) - a field read off a record that may not be there.
details = extractOne(…)may have found nothing, sodetails.summaryistext | absent, as TypeScript typesdetails?.summary. Testdetails != nullfirst, orcoalescethe field. A graph literal's required field refuses such a value too: declare the field<text | null>, or fall back. - a typed extracted field, and a lookup by a computed key. A
<number>,<date>,<boolean>or set-of-values field of anextractis something the model was asked for and may not have found, so reading one isT | absent— a write field takes it with the?:fill rather than plainly. A text field is never absent: one the model did not find reads as"", so test it with!= ""— a null test on it is refused. Annotate it<text | null>to have a missing one arrive null instead: it is thentext | absentlike the rest, and a null test narrows it.d[key]with a key worked out at run time is the same: a key that is not there reads nothing.
Reading such a value is always fine, and so is interpolating it — it prints as nothing. Testing it needs no guard either. == and != take it on either side: a missing value equals only null, so o.stage == "Seed" is false and o.stage != "Seed" true when the model found nothing. A condition takes a <boolean> that may be missing and reads a missing one as false — if (o.viable) { … }, o.viable ? a : b, a && o.viable, !o.viable.
What is refused is using it somewhere a value is genuinely required — a write field, a write or traversal target, a field read off it, an ordered comparison (<, <=, >, >=), arithmetic (n + 1, -n, Number.round(n), and List.sum, List.avg, List.min or List.max over a list that may hold an absent member) — and the refusal comes while you write rather than at run time. There is no "it will probably be there".
Four things discharge it, and one of them always fits:
- Fill it with
?:. A set-if-empty write field takes a possibly-missing value happily: present, it lands; missing, that one field simply isn't written and the rest of the write goes ahead.Description ?: "Headcount: ${a.Answer}" - Gate on it with a traversal. A block headed by the landing runs only when the landing is there, and everything inside it knows so:
q-[a:Response]-> { … }. - Test it with
==. Comparing againstnull(x == null/x != null) is the dedicated presence test, and the one place==is deliberately loose:nulland absent mean the same thing here, so it's legal against any possibly-missing value. Comparing against a present value works too and narrows the same way (if (a.Answer == "pursue") { … }is legal even thougha.Answermay be absent). Either way, the arm that took the comparison knows the value is present — and for== nullused as a guard clause (if (x == null) { fail("…") }), so does everything after theif, not just the arm. A test on a field read off a bound record proves the record too (if (channel.Topic != null)), so that arm may write offchannel. The value-level formx != null ? … : …narrows only its first branch. See narrowing-past-a-guard in the branching chapter. - Fall back with
coalesce.coalesce(a, b)answers the first of its arguments that has a value, so the moment one of them always does — a literal, or anything already known to be there — the result always does too:Name: coalesce(only(msg-[:Attachments]->.Name), "no attachment"). Acoalescewhose arguments might all be empty is itself possibly-empty and is refused in the same places, so the one-argument form discharges nothing at all.
For a list that may hold absent members, drop them (List.sum(List.compact(xs))) or fill them (List.sum(map(xs, (x) => coalesce(x, 0)))) before folding.
Ordered comparisons and arithmetic get no licence from the ?: fill — there is no write field to fill — so either give the value a fallback with coalesce, or prove presence first (a guard clause, a != null test, a traversal gate) and order inside the branch — or, for a guard clause, the code after it — that proved it.
helper-families #
The standard namespaces — Currency, Text, Url, Date, DateTime, Number — do the messy real-world conversions deterministically: no model involved, the same input always gives the same answer, and an input the function can't read gives an empty value rather than an error. Currency reads money figures out of text ("£1.2m" → 1200000, or its code → "GBP"); Text does surgical string work (a regex extraction, a slug); Url.host(text) reads the host out of a link (www. kept, no port, no path) and gives empty for text that isn't a URL — the same shape as Date.parse, one function reading a shape out of loose text; Date handles dates without guesswork — Date.parse("12 March 2026") → "2026-03-12", while an ambiguous numeric form ("12/03/2026" — day-first or month-first?) deliberately gives empty rather than a guess. Date.format(value, "MMMM D, YYYY") writes a date out for a person to read — "August 31, 2026" — from the tokens YYYY YY MMMM MMM MM M DD D dddd ddd HH H mm m ss s, with anything that is not a letter printed as written; the pattern is written down and checked when you save, so an unknown token is named there rather than printed wrong. Date.today(zone) and DateTime.at(date, time, zone) do time zones — the next section. The reference chapter lists every member; a name that isn't one is refused when you save, with the namespace's real functions listed.
time-zones-and-windows #
Take the day in a place, move whole days on it, and anchor each end of the window on its own:
berlin_today = Date.today("Europe/Berlin")
win_end = DateTime.at(berlin_today, "07:00", "Europe/Berlin")
win_start = DateTime.at(Date.addDays(berlin_today, -1), "07:00", "Europe/Berlin")
Arithmetic happens in calendar space; anchoring happens last. Date.addDays moves a day on the calendar, and DateTime.at(date, time, zone) turns a day plus a wall-clock time into the one instant it names there. Anchor each end separately and the window between yesterday 07:00 and today 07:00 comes out 23 or 25 hours long on the two days a year the clocks move, without you writing anything down. Anchor one end and subtract 24 hours instead, and those two days silently run from 06:00 or 08:00.
Date.today(zone) reads the moment the run fired, in that zone — a run firing at 00:30 in Berlin is on the Berlin date, not yesterday's UTC one. Every reading of the clock in one run gives the same answer, including after the run waits for a person, so a window computed at the start and one computed at the end agree.
Both the zone ("Europe/Berlin", "America/New_York", "UTC") and the time ("07:00", 24-hour HH:mm) are written down and checked when you save. DateTime.at always names an instant: on the spring-forward morning a time the clock skips resolves forward to the moment the clock resumed, and on the fall-back morning a time that happens twice takes the first of the two.
coercers #
Date.from(value), DateTime.from(value) and Number.parse(value) are coercers — they say "treat this as a calendar day / an instant / a number". Reach for a coercer when a comparison spans two kinds of value: a comparison only makes sense within one kind, so Snoozed Until` <= 5` is refused when you save andSnoozed Until<= Date.from(…) is the repair. (Dates and timestamps compare freely — a date is midnight. When a value's type isn't known, the comparison is left alone; only a definite clash is flagged.)
Prefer a helper over ai() whenever one fits: it is instant, free, and gives the same answer every run.
Beyond the built-ins, a target system may supply functions of its own on particular fields — a chat system's message formatter on a message's text, say. Those are members of the adapter's imported package (Message: slack.<formatter>(…)) and run as the value of the write field that advertises them: the target supplies the function, so it exists exactly where you write to that target. Calling one anywhere else — a condition, a plain binding — or calling a name no field advertises, fails the run naming the function.
file-artifacts #
File: pdf.render(digest)
File: File.from(digest, "text")
pdf.render(content) (the @lf/pdf plugin, imported with import { `@lf/pdf` as pdf } from plugins) and File.from(content, "text") turn a composed string into a file you can write into a file-typed field — an attachment, a document slot. The content is drawn or kept as plain text preserving your line breaks, so compose it first (interpolation, or Text.join over a block's results — the patterns chapter has the digest shape) and then wrap it. The default PDF writer draws Latin-1 text; any other character is drawn as ? and the run gets a warning naming @lf/pdf. The artifact remembers where its content came from: its provenance trail carries the reads that fed the string.
Read a file the other way — File.read(file) hands back the text in it, and Text.chunks cuts a long text into pieces:
text = File.read(f.File)
pieces = Text.chunks(coalesce(text, ""), { size: 40000, overlap: 2000 })
File.readanswerstext | absent, discharged like any other absence —coalesce, a?:write field, an== nullguard. Why a file gave nothing back is on the run's own record.- An extraction reads a file in its content itself. Reach for
File.readwhere you want the text in hand — to cut it, measure it, or pass it to a plugin. Text.chunks(text, { size, overlap })hands back a list of text pieces, each at mostsizecharacters and repeatingoverlapcharacters of the one before it.unit:takes"chars"and nothing else.- Say
entitiesinstead ofsizeto cut by what a piece is expected to yield —Text.chunks(text, { entities: 20 })fills each piece with about twenty records' worth of lines, and never splits a line. One of the two is required, andoverlapworks with either. extract(pieces, Shape)is one extraction reading every piece;map(pieces, (p) => extract([p], Shape))is one per piece (extraction, long-documents).