@tiagojct/subsub 0.13.1 → 0.15.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/CHANGELOG.md +29 -0
- package/dist/bench.js +324 -10
- package/dist/notes.js +90 -0
- package/dist/profiles.js +5 -3
- package/dist/roles.js +19 -0
- package/dist/subsub.js +64 -13
- package/package.json +1 -1
- package/prompts/lit.md +21 -0
- package/roles/librarian.md +5 -0
- package/roles/researcher.md +2 -1
- package/src/bench.ts +327 -10
- package/src/notes.ts +89 -0
- package/src/profiles.ts +5 -3
- package/src/roles.ts +20 -0
- package/src/subsub.ts +71 -12
- package/web/app.js +5 -4
- package/web/i18n.js +2 -2
package/src/notes.ts
CHANGED
|
@@ -44,3 +44,92 @@ export function applyEdits(current: string, edits: Array<{ oldText?: unknown; ne
|
|
|
44
44
|
}
|
|
45
45
|
return out;
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------- quotes against the full text
|
|
49
|
+
|
|
50
|
+
/** The Zotero item key in a note's front matter (zotero: zotero://select/library/items/KEY). */
|
|
51
|
+
export function itemKeyOf(text: string): string | undefined {
|
|
52
|
+
const front = frontMatter(text);
|
|
53
|
+
const z = front ? field(front, "zotero") : undefined;
|
|
54
|
+
return z ? /items\/([A-Z0-9]{8})\b/.exec(z)?.[1] : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Direct quotes in a note: text in double quotes, and blockquote lines, of 25 characters or more.
|
|
59
|
+
* Shorter quotes are usually terms, not passages, and are not checked.
|
|
60
|
+
*/
|
|
61
|
+
export function quotesIn(text: string): string[] {
|
|
62
|
+
const body = text.replace(FRONT, "");
|
|
63
|
+
const out = new Set<string>();
|
|
64
|
+
// An opening mark follows a space or a bracket and a closing mark precedes one, so the text
|
|
65
|
+
// between one quote and the next ("a" (p. 3); then "b") is not taken for a quote.
|
|
66
|
+
for (const m of body.matchAll(/(?:^|[\s(\[—–])"([^"\s][^"\n]{23,598}[^"\s])"(?=[\s.,;:)\]!?]|$)/gm)) out.add(m[1].trim());
|
|
67
|
+
for (const m of body.matchAll(/“([^“”\n]{25,600})”/g)) out.add(m[1].trim());
|
|
68
|
+
for (const m of body.matchAll(/^>[ \t]*(.{25,})$/gm)) {
|
|
69
|
+
// A blockquote may end with its source: "... (p. 12)" or "... [@smith2026, p. 12]".
|
|
70
|
+
const q = m[1].replace(/\s*(?:\((?:p|pp)\.[^)]*\)|\[@[^\]]*\]|[-—–]\s*p\.?\s*\d+.*)\s*$/i, "").replace(/^["“]|["”]$/g, "").trim();
|
|
71
|
+
if (q.length >= 25) out.add(q);
|
|
72
|
+
}
|
|
73
|
+
return [...out];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Letters and digits only, lower case: PDF text breaks lines, hyphenates words and changes quote marks. */
|
|
77
|
+
export function matchable(s: string): string {
|
|
78
|
+
return s
|
|
79
|
+
.normalize("NFKD")
|
|
80
|
+
.replace(/[̀-ͯ]/g, "")
|
|
81
|
+
.replace(/(\w)-\s*\n\s*(\w)/g, "$1$2")
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The quotes that are not in the full text. A quote with an ellipsis is checked in parts;
|
|
88
|
+
* square brackets (an editor's insertion) are left out of the comparison.
|
|
89
|
+
*/
|
|
90
|
+
export function missingQuotes(note: string, fullText: string, already: string[] = []): string[] {
|
|
91
|
+
const source = matchable(fullText);
|
|
92
|
+
const old = new Set(already);
|
|
93
|
+
return quotesIn(note)
|
|
94
|
+
.filter((q) => !old.has(q))
|
|
95
|
+
.filter((q) => {
|
|
96
|
+
const parts = q.split(/\s*(?:\.\.\.|…|\[\s*(?:\.\.\.|…)\s*\])\s*/).map((p) => p.replace(/\[[^\]]*\]/g, " "));
|
|
97
|
+
return parts.filter((p) => matchable(p).length >= 12).some((p) => !appearsIn(source, p));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a passage is in the (matchable) source: whole, or as groups of three words in order with
|
|
103
|
+
* short gaps, because a PDF's running header or footer can fall in the middle of a sentence.
|
|
104
|
+
*/
|
|
105
|
+
export function appearsIn(source: string, passage: string, gap = 300): boolean {
|
|
106
|
+
if (source.includes(matchable(passage))) return true;
|
|
107
|
+
const words = passage.split(/\s+/).map(matchable).filter(Boolean);
|
|
108
|
+
const groups: string[] = [];
|
|
109
|
+
for (let i = 0; i < words.length; i += 3) groups.push(words.slice(i, i + 3).join(""));
|
|
110
|
+
if (groups.length < 2) return false;
|
|
111
|
+
for (let start = source.indexOf(groups[0]), tries = 0; start >= 0 && tries < 50; start = source.indexOf(groups[0], start + 1), tries++) {
|
|
112
|
+
let end = start + groups[0].length;
|
|
113
|
+
let ok = true;
|
|
114
|
+
for (const g of groups.slice(1)) {
|
|
115
|
+
const at = source.indexOf(g, end);
|
|
116
|
+
if (at < 0 || at - end > gap) {
|
|
117
|
+
ok = false;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
end = at + g.length;
|
|
121
|
+
}
|
|
122
|
+
if (ok) return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function quoteProblem(missing: string[]): string | undefined {
|
|
128
|
+
if (!missing.length) return undefined;
|
|
129
|
+
const shown = missing.slice(0, 5).map((q) => `- "${q.length > 160 ? `${q.slice(0, 160)}...` : q}"`);
|
|
130
|
+
return [
|
|
131
|
+
`${missing.length} quote(s) in this note are not in the item's full text:`,
|
|
132
|
+
...shown,
|
|
133
|
+
"Copy each quote word for word from the full text (zotero_get_fulltext), or remove the quote marks and say it in your own words. Then write the note again.",
|
|
134
|
+
].join("\n");
|
|
135
|
+
}
|
package/src/profiles.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Scholar. A profile is the user's own choice (/profile changes it), not a lock.
|
|
5
5
|
*
|
|
6
6
|
* - reader: explains each step; reading notes are quotes with page numbers and
|
|
7
|
-
* questions; no summaries or syntheses;
|
|
7
|
+
* questions; no summaries or syntheses; /lit gives a reading list;
|
|
8
|
+
* no bulk library changes.
|
|
8
9
|
* - scholar: literature notes, syntheses, searches, alerts; all library changes.
|
|
9
10
|
* - author: scholar plus manuscript work: citation check, bibliography, comments
|
|
10
11
|
* on the argument. Does not write the manuscript's paragraphs.
|
|
@@ -36,15 +37,16 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
36
37
|
reader: {
|
|
37
38
|
name: "reader",
|
|
38
39
|
label: "Reader",
|
|
39
|
-
summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
|
|
40
|
+
summary: "explains each step; reading notes are quotes and questions; /lit gives a reading list; no summaries; no bulk library changes",
|
|
40
41
|
without: [...BULK_LIBRARY, ...MANUSCRIPT],
|
|
41
|
-
withoutPrompts: ["
|
|
42
|
+
withoutPrompts: ["compare", "review", "digest"],
|
|
42
43
|
batch: 10,
|
|
43
44
|
rules: [
|
|
44
45
|
"Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
|
|
45
46
|
"- Before each step, say in one or two lines what you will do and why.",
|
|
46
47
|
"- A literature note is a reading note: the full reference, then direct quotes with page numbers (or section names), and under each quote one question for {{user}} to answer. Do not write summaries, syntheses, conclusions or paragraphs that {{user}} could hand in as their own text. When a prompt or the shared rules ask for a literature note or a synthesis, make a reading note instead and say so.",
|
|
47
48
|
"- For searches, list works with one line each on what they study, not on what they conclude.",
|
|
49
|
+
"- /lit gives a reading list, not a review: plan, search, screen and read as the prompt says, then write the note in the Reader form that the prompt gives (no answer, no agreement or disagreement, no conclusions).",
|
|
48
50
|
"- If {{user}} asks for a summary, a synthesis or text for an assignment, say that the Reader profile does not write these, and offer quotes and questions. {{User}} can change the profile with /profile.",
|
|
49
51
|
"- Tag review notes: at most 10 items. Explain each proposed tag in the Reason column.",
|
|
50
52
|
].join("\n"),
|
package/src/roles.ts
CHANGED
|
@@ -116,3 +116,23 @@ export function gateKind(toolName: string, input: Record<string, unknown>): Gate
|
|
|
116
116
|
if (toolName === "write" || toolName === "edit") return "path";
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Tools that send a query to an outside service (PubMed, Europe PMC, OpenAlex, Crossref, a web search). */
|
|
121
|
+
const OUTBOUND = new Set(["scholar_search_pubmed", "scholar_search_openalex", "scholar_search_europepmc", "scholar_search_multi", "zotero_find_reference", "zotero_web_search"]);
|
|
122
|
+
export const MAX_QUERY_CHARS = 1500;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Why an outside search is refused: a query is search terms, so a query that is long or holds
|
|
126
|
+
* several lines is text from a note, a full text or the library, sent where it should not go
|
|
127
|
+
* (for example because a document asked for it).
|
|
128
|
+
*/
|
|
129
|
+
export function outboundProblem(toolName: string, input: Record<string, unknown>): string | undefined {
|
|
130
|
+
if (!OUTBOUND.has(toolName)) return undefined;
|
|
131
|
+
const queries = [input.query, ...(Array.isArray(input.queries) ? input.queries : [])].filter((q): q is string => typeof q === "string");
|
|
132
|
+
for (const q of queries) {
|
|
133
|
+
if (q.length > MAX_QUERY_CHARS || q.split("\n").length > 3) {
|
|
134
|
+
return `This search query is ${q.length} characters on ${q.split("\n").length} lines. A query is search terms; send no passages of notes, full texts or library records to an outside service. Shorten it to the search terms.`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
package/src/subsub.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { Bridge, type ServerSpec } from "./bridge.ts";
|
|
|
24
24
|
import {
|
|
25
25
|
expand,
|
|
26
26
|
findUv,
|
|
27
|
+
FREE_MODELS,
|
|
27
28
|
isProfile,
|
|
28
29
|
loadConfig,
|
|
29
30
|
parseEnvFile,
|
|
@@ -39,14 +40,14 @@ import {
|
|
|
39
40
|
} from "./config.ts";
|
|
40
41
|
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
41
42
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
42
|
-
import { applyEdits, checkLiteratureNote } from "./notes.ts";
|
|
43
|
+
import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.ts";
|
|
43
44
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
44
45
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
|
|
45
46
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
|
|
46
47
|
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
47
48
|
import { systemAddition } from "./prompt.ts";
|
|
48
49
|
import { logUsage } from "./usage.ts";
|
|
49
|
-
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
|
|
50
|
+
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
|
|
50
51
|
|
|
51
52
|
const MODE_ENTRY = "subsub-mode";
|
|
52
53
|
|
|
@@ -265,6 +266,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
265
266
|
emitWeb();
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
let freeNoted = false;
|
|
268
270
|
/** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
|
|
269
271
|
async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
|
|
270
272
|
mode = next;
|
|
@@ -274,7 +276,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
274
276
|
const i = spec.indexOf("/");
|
|
275
277
|
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
276
278
|
const ok = model ? await pi.setModel(model) : false;
|
|
277
|
-
if (!ok)
|
|
279
|
+
if (!ok)
|
|
280
|
+
ctx.ui.notify(
|
|
281
|
+
L(
|
|
282
|
+
`Sub-Sub: cannot use ${spec}: no key for that provider, or the provider renamed or retired the model. Keeping the current model. Choose another with the model button (subsub doctor lists the settings).`,
|
|
283
|
+
`Sub-Sub: não é possível usar ${spec}: não há chave para esse fornecedor, ou o fornecedor mudou o nome do modelo ou retirou-o. Mantém-se o modelo atual. Escolha outro com o botão do modelo (o subsub doctor mostra as definições).`,
|
|
284
|
+
),
|
|
285
|
+
"warning",
|
|
286
|
+
);
|
|
287
|
+
// The free model is for trying Sub-Sub: its research notes were thin in the model test.
|
|
288
|
+
else if (mode === "researcher" && spec === FREE_MODELS.researcher && !freeNoted) {
|
|
289
|
+
freeNoted = true;
|
|
290
|
+
ctx.ui.notify(
|
|
291
|
+
L(
|
|
292
|
+
"Sub-Sub: the free model is good for trying Sub-Sub and for library tasks. In our test its literature notes and reviews were thin; for research you rely on, choose a paid model (see the Models page).",
|
|
293
|
+
"Sub-Sub: o modelo gratuito serve para experimentar o Sub-Sub e para tarefas de biblioteca. No nosso teste, as suas notas de literatura e revisões foram pobres; para investigação em que precise de confiar, escolha um modelo pago (veja a página Modelos).",
|
|
294
|
+
),
|
|
295
|
+
"info",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
278
298
|
}
|
|
279
299
|
if (remember) pi.appendEntry(MODE_ENTRY, { mode });
|
|
280
300
|
status(ctx);
|
|
@@ -419,6 +439,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
419
439
|
return { block: true, reason: why };
|
|
420
440
|
}
|
|
421
441
|
}
|
|
442
|
+
const outbound = outboundProblem(name, input);
|
|
443
|
+
if (outbound) {
|
|
444
|
+
if (benchOut) record({ kind: "outbound_blocked", tool: name });
|
|
445
|
+
return { block: true, reason: outbound };
|
|
446
|
+
}
|
|
422
447
|
// grep, find and ls search the working folder when no path is given.
|
|
423
448
|
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
424
449
|
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
@@ -429,7 +454,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
429
454
|
if (benchOut) return benchGate(name, input, kind, ctx.cwd, ctx.signal);
|
|
430
455
|
if (kind === "path") {
|
|
431
456
|
const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
|
|
432
|
-
const noteProblem = literatureNoteProblem(name, target, input);
|
|
457
|
+
const noteProblem = await literatureNoteProblem(name, target, input, ctx.signal);
|
|
433
458
|
if (noteProblem) return { block: true, reason: noteProblem };
|
|
434
459
|
const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
435
460
|
if (verdict.ok) return undefined;
|
|
@@ -487,15 +512,49 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
487
512
|
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
488
513
|
});
|
|
489
514
|
|
|
490
|
-
/**
|
|
491
|
-
|
|
515
|
+
/**
|
|
516
|
+
* A literature note (front matter with a citekey) must state its evidence, and a note written
|
|
517
|
+
* from the full text may quote only what the full text says; see notes.ts.
|
|
518
|
+
*/
|
|
519
|
+
async function literatureNoteProblem(name: string, target: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<string | undefined> {
|
|
492
520
|
if (!target.toLowerCase().endsWith(".md")) return undefined;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
521
|
+
let text: string | undefined;
|
|
522
|
+
let before = "";
|
|
523
|
+
if (name === "write") text = String(input.content ?? "");
|
|
524
|
+
else if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
|
|
525
|
+
before = readFileSync(target, "utf8");
|
|
526
|
+
text = applyEdits(before, input.edits as Array<{ oldText?: unknown; newText?: unknown }>);
|
|
527
|
+
}
|
|
528
|
+
if (text === undefined) return undefined;
|
|
529
|
+
const problem = checkLiteratureNote(text);
|
|
530
|
+
if (problem) return problem;
|
|
531
|
+
const front = frontMatter(text);
|
|
532
|
+
const key = itemKeyOf(text);
|
|
533
|
+
if (!front || !key || field(front, "evidence")?.toLowerCase() !== "full text" || !quotesIn(text).length) return undefined;
|
|
534
|
+
const full = await fullTextOf(key, signal);
|
|
535
|
+
// Without the full text (no PDF indexed, Zotero closed) there is nothing to check against.
|
|
536
|
+
return full ? quoteProblem(missingQuotes(text, full, quotesIn(before))) : undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The whole indexed full text of an item, read in parts, kept for the session. */
|
|
540
|
+
const fullTexts = new Map<string, string | null>();
|
|
541
|
+
async function fullTextOf(key: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
542
|
+
if (fullTexts.has(key)) return fullTexts.get(key) ?? undefined;
|
|
543
|
+
let text = "";
|
|
544
|
+
let offset: number | null = 0;
|
|
545
|
+
try {
|
|
546
|
+
for (let part = 0; offset !== null && part < 20; part++) {
|
|
547
|
+
const res = await current().call("zotero_get_fulltext", { key, offset, max_chars: 60000 }, signal);
|
|
548
|
+
if (res.isError) break;
|
|
549
|
+
const data = (res.data ?? {}) as { text?: string; next_offset?: number | null };
|
|
550
|
+
text += data.text ?? "";
|
|
551
|
+
offset = data.next_offset ?? null;
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
/* no full text: the check is skipped */
|
|
497
555
|
}
|
|
498
|
-
|
|
556
|
+
fullTexts.set(key, text || null);
|
|
557
|
+
return text || undefined;
|
|
499
558
|
}
|
|
500
559
|
|
|
501
560
|
/**
|
|
@@ -528,7 +587,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
528
587
|
async function benchGate(name: string, input: Record<string, unknown>, kind: string, cwd: string, signal?: AbortSignal) {
|
|
529
588
|
if (kind === "path") {
|
|
530
589
|
const target = normalizeToolPath(String(input.path ?? ""), cwd);
|
|
531
|
-
const noteProblem = literatureNoteProblem(name, target, input);
|
|
590
|
+
const noteProblem = await literatureNoteProblem(name, target, input, signal);
|
|
532
591
|
if (noteProblem) {
|
|
533
592
|
record({ kind: "note_blocked", tool: name, path: target, reason: noteProblem });
|
|
534
593
|
return { block: true, reason: noteProblem };
|
package/web/app.js
CHANGED
|
@@ -844,10 +844,11 @@ async function refreshSession() {
|
|
|
844
844
|
function dialogTitle(d) {
|
|
845
845
|
const raw = String(d.title ?? "").replace(/^Sub-Sub:\s*/, "");
|
|
846
846
|
let m;
|
|
847
|
-
|
|
848
|
-
if ((m = raw.match(/^
|
|
849
|
-
if (
|
|
850
|
-
if (
|
|
847
|
+
// The program sends the title in the user's language; both forms get the same friendly title.
|
|
848
|
+
if ((m = raw.match(/^(?:apply|aplicar) (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
|
|
849
|
+
if ((m = raw.match(/^(?:run|executar) (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
|
|
850
|
+
if (raw === "undo?" || raw === "anular?") return t("undo");
|
|
851
|
+
if ((m = raw.match(/^(?:write the Starbuck report to|escrever o relatório do Starbuck em) (.+)\?$/))) return t("starbuckReport", { x: m[1] });
|
|
851
852
|
if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
|
|
852
853
|
return raw;
|
|
853
854
|
}
|
package/web/i18n.js
CHANGED
|
@@ -143,7 +143,7 @@ export const STRINGS = {
|
|
|
143
143
|
ok: "OK",
|
|
144
144
|
cancel: "Cancelar",
|
|
145
145
|
close: "Fechar",
|
|
146
|
-
apply: "
|
|
146
|
+
apply: "Confirmar: {x}?",
|
|
147
147
|
run: "Executar {x}?",
|
|
148
148
|
undo: "Desfazer esta alteração?",
|
|
149
149
|
write: "Permitir escrever em {x}?",
|
|
@@ -260,7 +260,7 @@ export const STRINGS = {
|
|
|
260
260
|
*/
|
|
261
261
|
export const ACTIONS = {
|
|
262
262
|
research: [
|
|
263
|
-
{ cmd: "lit", fill: true,
|
|
263
|
+
{ cmd: "lit", fill: true, en: "Literature review", pt: "Revisão da literatura" },
|
|
264
264
|
{ cmd: "compare", fill: true, profiles: ["scholar", "author", "editor"], en: "Compare sources", pt: "Comparar fontes" },
|
|
265
265
|
{ cmd: "lit-note", fill: true, en: "Literature note", pt: "Nota de leitura" },
|
|
266
266
|
{ cmd: "synthesis", fill: true, en: "Synthesis", pt: "Síntese" },
|