I Built a Local-First Tool to Check Whether Your Side Project Conflicts With Your Job
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle. What I Built I spent some time reading other entries in this week's challenge before starting my own. There were roughl
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle.
What I Built
I spent some time reading other entries in this week's challenge before starting my own. There were roughly a hundred projects, including receipt sorters, invoice extractors, Etsy listing writers, GST tools for small businesses, quote generators, and more. Many were well built and solved practical problems.
But most of them start from the same assumption: you've already launched your side hustle.
I kept thinking about the step before that. If you have a full-time job and want to build something on the side, one of the first questions is whether your employment agreement puts any limits on what you can do. The relevant clauses might cover outside work, intellectual property, exclusivity, or disclosure requirements. And many of us signed that agreement, skimmed it once, and haven't looked at it since.
That's why I built Moonlight. It scans an employment contract, finds clauses that may be relevant to a side project, quotes the original wording, and highlights specific details worth reviewing.
Can you sell what you build?
For example, Moonlight might find an intellectual-property clause that says:
"all Works conceived, created or developed by the Employee
during the term of employment, whether or not during working
hours, whether or not using the resources of the Company, and
whether or not relating to the present business of the
Company, shall be the sole and exclusive property of the
Company..."
It then highlights the wording that matters:
- Reaches beyond working hours: "whether or not during working hours"
- May apply to unrelated work: "whether or not relating to the present business of the Company"
- Covers work done on personal equipment: "whether or not using the resources of the Company"
- Transfers ownership: "hereby assigns to the Company all right"
It also suggests a question to investigate: Does this clause cover work created outside working hours?
Moonlight does not decide whether you're legally allowed to run a side business. It shows you what the agreement says, points to the exact language, and helps you identify what to ask about. That boundary is intentional, and it influenced several of the technical decisions I'll explain below.
The intended user is someone who wants to start a side project but isn't sure what their employment agreement says. Before worrying about invoices or sales, they need to understand what they signed.
Demo
Paste a contract and click Read it. On my laptop, a 28-clause agreement takes around 90 seconds to process, or roughly five seconds per clause.
Moonlight also shows a status line so you can see what happened during the review:
28 clauses in document Β· 12 matched on keywords Β· 12 quotes verified Β·
0 readings discarded Β· 0 verdicts given Β· 88.3s Β· nothing left this laptop
There isn't a hosted demo, and that's a deliberate choice. Employment contracts can contain sensitive details about your salary, employer, and obligations. Sending them to a server just to get a first-pass review would defeat one of the main reasons I built this tool.
You can run it locally with npm install && npm start. A sample contract is included so you can try it without providing your own document.
Code
GitHub: imarpanpatra/moonlight MIT licensed.
The project uses Node.js, Express, Ollama, and gemma3:4b, with one application dependency.
ollama pull gemma3:4b
ollama serve
npm install && npm start # http://127.0.0.1:4455
npm run check # 56 tests
npm run airgap # checks that network traffic stays local
node src/eval.js # evaluates 14 labelled clauses
Here are the main files and what each one does:
| File | Responsibility |
|---|---|
src/clauses.js |
Splits the contract into clauses and retrieves candidates using keywords. No model involved. |
src/flags.js |
Detects specific clause properties and records the wording that triggered each flag. |
src/gemma.js |
The only file that opens a connection to the local model. The prompt lives here. |
src/review.js |
Coordinates the review, verifies quotes, strips verdicts, and orders the results. |
src/server.js |
Runs the Express server on 127.0.0.1. |
src/check.js |
Contains the 56 tests. |
src/eval.js |
Evaluates the model against 14 labelled clauses. |
src/airgap.js |
Instruments socket and DNS calls to check for non-local network access. |
How I Built It
Keep the model's job as small as possible
The first important decision was to avoid asking the model to read the entire contract and decide which clauses mattered.
Moonlight splits the document using clause numbering and carries section headings along with each clause. It then scores clauses against keyword sets for seven categories:
- Outside work
- Intellectual-property assignment
- Exclusivity
- Non-compete
- Non-solicitation
- Confidentiality
- Duty to disclose
A clause must contain an anchor phrase before it becomes a candidate. Without that requirement, generic wording such as "shall not" or "in writing" would match too many unrelated parts of a contract.
In my example, 28 clauses became 12 candidates. The model never sees the full agreement, and it doesn't decide which sections are relevant.
Extract the wording, then verify it
The model's job is to copy the operative sentence from a clause. Moonlight then checks whether the returned quote actually appears in the original text:
if (!normalise(clauseText).includes(normalise(quote))) {
// Not in the document. Discard the reading, keep the clause.
}
The check normalises whitespace before comparing the strings. If the quote isn't present, Moonlight treats it as an invalid reading rather than accepting it as a summary. The clause is still shown in its original form, labelled located, not read.
This matters because a paraphrase can sound convincing while changing the meaning of a legal clause. A person reviewing the output may not notice that an important qualification has disappeared. Checking the quote against the source gives the user a way to verify the evidence directly.
The evaluation changed the design
My first version asked gemma3:4b to classify each clause with a simple answer: does it apply outside the job yes, unclear, or no?
It answered unclear for all eight clauses I tested.
I revised the prompt, added clearer definitions, and included two worked examples. The same model then answered yes for all eight. The model and contract hadn't changed; only the wording of the prompt had.
That wasn't a reliable basis for a tool dealing with employment agreements, so I created a small evaluation set instead of continuing to tweak prompts by intuition. src/eval.js contains 14 labelled clauses: six that reach outside the job, five that do not, and three that are genuinely ambiguous.
Here's the result for gemma3:4b:
Model: gemma3:4b cases: 14
Classification: 7/14 (50%)
Quotes verbatim: 14/14 (100%)
Time: 72.4s total, 5.2s per clause
Confusion:
6 x yes -> yes
4 x no -> yes
3 x unclear -> yes
1 x no -> no
Binding clauses called harmless ("yes" -> "no"): 0
The results pointed in two different directions.
The model wasn't reliable at classification. It scored 50% and labelled 13 of the 14 clauses as yes. That could mean flagging something as ordinary as an annual-leave clause as relevant to side income.
It was reliable at copying the source text in this test. All 14 quotes matched the original wording. Across these examples, it didn't paraphrase or invent a sentence.
So I removed classification from the product and kept extraction. The replacement is src/flags.js, which uses pattern matching to identify specific properties and cites the phrase that triggered each one.
Examples include:
- Asks for permission rather than stating an outright ban: "without the prior written consent of the Company"
- Continues after employment ends: "at any time after its termination"
- Reaches beyond working hours: "whether or not during working hours"
- May cover unrelated work: "whether or not relating to the present business of the Company"
- Transfers ownership: "hereby assigns to the Company all right"
A flag can still be wrong, but its evidence is visible. You can inspect the exact phrase and decide whether the flag makes sense.
The permission distinction is especially important. A clause requiring written consent is not necessarily the same as a blanket ban. Treating those two situations as identical could discourage someone from pursuing a project they might otherwise be able to pursue after getting the required approval.
The evaluation also showed zero yes -> no errors: the model did not label a binding clause as harmless in this small test set. For this use case, missing a restrictive clause could be more damaging than asking someone to review an extra clause.
Why Open-Source AI Matters Here
The contract should stay on the user's machine
This is the main reason I chose a local model.
Uploading a client's contract to a hosted API can create a confidentiality risk. An employment agreement can reveal a person's salary, notice period, employer, and the exact obligations they're worried about. They may not want to send that information to a third party just to understand a clause.
For Moonlight, running an open-weight model locally isn't just a cost optimisation. It's part of the product's privacy design.
I wanted to verify that the application wasn't making external network connections, rather than simply claiming that it was private. src/airgap.js instruments net.Socket.prototype.connect, tls.connect, and dns.lookup before the app is imported. It runs a complete review and fails if it detects a connection to a non-loopback host. DNS lookups count too, because even a lookup can disclose information to a resolver.
Here's one run:
$ npm run airgap
Outbound connections observed: 7
7 x tcp 127.0.0.1:11434
DNS lookups observed: 0
PASS. Every connection was to loopback. The contract never left this machine.
This test checks the network activity observed during that run. It's a practical safeguard, not a claim that any software can be proven secure by one test alone.
Open weights made the failure visible
Because the model runs locally, I could try a smaller one by changing the configuration. gemma3:1b is around 815 MB, compared with 3.3 GB for gemma3:4b, and was 15% faster in my test. I ran it against the same evaluation set.
Model: gemma3:1b cases: 14
Classification: 6/14 (43%)
Quotes verbatim: 0/14 (0%)
The smaller model failed in two different ways.
On long clauses, it sometimes stopped following the expected output format. When asked for five keys, it returned only one:
{"governs_side_income":"yes"}
It generated eleven tokens and stopped, with done_reason: stop. Since there was no quote to verify, Moonlight labelled the clause located, not read and displayed the original text. The result was incomplete, but the app didn't present it as a successful reading.
On shorter clauses, it sometimes invented a quote by removing an important qualification. Given an outside-work clause, it produced:
"The Employee shall not engage in any other employment, consultancy, trade or business, whether or not for remuneration"
But the contract actually said:
"the Employee shall not, without the prior written consent of the Company, engage in any other employment, consultancy, trade or business"
The model omitted the consent requirement. Those words change how the clause reads: the first version sounds like an outright prohibition, while the original makes prior written consent part of the condition.
The output was fluent, but it wasn't faithful to the source. That is exactly the kind of mistake that could mislead someone reading a contract summary.
The quote-verification check caught it because the generated sentence wasn't present in the original text. I didn't have to invent a hypothetical failure to justify the check; it appeared in the first clause I tested with the smaller model.
That's one of the benefits of being able to run and evaluate the model yourself. I could switch models, run the same tests, and inspect the failure rather than relying only on a model card or benchmark. Open weights made the behaviour inspectable, and that directly influenced the design of Moonlight.
My Agent Session
The full build, including the experiments that changed the design, is available here:
I've entered the Hacktoberfest DEV Challenge. I want to submit a project for Week 2.
Week 2 is "Side Hustle": build something with open-weight models at its core that someone would actually pay for. Deadline is Oct 18, 11:59 PM PDT. Before picking an idea, let me read what the other entrants have already built, so we aren't the twelfth receipt sorter.
tag: hf26challenge, per_page: 100
100 submissions. Clusters: ~12 receipt/invoice sorters, ~9 Etsy listing writers, ~7 scope-creep auditors, ~10 GST/khata bookkeepers, ~8 generic 'local AI business assistants', ~3 menu translators.
The field is saturated on the back office. Every one of these helps you run a side hustle. Not one checks whether you are allowed to have one.
Most side hustlers are employed. The document that decides whether they can sell anything is their own employment contract: moonlighting clauses, IP assignment, duty to disclose. It is also the single least uploadable document a person owns, which makes local inference the reason the product exists rather than a nice-to-have.
Build that one.
Architecture: retrieval is arithmetic, the model only quotes, and every quote is checked against the document before display. The model never sees the whole contract and never decides which clauses matter.
src/clauses.js - clause segmentation and keyword retrieval, seven categories, anchors required
Written. 28 clauses in the sample contract reduce to 12 candidates.
src/review.js - verifyQuote(): reject any quote not present in the clause text after whitespace normalisation
Written.
First run of the classifier. I asked gemma3:4b to label each clause yes/unclear/no on whether it reaches outside the job.
node src/check.js
Live model: 8 clauses read.
[unclear] Inventions and IP 9.2
[unclear] Outside work 6.1
[unclear] Exclusivity 4.2
[unclear] Confidentiality 8.1
... all 8 returned 'unclear'
Every clause came back 'unclear'. That is a useless triage. Rewriting the prompt with sharp definitions and two worked examples.
node src/check.js (after prompt rewrite)
Live model: 8 clauses read.
[yes] Inventions and IP 9.2
[yes] Outside work 6.1
[yes] Exclusivity 4.2
[yes] Confidentiality 8.1
... all 8 returned 'yes'
Now every clause is 'yes'. Same model, same contract, temperature 0, opposite output. The only thing that changed was my wording.
That is not a judgement, it is a coin landing wherever the prompt tilts it. I am not going to guess which prompt is better. I am going to measure it.
src/eval.js - 14 labelled clauses: 6 that reach outside the job, 5 that cannot, 3 that genuinely read both ways. Retrieval deliberately bypassed so this measures the model alone.
Written.
node src/eval.js (model: gemma3:4b)
Classification: 7/14 (50%)
Quotes verbatim: 14/14 (100%)
Time: 72.4s total, 5.2s per clause
Confusion:
6 x yes -> yes
4 x no -> yes
3 x unclear -> yes
1 x no -> no
Binding clauses called harmless ("yes" -> "no"): 0
Two findings pointing in opposite directions.
It cannot judge: 50%, answering 'yes' to 13 of 14 clauses. It flags the annual leave clause as governing side income.
It can extract perfectly: 14/14 verbatim, not one paraphrase, across every clause type.
So the classification gets deleted from the product and the extraction is kept. What replaces it is pattern matching over the clause text, where each flag cites the exact words that triggered it and a wrong flag is therefore wrong visibly.
src/flags.js - deterministic clause properties: requires_consent, survives_termination, beyond_working_hours, regardless_of_relevance, regardless_of_resources, duty_to_disclose, assigns_ownership. Each returns the matched evidence.
Written. Model's classification demoted to a recorded field that nothing reads.
Does the smaller model do any better?
Open weights mean it is one environment variable away. gemma3:1b is 815 MB against 3.3 GB and 15% faster.
MOONLIGHT_MODEL=gemma3:1b node src/eval.js
Classification: 6/14 (43%)
Quotes verbatim: 0/14 (0%)
Zero verbatim quotes. Two distinct failures. On long clauses it abandons the schema after one key: {"governs_side_income":"yes"}, eleven tokens, done_reason stop. On short clauses it quotes something that was never there.
gemma3:1b on clause 6.1 (outside work)
model quote: "The Employee shall not engage in any other employment, consultancy, trade or business, whether or not for remuneration"
contract text: "the Employee shall not, without the prior written consent of the Company, engage in any other employment, consultancy, trade or business"
verifyQuote -> REJECTED (not-in-document)
It deleted "without the prior written consent of the Company".
That edit turns a permission you can go and ask for into an absolute prohibition. Someone reading that closes the tab and does not start the business. The gate caught it because those words are not in the document. I did not have to construct the example to argue for the rule: the first clause I tested on the smaller model produced it.
scan the codebase for bugs or memory leak if found then fix it
Four passes until one came back empty. No memory leaks: no setInterval, no caches, no cross-request state, the one setTimeout is cleared in a finally, and listeners attach once. But the passes turned up worse than a leak.
segment() on a clause that wraps onto a line starting with a digit
clause "1.1" (71 chars): "1.1 The Employee shall work such hours as required, being no fewer than"
clause "45" (71 chars): "45 hours per week, and the Company shall give the Employee no less than"
clause "60" (71 chars): "60 days written notice of any material change..."
When a fragment falls under the 40-char floor it is dropped entirely:
"14 days notice of any such request" survived segmentation: False
The clause number regex made punctuation optional, so any hard-wrapped line beginning with a digit started a new clause. Clause text was silently truncated and the tail deleted. For a tool whose promise is 'I found the clauses', showing a clause with its final obligation missing is the worst failure available.
absent categories computed after the candidate limit is applied
limit 4 -> reported absent: ["exclusivity", "non_compete", "confidentiality"]
limit 100 -> reported absent: ["non_compete"]
The UI would say "Not found anywhere in this document" about clauses that are present.
model returns bare null instead of an object
raw=null -> THROWS TypeError: Cannot read properties of null (reading 'quote')
Escapes readOne, unwinds review(), loses all 12 clauses to one malformed reply.
Fixed all three plus four more: a clause start now needs punctuation, a dotted number or an explicit word; absence is computed before the cap; malformed responses cost one clause. Also: the displayed quote is now the document's own characters rather than the model's string, since verification normalises case and a model answering in block capitals was being presented as a verbatim quotation.
node src/check.js
56 passed, 0 failed
npm run airgap
Reviewing the sample contract with all sockets instrumented...
Outbound connections observed: 7
7 x tcp 127.0.0.1:11434
DNS lookups observed: 0
PASS. Every connection was to loopback. The contract never left this machine.
If you're reviewing the session, the two useful moments are the evaluation that led me to remove classification and the smaller model's quote that dropped the consent requirement.
My First User
Moonlight is still an early project. I built it during the challenge window, and so far I've used it myself on one real contract that isn't included in the repository.
It surfaced three clauses I'd skimmed past. The flag about needing permission rather than facing an outright ban was the one that most changed how I understood the wording.
The sample contract included with the project is fictional and exists to exercise the code.
What Moonlight Is Not
Moonlight isn't a lawyer, and it doesn't claim to be one. It won't tell you whether a clause is allowed, enforceable, or advisable. If the model generates a verdict anyway, stripVerdicts() removes those statements. The 0 verdicts given status is a count of what the application did, not a marketing slogan.
The retrieval system is keyword-based, so a clause written in unexpected language can be missed. If Moonlight doesn't find something, that means its current keyword rules didn't identify it; it doesn't prove that the contract contains no relevant restrictions. The interface makes that limitation explicit.
Use it as a first-pass reading aid, verify the quoted text, and consult a qualified professional when you need legal advice.
Prize Category: Best Use of Gemma
Moonlight uses gemma3:4b through Ollama, running locally on a machine with a 4 GB RTX 3050. I also tested gemma3:1b against the same 14-clause evaluation set, and the results are included above.
The project is built around a simple idea: when a document is sensitive, the model should be able to run on the same machine that holds it.
Built during the Week 2 challenge window with Node.js, Express, Ollama, gemma3:4b, one application dependency, and an MIT license. No hosted inference API is required.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.