zam-core 0.5.3 → 0.6.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/dist/cli/index.js +1316 -22
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +97 -1
- package/dist/index.js +452 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync16 } from "fs";
|
|
5
5
|
import { dirname as dirname10, join as join25 } from "path";
|
|
6
6
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
7
7
|
import { Command as Command25 } from "commander";
|
|
@@ -629,6 +629,24 @@ CREATE TABLE IF NOT EXISTS session_syntheses (
|
|
|
629
629
|
PRIMARY KEY (session_id, token_id)
|
|
630
630
|
);
|
|
631
631
|
|
|
632
|
+
-- Sources: textbook files, web links, or scan paths
|
|
633
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
634
|
+
id TEXT PRIMARY KEY,
|
|
635
|
+
type TEXT NOT NULL CHECK (type IN ('file', 'web', 'scan')),
|
|
636
|
+
uri TEXT NOT NULL UNIQUE,
|
|
637
|
+
content TEXT,
|
|
638
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
-- Token sources: mapping between tokens and their sources
|
|
642
|
+
CREATE TABLE IF NOT EXISTS token_sources (
|
|
643
|
+
token_id TEXT NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
|
|
644
|
+
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
645
|
+
excerpt TEXT NOT NULL DEFAULT '',
|
|
646
|
+
page_number TEXT,
|
|
647
|
+
PRIMARY KEY (token_id, source_id)
|
|
648
|
+
);
|
|
649
|
+
|
|
632
650
|
-- User configuration
|
|
633
651
|
CREATE TABLE IF NOT EXISTS user_config (
|
|
634
652
|
key TEXT PRIMARY KEY,
|
|
@@ -956,6 +974,24 @@ async function runMigrations(db) {
|
|
|
956
974
|
PRIMARY KEY (session_id, token_id)
|
|
957
975
|
)
|
|
958
976
|
`);
|
|
977
|
+
await db.exec(`
|
|
978
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
979
|
+
id TEXT PRIMARY KEY,
|
|
980
|
+
type TEXT NOT NULL CHECK (type IN ('file', 'web', 'scan')),
|
|
981
|
+
uri TEXT NOT NULL UNIQUE,
|
|
982
|
+
content TEXT,
|
|
983
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
984
|
+
)
|
|
985
|
+
`);
|
|
986
|
+
await db.exec(`
|
|
987
|
+
CREATE TABLE IF NOT EXISTS token_sources (
|
|
988
|
+
token_id TEXT NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
|
|
989
|
+
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
990
|
+
excerpt TEXT NOT NULL DEFAULT '',
|
|
991
|
+
page_number TEXT,
|
|
992
|
+
PRIMARY KEY (token_id, source_id)
|
|
993
|
+
)
|
|
994
|
+
`);
|
|
959
995
|
}
|
|
960
996
|
|
|
961
997
|
// src/kernel/db/snapshot.ts
|
|
@@ -1650,6 +1686,415 @@ async function listTokens(db, options) {
|
|
|
1650
1686
|
"SELECT * FROM tokens WHERE deprecated_at IS NULL ORDER BY bloom_level, domain, slug"
|
|
1651
1687
|
).all();
|
|
1652
1688
|
}
|
|
1689
|
+
function slugify(text) {
|
|
1690
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
1691
|
+
}
|
|
1692
|
+
async function generateTokenSlug(db, domain, concept, question) {
|
|
1693
|
+
const baseText = question && question.trim().length > 0 ? question : concept;
|
|
1694
|
+
const cleanDomain = slugify(domain || "");
|
|
1695
|
+
const cleanBase = slugify(baseText);
|
|
1696
|
+
let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
|
|
1697
|
+
if (baseSlug.length > 60) {
|
|
1698
|
+
baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
|
|
1699
|
+
}
|
|
1700
|
+
if (!baseSlug) {
|
|
1701
|
+
baseSlug = "token";
|
|
1702
|
+
}
|
|
1703
|
+
let slug = baseSlug;
|
|
1704
|
+
let counter = 1;
|
|
1705
|
+
while (true) {
|
|
1706
|
+
const existing = await db.prepare("SELECT id FROM tokens WHERE slug = ?").get(slug);
|
|
1707
|
+
if (!existing) {
|
|
1708
|
+
return slug;
|
|
1709
|
+
}
|
|
1710
|
+
const suffix = `-${counter}`;
|
|
1711
|
+
slug = baseSlug.slice(0, 60 - suffix.length).replace(/-$/, "") + suffix;
|
|
1712
|
+
counter++;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
async function listPersonalCards(db, userId, options) {
|
|
1716
|
+
let sql = `
|
|
1717
|
+
SELECT
|
|
1718
|
+
t.id AS tokenId,
|
|
1719
|
+
t.slug,
|
|
1720
|
+
t.concept,
|
|
1721
|
+
t.domain,
|
|
1722
|
+
t.bloom_level AS bloomLevel,
|
|
1723
|
+
t.context,
|
|
1724
|
+
t.symbiosis_mode AS symbiosisMode,
|
|
1725
|
+
t.source_link AS sourceLink,
|
|
1726
|
+
t.question,
|
|
1727
|
+
t.created_at AS createdAt,
|
|
1728
|
+
t.updated_at AS updatedAt,
|
|
1729
|
+
c.id AS cardId,
|
|
1730
|
+
c.state,
|
|
1731
|
+
c.due_at AS dueAt,
|
|
1732
|
+
c.stability,
|
|
1733
|
+
c.difficulty,
|
|
1734
|
+
c.reps,
|
|
1735
|
+
c.lapses,
|
|
1736
|
+
c.elapsed_days AS elapsedDays,
|
|
1737
|
+
c.scheduled_days AS scheduledDays,
|
|
1738
|
+
c.blocked
|
|
1739
|
+
FROM tokens t
|
|
1740
|
+
INNER JOIN cards c ON c.token_id = t.id AND c.user_id = ?
|
|
1741
|
+
WHERE t.deprecated_at IS NULL
|
|
1742
|
+
`;
|
|
1743
|
+
const values = [userId];
|
|
1744
|
+
if (options?.domain) {
|
|
1745
|
+
sql += " AND t.domain = ?";
|
|
1746
|
+
values.push(options.domain);
|
|
1747
|
+
}
|
|
1748
|
+
if (options?.query) {
|
|
1749
|
+
const terms = options.query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1750
|
+
for (const term of terms) {
|
|
1751
|
+
sql += ` AND (lower(t.slug) LIKE ? OR lower(t.concept) LIKE ? OR lower(t.domain) LIKE ? OR lower(t.question) LIKE ?)`;
|
|
1752
|
+
const pattern = `%${term}%`;
|
|
1753
|
+
values.push(pattern, pattern, pattern, pattern);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
sql += " ORDER BY t.created_at DESC";
|
|
1757
|
+
const rows = await db.prepare(sql).all(...values);
|
|
1758
|
+
return rows;
|
|
1759
|
+
}
|
|
1760
|
+
async function importCurriculumCards(db, userId, cards) {
|
|
1761
|
+
let createdCount = 0;
|
|
1762
|
+
let ensuredCount = 0;
|
|
1763
|
+
await db.transaction(async (tx) => {
|
|
1764
|
+
for (const card of cards) {
|
|
1765
|
+
const bloom = card.bloom_level !== void 0 ? card.bloom_level : 1;
|
|
1766
|
+
if (bloom < 1 || bloom > 5) {
|
|
1767
|
+
throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
|
|
1768
|
+
}
|
|
1769
|
+
let symbiosisMode = null;
|
|
1770
|
+
if (card.symbiosis_mode) {
|
|
1771
|
+
if (!["shadowing", "copilot", "autonomy", "none"].includes(
|
|
1772
|
+
card.symbiosis_mode
|
|
1773
|
+
)) {
|
|
1774
|
+
throw new Error(`Invalid symbiosis_mode: ${card.symbiosis_mode}`);
|
|
1775
|
+
}
|
|
1776
|
+
symbiosisMode = card.symbiosis_mode === "none" ? null : card.symbiosis_mode;
|
|
1777
|
+
}
|
|
1778
|
+
const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
|
|
1779
|
+
const cleanDomain = slugify(card.domain || "");
|
|
1780
|
+
const cleanBase = slugify(baseText);
|
|
1781
|
+
let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
|
|
1782
|
+
if (baseSlug.length > 60) {
|
|
1783
|
+
baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
|
|
1784
|
+
}
|
|
1785
|
+
if (!baseSlug) {
|
|
1786
|
+
baseSlug = "token";
|
|
1787
|
+
}
|
|
1788
|
+
let token = await getTokenBySlug(tx, baseSlug);
|
|
1789
|
+
if (!token) {
|
|
1790
|
+
const finalSlug = await generateTokenSlug(
|
|
1791
|
+
tx,
|
|
1792
|
+
card.domain,
|
|
1793
|
+
card.concept,
|
|
1794
|
+
card.question
|
|
1795
|
+
);
|
|
1796
|
+
token = await createToken(tx, {
|
|
1797
|
+
slug: finalSlug,
|
|
1798
|
+
concept: card.concept,
|
|
1799
|
+
domain: card.domain,
|
|
1800
|
+
bloom_level: bloom,
|
|
1801
|
+
context: card.context || "",
|
|
1802
|
+
symbiosis_mode: symbiosisMode,
|
|
1803
|
+
source_link: card.source_link || null,
|
|
1804
|
+
question: card.question || null
|
|
1805
|
+
});
|
|
1806
|
+
createdCount++;
|
|
1807
|
+
}
|
|
1808
|
+
const existingCard = await getCard(tx, token.id, userId);
|
|
1809
|
+
if (!existingCard) {
|
|
1810
|
+
await ensureCard(tx, token.id, userId);
|
|
1811
|
+
ensuredCount++;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
return { createdCount, ensuredCount };
|
|
1816
|
+
}
|
|
1817
|
+
async function confirmCardSplit(db, userId, originalSlug, action, originalQuestion, originalConcept, proposals) {
|
|
1818
|
+
if (action !== "block" && action !== "remove") {
|
|
1819
|
+
throw new Error(`Invalid split action: ${action}`);
|
|
1820
|
+
}
|
|
1821
|
+
if (proposals.length < 2 || proposals.length > 4) {
|
|
1822
|
+
throw new Error("A card split requires between 2 and 4 proposals");
|
|
1823
|
+
}
|
|
1824
|
+
const originalToken = await getTokenBySlug(db, originalSlug);
|
|
1825
|
+
if (!originalToken) {
|
|
1826
|
+
throw new Error(`Original token not found: ${originalSlug}`);
|
|
1827
|
+
}
|
|
1828
|
+
let createdCount = 0;
|
|
1829
|
+
let ensuredCount = 0;
|
|
1830
|
+
await db.transaction(async (tx) => {
|
|
1831
|
+
const originalCard = await getCard(tx, originalToken.id, userId);
|
|
1832
|
+
if (!originalCard) {
|
|
1833
|
+
throw new Error(
|
|
1834
|
+
`Card not found for token ${originalSlug} and user ${userId}`
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
const proposalTokens = [];
|
|
1838
|
+
for (const card of proposals) {
|
|
1839
|
+
const bloom = card.bloom_level !== void 0 ? card.bloom_level : 1;
|
|
1840
|
+
if (bloom < 1 || bloom > 5) {
|
|
1841
|
+
throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
|
|
1842
|
+
}
|
|
1843
|
+
let symbiosisMode = null;
|
|
1844
|
+
if (card.symbiosis_mode) {
|
|
1845
|
+
if (!["shadowing", "copilot", "autonomy", "none"].includes(
|
|
1846
|
+
card.symbiosis_mode
|
|
1847
|
+
)) {
|
|
1848
|
+
throw new Error(`Invalid symbiosis_mode: ${card.symbiosis_mode}`);
|
|
1849
|
+
}
|
|
1850
|
+
symbiosisMode = card.symbiosis_mode === "none" ? null : card.symbiosis_mode;
|
|
1851
|
+
}
|
|
1852
|
+
const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
|
|
1853
|
+
const cleanDomain = slugify(card.domain || "");
|
|
1854
|
+
const cleanBase = slugify(baseText);
|
|
1855
|
+
let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
|
|
1856
|
+
if (baseSlug.length > 60) {
|
|
1857
|
+
baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
|
|
1858
|
+
}
|
|
1859
|
+
if (!baseSlug) {
|
|
1860
|
+
baseSlug = "token";
|
|
1861
|
+
}
|
|
1862
|
+
let token = await getTokenBySlug(tx, baseSlug);
|
|
1863
|
+
if (!token) {
|
|
1864
|
+
const finalSlug = await generateTokenSlug(
|
|
1865
|
+
tx,
|
|
1866
|
+
card.domain,
|
|
1867
|
+
card.concept,
|
|
1868
|
+
card.question
|
|
1869
|
+
);
|
|
1870
|
+
token = await createToken(tx, {
|
|
1871
|
+
slug: finalSlug,
|
|
1872
|
+
concept: card.concept,
|
|
1873
|
+
domain: card.domain,
|
|
1874
|
+
bloom_level: bloom,
|
|
1875
|
+
context: card.context || "",
|
|
1876
|
+
symbiosis_mode: symbiosisMode,
|
|
1877
|
+
source_link: card.source_link || originalToken.source_link || null,
|
|
1878
|
+
question: card.question || null
|
|
1879
|
+
});
|
|
1880
|
+
createdCount++;
|
|
1881
|
+
}
|
|
1882
|
+
if (token.id === originalToken.id) {
|
|
1883
|
+
throw new Error("A token cannot be a prerequisite of itself");
|
|
1884
|
+
}
|
|
1885
|
+
await assertPrerequisiteDoesNotCreateCycle(
|
|
1886
|
+
tx,
|
|
1887
|
+
originalToken.id,
|
|
1888
|
+
token.id
|
|
1889
|
+
);
|
|
1890
|
+
proposalTokens.push(token);
|
|
1891
|
+
const existingCard = await getCard(tx, token.id, userId);
|
|
1892
|
+
if (!existingCard) {
|
|
1893
|
+
await ensureCard(tx, token.id, userId);
|
|
1894
|
+
ensuredCount++;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (action === "block") {
|
|
1898
|
+
await tx.prepare(
|
|
1899
|
+
"UPDATE tokens SET question = ?, concept = ?, updated_at = ? WHERE id = ?"
|
|
1900
|
+
).run(
|
|
1901
|
+
originalQuestion || null,
|
|
1902
|
+
originalConcept,
|
|
1903
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
1904
|
+
originalToken.id
|
|
1905
|
+
);
|
|
1906
|
+
for (const propToken of proposalTokens) {
|
|
1907
|
+
await tx.prepare(
|
|
1908
|
+
"INSERT OR IGNORE INTO prerequisites (token_id, requires_id) VALUES (?, ?)"
|
|
1909
|
+
).run(originalToken.id, propToken.id);
|
|
1910
|
+
}
|
|
1911
|
+
await tx.prepare(
|
|
1912
|
+
"UPDATE cards SET blocked = 1 WHERE token_id = ? AND user_id = ?"
|
|
1913
|
+
).run(originalToken.id, userId);
|
|
1914
|
+
for (const propToken of proposalTokens) {
|
|
1915
|
+
const card = await ensureCard(tx, propToken.id, userId);
|
|
1916
|
+
if (card.blocked === 1) {
|
|
1917
|
+
const prereqOfPrereq = await tx.prepare(
|
|
1918
|
+
"SELECT COUNT(*) as n FROM prerequisites WHERE token_id = ?"
|
|
1919
|
+
).get(propToken.id);
|
|
1920
|
+
if (prereqOfPrereq.n === 0) {
|
|
1921
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1922
|
+
await tx.prepare("UPDATE cards SET blocked = 0, due_at = ? WHERE id = ?").run(now, card.id);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
} else if (action === "remove") {
|
|
1927
|
+
await deleteCardForUser(tx, originalToken.id, userId);
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
return { createdCount, ensuredCount };
|
|
1931
|
+
}
|
|
1932
|
+
async function confirmFoundations(db, userId, originalSlug, proposals) {
|
|
1933
|
+
const originalToken = await getTokenBySlug(db, originalSlug);
|
|
1934
|
+
if (!originalToken) {
|
|
1935
|
+
throw new Error(`Original token not found: ${originalSlug}`);
|
|
1936
|
+
}
|
|
1937
|
+
let createdCount = 0;
|
|
1938
|
+
let linkedCount = 0;
|
|
1939
|
+
await db.transaction(async (tx) => {
|
|
1940
|
+
const originalCard = await getCard(tx, originalToken.id, userId);
|
|
1941
|
+
if (!originalCard) {
|
|
1942
|
+
throw new Error(
|
|
1943
|
+
`Card not found for token ${originalSlug} and user ${userId}`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
for (const card of proposals) {
|
|
1947
|
+
let targetTokenId;
|
|
1948
|
+
if (card.exists && card.slug) {
|
|
1949
|
+
const existingToken = await getTokenBySlug(tx, card.slug);
|
|
1950
|
+
if (!existingToken) {
|
|
1951
|
+
throw new Error(`Prerequisite token not found by slug: ${card.slug}`);
|
|
1952
|
+
}
|
|
1953
|
+
targetTokenId = existingToken.id;
|
|
1954
|
+
const existingCard = await getCard(tx, existingToken.id, userId);
|
|
1955
|
+
if (!existingCard) {
|
|
1956
|
+
await ensureCard(tx, existingToken.id, userId);
|
|
1957
|
+
}
|
|
1958
|
+
linkedCount++;
|
|
1959
|
+
} else {
|
|
1960
|
+
const bloom = card.bloom_level !== void 0 ? card.bloom_level : 1;
|
|
1961
|
+
if (bloom < 1 || bloom > 5) {
|
|
1962
|
+
throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
|
|
1963
|
+
}
|
|
1964
|
+
let symbiosisMode = null;
|
|
1965
|
+
if (card.symbiosis_mode) {
|
|
1966
|
+
if (!["shadowing", "copilot", "autonomy", "none"].includes(
|
|
1967
|
+
card.symbiosis_mode
|
|
1968
|
+
)) {
|
|
1969
|
+
throw new Error(`Invalid symbiosis_mode: ${card.symbiosis_mode}`);
|
|
1970
|
+
}
|
|
1971
|
+
symbiosisMode = card.symbiosis_mode === "none" ? null : card.symbiosis_mode;
|
|
1972
|
+
}
|
|
1973
|
+
const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
|
|
1974
|
+
const cleanDomain = slugify(card.domain || "");
|
|
1975
|
+
const cleanBase = slugify(baseText);
|
|
1976
|
+
let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
|
|
1977
|
+
if (baseSlug.length > 60) {
|
|
1978
|
+
baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
|
|
1979
|
+
}
|
|
1980
|
+
if (!baseSlug) {
|
|
1981
|
+
baseSlug = "token";
|
|
1982
|
+
}
|
|
1983
|
+
let token = await getTokenBySlug(tx, baseSlug);
|
|
1984
|
+
if (!token) {
|
|
1985
|
+
const finalSlug = await generateTokenSlug(
|
|
1986
|
+
tx,
|
|
1987
|
+
card.domain,
|
|
1988
|
+
card.concept,
|
|
1989
|
+
card.question
|
|
1990
|
+
);
|
|
1991
|
+
token = await createToken(tx, {
|
|
1992
|
+
slug: finalSlug,
|
|
1993
|
+
concept: card.concept,
|
|
1994
|
+
domain: card.domain,
|
|
1995
|
+
bloom_level: bloom,
|
|
1996
|
+
context: card.context || "",
|
|
1997
|
+
symbiosis_mode: symbiosisMode,
|
|
1998
|
+
source_link: card.source_link || originalToken.source_link || null,
|
|
1999
|
+
question: card.question || null
|
|
2000
|
+
});
|
|
2001
|
+
createdCount++;
|
|
2002
|
+
}
|
|
2003
|
+
targetTokenId = token.id;
|
|
2004
|
+
const existingCard = await getCard(tx, token.id, userId);
|
|
2005
|
+
if (!existingCard) {
|
|
2006
|
+
await ensureCard(tx, token.id, userId);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
if (targetTokenId === originalToken.id) {
|
|
2010
|
+
throw new Error("A token cannot be a prerequisite of itself");
|
|
2011
|
+
}
|
|
2012
|
+
await assertPrerequisiteDoesNotCreateCycle(
|
|
2013
|
+
tx,
|
|
2014
|
+
originalToken.id,
|
|
2015
|
+
targetTokenId
|
|
2016
|
+
);
|
|
2017
|
+
await tx.prepare(
|
|
2018
|
+
"INSERT OR IGNORE INTO prerequisites (token_id, requires_id) VALUES (?, ?)"
|
|
2019
|
+
).run(originalToken.id, targetTokenId);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
return { createdCount, linkedCount };
|
|
2023
|
+
}
|
|
2024
|
+
async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
2025
|
+
let createdCount = 0;
|
|
2026
|
+
let linkedCount = 0;
|
|
2027
|
+
await db.transaction(async (tx) => {
|
|
2028
|
+
const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(sourceId);
|
|
2029
|
+
if (!source) {
|
|
2030
|
+
throw new Error(`Source not found: ${sourceId}`);
|
|
2031
|
+
}
|
|
2032
|
+
for (const card of proposals) {
|
|
2033
|
+
const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
|
|
2034
|
+
const cleanDomain = slugify(card.domain || "");
|
|
2035
|
+
const cleanBase = slugify(baseText);
|
|
2036
|
+
let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
|
|
2037
|
+
if (baseSlug.length > 60) {
|
|
2038
|
+
baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
|
|
2039
|
+
}
|
|
2040
|
+
if (!baseSlug) {
|
|
2041
|
+
baseSlug = "token";
|
|
2042
|
+
}
|
|
2043
|
+
let token = await getTokenBySlug(tx, baseSlug);
|
|
2044
|
+
if (!token) {
|
|
2045
|
+
const finalSlug = await generateTokenSlug(
|
|
2046
|
+
tx,
|
|
2047
|
+
card.domain,
|
|
2048
|
+
card.concept,
|
|
2049
|
+
card.question
|
|
2050
|
+
);
|
|
2051
|
+
const bloom = card.bloom_level !== void 0 ? card.bloom_level : 1;
|
|
2052
|
+
let symbiosisMode = null;
|
|
2053
|
+
if (card.symbiosis_mode && card.symbiosis_mode !== "none") {
|
|
2054
|
+
symbiosisMode = card.symbiosis_mode;
|
|
2055
|
+
}
|
|
2056
|
+
token = await createToken(tx, {
|
|
2057
|
+
slug: finalSlug,
|
|
2058
|
+
concept: card.concept,
|
|
2059
|
+
domain: card.domain,
|
|
2060
|
+
bloom_level: bloom,
|
|
2061
|
+
context: card.excerpt || "",
|
|
2062
|
+
symbiosis_mode: symbiosisMode,
|
|
2063
|
+
question: card.question || null
|
|
2064
|
+
});
|
|
2065
|
+
createdCount++;
|
|
2066
|
+
} else {
|
|
2067
|
+
linkedCount++;
|
|
2068
|
+
}
|
|
2069
|
+
const existingCard = await getCard(tx, token.id, userId);
|
|
2070
|
+
if (!existingCard) {
|
|
2071
|
+
await ensureCard(tx, token.id, userId);
|
|
2072
|
+
}
|
|
2073
|
+
await tx.prepare(
|
|
2074
|
+
`INSERT INTO token_sources (token_id, source_id, excerpt, page_number)
|
|
2075
|
+
VALUES (?, ?, ?, ?)
|
|
2076
|
+
ON CONFLICT(token_id, source_id) DO UPDATE SET
|
|
2077
|
+
excerpt = excluded.excerpt,
|
|
2078
|
+
page_number = excluded.page_number`
|
|
2079
|
+
).run(token.id, sourceId, card.excerpt || "", card.page_number || null);
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
return { createdCount, linkedCount };
|
|
2083
|
+
}
|
|
2084
|
+
async function assertPrerequisiteDoesNotCreateCycle(db, tokenId, prerequisiteId) {
|
|
2085
|
+
const cycleCheck = await db.prepare(
|
|
2086
|
+
`WITH RECURSIVE dependents(token_id) AS (
|
|
2087
|
+
SELECT token_id FROM prerequisites WHERE requires_id = ?
|
|
2088
|
+
UNION
|
|
2089
|
+
SELECT p.token_id FROM prerequisites p
|
|
2090
|
+
JOIN dependents d ON p.requires_id = d.token_id
|
|
2091
|
+
)
|
|
2092
|
+
SELECT COUNT(*) as n FROM dependents WHERE token_id = ?`
|
|
2093
|
+
).get(tokenId, prerequisiteId);
|
|
2094
|
+
if (cycleCheck.n > 0) {
|
|
2095
|
+
throw new Error("Cannot add prerequisite: would create a cycle");
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
1653
2098
|
|
|
1654
2099
|
// src/kernel/models/prerequisite.ts
|
|
1655
2100
|
async function buildAncestorMap(db) {
|
|
@@ -5390,14 +5835,19 @@ var agentCommand = new Command("agent").description("Provision and inspect the a
|
|
|
5390
5835
|
// src/cli/commands/bridge.ts
|
|
5391
5836
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
5392
5837
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
5393
|
-
import { existsSync as existsSync17, readdirSync as readdirSync2, readFileSync as
|
|
5838
|
+
import { existsSync as existsSync17, readdirSync as readdirSync2, readFileSync as readFileSync12, rmSync as rmSync3 } from "fs";
|
|
5394
5839
|
import { homedir as homedir10, tmpdir as tmpdir3 } from "os";
|
|
5395
5840
|
import { join as join18, resolve as resolve4 } from "path";
|
|
5396
5841
|
import { Command as Command2 } from "commander";
|
|
5842
|
+
import { ulid as ulid8 } from "ulid";
|
|
5843
|
+
|
|
5844
|
+
// src/cli/adapters/source-reader.ts
|
|
5845
|
+
import dns from "dns";
|
|
5846
|
+
import fs from "fs";
|
|
5397
5847
|
|
|
5398
5848
|
// src/cli/llm/client.ts
|
|
5399
5849
|
import { spawn as spawn2 } from "child_process";
|
|
5400
|
-
import { existsSync as existsSync14 } from "fs";
|
|
5850
|
+
import { existsSync as existsSync14, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
|
|
5401
5851
|
var DEFAULT_LLM_URL = "http://localhost:8000/v1";
|
|
5402
5852
|
var DEFAULT_LLM_MAX_TOKENS = 1e4;
|
|
5403
5853
|
var RECALL_QUESTION_MAX_OUTPUT_TOKENS = 400;
|
|
@@ -5570,10 +6020,21 @@ async function getLegacyRoleConfig(db, role, enabled) {
|
|
|
5570
6020
|
function assertChatCompletions(cfg) {
|
|
5571
6021
|
if (cfg.apiFlavor !== "chat-completions") {
|
|
5572
6022
|
throw new Error(
|
|
5573
|
-
`This role is configured for a "${cfg.apiFlavor}" provider, which is not supported here yet. Use a chat-completions provider for
|
|
6023
|
+
`This role is configured for a "${cfg.apiFlavor}" provider, which is not supported here yet. Use a chat-completions provider for this role.`
|
|
5574
6024
|
);
|
|
5575
6025
|
}
|
|
5576
6026
|
}
|
|
6027
|
+
async function getVisionConfig(db) {
|
|
6028
|
+
const p = await getProviderForRole(db, "vision");
|
|
6029
|
+
return {
|
|
6030
|
+
enabled: p.enabled,
|
|
6031
|
+
url: p.url,
|
|
6032
|
+
model: p.model,
|
|
6033
|
+
apiKey: p.apiKey,
|
|
6034
|
+
locale: p.locale,
|
|
6035
|
+
maxFrames: p.maxFrames
|
|
6036
|
+
};
|
|
6037
|
+
}
|
|
5577
6038
|
var LANGUAGE_NAMES = {
|
|
5578
6039
|
en: "English",
|
|
5579
6040
|
de: "German",
|
|
@@ -5721,6 +6182,306 @@ Evaluation:`;
|
|
|
5721
6182
|
providerName: endpoint.providerName
|
|
5722
6183
|
};
|
|
5723
6184
|
}
|
|
6185
|
+
var MAX_IMPORT_TEXT_CHARS = 2e5;
|
|
6186
|
+
var VALID_GENERATED_MODES = /* @__PURE__ */ new Set(["shadowing", "copilot", "autonomy"]);
|
|
6187
|
+
function parseGeneratedCardArray(responseText, label, limits) {
|
|
6188
|
+
const startIdx = responseText.indexOf("[");
|
|
6189
|
+
const endIdx = responseText.lastIndexOf("]");
|
|
6190
|
+
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
|
|
6191
|
+
throw new Error(`Invalid ${label} response: JSON array brackets not found`);
|
|
6192
|
+
}
|
|
6193
|
+
let parsed;
|
|
6194
|
+
try {
|
|
6195
|
+
parsed = JSON.parse(responseText.substring(startIdx, endIdx + 1));
|
|
6196
|
+
} catch {
|
|
6197
|
+
throw new Error(`Invalid ${label} response: malformed JSON`);
|
|
6198
|
+
}
|
|
6199
|
+
if (!Array.isArray(parsed)) {
|
|
6200
|
+
throw new Error(`Invalid ${label} response: expected a JSON array`);
|
|
6201
|
+
}
|
|
6202
|
+
if (parsed.length < limits.min || parsed.length > limits.max) {
|
|
6203
|
+
throw new Error(
|
|
6204
|
+
`Invalid ${label} response: expected ${limits.min}-${limits.max} cards, got ${parsed.length}`
|
|
6205
|
+
);
|
|
6206
|
+
}
|
|
6207
|
+
return parsed.map((value, index) => {
|
|
6208
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
6209
|
+
throw new Error(
|
|
6210
|
+
`Invalid ${label} card at index ${index}: expected an object`
|
|
6211
|
+
);
|
|
6212
|
+
}
|
|
6213
|
+
const card = value;
|
|
6214
|
+
for (const field of ["question", "concept", "domain", "context"]) {
|
|
6215
|
+
if (typeof card[field] !== "string" || card[field].trim().length === 0) {
|
|
6216
|
+
throw new Error(
|
|
6217
|
+
`Invalid ${label} card at index ${index}: ${field} must be a non-empty string`
|
|
6218
|
+
);
|
|
6219
|
+
}
|
|
6220
|
+
}
|
|
6221
|
+
if (typeof card.bloom_level !== "number" || !Number.isInteger(card.bloom_level) || card.bloom_level < 1 || card.bloom_level > 5) {
|
|
6222
|
+
throw new Error(
|
|
6223
|
+
`Invalid ${label} card at index ${index}: bloom_level must be an integer from 1 to 5`
|
|
6224
|
+
);
|
|
6225
|
+
}
|
|
6226
|
+
if (typeof card.symbiosis_mode !== "string" || !VALID_GENERATED_MODES.has(card.symbiosis_mode)) {
|
|
6227
|
+
throw new Error(
|
|
6228
|
+
`Invalid ${label} card at index ${index}: invalid symbiosis_mode`
|
|
6229
|
+
);
|
|
6230
|
+
}
|
|
6231
|
+
return {
|
|
6232
|
+
question: card.question,
|
|
6233
|
+
concept: card.concept,
|
|
6234
|
+
domain: card.domain,
|
|
6235
|
+
context: card.context,
|
|
6236
|
+
bloom_level: card.bloom_level,
|
|
6237
|
+
symbiosis_mode: card.symbiosis_mode,
|
|
6238
|
+
source_link: null
|
|
6239
|
+
};
|
|
6240
|
+
});
|
|
6241
|
+
}
|
|
6242
|
+
async function importCurriculumViaLLM(db, text, targetCategory, sourceUrl) {
|
|
6243
|
+
if (text.length > MAX_IMPORT_TEXT_CHARS) {
|
|
6244
|
+
throw new Error(
|
|
6245
|
+
`Curriculum text exceeds the ${MAX_IMPORT_TEXT_CHARS.toLocaleString()} character limit`
|
|
6246
|
+
);
|
|
6247
|
+
}
|
|
6248
|
+
const cfg = await getProviderForRole(db, "text");
|
|
6249
|
+
const endpoint = await resolveUsableTextEndpoint(db);
|
|
6250
|
+
const langName = LANGUAGE_NAMES[cfg.locale] || "English";
|
|
6251
|
+
const systemPrompt = `You are ZAM, a highly precise agentic curriculum parser.
|
|
6252
|
+
Your task is to analyze curriculum objectives, syllabus requirements, or textbook notes, and extract atomic facts, concepts, or skills as structured learning cards in ${langName}.
|
|
6253
|
+
|
|
6254
|
+
For each extracted learning card, you MUST generate:
|
|
6255
|
+
1. "question": A clear, concise active-recall question testing the concept. The question must not reveal the answer itself.
|
|
6256
|
+
2. "concept": The reference answer, core fact, or target conceptual explanation.
|
|
6257
|
+
3. "domain": The category of the card. Use "${targetCategory}" as the default, but you may refine it if a specific sub-topic is evident.
|
|
6258
|
+
4. "context": The exact sentence or short excerpt from the source text that this card is based on.
|
|
6259
|
+
5. "bloom_level": Initial Bloom cognitive level (1 = Remember, 2 = Understand, 3 = Apply, 4 = Analyze, 5 = Synthesize).
|
|
6260
|
+
6. "symbiosis_mode": ZAM agent symbiosis level: "shadowing" (reading/monitoring), "copilot" (interactive helper), or "autonomy" (autonomous tasks).
|
|
6261
|
+
|
|
6262
|
+
Guidelines:
|
|
6263
|
+
- Break complex requirements into multiple separate, atomic cards.
|
|
6264
|
+
- Keep questions focused on one fact.
|
|
6265
|
+
- Do not repeat the same concept in multiple cards.
|
|
6266
|
+
- Output ONLY a raw valid JSON array of objects. Do NOT wrap the JSON in markdown code blocks, HTML, or include any preamble, headers, or conversational filler.`;
|
|
6267
|
+
const userPrompt = `Curriculum Text to Parse:
|
|
6268
|
+
${text}
|
|
6269
|
+
|
|
6270
|
+
Target Category: ${targetCategory}
|
|
6271
|
+
${sourceUrl ? `Source Reference Link: ${sourceUrl}` : ""}
|
|
6272
|
+
|
|
6273
|
+
JSON Array Output:`;
|
|
6274
|
+
const res = await fetchWithInteractiveTimeout(
|
|
6275
|
+
`${endpoint.url}/chat/completions`,
|
|
6276
|
+
{
|
|
6277
|
+
method: "POST",
|
|
6278
|
+
headers: {
|
|
6279
|
+
"Content-Type": "application/json",
|
|
6280
|
+
Authorization: `Bearer ${endpoint.apiKey}`
|
|
6281
|
+
},
|
|
6282
|
+
body: JSON.stringify({
|
|
6283
|
+
model: endpoint.model,
|
|
6284
|
+
messages: [
|
|
6285
|
+
{ role: "system", content: systemPrompt },
|
|
6286
|
+
{ role: "user", content: userPrompt }
|
|
6287
|
+
],
|
|
6288
|
+
temperature: 0.1,
|
|
6289
|
+
max_tokens: DEFAULT_LLM_MAX_TOKENS
|
|
6290
|
+
}),
|
|
6291
|
+
locale: cfg.locale
|
|
6292
|
+
}
|
|
6293
|
+
);
|
|
6294
|
+
const responseText = await readChatContent(res, "LLM curriculum import");
|
|
6295
|
+
return parseGeneratedCardArray(responseText, "curriculum import", {
|
|
6296
|
+
min: 0,
|
|
6297
|
+
max: 200
|
|
6298
|
+
}).map((card) => ({ ...card, source_link: sourceUrl || null }));
|
|
6299
|
+
}
|
|
6300
|
+
async function generateSplitProposalsViaLLM(db, token) {
|
|
6301
|
+
const cfg = await getProviderForRole(db, "text");
|
|
6302
|
+
const endpoint = await resolveUsableTextEndpoint(db);
|
|
6303
|
+
const langName = LANGUAGE_NAMES[cfg.locale] || "English";
|
|
6304
|
+
const systemPrompt = `You are ZAM, a highly precise agentic learning assistant.
|
|
6305
|
+
Your task is to analyze a learning card that is too broad or covers multiple ideas, and split it into 2 to 4 atomic, focused proposal cards in ${langName}.
|
|
6306
|
+
|
|
6307
|
+
The input card details are:
|
|
6308
|
+
- Question: ${token.question || "N/A"}
|
|
6309
|
+
- Core Concept: ${token.concept}
|
|
6310
|
+
- Domain: ${token.domain}
|
|
6311
|
+
- Excerpt Context: ${token.context || "N/A"}
|
|
6312
|
+
|
|
6313
|
+
For each split proposal card, you MUST generate:
|
|
6314
|
+
1. "question": A clear, concise active-recall question testing a single focused fact or concept. The question must not reveal the answer itself.
|
|
6315
|
+
2. "concept": The reference answer or core fact.
|
|
6316
|
+
3. "domain": The category of the card (default to "${token.domain}").
|
|
6317
|
+
4. "context": The context excerpt from the original card's context or a derivation of it.
|
|
6318
|
+
5. "bloom_level": Bloom taxonomy level (1 to 5).
|
|
6319
|
+
6. "symbiosis_mode": Symbiosis mode ("shadowing", "copilot", or "autonomy").
|
|
6320
|
+
|
|
6321
|
+
Guidelines:
|
|
6322
|
+
- Make sure each card is completely atomic (covers exactly one concept).
|
|
6323
|
+
- Do not repeat the same concept across cards.
|
|
6324
|
+
- Output ONLY a raw valid JSON array of objects. Do NOT wrap the JSON in markdown code blocks, HTML, or include any conversational filler.`;
|
|
6325
|
+
const userPrompt = `Split the broad card details above into 2 to 4 atomic cards.
|
|
6326
|
+
|
|
6327
|
+
JSON Array Output:`;
|
|
6328
|
+
const res = await fetchWithInteractiveTimeout(
|
|
6329
|
+
`${endpoint.url}/chat/completions`,
|
|
6330
|
+
{
|
|
6331
|
+
method: "POST",
|
|
6332
|
+
headers: {
|
|
6333
|
+
"Content-Type": "application/json",
|
|
6334
|
+
Authorization: `Bearer ${endpoint.apiKey}`
|
|
6335
|
+
},
|
|
6336
|
+
body: JSON.stringify({
|
|
6337
|
+
model: endpoint.model,
|
|
6338
|
+
messages: [
|
|
6339
|
+
{ role: "system", content: systemPrompt },
|
|
6340
|
+
{ role: "user", content: userPrompt }
|
|
6341
|
+
],
|
|
6342
|
+
temperature: 0.2,
|
|
6343
|
+
max_tokens: DEFAULT_LLM_MAX_TOKENS
|
|
6344
|
+
}),
|
|
6345
|
+
locale: cfg.locale
|
|
6346
|
+
}
|
|
6347
|
+
);
|
|
6348
|
+
const responseText = await readChatContent(res, "LLM card split proposals");
|
|
6349
|
+
return parseGeneratedCardArray(responseText, "card split", {
|
|
6350
|
+
min: 2,
|
|
6351
|
+
max: 4
|
|
6352
|
+
}).map((card) => ({ ...card, source_link: token.source_link || null }));
|
|
6353
|
+
}
|
|
6354
|
+
async function generateFoundationsProposalsViaLLM(db, token) {
|
|
6355
|
+
const cfg = await getProviderForRole(db, "text");
|
|
6356
|
+
const endpoint = await resolveUsableTextEndpoint(db);
|
|
6357
|
+
const langName = LANGUAGE_NAMES[cfg.locale] || "English";
|
|
6358
|
+
const systemPrompt = `You are ZAM, a highly precise agentic learning assistant.
|
|
6359
|
+
Your task is to analyze a learning card and propose 2 to 4 atomic, foundational prerequisite concepts (foundations) that a learner must master *before* studying this card, in ${langName}.
|
|
6360
|
+
|
|
6361
|
+
The current card details are:
|
|
6362
|
+
- Question: ${token.question || "N/A"}
|
|
6363
|
+
- Core Concept: ${token.concept}
|
|
6364
|
+
- Domain: ${token.domain}
|
|
6365
|
+
- Excerpt Context: ${token.context || "N/A"}
|
|
6366
|
+
|
|
6367
|
+
For each proposed foundational card, you MUST generate:
|
|
6368
|
+
1. "question": A clear, concise active-recall question testing a single foundational fact or prerequisite concept.
|
|
6369
|
+
2. "concept": The reference answer or core fact.
|
|
6370
|
+
3. "domain": The category of the card (default to "${token.domain}").
|
|
6371
|
+
4. "context": The context excerpt explaining where this prerequisite fits in the learning hierarchy.
|
|
6372
|
+
5. "bloom_level": Bloom taxonomy level (typically 1 or 2 for foundational facts).
|
|
6373
|
+
6. "symbiosis_mode": Symbiosis mode ("shadowing", "copilot", or "autonomy").
|
|
6374
|
+
|
|
6375
|
+
Guidelines:
|
|
6376
|
+
- Recommend only highly relevant and necessary prerequisites.
|
|
6377
|
+
- Keep each proposed card atomic and focused on one concept.
|
|
6378
|
+
- Output ONLY a raw valid JSON array of objects. Do NOT wrap the JSON in markdown code blocks, HTML, or include any conversational filler.`;
|
|
6379
|
+
const userPrompt = `Suggest 2 to 4 foundational prerequisite cards for the card above.
|
|
6380
|
+
|
|
6381
|
+
JSON Array Output:`;
|
|
6382
|
+
const res = await fetchWithInteractiveTimeout(
|
|
6383
|
+
`${endpoint.url}/chat/completions`,
|
|
6384
|
+
{
|
|
6385
|
+
method: "POST",
|
|
6386
|
+
headers: {
|
|
6387
|
+
"Content-Type": "application/json",
|
|
6388
|
+
Authorization: `Bearer ${endpoint.apiKey}`
|
|
6389
|
+
},
|
|
6390
|
+
body: JSON.stringify({
|
|
6391
|
+
model: endpoint.model,
|
|
6392
|
+
messages: [
|
|
6393
|
+
{ role: "system", content: systemPrompt },
|
|
6394
|
+
{ role: "user", content: userPrompt }
|
|
6395
|
+
],
|
|
6396
|
+
temperature: 0.2,
|
|
6397
|
+
max_tokens: DEFAULT_LLM_MAX_TOKENS
|
|
6398
|
+
}),
|
|
6399
|
+
locale: cfg.locale
|
|
6400
|
+
}
|
|
6401
|
+
);
|
|
6402
|
+
const responseText = await readChatContent(
|
|
6403
|
+
res,
|
|
6404
|
+
"LLM card foundations proposals"
|
|
6405
|
+
);
|
|
6406
|
+
return parseGeneratedCardArray(responseText, "foundation proposal", {
|
|
6407
|
+
min: 2,
|
|
6408
|
+
max: 4
|
|
6409
|
+
}).map((card) => ({ ...card, source_link: token.source_link || null }));
|
|
6410
|
+
}
|
|
6411
|
+
async function extractTextFromScanViaLLM(db, imagePath) {
|
|
6412
|
+
const p = await getProviderForRole(db, "vision");
|
|
6413
|
+
if (!p.enabled) {
|
|
6414
|
+
throw new Error(
|
|
6415
|
+
"Vision role is not enabled in settings (llm.vision.enabled)"
|
|
6416
|
+
);
|
|
6417
|
+
}
|
|
6418
|
+
if (!existsSync14(imagePath)) {
|
|
6419
|
+
throw new Error(`Scan file not found: ${imagePath}`);
|
|
6420
|
+
}
|
|
6421
|
+
const stat = statSync2(imagePath);
|
|
6422
|
+
if (!stat.isFile()) {
|
|
6423
|
+
throw new Error(`Scan path is not a file: ${imagePath}`);
|
|
6424
|
+
}
|
|
6425
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
6426
|
+
throw new Error("Scan file exceeds 10MB limit");
|
|
6427
|
+
}
|
|
6428
|
+
const ext = imagePath.split(".").pop()?.toLowerCase();
|
|
6429
|
+
const mimeByExtension = {
|
|
6430
|
+
jpeg: "image/jpeg",
|
|
6431
|
+
jpg: "image/jpeg",
|
|
6432
|
+
png: "image/png",
|
|
6433
|
+
webp: "image/webp"
|
|
6434
|
+
};
|
|
6435
|
+
const mime = ext ? mimeByExtension[ext] : void 0;
|
|
6436
|
+
if (!mime) {
|
|
6437
|
+
throw new Error("Unsupported scan format; use PNG, JPEG, or WebP");
|
|
6438
|
+
}
|
|
6439
|
+
const imageBytes = readFileSync9(imagePath);
|
|
6440
|
+
const dataUrl = `data:${mime};base64,${imageBytes.toString("base64")}`;
|
|
6441
|
+
const visionEndpoint = await getVisionConfig(db);
|
|
6442
|
+
const langName = LANGUAGE_NAMES[p.locale] || "English";
|
|
6443
|
+
const systemPrompt = "You are ZAM's OCR processor. Extract all visible text from the image exactly as written. Output only the extracted text without commentary, formatting, or prefixes.";
|
|
6444
|
+
const res = await fetchWithInteractiveTimeout(
|
|
6445
|
+
`${visionEndpoint.url}/chat/completions`,
|
|
6446
|
+
{
|
|
6447
|
+
method: "POST",
|
|
6448
|
+
headers: {
|
|
6449
|
+
"Content-Type": "application/json",
|
|
6450
|
+
Authorization: `Bearer ${visionEndpoint.apiKey || DEFAULT_LLM_API_KEY}`
|
|
6451
|
+
},
|
|
6452
|
+
body: JSON.stringify({
|
|
6453
|
+
model: visionEndpoint.model,
|
|
6454
|
+
messages: [
|
|
6455
|
+
{ role: "system", content: systemPrompt },
|
|
6456
|
+
{
|
|
6457
|
+
role: "user",
|
|
6458
|
+
content: [
|
|
6459
|
+
{
|
|
6460
|
+
type: "text",
|
|
6461
|
+
text: `Extract all text from this scan in ${langName}:`
|
|
6462
|
+
},
|
|
6463
|
+
{
|
|
6464
|
+
type: "image_url",
|
|
6465
|
+
image_url: { url: dataUrl }
|
|
6466
|
+
}
|
|
6467
|
+
]
|
|
6468
|
+
}
|
|
6469
|
+
],
|
|
6470
|
+
temperature: 0,
|
|
6471
|
+
max_tokens: DEFAULT_LLM_MAX_TOKENS
|
|
6472
|
+
}),
|
|
6473
|
+
locale: p.locale
|
|
6474
|
+
}
|
|
6475
|
+
);
|
|
6476
|
+
if (!res.ok) {
|
|
6477
|
+
throw new Error(`LLM vision OCR request failed with status ${res.status}`);
|
|
6478
|
+
}
|
|
6479
|
+
const responseText = await readChatContent(
|
|
6480
|
+
res,
|
|
6481
|
+
"LLM scan OCR text extraction"
|
|
6482
|
+
);
|
|
6483
|
+
return responseText.trim();
|
|
6484
|
+
}
|
|
5724
6485
|
async function translateQuestionViaLLM(db, question) {
|
|
5725
6486
|
const cfg = await getProviderForRole(db, "recall");
|
|
5726
6487
|
const endpoint = await resolveUsableRecallEndpoint(db);
|
|
@@ -5841,6 +6602,21 @@ async function resolveUsableRecallEndpoint(db) {
|
|
|
5841
6602
|
};
|
|
5842
6603
|
return selected.endpoint;
|
|
5843
6604
|
}
|
|
6605
|
+
async function resolveUsableTextEndpoint(db) {
|
|
6606
|
+
const cfg = await getProviderForRole(db, "text");
|
|
6607
|
+
if (!cfg.enabled) {
|
|
6608
|
+
throw new Error(
|
|
6609
|
+
"Text LLM integration is disabled in settings (llm.enabled)"
|
|
6610
|
+
);
|
|
6611
|
+
}
|
|
6612
|
+
assertChatCompletions(cfg);
|
|
6613
|
+
const chain = await checkProviderChain(cfg);
|
|
6614
|
+
const selected = chain.firstUsable;
|
|
6615
|
+
if (!selected || !isEndpointUsable(selected)) {
|
|
6616
|
+
throw new Error("No text LLM endpoint is online");
|
|
6617
|
+
}
|
|
6618
|
+
return selected.endpoint;
|
|
6619
|
+
}
|
|
5844
6620
|
async function prepareRecallChain(db, opts) {
|
|
5845
6621
|
const cfg = await getProviderForRole(db, "recall");
|
|
5846
6622
|
const fail = (reason, partial = {}) => ({
|
|
@@ -6337,9 +7113,145 @@ async function ensureHighQualityQuestion(db, token) {
|
|
|
6337
7113
|
return null;
|
|
6338
7114
|
}
|
|
6339
7115
|
|
|
7116
|
+
// src/cli/adapters/source-reader.ts
|
|
7117
|
+
var MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
7118
|
+
var MAX_REDIRECTS = 5;
|
|
7119
|
+
function isPrivateOrReservedIp(address) {
|
|
7120
|
+
const ip = address.toLowerCase().split("%", 1)[0];
|
|
7121
|
+
const mappedV4 = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
7122
|
+
const ipv4 = mappedV4 ?? (ip.includes(".") ? ip : null);
|
|
7123
|
+
if (ipv4) {
|
|
7124
|
+
const parts = ipv4.split(".").map(Number);
|
|
7125
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
7126
|
+
return true;
|
|
7127
|
+
}
|
|
7128
|
+
const [a, b] = parts;
|
|
7129
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 192 && b === 0 || a === 192 && b === 0 && parts[2] === 2 || a === 198 && (b === 18 || b === 19 || b === 51) || a === 203 && b === 0 && parts[2] === 113 || a >= 224;
|
|
7130
|
+
}
|
|
7131
|
+
return ip === "::" || ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || /^fe[89ab]/.test(ip) || ip.startsWith("ff") || ip.startsWith("2001:db8:");
|
|
7132
|
+
}
|
|
7133
|
+
function cleanHtml(html) {
|
|
7134
|
+
let text = html.replace(
|
|
7135
|
+
/<(head|script|style|svg)[^>]*>[\s\S]*?<\/\1>/gi,
|
|
7136
|
+
" "
|
|
7137
|
+
);
|
|
7138
|
+
text = text.replace(/<[^>]+>/g, " ");
|
|
7139
|
+
text = text.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'");
|
|
7140
|
+
return text.replace(/\s+/g, " ").trim();
|
|
7141
|
+
}
|
|
7142
|
+
async function isSafeUrl(urlString) {
|
|
7143
|
+
try {
|
|
7144
|
+
const url = new URL(urlString);
|
|
7145
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
7146
|
+
return false;
|
|
7147
|
+
}
|
|
7148
|
+
if (url.username || url.password) return false;
|
|
7149
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
7150
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost"))
|
|
7151
|
+
return false;
|
|
7152
|
+
const results = await dns.promises.lookup(hostname, {
|
|
7153
|
+
all: true,
|
|
7154
|
+
verbatim: true
|
|
7155
|
+
});
|
|
7156
|
+
return results.length > 0 && results.every((result) => !isPrivateOrReservedIp(result.address));
|
|
7157
|
+
} catch {
|
|
7158
|
+
return false;
|
|
7159
|
+
}
|
|
7160
|
+
}
|
|
7161
|
+
async function readLocalFile(filepath) {
|
|
7162
|
+
if (!fs.existsSync(filepath)) {
|
|
7163
|
+
throw new Error(`File not found: ${filepath}`);
|
|
7164
|
+
}
|
|
7165
|
+
const stat = fs.statSync(filepath);
|
|
7166
|
+
if (!stat.isFile()) {
|
|
7167
|
+
throw new Error(`Not a file: ${filepath}`);
|
|
7168
|
+
}
|
|
7169
|
+
if (stat.size > MAX_SOURCE_BYTES) {
|
|
7170
|
+
throw new Error("File exceeds 2MB limit");
|
|
7171
|
+
}
|
|
7172
|
+
return fs.readFileSync(filepath, "utf-8");
|
|
7173
|
+
}
|
|
7174
|
+
async function readWebLink(url) {
|
|
7175
|
+
const controller = new AbortController();
|
|
7176
|
+
const timeoutId = setTimeout(() => controller.abort(), 1e4);
|
|
7177
|
+
try {
|
|
7178
|
+
let currentUrl = new URL(url);
|
|
7179
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
7180
|
+
if (!await isSafeUrl(currentUrl.href)) {
|
|
7181
|
+
throw new Error(
|
|
7182
|
+
`Access denied to unsafe target URL: ${currentUrl.href}`
|
|
7183
|
+
);
|
|
7184
|
+
}
|
|
7185
|
+
const res = await fetch(currentUrl, {
|
|
7186
|
+
redirect: "manual",
|
|
7187
|
+
signal: controller.signal,
|
|
7188
|
+
headers: {
|
|
7189
|
+
"User-Agent": "ZAM-Content-Studio/0.6.0"
|
|
7190
|
+
}
|
|
7191
|
+
});
|
|
7192
|
+
if (res.status >= 300 && res.status < 400) {
|
|
7193
|
+
const location = res.headers.get("location");
|
|
7194
|
+
if (!location) {
|
|
7195
|
+
throw new Error("Web server returned a redirect without a location");
|
|
7196
|
+
}
|
|
7197
|
+
if (redirects === MAX_REDIRECTS) {
|
|
7198
|
+
throw new Error(`Web request exceeded ${MAX_REDIRECTS} redirects`);
|
|
7199
|
+
}
|
|
7200
|
+
currentUrl = new URL(location, currentUrl);
|
|
7201
|
+
continue;
|
|
7202
|
+
}
|
|
7203
|
+
if (!res.ok) {
|
|
7204
|
+
throw new Error(`Web server responded with status ${res.status}`);
|
|
7205
|
+
}
|
|
7206
|
+
const contentType = res.headers.get("content-type") || "";
|
|
7207
|
+
if (!contentType.includes("text/html") && !contentType.includes("text/plain") && !contentType.includes("application/xhtml+xml") && !contentType.includes("text/xml")) {
|
|
7208
|
+
throw new Error(`Unsupported content type: ${contentType}`);
|
|
7209
|
+
}
|
|
7210
|
+
const declaredLength = Number(res.headers.get("content-length"));
|
|
7211
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_SOURCE_BYTES) {
|
|
7212
|
+
throw new Error("Response body exceeds 2MB limit");
|
|
7213
|
+
}
|
|
7214
|
+
const text = await readResponseBodyWithLimit(res, MAX_SOURCE_BYTES);
|
|
7215
|
+
if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {
|
|
7216
|
+
return cleanHtml(text);
|
|
7217
|
+
}
|
|
7218
|
+
return text;
|
|
7219
|
+
}
|
|
7220
|
+
throw new Error("Web request failed");
|
|
7221
|
+
} catch (err) {
|
|
7222
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
7223
|
+
throw new Error("Connection request timed out after 10 seconds");
|
|
7224
|
+
}
|
|
7225
|
+
throw err;
|
|
7226
|
+
} finally {
|
|
7227
|
+
clearTimeout(timeoutId);
|
|
7228
|
+
}
|
|
7229
|
+
}
|
|
7230
|
+
async function readResponseBodyWithLimit(response, maxBytes) {
|
|
7231
|
+
if (!response.body) return "";
|
|
7232
|
+
const reader = response.body.getReader();
|
|
7233
|
+
const decoder = new TextDecoder();
|
|
7234
|
+
let totalBytes = 0;
|
|
7235
|
+
let text = "";
|
|
7236
|
+
while (true) {
|
|
7237
|
+
const { done, value } = await reader.read();
|
|
7238
|
+
if (done) break;
|
|
7239
|
+
totalBytes += value.byteLength;
|
|
7240
|
+
if (totalBytes > maxBytes) {
|
|
7241
|
+
await reader.cancel();
|
|
7242
|
+
throw new Error("Response body exceeds 2MB limit");
|
|
7243
|
+
}
|
|
7244
|
+
text += decoder.decode(value, { stream: true });
|
|
7245
|
+
}
|
|
7246
|
+
return text + decoder.decode();
|
|
7247
|
+
}
|
|
7248
|
+
async function readImageOCR(db, imagePath) {
|
|
7249
|
+
return extractTextFromScanViaLLM(db, imagePath);
|
|
7250
|
+
}
|
|
7251
|
+
|
|
6340
7252
|
// src/cli/llm/vision.ts
|
|
6341
7253
|
import { randomBytes } from "crypto";
|
|
6342
|
-
import { readFileSync as
|
|
7254
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
6343
7255
|
import { tmpdir as tmpdir2 } from "os";
|
|
6344
7256
|
import { basename as basename3, join as join14 } from "path";
|
|
6345
7257
|
var LANGUAGE_NAMES2 = {
|
|
@@ -6412,7 +7324,7 @@ async function observeUiSnapshotViaLLM(db, input8) {
|
|
|
6412
7324
|
}
|
|
6413
7325
|
}
|
|
6414
7326
|
for (const file of sampledFiles) {
|
|
6415
|
-
const bytes =
|
|
7327
|
+
const bytes = readFileSync10(join14(tempDir, file));
|
|
6416
7328
|
imageUrls.push(`data:image/png;base64,${bytes.toString("base64")}`);
|
|
6417
7329
|
}
|
|
6418
7330
|
} finally {
|
|
@@ -6422,7 +7334,7 @@ async function observeUiSnapshotViaLLM(db, input8) {
|
|
|
6422
7334
|
}
|
|
6423
7335
|
}
|
|
6424
7336
|
} else {
|
|
6425
|
-
const imageBytes =
|
|
7337
|
+
const imageBytes = readFileSync10(input8.imagePath);
|
|
6426
7338
|
const ext = input8.imagePath.split(".").pop()?.toLowerCase();
|
|
6427
7339
|
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
|
6428
7340
|
imageUrls.push(`data:${mime};base64,${imageBytes.toString("base64")}`);
|
|
@@ -6890,7 +7802,7 @@ import {
|
|
|
6890
7802
|
existsSync as existsSync15,
|
|
6891
7803
|
lstatSync,
|
|
6892
7804
|
mkdirSync as mkdirSync8,
|
|
6893
|
-
readFileSync as
|
|
7805
|
+
readFileSync as readFileSync11,
|
|
6894
7806
|
realpathSync,
|
|
6895
7807
|
rmSync as rmSync2,
|
|
6896
7808
|
symlinkSync,
|
|
@@ -6978,7 +7890,7 @@ function isZamSkillCopy(destinationDir) {
|
|
|
6978
7890
|
if (!lstatSync(destinationDir).isDirectory()) return false;
|
|
6979
7891
|
const skillFile = join15(destinationDir, "SKILL.md");
|
|
6980
7892
|
if (!existsSync15(skillFile)) return false;
|
|
6981
|
-
return /^name:\s*zam\s*$/m.test(
|
|
7893
|
+
return /^name:\s*zam\s*$/m.test(readFileSync11(skillFile, "utf8"));
|
|
6982
7894
|
} catch {
|
|
6983
7895
|
return false;
|
|
6984
7896
|
}
|
|
@@ -7111,7 +8023,7 @@ ${ZAM_BLOCK_END}`;
|
|
|
7111
8023
|
}
|
|
7112
8024
|
return "write";
|
|
7113
8025
|
}
|
|
7114
|
-
const existing =
|
|
8026
|
+
const existing = readFileSync11(dest, "utf8");
|
|
7115
8027
|
if (existing.includes(block)) return "skip";
|
|
7116
8028
|
const start = existing.indexOf(ZAM_BLOCK_START);
|
|
7117
8029
|
const end = existing.indexOf(ZAM_BLOCK_END);
|
|
@@ -8646,7 +9558,7 @@ bridgeCommand.command("capture-ui").description("Capture a screenshot for agent-
|
|
|
8646
9558
|
return;
|
|
8647
9559
|
}
|
|
8648
9560
|
}
|
|
8649
|
-
const imageBytes =
|
|
9561
|
+
const imageBytes = readFileSync12(outputPath);
|
|
8650
9562
|
const base64 = imageBytes.toString("base64");
|
|
8651
9563
|
jsonOut2({
|
|
8652
9564
|
sessionId: opts.session ?? null,
|
|
@@ -8768,7 +9680,7 @@ bridgeCommand.command("stop-recording").description(
|
|
|
8768
9680
|
}
|
|
8769
9681
|
const sessionId = opts.session;
|
|
8770
9682
|
const statePath = join18(tmpdir3(), `zam-recording-${sessionId}.json`);
|
|
8771
|
-
const { existsSync: existsSync26, readFileSync:
|
|
9683
|
+
const { existsSync: existsSync26, readFileSync: readFileSync17, rmSync: rmSync4 } = await import("fs");
|
|
8772
9684
|
if (!existsSync26(statePath)) {
|
|
8773
9685
|
jsonOut2({
|
|
8774
9686
|
sessionId,
|
|
@@ -8777,7 +9689,7 @@ bridgeCommand.command("stop-recording").description(
|
|
|
8777
9689
|
});
|
|
8778
9690
|
return;
|
|
8779
9691
|
}
|
|
8780
|
-
const state = JSON.parse(
|
|
9692
|
+
const state = JSON.parse(readFileSync17(statePath, "utf8"));
|
|
8781
9693
|
const { pid, outputPath } = state;
|
|
8782
9694
|
try {
|
|
8783
9695
|
process.kill(pid, "SIGINT");
|
|
@@ -9291,6 +10203,388 @@ bridgeCommand.command("get-neighborhood").description(
|
|
|
9291
10203
|
});
|
|
9292
10204
|
});
|
|
9293
10205
|
});
|
|
10206
|
+
bridgeCommand.command("personal-card-list").description("List and search personal learning cards (JSON)").option("--user <id>", "User ID (default: whoami)").option("--query <query>", "Text search query").option("--domain <domain>", "Filter by category/domain").action(async (opts) => {
|
|
10207
|
+
await withDb2(async (db) => {
|
|
10208
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10209
|
+
const cards = await listPersonalCards(db, userId, {
|
|
10210
|
+
query: opts.query,
|
|
10211
|
+
domain: opts.domain
|
|
10212
|
+
});
|
|
10213
|
+
jsonOut2({ cards });
|
|
10214
|
+
});
|
|
10215
|
+
});
|
|
10216
|
+
bridgeCommand.command("personal-card-create").description("Atomically create a token and its personal card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--concept <concept>", "Concept description / answer").option("--domain <domain>", "Knowledge category / domain", "").option("--question <question>", "Question prompt for recall").option("--source-link <link>", "Source file path or reference URL").option("--bloom <level>", "Bloom taxonomy level (1-5)", "1").option(
|
|
10217
|
+
"--mode <mode>",
|
|
10218
|
+
"Symbiosis mode: shadowing | copilot | autonomy | none",
|
|
10219
|
+
"none"
|
|
10220
|
+
).option("--context <context>", "Context description", "").action(async (opts) => {
|
|
10221
|
+
await withDb2(async (db) => {
|
|
10222
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10223
|
+
const bloom = Number(opts.bloom);
|
|
10224
|
+
if (bloom < 1 || bloom > 5) {
|
|
10225
|
+
jsonError("bloom must be between 1 and 5");
|
|
10226
|
+
}
|
|
10227
|
+
const mode = opts.mode === "none" ? null : opts.mode;
|
|
10228
|
+
if (mode && !["shadowing", "copilot", "autonomy"].includes(mode)) {
|
|
10229
|
+
jsonError(`Invalid mode: ${opts.mode}`);
|
|
10230
|
+
}
|
|
10231
|
+
const slug = await generateTokenSlug(
|
|
10232
|
+
db,
|
|
10233
|
+
opts.domain,
|
|
10234
|
+
opts.concept,
|
|
10235
|
+
opts.question
|
|
10236
|
+
);
|
|
10237
|
+
let question = opts.question || null;
|
|
10238
|
+
if (!question) {
|
|
10239
|
+
question = generateConceptFreeCue(bloom, slug, opts.domain);
|
|
10240
|
+
}
|
|
10241
|
+
const { token, card } = await db.transaction(async (tx) => {
|
|
10242
|
+
const createdToken = await createToken(tx, {
|
|
10243
|
+
slug,
|
|
10244
|
+
concept: opts.concept,
|
|
10245
|
+
domain: opts.domain,
|
|
10246
|
+
bloom_level: bloom,
|
|
10247
|
+
context: opts.context,
|
|
10248
|
+
symbiosis_mode: mode,
|
|
10249
|
+
source_link: opts.sourceLink || null,
|
|
10250
|
+
question
|
|
10251
|
+
});
|
|
10252
|
+
const createdCard = await ensureCard(tx, createdToken.id, userId);
|
|
10253
|
+
return { token: createdToken, card: createdCard };
|
|
10254
|
+
});
|
|
10255
|
+
jsonOut2({
|
|
10256
|
+
success: true,
|
|
10257
|
+
token: {
|
|
10258
|
+
id: token.id,
|
|
10259
|
+
slug: token.slug,
|
|
10260
|
+
concept: token.concept,
|
|
10261
|
+
domain: token.domain,
|
|
10262
|
+
bloomLevel: token.bloom_level,
|
|
10263
|
+
context: token.context,
|
|
10264
|
+
symbiosisMode: token.symbiosis_mode,
|
|
10265
|
+
sourceLink: token.source_link,
|
|
10266
|
+
question: token.question,
|
|
10267
|
+
createdAt: token.created_at,
|
|
10268
|
+
updatedAt: token.updated_at
|
|
10269
|
+
},
|
|
10270
|
+
card: {
|
|
10271
|
+
id: card.id,
|
|
10272
|
+
tokenId: card.token_id,
|
|
10273
|
+
userId: card.user_id,
|
|
10274
|
+
state: card.state,
|
|
10275
|
+
dueAt: card.due_at,
|
|
10276
|
+
blocked: card.blocked
|
|
10277
|
+
}
|
|
10278
|
+
});
|
|
10279
|
+
});
|
|
10280
|
+
});
|
|
10281
|
+
bridgeCommand.command("personal-card-update").description("Update the mutable token fields of a personal card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--slug <slug>", "Token slug to update").option("--concept <concept>", "Updated concept text").option("--domain <domain>", "Updated domain / category").option("--bloom <level>", "Updated Bloom taxonomy level (1-5)").option("--context <context>", "Updated context").option(
|
|
10282
|
+
"--mode <mode>",
|
|
10283
|
+
"Updated symbiosis mode: shadowing | copilot | autonomy | none"
|
|
10284
|
+
).option("--source-link <link>", "Updated source link").option("--question <question>", "Updated question text").action(async (opts) => {
|
|
10285
|
+
await withDb2(async (db) => {
|
|
10286
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10287
|
+
const updates = {};
|
|
10288
|
+
if (opts.concept !== void 0) updates.concept = opts.concept;
|
|
10289
|
+
if (opts.domain !== void 0) updates.domain = opts.domain;
|
|
10290
|
+
if (opts.bloom !== void 0) {
|
|
10291
|
+
const bloom = Number(opts.bloom);
|
|
10292
|
+
if (bloom < 1 || bloom > 5) {
|
|
10293
|
+
jsonError("bloom must be between 1 and 5");
|
|
10294
|
+
}
|
|
10295
|
+
updates.bloom_level = bloom;
|
|
10296
|
+
}
|
|
10297
|
+
if (opts.context !== void 0) updates.context = opts.context;
|
|
10298
|
+
if (opts.sourceLink !== void 0) {
|
|
10299
|
+
updates.source_link = opts.sourceLink === "" ? null : opts.sourceLink;
|
|
10300
|
+
}
|
|
10301
|
+
if (opts.question !== void 0) {
|
|
10302
|
+
updates.question = opts.question === "" ? null : opts.question;
|
|
10303
|
+
}
|
|
10304
|
+
if (opts.mode !== void 0) {
|
|
10305
|
+
const validModes = ["shadowing", "copilot", "autonomy", "none"];
|
|
10306
|
+
if (!validModes.includes(opts.mode)) {
|
|
10307
|
+
jsonError(`Invalid mode: ${opts.mode}`);
|
|
10308
|
+
}
|
|
10309
|
+
updates.symbiosis_mode = opts.mode === "none" ? null : opts.mode;
|
|
10310
|
+
}
|
|
10311
|
+
const token = await db.transaction(async (tx) => {
|
|
10312
|
+
const existingToken = await getTokenBySlug(tx, opts.slug);
|
|
10313
|
+
if (!existingToken) {
|
|
10314
|
+
throw new Error(`Token not found: ${opts.slug}`);
|
|
10315
|
+
}
|
|
10316
|
+
const card = await getCard(tx, existingToken.id, userId);
|
|
10317
|
+
if (!card) {
|
|
10318
|
+
throw new Error(
|
|
10319
|
+
`Card not found for token ${opts.slug} and user ${userId}`
|
|
10320
|
+
);
|
|
10321
|
+
}
|
|
10322
|
+
return updateToken(tx, opts.slug, updates);
|
|
10323
|
+
});
|
|
10324
|
+
jsonOut2({
|
|
10325
|
+
success: true,
|
|
10326
|
+
token: {
|
|
10327
|
+
id: token.id,
|
|
10328
|
+
slug: token.slug,
|
|
10329
|
+
concept: token.concept,
|
|
10330
|
+
domain: token.domain,
|
|
10331
|
+
bloomLevel: token.bloom_level,
|
|
10332
|
+
context: token.context,
|
|
10333
|
+
symbiosisMode: token.symbiosis_mode,
|
|
10334
|
+
sourceLink: token.source_link,
|
|
10335
|
+
question: token.question,
|
|
10336
|
+
createdAt: token.created_at,
|
|
10337
|
+
updatedAt: token.updated_at
|
|
10338
|
+
}
|
|
10339
|
+
});
|
|
10340
|
+
});
|
|
10341
|
+
});
|
|
10342
|
+
bridgeCommand.command("personal-card-remove").description(
|
|
10343
|
+
"Remove a personal card (optionally previewing the effects) (JSON)"
|
|
10344
|
+
).option("--user <id>", "User ID (default: whoami)").requiredOption("--slug <slug>", "Token slug").option("--confirm", "Perform the deletion instead of a preview").action(async (opts) => {
|
|
10345
|
+
await withDb2(async (db) => {
|
|
10346
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10347
|
+
const token = await getTokenBySlug(db, opts.slug);
|
|
10348
|
+
if (!token) {
|
|
10349
|
+
jsonError(`Token not found: ${opts.slug}`);
|
|
10350
|
+
}
|
|
10351
|
+
const card = await getCard(db, token.id, userId);
|
|
10352
|
+
if (!card) {
|
|
10353
|
+
jsonError(`Card not found for token ${opts.slug} and user ${userId}`);
|
|
10354
|
+
}
|
|
10355
|
+
if (!opts.confirm) {
|
|
10356
|
+
const impact = await getCardDeletionImpact(db, token.id, userId);
|
|
10357
|
+
jsonOut2({
|
|
10358
|
+
success: true,
|
|
10359
|
+
preview: true,
|
|
10360
|
+
requiresConfirmation: true,
|
|
10361
|
+
token: { id: token.id, slug: token.slug },
|
|
10362
|
+
impact
|
|
10363
|
+
});
|
|
10364
|
+
return;
|
|
10365
|
+
}
|
|
10366
|
+
const result = await deleteCardForUser(db, token.id, userId);
|
|
10367
|
+
jsonOut2({
|
|
10368
|
+
success: true,
|
|
10369
|
+
deletedCard: {
|
|
10370
|
+
id: result.card.id,
|
|
10371
|
+
tokenId: result.card.token_id,
|
|
10372
|
+
userId: result.card.user_id
|
|
10373
|
+
},
|
|
10374
|
+
impact: result.impact
|
|
10375
|
+
});
|
|
10376
|
+
});
|
|
10377
|
+
});
|
|
10378
|
+
bridgeCommand.command("personal-card-delete").description("Hard-delete a token and all its dependencies (JSON)").requiredOption("--slug <slug>", "Token slug").option("--confirm", "Perform the deletion instead of a preview").action(async (opts) => {
|
|
10379
|
+
await withDb2(async (db) => {
|
|
10380
|
+
const token = await getTokenBySlug(db, opts.slug);
|
|
10381
|
+
if (!token) {
|
|
10382
|
+
jsonError(`Token not found: ${opts.slug}`);
|
|
10383
|
+
}
|
|
10384
|
+
if (!opts.confirm) {
|
|
10385
|
+
const impact = await getTokenDeleteImpact(db, opts.slug);
|
|
10386
|
+
jsonOut2({
|
|
10387
|
+
success: true,
|
|
10388
|
+
preview: true,
|
|
10389
|
+
requiresConfirmation: true,
|
|
10390
|
+
token: { id: token.id, slug: token.slug },
|
|
10391
|
+
impact
|
|
10392
|
+
});
|
|
10393
|
+
return;
|
|
10394
|
+
}
|
|
10395
|
+
const result = await deleteToken(db, opts.slug);
|
|
10396
|
+
jsonOut2({
|
|
10397
|
+
success: true,
|
|
10398
|
+
deletedToken: {
|
|
10399
|
+
id: result.token.id,
|
|
10400
|
+
slug: result.token.slug
|
|
10401
|
+
},
|
|
10402
|
+
impact: result.impact
|
|
10403
|
+
});
|
|
10404
|
+
});
|
|
10405
|
+
});
|
|
10406
|
+
bridgeCommand.command("personal-card-import-curriculum").description("Parse curriculum text using LLM and import cards (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--text <text>", "Curriculum syllabus/content text").requiredOption(
|
|
10407
|
+
"--domain <domain>",
|
|
10408
|
+
"Default category/domain for imported cards"
|
|
10409
|
+
).option("--source <url>", "Provenance source link or URL").option("--preview", "Return parsed cards without saving them").action(async (opts) => {
|
|
10410
|
+
await withDb2(async (db) => {
|
|
10411
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10412
|
+
const cards = await importCurriculumViaLLM(
|
|
10413
|
+
db,
|
|
10414
|
+
opts.text,
|
|
10415
|
+
opts.domain,
|
|
10416
|
+
opts.source || null
|
|
10417
|
+
);
|
|
10418
|
+
if (opts.preview) {
|
|
10419
|
+
jsonOut2({
|
|
10420
|
+
success: true,
|
|
10421
|
+
proposals: cards
|
|
10422
|
+
});
|
|
10423
|
+
return;
|
|
10424
|
+
}
|
|
10425
|
+
const result = await importCurriculumCards(db, userId, cards);
|
|
10426
|
+
jsonOut2({
|
|
10427
|
+
success: true,
|
|
10428
|
+
createdCount: result.createdCount,
|
|
10429
|
+
ensuredCount: result.ensuredCount
|
|
10430
|
+
});
|
|
10431
|
+
});
|
|
10432
|
+
});
|
|
10433
|
+
bridgeCommand.command("personal-card-split-proposals").description("Generate atomic proposals for splitting a card (JSON)").requiredOption("--slug <slug>", "Original token slug").action(async (opts) => {
|
|
10434
|
+
await withDb2(async (db) => {
|
|
10435
|
+
const token = await getTokenBySlug(db, opts.slug);
|
|
10436
|
+
if (!token) {
|
|
10437
|
+
jsonError(`Token not found: ${opts.slug}`);
|
|
10438
|
+
}
|
|
10439
|
+
const proposals = await generateSplitProposalsViaLLM(db, token);
|
|
10440
|
+
jsonOut2({
|
|
10441
|
+
success: true,
|
|
10442
|
+
proposals
|
|
10443
|
+
});
|
|
10444
|
+
});
|
|
10445
|
+
});
|
|
10446
|
+
bridgeCommand.command("personal-card-confirm-split").description("Save confirmed card split and modify original card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--slug <slug>", "Original token slug").requiredOption(
|
|
10447
|
+
"--action <action>",
|
|
10448
|
+
"Original card action: 'block' or 'remove'"
|
|
10449
|
+
).requiredOption(
|
|
10450
|
+
"--original-question <question>",
|
|
10451
|
+
"Rewritten question of the original card"
|
|
10452
|
+
).requiredOption(
|
|
10453
|
+
"--original-concept <concept>",
|
|
10454
|
+
"Rewritten concept of the original card"
|
|
10455
|
+
).requiredOption(
|
|
10456
|
+
"--proposals <json>",
|
|
10457
|
+
"JSON string representing proposals array"
|
|
10458
|
+
).action(async (opts) => {
|
|
10459
|
+
await withDb2(async (db) => {
|
|
10460
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10461
|
+
const proposals = JSON.parse(opts.proposals);
|
|
10462
|
+
const result = await confirmCardSplit(
|
|
10463
|
+
db,
|
|
10464
|
+
userId,
|
|
10465
|
+
opts.slug,
|
|
10466
|
+
opts.action,
|
|
10467
|
+
opts.originalQuestion,
|
|
10468
|
+
opts.originalConcept,
|
|
10469
|
+
proposals
|
|
10470
|
+
);
|
|
10471
|
+
jsonOut2({
|
|
10472
|
+
success: true,
|
|
10473
|
+
createdCount: result.createdCount,
|
|
10474
|
+
ensuredCount: result.ensuredCount
|
|
10475
|
+
});
|
|
10476
|
+
});
|
|
10477
|
+
});
|
|
10478
|
+
bridgeCommand.command("personal-card-foundations-proposals").description("Generate prerequisite suggestions for a card (JSON)").requiredOption("--slug <slug>", "Original token slug").action(async (opts) => {
|
|
10479
|
+
await withDb2(async (db) => {
|
|
10480
|
+
const token = await getTokenBySlug(db, opts.slug);
|
|
10481
|
+
if (!token) {
|
|
10482
|
+
jsonError(`Token not found: ${opts.slug}`);
|
|
10483
|
+
}
|
|
10484
|
+
const proposals = await generateFoundationsProposalsViaLLM(db, token);
|
|
10485
|
+
const resolvedProposals = [];
|
|
10486
|
+
for (const prop of proposals) {
|
|
10487
|
+
const baseText = prop.question && prop.question.trim().length > 0 ? prop.question : prop.concept;
|
|
10488
|
+
const cleanDomain = slugify(prop.domain || "");
|
|
10489
|
+
const cleanBase = slugify(baseText);
|
|
10490
|
+
let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
|
|
10491
|
+
if (baseSlug.length > 60) {
|
|
10492
|
+
baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
|
|
10493
|
+
}
|
|
10494
|
+
if (!baseSlug) {
|
|
10495
|
+
baseSlug = "token";
|
|
10496
|
+
}
|
|
10497
|
+
const existingToken = await getTokenBySlug(db, baseSlug);
|
|
10498
|
+
if (existingToken) {
|
|
10499
|
+
resolvedProposals.push({
|
|
10500
|
+
...prop,
|
|
10501
|
+
exists: true,
|
|
10502
|
+
slug: existingToken.slug,
|
|
10503
|
+
question: existingToken.question || prop.question,
|
|
10504
|
+
concept: existingToken.concept,
|
|
10505
|
+
domain: existingToken.domain
|
|
10506
|
+
});
|
|
10507
|
+
} else {
|
|
10508
|
+
resolvedProposals.push({
|
|
10509
|
+
...prop,
|
|
10510
|
+
exists: false,
|
|
10511
|
+
slug: null
|
|
10512
|
+
});
|
|
10513
|
+
}
|
|
10514
|
+
}
|
|
10515
|
+
jsonOut2({
|
|
10516
|
+
success: true,
|
|
10517
|
+
proposals: resolvedProposals
|
|
10518
|
+
});
|
|
10519
|
+
});
|
|
10520
|
+
});
|
|
10521
|
+
bridgeCommand.command("personal-card-confirm-foundations").description("Save confirmed foundational prerequisites (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--slug <slug>", "Original token slug").requiredOption(
|
|
10522
|
+
"--proposals <json>",
|
|
10523
|
+
"JSON string representing proposals array"
|
|
10524
|
+
).action(async (opts) => {
|
|
10525
|
+
await withDb2(async (db) => {
|
|
10526
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10527
|
+
const proposals = JSON.parse(opts.proposals);
|
|
10528
|
+
const result = await confirmFoundations(db, userId, opts.slug, proposals);
|
|
10529
|
+
jsonOut2({
|
|
10530
|
+
success: true,
|
|
10531
|
+
createdCount: result.createdCount,
|
|
10532
|
+
linkedCount: result.linkedCount
|
|
10533
|
+
});
|
|
10534
|
+
});
|
|
10535
|
+
});
|
|
10536
|
+
bridgeCommand.command("personal-source-import").description(
|
|
10537
|
+
"Fetch and clean plain text from local file, web link, or vision scan (JSON)"
|
|
10538
|
+
).requiredOption("--type <file|web|scan>", "Source type").requiredOption("--uri <uri>", "Source path or URL").action(async (opts) => {
|
|
10539
|
+
await withDb2(async (db) => {
|
|
10540
|
+
let content = "";
|
|
10541
|
+
if (opts.type === "file") {
|
|
10542
|
+
content = await readLocalFile(opts.uri);
|
|
10543
|
+
} else if (opts.type === "web") {
|
|
10544
|
+
content = await readWebLink(opts.uri);
|
|
10545
|
+
} else if (opts.type === "scan") {
|
|
10546
|
+
content = await readImageOCR(db, opts.uri);
|
|
10547
|
+
} else {
|
|
10548
|
+
throw new Error(`Invalid source type: ${opts.type}`);
|
|
10549
|
+
}
|
|
10550
|
+
const sourceId = ulid8();
|
|
10551
|
+
await db.prepare(
|
|
10552
|
+
`INSERT INTO sources (id, type, uri, content)
|
|
10553
|
+
VALUES (?, ?, ?, ?)
|
|
10554
|
+
ON CONFLICT(uri) DO UPDATE SET
|
|
10555
|
+
type = excluded.type,
|
|
10556
|
+
content = excluded.content`
|
|
10557
|
+
).run(sourceId, opts.type, opts.uri, content);
|
|
10558
|
+
const record = await db.prepare("SELECT id, content FROM sources WHERE uri = ?").get(opts.uri);
|
|
10559
|
+
jsonOut2({
|
|
10560
|
+
success: true,
|
|
10561
|
+
sourceId: record.id,
|
|
10562
|
+
content: record.content
|
|
10563
|
+
});
|
|
10564
|
+
});
|
|
10565
|
+
});
|
|
10566
|
+
bridgeCommand.command("personal-source-confirm-import").description(
|
|
10567
|
+
"Confirm and save cards generated from a source reference (JSON)"
|
|
10568
|
+
).option("--user <id>", "User ID (default: whoami)").requiredOption("--sourceId <id>", "Source database ID").requiredOption(
|
|
10569
|
+
"--proposals <json>",
|
|
10570
|
+
"JSON string representing proposals array"
|
|
10571
|
+
).action(async (opts) => {
|
|
10572
|
+
await withDb2(async (db) => {
|
|
10573
|
+
const userId = await resolveUser(opts, db, { json: true });
|
|
10574
|
+
const proposals = JSON.parse(opts.proposals);
|
|
10575
|
+
const result = await confirmSourceImport(
|
|
10576
|
+
db,
|
|
10577
|
+
userId,
|
|
10578
|
+
opts.sourceId,
|
|
10579
|
+
proposals
|
|
10580
|
+
);
|
|
10581
|
+
jsonOut2({
|
|
10582
|
+
success: true,
|
|
10583
|
+
createdCount: result.createdCount,
|
|
10584
|
+
ensuredCount: result.linkedCount
|
|
10585
|
+
});
|
|
10586
|
+
});
|
|
10587
|
+
});
|
|
9294
10588
|
bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/stdout server").option("--stdin", "Use stdin/stdout for communication").action(async (_opts) => {
|
|
9295
10589
|
isServeMode = true;
|
|
9296
10590
|
const {
|
|
@@ -11525,7 +12819,7 @@ ${"\u2550".repeat(50)}`);
|
|
|
11525
12819
|
});
|
|
11526
12820
|
|
|
11527
12821
|
// src/cli/commands/session.ts
|
|
11528
|
-
import { readFileSync as
|
|
12822
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
11529
12823
|
import { input as input6, select as select2 } from "@inquirer/prompts";
|
|
11530
12824
|
import { Command as Command14 } from "commander";
|
|
11531
12825
|
var sessionCommand = new Command14("session").description(
|
|
@@ -11717,7 +13011,7 @@ function loadPatternFile(path) {
|
|
|
11717
13011
|
if (!path) return [];
|
|
11718
13012
|
let parsed;
|
|
11719
13013
|
try {
|
|
11720
|
-
parsed = JSON.parse(
|
|
13014
|
+
parsed = JSON.parse(readFileSync13(path, "utf-8"));
|
|
11721
13015
|
} catch (err) {
|
|
11722
13016
|
throw new Error(
|
|
11723
13017
|
`Cannot read synthesis patterns from ${path}: ${err.message}`
|
|
@@ -12266,7 +13560,7 @@ skillCommand.command("add").description("Register a new agent skill").requiredOp
|
|
|
12266
13560
|
});
|
|
12267
13561
|
|
|
12268
13562
|
// src/cli/commands/snapshot.ts
|
|
12269
|
-
import { existsSync as existsSync22, mkdirSync as mkdirSync13, readFileSync as
|
|
13563
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync13, readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "fs";
|
|
12270
13564
|
import { dirname as dirname7, join as join21 } from "path";
|
|
12271
13565
|
import { Command as Command18 } from "commander";
|
|
12272
13566
|
function defaultOutName() {
|
|
@@ -12319,7 +13613,7 @@ var importCmd = new Command18("import").description("Restore a snapshot into the
|
|
|
12319
13613
|
console.error(`Error: Snapshot file not found: ${file}`);
|
|
12320
13614
|
process.exit(1);
|
|
12321
13615
|
}
|
|
12322
|
-
const snapshot =
|
|
13616
|
+
const snapshot = readFileSync14(file, "utf-8");
|
|
12323
13617
|
db = await openDatabaseWithSync({ initialize: true });
|
|
12324
13618
|
const result = await importSnapshot(db, snapshot, { force: opts.force });
|
|
12325
13619
|
await db.close();
|
|
@@ -12341,7 +13635,7 @@ var verifyCmd = new Command18("verify").description("Check a snapshot's manifest
|
|
|
12341
13635
|
console.error(`Error: Snapshot file not found: ${file}`);
|
|
12342
13636
|
process.exit(1);
|
|
12343
13637
|
}
|
|
12344
|
-
const manifest = verifySnapshot(
|
|
13638
|
+
const manifest = verifySnapshot(readFileSync14(file, "utf-8"));
|
|
12345
13639
|
const { total, nonEmpty } = summarize(manifest.tables);
|
|
12346
13640
|
console.log(`Valid snapshot (format v${manifest.version})`);
|
|
12347
13641
|
console.log(` created: ${manifest.createdAt}`);
|
|
@@ -12969,7 +14263,7 @@ ${C3.dim}After that, 'zam ui' launches it directly.${C3.reset}`
|
|
|
12969
14263
|
|
|
12970
14264
|
// src/cli/commands/update.ts
|
|
12971
14265
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
12972
|
-
import { existsSync as existsSync24, readFileSync as
|
|
14266
|
+
import { existsSync as existsSync24, readFileSync as readFileSync15, realpathSync as realpathSync2 } from "fs";
|
|
12973
14267
|
import { dirname as dirname9, join as join23 } from "path";
|
|
12974
14268
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
12975
14269
|
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
@@ -12995,7 +14289,7 @@ function currentVersion() {
|
|
|
12995
14289
|
for (const up of ["..", "../..", "../../.."]) {
|
|
12996
14290
|
try {
|
|
12997
14291
|
const pkg2 = JSON.parse(
|
|
12998
|
-
|
|
14292
|
+
readFileSync15(join23(here, up, "package.json"), "utf-8")
|
|
12999
14293
|
);
|
|
13000
14294
|
if (pkg2.version) return pkg2.version;
|
|
13001
14295
|
} catch {
|
|
@@ -13006,7 +14300,7 @@ function currentVersion() {
|
|
|
13006
14300
|
function versionAt(dir) {
|
|
13007
14301
|
try {
|
|
13008
14302
|
const pkg2 = JSON.parse(
|
|
13009
|
-
|
|
14303
|
+
readFileSync15(join23(dir, "package.json"), "utf-8")
|
|
13010
14304
|
);
|
|
13011
14305
|
return pkg2.version ?? "unknown";
|
|
13012
14306
|
} catch {
|
|
@@ -13641,7 +14935,7 @@ workspaceCommand.command("backup").description("Back up the local ZAM database i
|
|
|
13641
14935
|
// src/cli/index.ts
|
|
13642
14936
|
var __dirname = dirname10(fileURLToPath5(import.meta.url));
|
|
13643
14937
|
var pkg = JSON.parse(
|
|
13644
|
-
|
|
14938
|
+
readFileSync16(join25(__dirname, "..", "..", "package.json"), "utf-8")
|
|
13645
14939
|
);
|
|
13646
14940
|
var program = new Command25();
|
|
13647
14941
|
program.name("zam").description(
|